JS 中任务执行顺序
最近看面试题经常会看到一些关于宏任务、微任务执行顺序的题,结合了一些掘金大佬的博客写写自己的理解。
2021年7月24日
更新如下:
- 更新事件循环的机制;
- 更新宏任务/微任务的区别;
- 更新JS单线程和执行宿主多线程的关系;
event-loop
2021年7月24日
更新:
关于事件循环:
调用栈和事件队列
- 调用栈
调用栈call stack
负责跟踪所有需要执行的代码;每当函数执行完成时,就从堆栈中弹出该执行函数;如果有代码要执行,就push进去;
- 事件队列
事件队列event queue
负责将新的函数发送到队列里处理,是一种队列的数据结构;
-
当事件队列中执行了异步函数时,会将其发送到浏览器API中,让浏览器的其他线程来执行对应的操作;比如
setTimeout
定时器触发线程,HTTP
请求线程;这些异步操作通常带有回调函数,异步方法执行完了之后,会将回调的结果发送到事件队列里; -
JS本身是单线程的,由宿主(浏览器,node)API充当单独的线程;事件循环正好促进了这一过程,事件循环会不断的检查调用栈是否为空,如果为空,则从事件队列中添加新的函数进入调用栈;如果不为空,则处理当前函数的调用;
例题
因为 JS 是单线程执行的,所以在同一时刻只有一个任务在执行;我们得搞清楚当前时刻是哪个函数执行在占用主线程,才能解决输出先后顺序的问题。
先上例题:
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(1);
setTimeout(() => {
console.log(2);
}, 1000);
async function fn() {
console.log(3);
setTimeout(() => {
console.log(4);
}, 20);
return Promise.reject();
}
async function run() {
console.log(5);
await fn();
console.log(6);
}
run();
//需要执行150ms左右
for (let i = 0; i < 90000000; i++) {}
setTimeout(() => {
console.log(7);
new Promise(resolve => {
console.log(8);
resolve();
}).then(() => {
console.log(9);
});
}, 0);
console.log(10);
// 1 5 3 10 4 7 8 9 2
这题涉及到了同步/异步任务执行顺序的问题,还有Promise, await
的问题;
先介绍下事件执行机制吧,如下图,事件会被区分为同步/异步任务;同步任务不用多说,顺序执行,在执行同步任务时,如果有异步任务要执行,则会按照下图,在event-table
中注册回调函数,说白了就是放任异步任务执行(因为异步任务通常是要等待其他事件执行完毕),在等待这段时间,引擎会继续执行同步任务,在异步任务执行完了之后,把回调函数放入event-queue
队列里,在恰当的时间从队列里取出(通常是执行完了所有同步任务),执行回调函数。
而异步任务被分成两种,一种是宏任务(macro task),一种是微任务(micro task),这两种任务的执行顺序是:每次从调用队列中取出一个宏任务,执行完了之后,执行当前微任务队列中所有的微任务(清空微任务),然后再取出一个宏任务,如此循环;另外有一点需要注意,主线程执行完同步任务后,会优先清空微任务队列。
基于微任务的技术有 MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术,本题中 resolve()、await fn()都是微任务。不管宏任务是否到达时间,以及放置的先后顺序,每次主线程执行栈为空的时候,引擎会优先处理微任务队列,处理完微任务队列里的所有任务,再去处理宏任务。
宏任务、微任务
在事件循环时提到过,异步任务是一个队列,实际上在JS实现时,异步任务队列被分成两类队列——宏任务,微任务队列;
这两类队列执行顺序有差异,一般认为宏任务在上次事件循环结束后,下次事件循环开启时执行;而微任务是在当前事件循环的结束前执行;所以微任务一般比宏任务先执行;并且微任务队列只有一个,而宏任务队列可能有多个。
接下来看一下两种异步任务的分类;
- 宏任务
event | 浏览器环境 | node 环境 |
---|---|---|
I/O | √ | √ |
setTimeout | √ | √ |
setInterval | √ | √ |
setImmediate | × | √ |
- 微任务
event | 浏览器环境 | node 环境 |
---|---|---|
process.nextTick | × | √ |
MutationObserver | √ | × |
Promise.then catch finally | √ | √ |
- 宏任务:包括整体代码 script,setTimeout,setInterval
- 微任务:Promise.then(非 new Promise),process.nextTick(node 中)
注意,Promise
对象的then
和catch
才是微任务,本身的内部代码不是。像例题中的new Promise(res => { console.log() })
其实是同步代码。
另外关于async/await
,async
函数中在await
之前的代码都是同步代码,紧跟在await
后面的代码,无论是否返回Promise
对象(因为就算不显式返回Promise
,async
函数也会隐式返回Promise
对象的),只有当await
语句执行完毕后,才会把await
语句后面的语句加入到微任务队列中。
2021年7月24日
更新如下:
简单小结一下微任务和宏任务的本质区别。
-
宏任务特征:有明确的异步任务需要执行和回调;需要其他异步线程支持。
-
微任务特征:没有明确的异步任务需要执行,只有回调;不需要其他异步线程支持。
定时器误差
2021年7月24日
更新如下:
在事件循环中,总是先执行同步任务,然后才会去事件队列中取出异步回调执行;所以当执行setTimeout
时,浏览器会启动新的定时器线程来计时,计时结束后触发定时器事件,把回调函数push进事件队列中(这里是宏任务队列),等待JS主线程取出来执行;因为这是个被动的状态,也就是说主线程如果还在执行同步任务,那么此时的宏任务只能挂起等待,所以会造成计时器不准确的问题。
换句话说,同步任务执行的时间越长,计时器的误差就越大;而且不仅是同步任务,由于微任务会在宏任务之前被执行,所以微任务也会影响计时器;再极端点,如果同步任务里出现了死循环,或者微任务里又在调用其他微任务,那么宏任务就一直不会被执行,所以提升主线程的代码效率是十分重要的。
一个很简单的场景就是我们界面上有一个时钟精确到秒,每秒更新一次时间。你会发现有时候秒数会直接跳过 2 秒间隔,就是这个原因。
关于requestAnimationFrame
2021年7月24日
更新如下:
在微任务队列执行完成后,也就是一次事件循环结束后,浏览器会执行视图渲染,浏览器也会对视图渲染进行优化,比如像Vue.nextTick
那样合并多次渲染,最后只集中做一次视图重绘;所以视图更新并不一定是每次操作DOM都会立刻刷新视图;并且,在视图更新前,会先执行requestAnimationFrame
的回调,所以其实把这个函数单独归类为宏任务或者微任务都不靠谱,它应该既不是微任务,也不是宏任务。
关于Promise
对象的执行顺序
先看个例题吧,例题里头全是Promise
对象,我是看了 Stack Overflow 上的解答,才比较好的理解这玩意的,Stack Overflow
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
new Promise(resolve => {
resolve(); // PROMISE A
})
.then(() => {
// 1st then
new Promise(resolve => {
resolve(); // PROMISE B
})
.then(() => {
// inner 1st then
console.log(1); //PROMISE C
})
.then(() => {
// inner 2nd then
console.log(2);
})
.then(() => {
// inner 3rd then
console.log(3.1);
});
})
.then(() => {
// 2nd then
console.log(1.1); // PROMISE D
new Promise(resolve => {
resolve();
});
});
最高票的回答已经解释了,promise A
是个同步任务,执行后会把第一个then
放入微任务队列,执行完了之后发现主进程没有其他任务了,所以会执行微任务then
;里面又new Promise
了,这是微任务里的同步任务,所以会执行promise B
的resolve
,此时会把内嵌的inner 1st then
放入微任务队列;
这时其实已经清空了当前的微任务,所以会去看看还有没有宏任务(没了),所以主进程应该会继续往下运行,即2nd then
(因为此时1st then
已经是fulfilled
状态了,不信可以看看下面的例子),这其实挺符合逻辑的,1st then
并没有未捕获的异常抛出,会被当成Promise
对象的状态已经确定了,所以会执行2nd then
;
说是执行可能不太准确,应该是把2nd then
放入到微任务队列,因为then
是微任务,是吧;
此时发现同步任务已经结束了,会去清空微任务队列,微任务队列有俩任务,一个是inner 1st then
的代码,另一个就是2nd then
的代码,按队列顺序执行,打印出1
,把inner 2nd then
放入微任务队列;打印出1.1
,new Promise
把整个Promise
对象的状态改写为resolved
;检查没有同步任务了,没有宏任务了,继续执行微任务,打印2
,把inner 3rd then
放入微任务队列;检查没有同步任务了,没有宏任务了,继续执行微任务,打印3
,结束了。
下面是一个更复杂的例子,就不逐条解释了。
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
45
46
47
48
49
50
51
52
53
new Promise(resolve => {
resolve();
})
.then(() => {
new Promise(resolve => {
resolve();
})
.then(() => {
console.log(1);
})
.then(() => {
console.log(2);
})
.then(() => {
console.log(3.1);
});
})
.then(() => {
console.log(1.1);
new Promise(resolve => {
resolve();
})
.then(() => {
new Promise(resolve => {
resolve();
})
.then(() => {
console.log(4);
})
.then(() => {
console.log(6);
});
})
.then(() => {
console.log(5);
});
})
.then(() => {
console.log(3);
});
console.log(0);
/*
0
1
1.1
2
3
3.1
4
5
6
*/
刚刚说主进程会继续往下执行到2nd then
,看下面的例子,在inner 1st then
里面抛出了一个异常,2nd then
的时候resolve
带了参数123123
,运行后会发现,1.1
被打印出来,然后Promise {<fulfilled>: 123123}
,这就证明了确实主进程认为第一个then
已经结束了。
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
new Promise(resolve => {
resolve(); // PROMISE A
})
.then(() => {
// 1st then
new Promise(resolve => {
resolve(); // PROMISE B
})
.then(() => {
// inner 1st then
throw new Error(1); //PROMISE C
})
.then(() => {
console.log(2);
})
.then(() => {
console.log(3.1);
});
})
.then(() => {
// 2nd then
console.log(1.1); // PROMISE D
return new Promise(resolve => {
resolve(123123);
});
});
NodeJS的事件循环
由于JS引擎本身不实现事件循环,是由宿主实现的,所以在Nodejs
中也是循环+任务队列的流程,以及微任务先于宏任务,大致表现和浏览器一致;在Node
中主要新增了一些任务类型和任务阶段。
Node中的异步方法
因为Node底层的JS引擎也是V8,所以大部分浏览器的异步方法,在Node中也一样,新增了以下的:
- 文件I/O:分为同步读取和异步读取;
setImmediate()
:和setTimeout
间隔时间设置0ms类似,在某些同步任务完成后立即执行;process.nextTick
:在某些同步任务完成后立即执行;server.close, socket.on('close', ...)
:关闭回调;
Node事件循环模型
Node的跨平台能力和事件循环机制,是基于基础库libuv
实现的,这个库是事件驱动的,并且封装了跨平台的API实现;
V8引擎将JS代码解析后调用对应的Node API
,这些API
会将任务交给libuv
分配,并将执行结果返回给V8
引擎;所以其实Node的事件循环主要在libuv
中完成的;
事件循环各阶段
Node的事件循环总共分成6个阶段,当这6阶段执行完一次后,才可以算是执行了一次事件循环;
Timer
:这阶段执行setTimeout, setInterval
;I/O callback
:执行系统级别的回调函数,比如TCP连接失败的回调;idle, prepare
:Node内部闲置,准备阶段,基本可以忽略;poll
:这个阶段是最主要的,几乎和I/O
相关的所有回调都在这个阶段执行;check
:执行setImmediate
的回调函数;close callback
:执行关闭请求的回调函数,比如socket.on('close')
这里着重讲下poll
阶段:
在timers
阶段完成后,应该会有个setTimeout, setInterval
的间隔时间T
,而到了poll
阶段,会在执行任务之后,检查当前阶段执行的时间是否超过了T
,也就是要去判断是否有timer
到期了需要执行回调;到期了就会结束当前poll
阶段,直接执行timers
阶段;
poll
阶段主循环为,如果poll
队列不为空,就从事件队列里拿出一个任务并执行,执行完后检查是否有到期任务,没有就接着循环,有的话就结束poll
阶段;
如果队列为空,那会去检查下一阶段的任务队列,也就是check
阶段的setImmediate
队列是否为空,如果check
阶段还有任务,就结束poll
阶段进入到check
阶段,否则就等待间隔时间T
;
在等待过程中,如果出现了新的回调函数,则会push到poll
队列里,进入poll
队列主循环;等待过程中没有出现新的回调函数的话,即等了T
个时间,就会结束本阶段。
对于每个阶段来说,每个阶段都有自己的宏任务和微任务队列
对于同一阶段的宏任务和微任务的执行顺序,在
Node-11
之后,顺序保持和浏览器端一致,即每次执行完宏任务,会先检查一下微任务队列;
对于不同阶段的任务队列,会按照上述的阶段顺序执行。
参考例子1,不同阶段任务:
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
const fs = require('fs');
fs.readFile(__filename, (data) => {
// poll(I/O 回调) 阶段
console.log('readFile')
Promise.resolve().then(() => {
console.error('promise1')
})
Promise.resolve().then(() => {
console.error('promise2')
})
});
setTimeout(() => {
// timers 阶段
console.log('timeout');
Promise.resolve().then(() => {
console.error('promise3')
})
Promise.resolve().then(() => {
console.error('promise4')
})
}, 0);
// 下面代码只是为了同步阻塞1秒钟,确保上面的异步任务已经准备好了
var startTime = new Date().getTime();
var endTime = startTime;
while(endTime - startTime < 1000) {
endTime = new Date().getTime();
}
// 最终输出 timeout promise3 promise4 readFile promise1 promise2
参考例子2,同阶段任务;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
setTimeout(() => {
console.log('timeout1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0);
setTimeout(() => {
console.log('timeout2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0);
// 浏览器运行,nodejs-11的版本
// “timeout1”、“promise1”、“timeout2”、“promise2”
// nodejs-11之前的版本
// “timeout1”、“timeout2”、“promise1”、“promise2”
NodeJS一些特殊的异步API
process.nextTick
,前面也提到过,这个方法会在同步任务完成后立即执行;先于其他异步任务,在当前同步代码执行完成后,不管其他异步任务,先执行nextTick
的回调。setImmediate, setTimeout
,这两个方法在同一个事件循环内处于两个不同的阶段,关于他们的执行顺序,要看他们在事件循环中处于什么阶段;
setTimeout(cb, 0)
其实在node中相当于setTimeout(cb, 1)
,由于1ms的误差,导致这俩在某个阶段会出现不同的输出
例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
// 输出:timeout、 setImmediate
// 也可能输出:setImmediate、 timeout
// 同时在poll阶段
const fs = require('fs');
fs.readFile(__filename, (data) => {
console.log('readFile');
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
// 输出:readFile、setImmediate、timeout
遇到一个不是很理解的;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const fs = require('fs')
setTimeout(() => {
console.log('timeout');
}, 10)
setImmediate(() => {
console.log('immediate');
})
fs.readFile(__filename, (err, data) => {
console.log('read');
})
const start = new Date.now()
let end = start
// 主线程暂停200ms
while (end - start > 200) {
end = new Date.now()
}
// timeout
// immediate
// read
正常按我自己的理解,immediate
应该比read
后输出,check
阶段处于poll
阶段后面,所以有点不懂,后续看懂了再来更新(TODO)。
参考文章
- https://kaiwu.lagou.com/course/courseInfo.htm?courseId=1076#/sale
- https://juejin.im/post/6844903657264136200
- https://stackoverflow.com/questions/58270410/how-to-understand-this-promise-execution-order
- https://mp.weixin.qq.com/s/kW05G0n1U58GfSQdPKyKaA