JS有一套基于事件循环的并发模型,这套并发模型负责执行代码、收集和处理事件,执行队列中的子任务。这套模型完全不同于C和Java中的并发模型。
运行时的概念
下面解释一个理论模型,现代JS引擎实现并高度优化了这个模型。
栈Stack
函数调用形成了一个frame的栈。 比如下面的代码
function foo(b) {
let a = 10
return a + b + 11
}
function bar(x) {
let y = 3
return foo(x * y)
}
const baz = bar(7) // assigns 42 to baz
执行顺序如下:
-
当调用
bar时,创建第一个frame,该frame拥有指向bar的入参和局部变量的引用 -
当
bar调用foo时,创建第二个frame,并放在第一个frame的上面,这第二个frame拥有指向foo的入参和局部变量的引用 -
当
foo内部的代码执行到return时,栈顶层的frame(也就是第二步的frame)从栈顶弹出,此时栈中只剩下第一步的frame,它成了栈顶元素 -
当
bar内部代码执行到return时,第一步的frame也从栈顶弹出,栈空
请注意,当函数return以后,它的入参和局部变量有可能持续的存在,因为入参和局部变量不是保存在栈里的。因此它们就可以在它们所在的外层函数return之后,仍然被嵌套在内部的函数所访问。(闭包)
堆Heap
对象分配在堆里,堆只是用来表示一大块内存区域的名字。
队列Queue
一个JS运行时使用一个消息队列,消息队列是一个等待被处理的消息列表,列表中的每个消息都有一个对应的函数,调用这个函数来处理该消息。
在事件循环的某一点上,运行时开始处理队列中的消息,并从队列中最老的一个开始。执行过程是,消息从队列中移除,而且把这个消息作为一个入参来调用对应的处理函数。和普通的函数调用一样,这里的函数调用也会创建一个新的栈frame。
函数的处理过程一直持续到栈再一次为空为止。然后,事件循环会处理队列中的下一个消息。
事件循环
事件循环因它的实现方式而得名,通常类似于:
while (queue.waitForMessage()) {
queue.processNextMessage()
}
queue.waitForMessage()以同步的方式等待一个消息的到来(如果这个消息还不可用,以及正在等待被处理)。
“全部运行”
在处理其他消息之前,每个消息都会被完全的处理。
这对于分析你的程序提供一些好的特性,包括,无论何时一个函数在运行时,它都不会被抢占,而且在其他代码运行之前该函数会被完全运行。这一点不同于C,比如在C中,一个函数运行在一个线程上,它可能会在任意时刻被运行时系统给中止,而在另一个线程上运行其他代码。
JS这种模型的的缺点就是,如果完全处理一个消息需要很长时间,web应用就无法处理用户的交互,比如点击和滚动。此时浏览器会出现一个dialog弹框,提示“a script is taking too long to run”。一个好的实践经验是,保持消息处理比较短,而且尽可能把一个消息分割成多个消息来处理。
添加消息
在web浏览器中,一个事件发生并且有和该事件相关联的事件监听器,消息就会随时被添加。如果事件没有对应的监听器,则该事件会丢失。所以在一个绑定了click事件监听器的元素上点击一下,就会添加一个消息。
setTimeout函数有2个入参,一个是要被添加到队列的消息,一个是时间值(可选,默认为0)。时间值代表最少在多久的延迟以后,把该消息添加进队列中。如果队列中没有其他消息,而且栈是空的,则该消息会刚好在延迟这么长时间后被处理。然而如果队列中还有其他消息,则setTImeout的消息就得等其他消息被处理后。正因如此,这个时间值参数就只是表示了最小延迟时间,而不是准确时间。
有一个例子来说明setTimeout这种情况(setTimeout不会在它的定时器到期时立即执行)
const s = new Date().getSeconds();
setTimeout(function() {
// prints out "2", meaning that the callback is not called immediately after 500 milliseconds.
console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500)
while (true) {
if (new Date().getSeconds() - s >= 2) {
console.log("Good, looped for 2 seconds")
break;
}
}
0延迟
0延迟并不真的意味着回调函数会在0毫秒后被执行。调用setTimeout时传入0毫秒延迟,并不会在0毫秒后执行回调函数。
何时执行依赖于队列中等待的任务数。在下面的例子中,消息“this is just a message”会在回调函数中的消息被处理之前输出到控制台,因为setTimeout的延迟值是运行时处理该请求所需要等待的最小时间。
最基本的,setTimeout要等待队列消息的所有代码都执行完,才会执行,即便你指定了时间限制。
(function() {
console.log('this is the start');
setTimeout(function cb() {
console.log('Callback 1: this is a msg from call back');
}); // has a default time value of 0
console.log('this is just a message');
setTimeout(function cb1() {
console.log('Callback 2: this is a msg from call back');
}, 0);
console.log('this is the end');
})();
// "this is the start"
// "this is just a message"
// "this is the end"
// "Callback 1: this is a msg from call back"
// "Callback 2: this is a msg from call back"
多个运行时互相通信
一个web worker,或者一个跨源的iframe都有自己独立的栈、堆、消息队列。两个不同的运行时只能通过postMessage方法发送消息来通信。如果其他运行时有监听message事件的话,postMessage方法就会添加一个消息给其他运行时。
用不阻塞
JS事件循环的一个有趣特性是,不同于许多其他语言,它永不阻塞。通常通过事件和回调函数来处理I/O,因此当应用在等待一个IndexedDB查询的返回,或者等待一个XHR请求的返回时,应用仍然可以处理其他事情,比如用户的输入。