JS进阶系列-第八篇-事件循环

Posted by Kylen on 2019-07-26

前言

关于单线程的一些概念和详情可以看之前的文章。CPU可以并发来实现物理上的多个任务同时执行,其实单核CPU同一时刻只能做一件事,由系统协调各进程线程的有序执行。单线程的JS同样,同一时刻只能做一件事,由事件循环机制(event loop)决定任务的执行顺序。我们是写JS,无时无刻不再跟JS打交道,所以事件循环机制是一个必须掌握的知识点。

浏览器中的JS

我们说JS是单线程的,其实只是说JS引擎线程,有关浏览器的多进程和JS单线程可以看这篇文章从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理

也就是说浏览器中的单个tab页其实包含的线程有

  • GUI渲染线程
  • JS引擎线程
    • 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
    • JS引擎一直等待着任务队列中任务的到来,然后加以处理
  • 事件触发线程
    • 归属浏览器而不是JS引擎,用来控制事件循环。
    • 事件触发,如点击事件,将对应的事件的任务添加到任务队列中
    • setTimeout之类的定时触发任务也受事件触发线程管控
  • 定时触发器线程
    • setTimeout setInterval所在线程
    • 专门用来计数,计时完毕添加到任务队列
  • 异步http请求线程。
    如下图所示:
    js-advanced 2019-07-22 下午5.42.53.png

JS引擎线程和GUI渲染线程互斥,所以如果JS计算任务过重,会导致页面渲染阻塞。

关于web worker,创建worker是,JS引擎向浏览器申请一个子线程,子线程不能操作DOM,不能进行IO操作,完全受主线程控制。这些子线程没有一个线程完整的功能,所以JS依然可以说是单线程。

Event Loop

在此之前我们已经知道了执行上下文、执行栈、变量对象、作用域链的概念,如果还不明白可以看之前的文章JS进阶系列-第四篇-执行上下文

我们知道JS引擎每遇到一段可执行代码都会创建一个执行上下文,执行上下文以栈的形式组织就是执行栈。但是JS除了同步任务之外,还有异步任务和事件回调函数。遇到这些时,JS的执行栈如何变化呢?

首先我们排列一下JS中的异步任务:setTimeout setInterval setImmediate Promise,还有像node中的process.nextTick I/O,以及我们不怎么用的MutationObserver
监听各个事件(比如onclick onLoad)以及设置相应的回调函数,这些回调函数什么时候进入执行栈?

在了解了JS执行栈的前提下,来看一张图:
js-advanced 2019-07-23 上午11.22.03.png

JS引擎主要负责解析JS代码,在图中主要负责执行栈的部分,同时一直等待任务队列里的任务入栈。执行栈中的JS执行完毕之后,GUI线程渲染DOM。在这个过程中可以看出任务队列十分关键。任务队列中的任务从何而来呢?同步的任务随代码的运行直接进入执行栈,由JS引擎线程执行。而上面提到的异步任务和事件回调函数则会被其他线程添加到任务队列中。估计也都能猜到了,没错定制触发器线程负责setTimeout setInterval的时间计数,计时完毕将任务添加到任务队列;当事件触发时,事件触发线程将对应的回调函数添加到任务队列。事件循环其实就是不断检查任务队列并把其中的任务依次添加到执行栈中的机制。

可以想象,任务队列中的任务种类很多,从大类型上来看,主要分两种,也就是我们常说的宏任务(Macro task)和微任务(Micro task),现在也叫task和job,宏任务队列每种类型都有自己的队列,微任务队列只有一个队列(这个还是先保留意见吧,不同类型的微任务是有优先级的,比如process.nextTick和Promise在同一个事件循环中并不是按先后顺序执行的)。在浏览器环境和node环境中具体的任务有一定的差异,所以分开来解释:

浏览器环境

task:主代码块 setTimeout setInterval
job:Promise MutationObserver Object.observe(已废弃)

在浏览器环境中,事件循环的顺序是:

  • 从最外层的script(主代码块)开始第一次循环,全局上下文进入执行栈,随着代码的执行,一部分任务进入宏任务队列,一部分任务进入微任务队列。
  • 当第一次执行到最后(执行栈中只剩下全局上下文)时,微任务队列依次进入执行栈,直至微任务队列清空,此时完成一次循环。
  • 下一次循环从宏任务队列开始,依据各种宏任务的优先级规则选取一个宏任务进入执行栈,宏任务执行中遇到宏任务依据规则进入对应的宏任务队列,微任务进入微任务队列。当前宏任务执行完毕之后,微任务队列依次进入执行栈,直至微任务队列清空,此时此次循环。
  • 继续循环上一步
    这是浏览器的规则,node环境的规则和浏览器的规则有一些不同之处,后面会提到。不同的浏览器和版本可能也有些不同。

关于Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('global1');
new Promise(resolve => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise1_then1');
}).then(() => {
console.log('promise1_then2');
}).then(() => {
console.log('promise1_then3');
});
console.log('global2');
// 输出顺序:global1 promise1 global2 promise1_then1 promise1_then2 promise1_then3

Promisethen方法中的任务会进入微任务队列,等待resolve之后再执行。

关于setTimeoutsetInterval

1
2
3
4
5
6
7
8
9
10
console.log('global1');
const tempInterval = setInterval(() => {
console.log('setInterval1');
clearInterval(tempInterval);
}, 0);
setTimeout(()=>{
console.log('setTimeout1');
}, 0)
console.log('global2');
// 输出顺序:global1 global2 setInterval1 setTimeout1

