JavaScript的并发模型与事件循环

174 阅读6分钟

并发模型与事件循环

JS有一个基于事件循环的并发模型。事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其他语言中的模型截然不同,例如C和Java。

运行时

可视化描述

10.svg

栈Stack

函数调用形成了一个由若干帧组成的栈。

function foo(b) {
  let a = 10;
  return a + b + 11;
}
​
function bar(x) {
  let y = 3;
  return foo(x * y);
}
​
// 返回42
console.log(bar(7));

以上代码分析:

当调用bar时,第一个帧Frame被创建并压入栈,帧中包含了bar的参数和局部变量。当bar调用foo时,第二个帧被创建并压入栈,放在第一个帧上面。帧中包含了foo的参数和局部变量。当foo执行完毕然后返回时,第二个帧就弹出栈(此时剩下bar函数的调用帧)。当bar也执行完毕然后返回时,第一个帧也被弹出,栈被清空。

堆Heap

对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

队列Queue

一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。

在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

事件循环Event Loop

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

while (queue.waitForMessage()) {
  queue.waitForMessage();
}

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

一直执行栈中的帧Frame,直到栈清空。

执行至完成

每一个消息被完整地执行后,其他消息才会被执行。这为程序的分析提供了一些优秀的特性。包括:当一个函数执行时,它不会被抢占,只有在它运行完毕后才会去运行任何其他的代码、才能修改这个函数操作的数据。

这与C语言不同,例如,如果函数在线程中运行,它可能在任何位置被终止,然后在另一个线程中运行其它代码。

这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互,例如点击或滚动。为了缓解这个问题,浏览器一般会弹出一个“这个脚本运行时间过长”的对话框。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。

添加消息

在浏览器里,每当一个事件发生并且有一个事件监听器绑定在该事件上时,一个消息就会被添加进消息队列。如果没有事件监听器,这个事件将会丢失。所以当一个带有点击事件处理器的元素被点击时,就会像其他事件一样产生一个类似的消息。

setTimeout

函数setTimeout接受两个参数:待加入队列的消息和一个时间值(毫秒,可选,默认为0)。

这个时间值代表了消息被实际加入到队列的最小延迟时间

如果队列中没有其它消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout消息必须等待其它消息处理完。

因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。

console.time('计时');
const s = new Date().getSeconds();
​
setTimeout(function() {
  console.log(`${new Date().getSeconds() - s}秒后执行`);
  console.timeEnd('计时');
}, 5000);
​
while(true) {
  if (new Date().getSeconds() - s >= 10) {
    console.log('循环10秒')
    break;
  }
}
​
// 循环10秒
// 10秒后执行
// 计时: 9.432s

分析:

function() {
  console.log(`${new Date().getSeconds() - s}秒后执行`);
  console.timeEnd('计时');
}

5000毫秒后,以上匿名函数会被加入队列。

但是,接下来的while语句会在10秒后才执行完毕,所以会一直等待它执行结束。

所以会输出10秒后执行

零延迟

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。

其等待的时间取决于队列里待处理的消息数量。在下面的例子中,"这是一条消息" 将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。

基本上,setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间

(function() {
​
  console.log('这是开始');
​
  setTimeout(function cb() {
    console.log('这是来自第一个回调的消息');
  });
​
  console.log('这是一条消息');
​
  setTimeout(function cb1() {
    console.log('这是来自第二个回调的消息');
  }, 0);
​
  console.log('这是结束');
​
})();
​
// "这是开始"
// "这是一条消息"
// "这是结束"
// "这是来自第一个回调的消息"
// "这是来自第二个回调的消息"

PS:上例,cb1会在cb2之前被添加到队列。

多个运行时互相通信

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

window.postMessage

window.postMessage()方法可以安全地实现跨源通信。

通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号(443为https的默认值),以及主机 (两个页面的模数Document.domain设置为相同的值) 时,这两个脚本才能相互通信。

window.postMessage()方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage() 方法分发一个MessageEvent。接收消息的窗口可以根据需要,自由处理此事件。传递给window.postMessage()的参数将通过消息事件对象暴露给接收消息的窗口。

永不阻塞

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

由于历史原因,alert或者同步XHR会阻塞!尽量避免使用它们!!!