浅谈 JavaScript Event Loop
前言
JavaScript固有单线程的特性,同一时间只能执行一个任务,并阻塞其他任务的执行。为了利用多核CPU的计算能力,HTML5提出的Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不可以操作DOM,所以并没有改变JS单线程的本质。
既然是单线程,那JS为什么可以执行异步操作,又是如何额外运行任务?本文简单介绍 JS 实现异步的原理—— Event Loop
(事件循环)。
1. 追本溯源
业务中经常需要异步或延时处理任务,但异步的任务却并不是等条件满足后在子线程中立即执行,甚至延迟的时间也不一定准确。
1.1 何时执行
这里以setTimeout为例:
/* code-1 */
console.log("start");
setTimeout(()=> console.log('timeout'), 0);
console.log("end");
// 14:00:35.918: start
// 14:00:35.920: end
// 14:00:35.921: timeout
/* code-2 */
console.log("start");
setTimeout(()=> console.log('timeout'), 0);
longTask(2000); // 为时2s的同步任务
console.log("end");
// 14:01:24.717: start
// 14:01:26.719: end
// 14:01:26.720: timeout
code-1
中setTimeout设置了0ms延时,回调函数似乎是立即执行了。然而当code-2
主线程中添加了为时2s的同步任务时,回调函数也延迟了2s的时间执行。可以看出,当主线程空闲时,才会执行异步的任务。
1.2 排队执行
当添加了多个定时器:
console.log("start");
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(`timeout-${i + 1}`);
}, 1000);
}
console.log("end");
// 14:02:17.693: start
// 14:02:17.696: end
// 14:02:18.699: timeout-1
// 14:02:18.700: timeout-2
// 14:02:18.700: timeout-3
三个异步任务似乎在主线程空闲后一起执行了。但如果异步任务执行的时长增加:
// ...
console.log(`timeout-${i + 1}`);
longTask(2000)
// ...
// 14:02:31.810: start
// 14:02:31.812: end
// 14:02:32.818: timeout-1
// 14:02:34.818: timeout-2
// 14:02:36.825: timeout-3
此时异步的任务执行时长大于setTimeout设置的延时,回调函数已不一定在setTimeout指定的时间执行。可见setTimeout的回调函数之间存在排队执行情况。
1.3 执行顺序
实现异步的任务源很多,例如setTimeout、setInterval、Promise.then等。
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise then'));
process.nextTick(() => console.log('nextTick'));
// nextTick
// promise then
// timeout
异步任务之间执行的顺序存在一定规则,可见任务不只是简单的排队。
2. 执行栈与任务队列
由于单线程的特性,所有任务需要排队,前一个任务结束,才会执行后一个任务。主线程可以挂起异步的任务,先运行后面的同步任务。异步任务被排入队列,等主线程空闲才一个一个执行。
主线程运行时,会形成一个执行栈
(execution context stack),所有的同步任务会被压入栈,异步任务会被放置到任务队列
(task queue)。当栈中的同步任务执行完时,主线程会查看任务队列中是否有任务,并将任务压入执行栈执行。
对于定时器之类的任务源,主线程首先要检查一下执行时间,事件只有到了规定的时间,才能返回主线程。
也存在当主线程同步任务耗时超过异步任务定时时间时,异步任务早已到了可以执行的时间,等主线程一空闲就会立即执行:
setTimeout(() => console.log('timeout-1'), 300);
setTimeout(() => console.log('timeout-2'), 200);
longTask(500);
setTimeout(() => console.log('timeout-3'), 100);
// 14:03:08.176: start
// 14:03:08.679: end
// 14:03:08.680: timeout-2
// 14:03:08.680: timeout-1
// 14:03:08.782: timeout-3
3. Event Loop
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop
(事件循环)。
事件循环只有一个,但任务队列可能有多个。事件循环中的异步队列可以分为:macro(宏任务)队列和 micro(微任务)队列。
- 宏任务主要包括:script(整体代码)、setTimeout、setInterval、setImmediate(NodeJS)、I/O操作、UI rendering(浏览器)等
- 微任务主要包括:process.nextTick(NodeJS)、Promise.then、Object.observer、MutationObserver(html5)等。
3.1 浏览器的 Event Loop
事件循环中,每一次循环称为 tick
,每一次 tick ,执行栈选择最先进入队列的宏任务 macroTask(通常是script整体代码),如果有则执行;执行完一个macroTask,检查是否存在微任务 microtask,如果存在则不停的执行,直至清空 microtask 队列。
举个🌰:
console.log("start");
// timeout1
setTimeout(() => {
console.log('timeout-1');
new Promise(function (resolve) {
console.log('timeout-1 promise');
resolve()
}).then(() => {
console.log('timeout-1 then')
})
}, 2000);
// timout2 loop
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log('timout-2 loop: ' + i)
}, i * 1000);
}
// promise1
new Promise(function (resolve) {
console.log('promise1');
resolve()
}).then(() => {
console.log('then1')
});
// timeout3
setTimeout(() => {
console.log('timeout-3');
new Promise(function (resolve) {
console.log('timeout-3 promise');
resolve()
}).then(() => {
console.log('timeout-3 then')
})
}, 1000);
// promise2
new Promise(function (resolve) {
console.log('promise2');
resolve()
}).then(() => {
console.log('then2');
});
console.log("end");
分析:
事件循环 | 当前执行 | 宏队列 | 微队列 |
---|---|---|---|
第一次tick | script(整体代码) | timeout-3(1s) timeout2-loop3(3s) timeout2-loop2(2s) timeout2-loop1(1s) timeout1(2s) |
promise2.then promise1.then |
第二次tick | timeout2-loop1(1s) | timeout-3(1s) timeout2-loop3(3s) timeout2-loop2(2s) timeout1(2s) |
|
第三次tick | timeout-3(1s) | timeout2-loop3(3s) timeout2-loop2(2s) timeout1(2s) |
timeout-3.promise.then |
第四次tick | timeout1(2s) | timeout2-loop3(3s) timeout2-loop2(2s)< |
timeout-1.promise.then |
第五次tick | timeout2-loop2(2s) | timeout2-loop3(3s) | |
第四次tick | timeout2-loop3(3s) |
Promise构造函数第一个参数在new的时候会直接执行;队列先进先出;每一次tick结束,微队列都会被清空。
输出:
promise1
promise2
then1
then2
timout-2 loop: 1
timeout-3
timeout-3 promise
timeout-3 then
timeout-1
timeout-1 promise
timeout-1 then
timout-2 loop: 2
timout-2 loop: 3
注意:浏览器中,宏队列中任务是一个一个执行,微队列的任务是一次清空。
3.2 NodeJS的Event Loop
上文提到的NodeJS中特有的方法,setImmediate,process.nextTick,在队列中有特殊的处理。
- process.nextTick:可以在当前执行栈的尾部——下一次Event Loop(主线程读取任务队列)之前——触发回调函数,所以它指定的任务总是在当前宏任务执行完后立即执行。
- setImmediate:可以在当前任务队列的尾部添加事件,它指定的任务总是在下一次时间循环时执行,与setTimeout(fn,0)类似。
需要注意的是,setImmediate与setTimeout(fn,0)的执行顺序并不确定,运行结果可能是 immediate->timeout,也可能是 timeout->immediate。但当我们在外封装一层setImmediate的时候,setImmediate的回调函数总是先执行。
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
// =>
setImmediate(() => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
这是因为 NodeJS 和浏览器的 Event Loop 机制存在差异。
Node.js采用V8作为JS的解析引擎,而I/O处理方面使用了自己设计的libuv。libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
libuv库负责Node API的执行,将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
libuv引擎中的事件循环阶段
可分为 6 个阶段:
- timers 定时器检测阶段:这个阶段执行timer(setTimeout、setInterval)的回调,由 poll 阶段控制
- I/O callbacks 事件回调阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
- idle, prepare 闲置阶段:仅node内部使用
- poll 轮询阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
- check 阶段:执行 setImmediate() 的回调
- close callbacks 关闭事件回调阶段:执行 socket 的 close 事件回调
事件循环原理
=> node 的初始化
- 初始化 node 环境;输入代码;process.nextTick 回调;执行 microtasks。
=> 进入 Event Loop
- 进入 timers 阶段
- 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
- 执行process.nextTick 任务。执行microtask。退出该阶段。
- 进入IO callbacks 阶段
- 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
- 执行process.nextTick 任务。执行microtask。退出该阶段。
- 进入 idle,prepare 阶段(其他与编程关系不大)
- 执行process.nextTick 任务。执行microtask。退出该阶段。
- 进入 poll 阶段
- 首先检查是否存在尚未完成的回调,如果存在:
- 第一种情况:
- 如果有可用回调(可用回调包含到期的定时器还有一些IO事件等),执行所有可用回调。
- 执行process.nextTick 任务。执行microtask。退出该阶段。
- 第二种情况,没有可用回调:
- 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
- 第一种情况:
- 如果不存在尚未完成的回调,退出。
- 首先检查是否存在尚未完成的回调,如果存在:
- 进入 check 阶段。
- 如果有immediate回调,则执行所有immediate回调。
- 执行process.nextTick 任务。执行microtask。退出该阶段。
- 进入 closing 阶段。
- 如果有immediate回调,则执行所有immediate回调。
- 执行process.nextTick 任务。执行microtask。退出该阶段。
- 检查是否有活跃的 handles(定时器、IO等事件句柄)。
- 如果有,继续下一轮循环。
- 如果没有,结束事件循环,退出程序。
setImmediate(() => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
再看这段代码,由于第一个setImmediate运行时已经在 check 阶段,所以immediate回调先执行。
与浏览器的差异
- 在事件循环的每一个子阶段退出之前都会检查是否有 process.nextTick 回调、是否有 microtaks并执行
- timers阶段可执行的setTimeout/setInterval都会依次执行,而浏览器端每次只执行一个宏任务
回到3.1的🌰,在NodeJS下执行的过程:
事件循环 | 当前执行 | 宏队列 | 微队列 |
---|---|---|---|
第一次tick | script(整体代码) | timeout-3(1s) timeout2-loop3(3s) timeout2-loop2(2s) timeout2-loop1(1s) timeout1(2s) |
promise2.then promise1.then |
第二次tick | timeout2-loop1(1s) timeout-3(1s) |
timeout2-loop3(3s) timeout2-loop2(2s) timeout1(2s) |
timeout-3.promise.then |
第三次tick | timeout1(2s) timeout2-loop2(2s) |
timeout2-loop3(3s) | timeout-1.promise.then |
第四次tick | timeout2-loop3(3s) | ||
输出: |
14:42:24.520: start
14:42:24.522: promise1
14:42:24.523: promise2
14:42:24.523: end
14:42:24.523: then1
14:42:24.523: then2
14:42:25.528: timout-2 loop: 1
14:42:25.528: timeout-3
14:42:25.528: timeout-3 promise
14:42:25.528: timeout-3 then
14:42:26.524: timeout-1
14:42:26.524: timeout-1 promise
14:42:26.524: timout-2 loop: 2
14:42:26.524: timeout-1 then
14:42:27.522: timout-2 loop: 3
4. 练习
一
setTimeout(() => {
console.log(1);
Promise.resolve().then(() => console.log(2))
}, 0);
Promise.resolve().then(() => {
console.log(3);
setTimeout(() => console.log(4), 0)
});
二
setImmediate(() => {
console.log(1);
setTimeout(() => console.log(2), 100);
setImmediate(() => console.log(3));
process.nextTick(() => console.log(4))
});
setImmediate(() => {
console.log(5);
setTimeout(() => console.log(6), 100);
setImmediate(() => console.log(7));
process.nextTick(() => console.log(8))
});
三
setImmediate(() => {
console.log(1);
setTimeout(() => console.log(2), 100);
setImmediate(() => console.log(3));
process.nextTick(() => console.log(4))
});
process.nextTick(() => {
console.log(5);
setTimeout(() => console.log(6), 100);
setImmediate(() => console.log(7));
process.nextTick(() =>console.log(8))
});
答案:
3 1 4 2
1 5 4 8 3 7 2 6
5 8 1 7 4 3 6 2