setTimeoutsetInterval是一个类型的宏任务,执行的时候进入同一个任务队列
注意:setTimeout有最小时间间隔,HTML5标准默认是4ms,但每个浏览器实现的最小间隔可能不一样。
前面我们说了setTimeout setInterval的计时是有定时触发器线程控制的。对于setInterval如果回调函数的时间过长(大于设定的间隔),就会出现定时触发器线程把下一个回调添加到了任务队列,而上一个任务还没有运行完毕,这时候就会无间隔的一个个运行。

再整体看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
console.log('global1');
setTimeout(() => {
console.log('setTimeout1');
new Promise(resolve => {
console.log('promise1');
resolve();
})
.then(() => {
console.log('promise1_then1');
})
.then(() => {
console.log('promise1_then2');
});
}, 0);
new Promise(resolve => {
console.log('promise2');
setTimeout(() => {
console.log('setTimeout2');
}, 0);
resolve();
})
.then(() => {
console.log('promise2_then1');
})
.then(() => {
console.log('promise2_then2');
});

console.log('global2');
// 执行顺序:global1 promise2 global2 promise2_then1 promise2_then2 setTimeout1 promise1 promise1_then1 promise1_then2 setTimeout2

按照事件循环的顺序,首先执行主代码段:

  • 输出global1

  • setTimeout1进入宏任务中的setTimeout队列

  • 输出promise2,setTimeout2进入宏任务中的setTimeout队列

  • promise2_then1 和 promise2_then2进入微任务队列

  • 输出global2
    所以当执行完最后一行代码console.log('global2');时,执行栈中和任务队列的状态是:
    js-advanced 2019-07-24 下午6.10.08.png

  • 微任务依次进入执行栈执行,输出promise2_then1 promise2_then2。第一次循环结束

  • 检查宏任务队列,setTimeout队列中setTimeout1进入执行栈

  • 输出setTimeout1 promise1 ,promise1_then1 promise1_then1进入微任务队列

  • 此时执行栈和任务队列的状态是:
    js-advanced 2019-07-24 下午6.16.23.png

  • 微任务依次进入执行栈执行,输出promise1_then1 promise1_then2,此次循环结束

  • 检查宏任务队列,setTimeout队列中setTimeout2进入执行栈

  • 输出setTimeout2

  • 完毕

node环境

task:主代码块 setTimeout setInterval setImmediate
job:Promise process.nextTick

node环境的事件循环的顺序和浏览器环境大体一致,不同的地方在于,同类型的宏任务会依次进入执行栈直至次宏任务队列被清空,然后再处理产生的微任务队列中的任务。

process.nextTick是node独有的,setImmediatenode支持,IE浏览器也支持(这里不考虑)
看一个具体的例子

1
2
3
4
5
6
7
8
9
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
39
40
41
42
43
44
console.log('global1');
setTimeout(function() {
console.log('timeout1');
process.nextTick(function() {
console.log('timeout1_nextTick');
});
new Promise(function(resolve) {
console.log('timeout1_promise');
resolve();
}).then(function() {
console.log('timeout1_then');
});
});
process.nextTick(function() {
console.log('global1_nextTick');
});
new Promise(function(resolve) {
console.log('global1_promise');
resolve();
}).then(function() {
console.log('global1_then');
});
setTimeout(function() {
console.log('timeout2');
process.nextTick(function() {
console.log('timeout2_nextTick');
});
new Promise(function(resolve) {
console.log('timeout2_promise');
resolve();
}).then(function() {
console.log('timeout2_then');
});
});
process.nextTick(function() {
console.log('global2_nextTick');
});
new Promise(function(resolve) {
console.log('global2_promise');
resolve();
}).then(function() {
console.log('global2_then');
});
console.log('global2');

一眼看上去觉得很复杂,简单说下过程吧。同样的,首先执行主代码段:

  • 输出global1,setTimeout1进入宏任务队列,global1_nextTick进入微任务队列,输出 global1_promise, global1_then进入微任务队列,setTimeout2进入宏任务队列,global2_nextTick进入微任务队列,输出global2_promise,global2_then进入微任务队列,输出global2
  • 此时执行栈和任务队列的状态时,注意此时在图中process.nextTickPromise队列是分开的,微任务队列最终只有一个,不过在整合阶段还是分开的,并不是按照事件循环中的顺序。最终会整合成一个队列
    js-advanced 2019-07-25 下午6.25.46.png
  • 执行微任务队列,process.nextTick优先级更高,所以依次进入执行栈依次输出global1_nextTick global2_nextTick global1_then global2_then,此次循环结束
  • 下一次寻汗开始,查找当前宏任务队列,setTimeout1 setTimeout2依次进入执行栈直至setTimeout队列清空
  • 输出timeout1,timeout1_nextTick进入微任务队列,输出timeout1_promise,timeout1_then进入微任务队列
  • 输出timeout2,timeout2_nextTick进入微任务队列,输出timeout2_promise,timeout2_then进入微任务队列
  • 此时执行栈和任务队列的状态是:
    js-advanced 2019-07-25 下午6.32.13.png
  • 执行微任务队列,输出timeout1_nextTick timeout2_nextTick timeout1_then timeout2_then

所以整体的输出顺序是:global1 global1_promise global2_promise global2 global1_nextTick global2_nextTick global1_then global2_then timeout1 timeout1_promise timeout2 timeout2_promise timeout1_nextTick timeout2_nextTick timeout1_then timeout2_then

关于setTimeoutsetImmediate

1
2
3
4
5
6
7
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
})
// 随缘输出,有可能输出 setTimeout setImmediate,也有可能输出setImmediate setTimeout

这里主要一个Timer的判定的不精确的造成的,在事件循环中,当进入下一次循环的时间可能过了1ms,也可能没过,这是由系统决定的,不一定。

参考链接

从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
事件循环机制
浏览器事件循环机制(event loop)
详解JavaScript中的Event Loop(事件循环)机制
Node 定时器详解