JavaScript并发模型基于事件循环。这个模型与像 C 或者 Java 这种其它语言中的模型截然不同。

运行时概念

下面的内容解释了一个理论模型。现代 JavaScript 引擎实现并着重优化了所描述的这些语义。

  • 可视化描述
    EventLoop

  • 栈(Stack)
    函数调用形成了一个栈帧:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function foo(b) {
    var a = 10;
    return a + b + 11;
    }

    function bar(x) {
    var y = 3;
    return foo(x * y);
    }

    console.log(bar(7)); // 返回 42

    当调用 bar 时,创建了第一个帧 ,帧中包含了 bar 的参数和局部变量。当 bar 调用 foo 时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了 foo 的参数和局部变量。当 foo 返回时,最上层的帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 返回的时候,栈就空了。

  • 堆(Heap)
    对象被分配在一个堆中,即用以表示一大块非结构化的内存区域。

  • 队列(Queue)

    • 一个 JavaScript 运行时包含了一个待处理的消息队列。每一个消息都关联着一个用以处理这个消息的函数。
    • 事件循环 期间的某个时刻,运行时从最先进入队列的消息开始处理队列中的消息。为此,这个消息会被移出队列,并作为输入参数调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。
    • 函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

事件循环

  • Why EventLoop?
    之所以称之为事件循环,是因为它经常按照类似如下的方式来被实现:

    1
    2
    3
    while (queue.waitForMessage()) {
    queue.processNextMessage();
    }

    如果当前没有任何消息,queue.waitForMessage()同步 地等待消息到达。

  • 执行至完成

    • 每一个消息完整的执行后,其它消息才会被执行。这为程序的分析提供了一些优秀的特性,包括:一个函数执行时,它永远不会被抢占,并且在其他代码运行之前完全运行(且可以修改此函数操作的数据)。这与C语言不同,例如,如果函数在线程中运行,它可能在任何位置被终止,然后在另一个线程中运行其他代码。
    • 这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用就无法处理用户的交互,例如点击或滚动。浏览器用“程序需要过长时间运行”的对话框来缓解这个问题。一个很好的做法是缩短消息处理,并在可能的情况下将一个消息裁剪成多个消息。
  • 添加消息

    • 在浏览器里,当一个事件发生且有一个事件监听器绑定在该事件上时,消息会被随时添加进队列。如果没有事件监听器,事件会丢失。所以点击一个附带点击事件处理函数的元素会添加一个消息,其它事件类似。
    • 函数 setTimeout 接受两个参数:待加入队列的消息和一个延迟(可选,默认为 0)。这个延迟代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout 消息必须等待其它消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。
    • 下面的例子演示了这个概念(setTimeout 并不会在计时器到期之后直接执行):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      const s = new Date().getSeconds();

      setTimeout(function() {
      // 这里的输出并没在 1000ms 之后立即执行,而是等待当前队列中的其它消息 `while(){...}` 执行完毕之后再执行,所以此处也会在等待 5s 后执行。
      console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
      }, 1000);

      while(true) {
      if(new Date().getSeconds() - s >= 5) {
      console.log("Good, looped for 5 seconds");
      break;
      }
      }

      /*
      ****** 5s后同时输出
      Good, looped for 5 seconds
      undefined
      Ran after 5 seconds
      ******
      */
  • 零延迟

    • 零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。
    • 其等待的时间取决于队列里待处理的消息数量。在下面的例子中,这是一条消息 将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。
    • 基本上,setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      (function() {

      console.log('这是开始');

      setTimeout(function cb() {
      console.log('这是来自第一个回调的消息');
      });

      console.log('这是一条消息');

      setTimeout(function cb1() {
      console.log('这是来自第二个回调的消息');
      }, 0);

      console.log('这是结束');

      })();

      // "这是开始"
      // "这是一条消息"
      // "这是结束"
      // 此处,函数返回了 undefined
      // "这是来自第一个回调的消息"
      // "这是来自第二个回调的消息"
  • 多个运行时互相通信

    • 一个 web worker 或者一个跨域的 iframe 都有自己的栈,堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一运行时侦听 message 事件,则此方法会向其添加消息。

永不阻塞

  • 事件循环模型的一个非常有趣的特性是,与许多其他语言不同,JavaScript 永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。
  • 遗留的例外是存在的,如 alert 或者同步 XHR,但应该尽量避免使用它们

参考文档 并发模型与事件循环