深入理解 Event Loop

166 阅读5分钟

众所周知,JavaScript(以下简称 JS) 是单线程语言,在 html5 中增加了 web workers,web workers 是新开了线程执行的,那么 JS 还是单线程的吗?当然是,为什么要设计成单线程?
网上有很多说法,大部分都说是多个线程同时对一个dom操作(同时修改dom内容,一个线程增加属性,一个线程删除属性),会非常混乱,当然如果支持多线程就会相应的就要加入多线程的锁机制,那么 JS 就变得非常复杂了,想想 JS 最开始设计的初衷就是用于用户交互,而且当时的原始需求是:功能不需要太强,语法较为简单,容易学习和部署,Brendan Eich 只用了10天,就设计完成了这种语言的第一版,因此也不可能加入多线程这么复杂的技术。
即使现在支持 web workers,由于没有多线程的机制,web workers 和执行线程只能通过 postMessage 来通信,而且由于没有锁,web workers 无法访问 window 和 document 对象。

JS 的单线程是指一个浏览器进程中只有一个 JS 的执行线程,即同一时刻内只会有一段代码在执行。

Micro-Task 与 Macro-Task
单线程如何实现异步?JS 设计了一个事件循环的方式。所有的代码执行均按照事件循环的方式进行。
事件循环中分两种任务:一个是宏任务(Macro-Task),另一个是微任务(Micro-Task)。常见的宏任务和微任务如下。
宏任务:script(整体代码)、setTimeout、setInterval、requestAnimationFrame、I/O、事件、MessageChannel、setImmediate (Node.js)
微任务:Promise.then、 MutaionObserver、process.nextTick (Node.js)
事件循环按下图的方式进行。

注意:
宏任务执行完后,需要清空当前微任务队列后才回去执行下一个宏任务,如果微任务里面产生了新的微任务,仍然会在当前事件循环里面被执行完,后面会举例说明。
来个示例验证下上面的流程。

[JavaScript]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<script>
console.log(1);
setTimeout(function timeout1() {
console.log(2);
}, 0);
Promise.resolve().then(function promise1() {
console.log(3);
setTimeout(function timeout2() {
console.log(4);
Promise.resolve().then(function promise2() {
console.log(5);
});
}, 0);
return Promise.resolve()
.then(function promise3() {
console.log(6);
return Promise.resolve().then(function promise4() {
console.log(7);
});
})
.then(function promise5() {
console.log(8);
});
})
console.log(9);
</script>
<script>
console.log(10);
setTimeout(function timeout3() {
console.log(11);
}, 0);
Promise.resolve().then(function promise6() {
console.log(12);
});
</script>


按照上面流程梳理下执行流程:

将两个宏任务(两个script代码)初始化进宏任务队列,宏任务队列为:[script1, script2]
script1 出队压入执行栈执行,宏任务队列为:[script2]
同步代码执行输出:1,
0ms 后把 timeout1 放入宏任务队列,宏任务队列为:[script2, timeout1]
promise1 入队,微任务队列为:[promise1]
同步代码执行输出:9
script1 执行完毕,进入微任务执行阶段,promise1 出队压入执行栈执行,微任务队列为空
同步代码执行输出:3
0ms 后把 timeout2 放入宏任务队列,宏任务队列为:[script2, timeout1, timeout2]
promise3 入队,微任务队列为:[promise3]
promise1 执行完毕,继续判断微任务队列是否为空,promise3 出队压入执行栈执行,微任务队列为空
同步代码执行输出:6
promise4 入队,微任务队列为:[promise4]
promise3 执行完毕,promise5 入队,微任务队列为:[promise4,promise5]
判断微任务队列是否为空,promise4 出队压入执行栈执行,微任务队列为:[promise5]
同步代码执行输出:7
promise4 执行完毕,继续判断微任务队列是否为空,promise5 出队压入执行栈执行,微任务队列为空
同步代码执行输出:8
微任务队列清空,宏任务 script2 出队压入执行栈执行,宏任务队列为空
同步代码执行输出:10
0ms 后把 timeout3 放入宏任务队列,宏任务队列为:[timeout1, timeout2, timeout3]
promise6 入队,微任务队列为:[promise6]
script2 执行完毕,进入微任务执行阶段,promise6 出队压入执行栈执行,微任务队列为空
同步代码执行输出:12
微任务队列为空,宏任务 timeout1 压入执行栈执行,宏任务队列为[timeout2, timeout3]
同步代码执行输出:2
timeout1执行完毕,微任务队列为空,宏任务 timeout2 压入执行栈执行,宏任务队列为[timeout3]
同步代码执行输出:4,promise2 入队,微任务队列为:[promise2]
timeout2 执行完毕,判断微任务队列是否为空,promise2 出队压入执行栈执行,微任务队列为空
同步代码执行输出:5
promise2执行完,微任务队列为空,宏任务 timeout2 压入执行栈执行,宏任务队列为空
同步代码执行输出:11
timeout3执行完毕,微任务队列为空,宏任务队列为空

setTimeout
setTimeout 的 delay 最小值在不同浏览器的有差异,在 Chrome 74 上测试的结果是 2ms,Firefox 67 上测试的记过是 1ms。
最小值是什么意思?就是小于这个值后,浏览器按照0处理。比如在 Chrome 上,测试下面的代码:
setTimeout(function(){console.log(1)},1.99);
setTimeout(function(){console.log(2)},0);
输出的结果为 1、2,而

[JavaScript]
纯文本查看
复制代码
1
2
setTimeout(function(){console.log(1)},2);
setTimeout(function(){console.log(2)},0);


输出的结果为 2、1,说明 2ms 是有效的。
另外 setTimeout 是从调用开始计时,到了时间就放入宏任务队列,我们来看下面的例子。

[JavaScript]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
11
var s = Date.now()
setTimeout(function timeout1() {
console.log(1)
}, 200)
while (Date.now() - s <= 200) {
}
setTimeout(function timeout2() {
console.log(2)
}, 0)


timeout1 200ms 后会放入到宏任务队列中
while 执行了 200ms,此时 timeout1 已经先添加到宏任务队列中,因此最终打印结果为:1、2
如果将 while 的时间设置小于 200ms,考虑到代码执行需要花费时间,将 while 的条件改为Date.now() - s <= 198
测试 while 执行只花费了 198ms,timeout2 会被先添加到宏任务队列中,因此最终打印结果会是:2、1


链接:https://juejin.cn/post/6844903848486797319