【问题标题】:Data Races in JavaScript?JavaScript 中的数据竞赛?
【发布时间】:2016-09-05 23:42:26
【问题描述】:

假设我运行了这段代码。

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

理论上,我知道这会带来数据竞争,两个线程同时尝试递增可能会导致单次递增,但是众所周知,nodejs(和 javascript)是单线程的。我能保证 score 的最终值等于任意长度吗?

【问题讨论】:

    标签: javascript node.js multithreading race-condition


    【解决方案1】:

    我是否保证 score 的最终值等于 任意长度?

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

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

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

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

    您可以在此处阅读有关 Javascript 的事件驱动性质的更多信息:How does JavaScript handle AJAX responses in the background?,该答案还包含许多其他参考。

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

    其他一些参考资料:

    how do I prevent event handlers to handle multiple events at once in javascript?

    Do I need to be concerned with race conditions with asynchronous Javascript?

    JavaScript - When exactly does the call stack become "empty"?

    Node.js server with multiple concurrent requests, how does it work?


    为了让您了解 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");
    

    您将在控制台中获得以下信息:

    A
    C
    D
    B
    

    在当前的 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() 回调完成执行,解释器再次检查事件队列以查看是否有任何其他事件可以运行。

    【讨论】:

    • 添加了一些参考资料。
    • 这个回答很有见地,非常感谢!
    • “还有一些竞争条件需要关注,但它们更多地与多个异步回调都可以访问的共享状态有关。” Promise.then() 链)。就像您有 3 个功能,一个检查文件是否存在,一个用于打开文件,一个用于访问其内容。如果您在第二个之前先运行最后一个,则不会被视为竞争条件。
    • @DanielT。 - 存在问题(可以访问共享状态的多个异步操作)。您建议使用 Promise 序列化所有操作是一种可能的处理方法,但还有很多其他方法 - 有时您不希望/不需要序列化操作,而只是将您的共享状态设计为可以安全地从多个不同的异步处理程序以不可预知的顺序。
    • @DanielT。 - 我完全理解运行到完成的过程,并且我自己就该主题发布了许多广为接受的答案。仅仅因为没有线程和中断并不意味着您不会遇到共享状态的并发问题。您可以随时代码调用异步操作,然后在回调中继续,因为您的代码路径基本上会将控制权返回到该点的事件循环,并且其他事件可以在您的最后一段逻辑仍在中流时运行(等待要完成的异步操作)。这会产生潜在的并发问题。
    【解决方案2】:

    Node 使用事件循环。你可以把它想象成一个队列。所以我们可以假设,您的 for 循环将 function() { score++; } 回调 arbitrary_length 次放在此队列中。之后js引擎将这些一个一个运行,每次增加score。所以是的。如果没有调用回调或从其他地方访问 score 变量,则唯一例外。

    实际上,您可以使用此模式并行执行任务,收集结果并在每个任务完成时调用单个回调。

    var results = [];
    for (var i = 0; i < arbitrary_length; i++) {
         async_task(i, function(result) {
              results.push(result);
              if (results.length == arbitrary_length)
                   tasksDone(results);
         });
    }
    

    【讨论】:

    • "每个任务完成时调用一个回调" 我怎么知道所有的任务都已经完成了。我只能想到忙着等到得分!=任意长度
    • @bilalba 是的,这就是你所能做的。实际上,这段代码与结果数组长度完全相同。 :-) 顺便说一句更好。为被拒绝的任务添加某种错误处理程序,否则当错误发生时您将等待永恒。
    • “我怎么知道所有的任务都已经完成了” Promise.all() 的确切原因。 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/…
    • @DanielT。对,我知道。 :-) 谢谢。
    【解决方案3】:

    函数的两次调用不能同时发生(b/c 节点是单线程的)所以这不会是一个问题。唯一的问题是如果在某些情况下 async_task(..) 丢弃回调。但是,如果,例如,'async_task(..)' 只是用给定的函数调用 setTimeout(..),那么是的,每个调用都会执行,它们永远不会相互冲突,并且 'score' 将具有预期的值, 'arbitrary_length', 在最后。

    当然,'arbitrary_length' 不能大到耗尽内存或溢出持有这些回调的任何集合。但是没有线程问题。

    【讨论】:

      【解决方案4】:

      我认为对于其他看到这一点的人来说值得注意的是,您的代码中有一个常见错误。对于变量 i,在将其传递给 async_task() 之前,您需要使用 let 或重新分配给另一个变量。当前的实现将导致每个函数获得 i 的最后一个值。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2013-12-20
        • 1970-01-01
        • 2012-09-06
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2015-09-15
        相关资源
        最近更新 更多