深入理解 Event Loop
一、同步和异步
JavaScript 是单线程的,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。
如果前一个任务耗时很长,后一个任务不得不一直等着。
如果排队是因为计算量大,CPU 忙不过来还能理解,但是很多时候 CPU 是空闲的,但由于 IO(输入输出)设备很慢(例如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。
因此,JavaScript 语言的设计者意识到主线程完全可以不管 IO 设备,直接挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种:
- 同步任务(synchronous)
- 异步任务(asynchronous)
1、同步任务
同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
2、异步任务
异步任务指的是,不进入主线程、而进入任务队列(task queue)的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
异步执行的运行机制:
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外还存在一个任务队列(task queue),只要异步任务有了运行结果,就在任务队列之中放置一个事件。
- 一旦执行栈中的同步任务执行完,就会读取并执行任务队列的异步任务。
- 主线程不断重复上面的第三步。
3、任务队列
任务队列(task queue)是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,任务队列上第一位的事件就自动进入主线程。
二、浏览器的 Event Loop
主线程从任务队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制称为 Event Loop(事件循环)
1、宏任务与微任务
浏览器端事件循环的异步队列有两种:
- 宏任务队列(macro-task)
- 微任务队列(micro-task)
当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,则读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。
常见的宏任务队列:
- script 代码块
- setTimeout
- setInterval
- I/O 操作
- UI 渲染
- ...
常见的微任务队列:
- new Promise().then(回调)
- callback
- Object.observe
- process.nextTick(Node 独有)
- MutationObserver(html5 新特性)
- ...
2、Event Loop 过程解析
再回过来看上面任务队列的流程图,一个完整的 Event Loop 过程,可以概括为以下阶段:
一开始执行栈为空,
微任务
队列为空,宏任务队列里有且只有一个 script 脚本(整体代码)。全局上下文(script 标签)被推入执行栈,同步代码执行。
判断是同步任务还是异步任务,异步任务还可以产生新的宏任务与
微任务
,分别被推入各自的任务队列中。同步代码执行完,script 脚本会被移出宏任务队列,这个过程本质上是队列的宏任务的执行和出队的过程。
上一步出队的是一个宏任务,这一步处理的是
微任务
。注意:当宏任务出队时,任务是一个一个执行的;而微任务
出队时,任务是一队一队执行的。因此,处理微任务
队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。执行渲染操作,更新界面。
检查是否存在 Web worker 任务,如果有,则对其进行处理。
上述过程循环往复,直到两个队列都清空。
总结一下,每一次循环都是一个这样的过程:
3、Event Loop 应用
示例 1
Promise.resolve().then(() => {
console.log('Promise1')
setTimeout(() => {
console.log('setTimeout2')
}, 0)
})
setTimeout(() => {
console.log('setTimeout1')
Promise.resolve().then(() => {
console.log('Promise2')
})
}, 0)
// 依次输出:
// Promise1
// setTimeout1
// Promise2
// setTimeout2
过程分析:
- 一开始执行栈的同步任务(这属于宏任务)执行完毕,会去查看是否有微任务队列,然后执行微任务队列中的所有任务输出 Promise1,同时会生成一个宏任务 setTimeout2。
- 然后去查看宏任务队列,宏任务 setTimeout1 在 setTimeout2 之前,先执行宏任务 setTimeout1,输出 setTimeout1。
- 在执行宏任务 setTimeout1 时会生成微任务 Promise2,放入微任务队列中,接着先去清空微任务队列中的所有任务,输出 Promise2。
- 清空完微任务队列中的所有任务后,就又会去宏任务队列取一个,这回执行的是 setTimeout2
示例 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);
})
setTimeout(() => {
console.log(6);
})
console.log(7);
// 依次输出:
// 1
// 4
// 7
// 5
// 2
// 3
// 6
过程分析:
Step 1
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[] | [] | 1 |
Step 2
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback1] | [] | 1 |
Step 3
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback1] | [callback2] | 1 、4 |
Step 4
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback1, callback3] | [callback2] | 1 、4 |
Step 5
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback1, callback3] | [callback2] | 1 、4 、7 |
到这里,全局 Script 代码执行完毕,进入下一个步骤,从微任务队列中依次取出任务执行,直到微任务队列队列为空。
Step 6
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback1, callback3] | [] | 1 、4 、7 、5 |
这里微任务队列中只有一个任务,执行完后开始从宏任务队列中取位于队首的任务执行。
Step 7
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback3] | [] | 1 、4 、7 、5 、2 |
执行 callback1 时又遇到了另一个 Promise,Promise 异步执行完后在微任务中又注册了一个 callback4 回调函数。
Step 8
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback3] | [callback4] | 1 、4 、7 、5 、2 |
取出一个宏任务执行完毕,然后再去微任务队列中依次取出执行。
Step 9
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[callback3] | [] | 1 、4 、7 、5 、2 、3 |
微任务队列全部执行完,再去宏任务队列中取第一个任务执行。
Step 10
原例子 | Stack Queue | 宏任务 | 微任务 | 打印结果 |
---|---|---|---|---|
[] | [] | 1 、4 、7 、5 、2 、3 、6 |
以上,全部执行完后,Stack Queue、宏任务队列、微任务队列均为空。
打印结果:1 、4 、7 、5 、2 、3 、6
示例 3
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
async1();
new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
});
console.log('script end');
// 依次输出:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout
过程分析:
三、Node.js 中的 Event Loop
Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境。
1、Node.js 的架构
Node.js 由 Libuv、Chrome V8、一些核心 API 构成,如图:
Node Standard Library
:Node.js 标准库,对外提供的 JavaScript 接口,例如模块http、buffer、fs、stream
等;Node bindings
:JavaScript 与 C++ 连接的桥梁,对下层模块进行封装,向上层提供基础的 API 接口;
2、Node.js 的运行机制
- V8 引擎解析 JavaScript 脚本;
- 解析后的代码,调用 Node API;
- libuv 库负责 Node API 的执行,它将不同的任务分配给不同的线程,形成一个 Event Loop,以异步的方式将任务的执行结果返回给 V8 引擎;
- V8 引擎再将结果返回给用户。
3、libuv 中的 Event-Loop
Node.js 采用 V8 作为 JS 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。核心源码如下:
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
// timers阶段
uv__run_timers(loop);
// I/O callbacks阶段
ran_pending = uv__run_pending(loop);
// idle阶段
uv__run_idle(loop);
// prepare阶段
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// poll阶段
uv__io_poll(loop, timeout);
// check阶段
uv__run_check(loop);
// close callbacks阶段
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
libuv 引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
从上图可看出 node 中的事件循环的顺序:
外部输入数据
-->
轮询阶段(poll)-->
检查阶段(check)-->
关闭事件回调阶段(close callback)-->
定时器检测阶段(timer)-->
I/O 事件回调阶段(pending callbacks)-->
闲置阶段(idle, prepare)-->
轮询阶段...
其中,各阶段的作用如下:
timers
: 定时器检测阶段,执行 timer(setTimeout、setInterval)的回调;I/O callbacks
: ,I/O 事件回调阶段,执行一些系统调用错误,例如网络通信的错误回调;idle, prepare
: 闲置阶段,仅 node 系统内部使用;poll
: 轮询阶段,执行 I/O 回调,同时还会检查定时器是否到期;check
: 检查阶段,执行setImmediate()
的回调;close callbacks
: 关闭事件回调阶段,处理关闭的回调,例如socket.on('close', ...)
就会在这个阶段被触发。
注意:上面六个阶段都不包括 process.nextTick()
下面看 timers
、poll
、check
这 3 个阶段(日常开发中绝大部分异步任务都在这 3 个阶段处理)
3-1、timers 定时器检测阶段
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer(setTimeout、setInterval),如果有则把它的回调压入 timer 的任务队列中等待执行。
事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对 timer 的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。举个例子:
setTimeout()
和 setImmediate()
非常相似,区别主要在于调用时机不同。
setImmediate
设计在 poll 阶段完成时执行,即 check 阶段;setTimeout
设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行。
setTimeout(function timeout() {
console.log('timeout');
}, 0);
setImmediate(function immediate() {
console.log('immediate');
});
对于以上代码来说,setTimeout
可能执行在前,也可能执行在后。- 首先
setTimeout(fn, 0) === setTimeout(fn, 1)
,这是由源码决定的。进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行setTimeout
回调。 - 如果准备时间花费小于 1ms,那么就是
setImmediate
回调先执行了。
但当二者在异步 I/O callback 内部调用时,总是先执行 setImmediate
,再执行 setTimeout
。
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
// immediate
// timeout
在上述代码中,setImmediate
永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate
回调,所以就直接跳转到 check 阶段去执行回调了。
3-2、poll 轮询阶段
poll 阶段主要做两件事:
- 处理 poll 队列的事件;
- 当有已超时的 timer,执行它的回调函数。
even loop 将同步执行 poll 队列里的回调,直到队列为空或执行的回调达到系统上限,接下来 even loop 会去检查有无预设的 setImmediate()
,分两种情况:
- 若有预设的
setImmediate()
, event loop 将结束 poll 阶段进入 check 阶段,并执行 check 阶段的任务队列; - 若没有预设的
setImmediate()
,event loop 将阻塞在该阶段等待。
注意:没有 setImmediate()
会导致 event loop 阻塞在 poll 阶段,这样之前设置的 timer 就无法执行了。因此,在 poll 阶段 event loop 会有一个检查机制,检查 timer 队列是否为空,如果 timer 队列非空,event loop 就开始下一轮事件循环,即重新进入到 timer 阶段。
3-3、check 检查阶段
setImmediate() 的回调会被加入 check 队列中,从 event loop 的阶段图可以知道,check 阶段的执行顺序在 poll 阶段之后,举个例子:
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function () {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function () {
console.log('promise3')
})
console.log('end')
// 浏览器输出: Node 输出:
// start start
// end end
// promise3 promise3
// timer1 timer1
// promise1 timer2
// timer2 promise1
// promise2 promise2
- 一开始执行栈的同步任务(这属于宏任务)执行完毕后(依次打印出 start end,并将 2 个 timer 依次放入 timer 队列),会先去执行微任务(这点跟浏览器端的一样),所以打印出 promise3;
- 然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise.then 回调放入微任务队列;
- 同样的步骤执行 timer2,打印 timer2;这点跟浏览器端相差比较大,timers 阶段有几个 setTimeout / setInterval 都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务。
4、宏任务与微任务
Node 端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列。
常见的宏任务队列:
- setTimeout
- setInterval
- setImmediate
- script(整体代码)
- I/O 操作等
- ...
常见的微任务队列:
- process.nextTick
- new Promise().the(回调) ...
5、process.nextTick
这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
// 依次输出:
// nextTick
// nextTick
// nextTick
// nextTick
// timer1
// promise1
四、两种 Event Loop 的区别
浏览器环境下,微任务的任务队列是在每个宏任务执行完之后执行:
而在 Node.js 中,微任务会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行微任务队列的任务:
举个例子
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function () {
console.log('promise2')
})
}, 0)
// 浏览器输出: Node 输出:
// timer1 timer1
// promise1 timer2
// timer2 promise1
// promise2 promise2
具体处理过程如下:
- 浏览器
- Node
- 首先进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise1.then 回调放入微任务队列;
- 同样的步骤执行 timer2,打印 timer2;
- 至此,timer 阶段执行结束;
- event loop 进入下一个阶段之前,执行微任务队列的所有任务,依次打印 promise1、promise2。
浏览器和 Node 环境下,微任务任务队列的执行时机不同:
- 浏览器中,微任务在事件循环的宏任务执行完之后执行;
- Node.js 中,微任务在事件循环的各个阶段之间执行。