JavaScript
的并发模型
基于事件循环
。这个模型与像C
或者Java
这种其它语言中的模型截然不同。
运行时概念
下面的内容解释了一个理论模型。现代 JavaScript 引擎实现并着重优化了所描述的这些语义。
可视化描述
栈(Stack)
函数调用形成了一个栈帧:1
2
3
4
5
6
7
8
9
10
11function 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
3while (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
21const 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
,但应该尽量避免使用它们
参考文档 并发模型与事件循环