JavaScript 中的数据竞争?

bil*_*lba 4 javascript multithreading race-condition node.js

假设我运行这段代码。

var score = 0;
for (var i = 0; i < arbitrary_length; i++) {
     async_task(i, function() { score++; }); // increment callback function
}
Run Code Online (Sandbox Code Playgroud)

从理论上讲,我理解这会带来数据竞争,并且尝试同时递增的两个线程可能会导致单个增量,但是,nodejs(和 javascript)已知是单线程的。我能保证得分的最终值等于任意长度吗?

jfr*_*d00 6

我能保证得分的最终值等于任意长度吗?

是的,只要所有的async_task()调用都调用回调一次且仅一次,就可以保证最终的score值等于argumentary_length。

Javascript 的单线程特性保证了永远不会有两段 Javascript 同时运行。相反,由于浏览器和 Node.js 中 Javascript 的事件驱动特性,一段 JS 运行完成,然后从事件队列中提取下一个事件,并触发一个回调,该回调也将运行完成。

不存在中断驱动的 Javascript 之类的东西(其中某些回调可能会中断当前正在运行的其他一些 Javascript 片段)。一切都通过事件队列序列化。这是一个巨大的简化,可以防止许多棘手的情况,否则当您同时运行多个线程或中断驱动的代码时,安全编程需要大量工作。

仍然有一些并发问题需要关注,但它们更多地与多个异步回调都可以访问的共享状态有关。虽然在任何给定时间只有一个人会访问它,但包含多个异步操作的一段代码仍然有可能在某个时间处于多个异步操作中间时将某些状态留在“中间”状态。一些其他异步操作可以运行并可以尝试访问该数据的点。

您可以在此处阅读有关 Javascript 事件驱动性质的更多信息:JavaScript 如何在后台处理 AJAX 响应?该答案还包含许多其他参考资料。

另一个类似的答案讨论了可能的共享数据竞争条件:Can this code Cause a race condition in socket io?

其他一些参考:

如何防止事件处理程序在 JavaScript 中同时处理多个事件?

我是否需要关心异步 Javascript 的竞争条件?

JavaScript - 调用堆栈何时变为“空”?

Node.js 服务器有多个并发请求,它是如何工作的?


为了让您了解 Javascript 中可能发生的并发问题(即使没有线程且没有中断,这里有一个来自我自己的代码的示例)。

我有一个 Raspberry Pi node.js 服务器来控制我家的阁楼风扇。每隔 10 秒,它就会检查两个温度探头,一个在阁楼内,一个在屋外,并决定如何控制风扇(通过继电器)。它还记录可以在图表中呈现的温度数据。它每小时一次将内存中收集到的最新温度数据保存到某些文件中,以便在断电或服务器崩溃时持久保存。该保存操作涉及一系列异步文件写入。这些异步写入中的每一项都会将控制权交还给系统,然后在调用异步回调信号完成时继续。因为这是一个低内存系统,并且数据可能会占用可用 RAM 的很大一部分,所以数据在写入之前不会复制到内存中(这根本不切实际)。因此,我将实时内存数据写入磁盘。

在任何这些异步文件 I/O 操作期间的任何时候,在等待回调来表示所涉及的许多文件写入完成时,服务器中的一个计时器可能会触发,我会收集一组新的温度数据并它将尝试修改我正在编写的内存数据集。这是一个等待发生的并发问题。如果它在我写入部分数据并等待写入完成后再写入其余部分时更改了数据,那么写入的数据很容易最终被损坏,因为我将写出数据的一部分,即数据将从我下面被修改,然后我将尝试写出更多数据,而没有意识到它已被更改。这是一个并发问题。

实际上,我有一个console.log()语句显式记录我的服务器上发生此并发问题的时间(并由我的代码安全处理)。在我的服务器上每隔几天就会发生一次。我知道它就在那里而且是真实的。

有很多方法可以解决这些类型的并发问题。最简单的方法是在内存中复制所有数据,然后写出该副本。由于没有线程或中断,因此在内存中进行复制不会出现并发问题(在复制过程中不会屈服于异步操作以产生并发问题)。但是,这在本例中并不实用。所以,我实现了一个队列。每当我开始写作时,我都会在管理数据的对象上设置一个标志。然后,每当系统想要在设置该标志时添加或修改存储数据中的数据时,这些更改都会进入队列。设置该标志时,不会触及实际数据。当数据已安全写入磁盘时,标志将重置并处理排队的项目。任何并发问题都被安全地避免了。


因此,这是您必须关心的并发问题的一个示例。Javascript 的一个重要的简化假设是,只要一段 Javascript 不故意将控制权返回给系统,它就会运行完成,而不会中断任何线程。这使得处理如上所述的并发问题变得非常非常容易,因为除非您有意识地将控制权交还给系统,否则您的代码永远不会被中断。这就是为什么我们在自己的 Javascript 中不需要互斥体和信号量以及其他类似的东西。如果需要的话,我们可以使用简单的标志(只是一个常规的 Javascript 变量),就像我上面描述的那样。


在任何完全同步的 Javascript 片段中,您永远不会被其他 Javascript 打断。在处理事件队列中的下一个事件之前,一段同步 Javascript 将运行完成。这就是 Javascript 作为“事件驱动”语言的含义。举个例子,如果您有以下代码:

 console.log("A");
 // schedule timer for 500 ms from now
 setTimeout(function() {
     console.log("B");
 }, 500);

 console.log("C");

 // spin for 1000ms
 var start = Date.now();
 while(Data.now() - start < 1000) {}

 console.log("D");
Run Code Online (Sandbox Code Playgroud)

您将在控制台中看到以下内容:

A
C
D
B
Run Code Online (Sandbox Code Playgroud)

在当前 Javascript 运行完成之前,无法处理计时器事件,即使它可能比这更早添加到事件队列中。JS 解释器的工作方式是,它运行当前的 JS,直到将控制权返回给系统,然后(并且只有此时)它从事件队列中获取下一个事件并调用与该事件关联的回调。

这是幕后事件的顺序。

  1. 这个JS开始运行。
  2. console.log("A")是输出。
  3. 从现在起 500 毫秒后将安排一个计时器事件。计时器子系统使用本机代码。
  4. console.log("C")是输出。
  5. 代码进入自旋循环。
  6. 在旋转循环中途的某个时间点,先前设置的计时器已准备好触发。由解释器实现来决定其工作原理,但最终结果是将计时器事件插入到 Javascript 事件队列中。
  7. 自旋循环结束。
  8. console.log("D")是输出。
  9. 这段 Javascript 完成并将控制权返回给系统。
  10. Javascript 解释器看到当前的 Javascript 片段已完成,因此它检查事件队列以查看是否有任何待处理的事件等待运行。它找到计时器事件和与该事件关联的回调,并调用该回调(启动新的 JS 执行块)。该代码开始运行并console.log("B")输出。
  11. setTimeout()回调完成执行,解释器再次检查事件队列以查看是否有任何其他事件准备运行。