前两天老师在课堂上讲到一些关于EventLoop的知识,顺便提问了我几个问题,当时只是简单的回答了JavaScript的事件执行机制,自我还感觉良好,但是老师后面的几个追问,当时确实被问的一脸懵逼,哑口无言,甚是尴尬。下了课之后再仔细查阅资料,发现之前对EventLoop了解的还是太浅显了,所以打算写篇文章,让自己对EventLoop理解更加透彻。
什么是EventLoop?
event loop是一个执行模型,是计算机系统的一种运行机制,在不同的地方有不同的实现。浏览器和NodeJS基于不同的技术实现了各自的Event Loop。
宏任务和微任务
宏任务
script
全部代码、setTimeout
、setInterval
、setImmediate
(浏览器暂时不支持,只有IE10支持,具体可见MDN
)、I/O
、UI Rendering。
- 宏任务又叫宏队列,macrotask,也叫tasks。 一些异步任务的回调会依次进入macrotask queue,等待后续被调用,这些异步任务包括:
setTimeout,setInterval,setImmediate (Node独有), requestAnimationFrame (浏览器独有),I/O,UI rendering (浏览器独有)
微任务
Process.nextTick(Node独有)
、Promise
、Object.observe(废弃) 、 async await
、MutationObserver
- 微队列,microtask,也叫jobs。 另一些异步任务的回调会依次进入micro task queue,等待后续被调用,这些异步任务包括:
process.nextTick (Node独有),Promise,Object.observe,MutationObserver(注:这里只针对浏览器和NodeJS
注:process.nextTick方法可以在当前"执行栈"的尾部,下一次Event Loop(主线程读取"任务队列")之前触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行。
浏览器的EventLoop
在浏览器中,EventLoop是这样执行的:
- 执行全局Script同步代码,这些同步代码有一些是同步语句,有一些是异步语句(比如setTimeout等);
- 全局Script同步代码执行完毕后,调用栈Stack会清空,查看是否会有异步执行
- 从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
- 继续取出位于队首的任务,放入调用栈Stack中执行,以此类推,直到直到把microtask queue中的所有任务都执行完毕。注意,如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行;
- microtask queue中的所有任务都执行完毕,此时microtask queue为空队列,调用栈Stack也为空,有必要就渲染页面;
- 取出宏队列macrotask queue中位于队首的任务,放入Stack中执行;
- 执行完毕后,调用栈Stack为空;
- 重复第3-7个步骤;
- 重复第3-7个步骤;
- ......
以下面的代码为例,会对浏览器的EventLoop理解更透彻:
console.log('a');
setTimeout(() => {
console.log('b');
Promise.resolve().then(() => {
console.log('c')
});
});
new Promise((resolve, reject) => {
console.log('d')
resolve('e')
}).then((data) => {
console.log(data);
})
setTimeout(() => {
console.log('f');
})
console.log('g');
//预期结果:
a
d
g
e
b
c
f
实际输出:
接下来我们仔细分析下这段代码的执行过程:
step1:
//Queue:
Stack Queue: [console]
Macrotask Queue: []
Microtask Queue: []
console.log('a')
打印结果:a
step2:
//Queue:
Stack Queue: [setTimeout]
Macrotask Queue: []
Microtask Queue: []
setTimeout(() => {
console.log('b');
Promise.resolve().then(() => {
console.log('c')
});
});
上文说到setTimeout属于macrotask,所以将SetTimeout的回调函数(这里为了便于区分,称作callback1)放到macrotask queue中。
//打印结果:
a
step3:
//Queue:
Stack Queue: [Promise]
Macrotask Queue: [callback1]
Microtask Queue: []
new Promise((resolve, reject) => {
console.log('d') //注意这里会同步执行
resolve('e')
}).then((data) => {
console.log(data);
})
我们把promise的回调函数称为callback2,由于promise属于microtask,所以放到micotask queue中。
//打印结果:
a
d
step4:
//Queue:
Stack Queue: [setTimeout]
Macrotask Queue: [callback1]
Microtask Queue: [callback2]
setTimeout(() => {
console.log('f');
})
setTimeout属于macrotask,所以将SetTimeout的回调函数(callback3)放到macrotask queue中。
//打印结果:
a
d
step5:
//Queue:
Stack Queue: [console]
Macrotask Queue: [callback1,callback3]
Microtask Queue: [callback2]
console.log('g');
//打印结果:
a
d
g
到这里为止,全局Script代码执行完了,进入下一个步骤,从microtask queue中依次取出任务执行,直到microtask queue队列为空。
step6:
此时执行callback2
//Queue:
Stack Queue: [promise]
Macrotask Queue: [callback1,callback3]
Microtask Queue: []
new Promise((resolve, reject) => {
console.log('d')
resolve('e')
}).then((data) => {
console.log(data);
})
//打印结果:
a
d
g
e
step7:microtask queue中只有一个任务,执行完后开始从宏任务队列macrotask queue中取位于队首的任务执行。
//Queue:
Stack Queue: [setTimeout]
Macrotask Queue: [callback3]
Microtask Queue: []
setTimeout(() => {
console.log('b');
Promise.resolve().then(() => {
console.log('c')
});
});
但是,执行callback1的时候又遇到了另一个Promise,那么这里应该怎么处理呢?Promise异步执行完后在microtask queue中又注册了一个callback4回调函数。
//Queue:
Stack Queue: [setTimeout]
Macrotask Queue: [callback3]
Microtask Queue: [callback4]
//打印结果:
a
d
g
e
b
step8:
//Queue:
Stack Queue: [promise]
Macrotask Queue: [callback3]
Microtask Queue: []
Promise.resolve().then(() => {
console.log('c');
})
//打印结果:
a
d
g
e
b
c
step9:
//Queue:
Stack Queue: [setTimeout]
Macrotask Queue: []
Microtask Queue: []
setTimeout(() => {
console.log('f');
})
//打印结果:
a
d
g
e
b
c
f
全部执行完后,Stack Queue为空,Macrotask Queue为空,Micro Queue为空。
//Queue:
Stack Queue: []
Macrotask Queue: []
Microtask Queue: []
终于一步一步的分析完了,接下来看下面的这个例子。
例2:
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3)
});
});
new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data);
Promise.resolve().then(() => {
console.log(6)
}).then(() => {
console.log(7)
setTimeout(() => {
console.log(8)
}, 0);
});
})
setTimeout(() => {
console.log(9);
})
console.log(10);
输出结果:
相信大家都答对了,这里的关键在前面已经提过:
在执行微队列microtask queue中任务的时候,如果又产生了microtask,那么会继续添加到队列的末尾,也会在这个周期执行,直到microtask queue为空停止。
注:当然如果你在microtask中不断的产生microtask,那么其他宏任务macrotask就无法执行了,但是这个操作也不是无限的,拿NodeJS中的微任务process.nextTick()来说,它的上限是1000个。
NodeJS中的Event Loop
Node
中的Event Loop
是基于libuv
实现的,而libuv
是 Node
的新跨平台抽象层,libuv使用异步,事件驱动的编程方式,核心是提供i/o
的事件循环和异步回调。libuv的API
包含有时间,非阻塞的网络,异步文件操作,子进程等等。 Event Loop
就是在libuv
中实现的。
NodeJS的Event Loop中,执行宏队列的回调任务有6个阶段,如下图:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
各个阶段执行的任务如下:
- timers阶段:这个阶段执行setTimeout和setInterval预定的callback
- I/O callback阶段:执行除了close事件的callbacks、被timers设定的callbacks、setImmediate()设定的callbacks这些之外的callbacks
- idle, prepare阶段:仅node内部使用
- poll阶段:获取新的I/O事件,适当的条件下node将阻塞在这里
- check阶段:执行setImmediate()设定的callbacks
- close callbacks阶段:执行socket.on('close', ....)这些callbacks
poll
该poll阶段有两个主要功能:
- 执行
I/O
回调。 - 处理轮询队列中的事件。
当事件循环进入poll
阶段并且在timers
中没有可以执行定时器时,将发生以下两种情况之一
- 如果
poll
队列不为空,则事件循环将遍历其同步执行它们的callback
队列,直到队列为空,或者达到system-dependent
(系统相关限制)。
如果poll
队列为空,则会发生以下两种情况之一
-
如果有
setImmediate()
回调需要执行,则会立即停止执行poll
阶段并进入执行check
阶段以执行回调。 -
如果没有
setImmediate()
回到需要执行,poll阶段将等待callback
被添加到队列中,然后立即执行。
当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
check
此阶段允许人员在poll阶段完成后立即执行回调。
如果poll
阶段闲置并且script
已排队setImmediate()
,则事件循环到达check阶段执行而不是继续等待。
setImmediate()
实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。它使用libuv API
来调度在poll
阶段完成后执行的回调。
通常,当代码被执行时,事件循环最终将达到poll
阶段,它将等待传入连接,请求等。
但是,如果已经调度了回调setImmediate()
,并且轮询阶段变为空闲,则它将结束并且到达check
阶段,而不是等待poll
事件。
由上面的介绍可以看到,回调事件主要位于4个macrotask queue中:
- Timers Queue
- IO Callbacks Queue
- Check Queue
- Close Callbacks Queue
这4个都属于宏队列,但是在浏览器中,可以认为只有一个宏队列,所有的macrotask都会被加到这一个宏队列中,但是在NodeJS中,不同的macrotask会被放置在不同的宏队列中。
NodeJS中微队列主要有2个:
- Next Tick Queue:是放置process.nextTick(callback)的回调任务的
- Other Micro Queue:放置其他microtask,比如Promise等
在浏览器中,也可以认为只有一个微队列,所有的microtask都会被加到这一个微队列中,但是在NodeJS中,不同的microtask会被放置在不同的微队列中。
所以NodeJS的Event Loop过程:
- 执行全局Script的同步代码
- 执行microtask微任务,先执行所有Next Tick Queue中的所有任务,再执行Other Microtask Queue中的所有任务
- 开始执行macrotask宏任务,共6个阶段,从第1个阶段开始执行相应每一个阶段macrotask中的所有任务,注意,这里是所有每个阶段宏任务队列的所有任务,在浏览器的Event Loop中是只取宏队列的第一个任务出来执行,每一个阶段的macrotask任务执行完毕后,开始执行微任务,也就是步骤2
- Timers Queue -> 步骤2 -> I/O Queue -> 步骤2 -> Check Queue -> 步骤2 -> Close Callback Queue -> 步骤2 -> Timers Queue ......
下面看一个例子:
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
process.nextTick(function() {
console.log('6');
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
// 正确答案
1
7
6
8
2
4
9
11
3
10
5
12
setTimeout 对比 setImmediate
- setTimeout(fn, 0)在Timers阶段执行,并且是在poll阶段进行判断是否达到指定的timer时间才会执行
- setImmediate(fn)在Check阶段执行
两者的执行顺序要根据当前的执行环境才能确定:
- 如果两者都在主模块(main module)调用,那么执行先后取决于进程性能,顺序随机
- 如果两者都不在主模块调用,即在一个I/O Circle中调用,那么setImmediate的回调永远先执行,因为会先到Check阶段
setImmediate 对比 process.nextTick
- setImmediate(fn)的回调任务会插入到宏队列Check Queue中
- process.nextTick(fn)的回调任务会插入到微队列Next Tick Queue中
- process.nextTick(fn)调用深度有限制,上限是1000,而setImmedaite则没有