EventLoop
# 浏览器事件循环
一图胜千言
# Task & Microtask
# 微任务有哪些
- process.nextTicknode
- promise.then
- Object.observe
- MutationObserver
# 宏任务有哪些
- script
- setTimeout
- setInterval
- setImmediate
- I/O 网络请求完成、文件读写完成事件
- UI rendering
- 用户交互事件(比如鼠标点击、滚动页面、放大缩小等)
# 事件循环的流程
- 执行同步代码,这属于宏任务
- 执行栈为空,查询是否有微任务需要执行
- 执行所有微任务
- 必要的话渲染 UI
- 然后开始下一轮 Event loop,执行宏任务中的异步代码
总结起来就是:一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务
# 一个例子
- 因为微任务是在 UI 渲染之前执行的,所以页面的背景会直接呈现黄色
<script>
document.body.style.background = 'red'
console.log(1)
Promise.resolve().then((_) => {
console.log(3)
document.body.style.background = 'yellow'
})
console.log(2)
</script>
2
3
4
5
6
7
8
9
10
11
12
- 因为宏任务的执行与 UI 渲染不在同一次事件循环周期,所以页面背景会有从红色变为黄色的过程
<script>
document.body.style.background = 'red'
console.log(1)
setTimeout((_) => {
console.log(3)
document.body.style.background = 'yellow'
})
console.log(2)
</script>
2
3
4
5
6
7
8
9
10
11
12
# Node 事件循环
当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,事件循环本质上就像一个 while 循环,源码参考 (opens new window)
以下内容来自libuv 源码粗读(6):libuv event-loop 详解 #35 (opens new window)
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); // 如果不存在直接更新event-loop的loop->time(libuv事件循环内部维护的时间)
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop); // 更新`loop->time`的时间
uv__run_timers(loop); // 处理timers相关事件
ran_pending = uv__run_pending(loop); // 处理pending相关事件
uv__run_idle(loop); // 处理idle相关事件
uv__run_prepare(loop); // 处理prepare相关事件
timeout = 0; // 初始化uv__io_poll的轮询时间timeout
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) // 添加对evnet-loop运行模式的判断,从而决定uv__io_poll要阻塞的时长
timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout); // 执行`uv__io_poll`阻塞循环`timeout`时长
uv__run_check(loop); // 处理check相关事件
uv__run_closing_handles(loop); // 处理close相关事件
if (mode == UV_RUN_ONCE) { // 添加对evnet-loop运行模式的判断,从而决定是否再次更新loop->time处理timers相关事件
/* UV_RUN_ONCE implies forward progress: at least one callback must have
* been invoked when it returns. uv__io_poll() can return without doing
* I/O (meaning: no callbacks) when its timeout expires - which means we
* have pending timers that satisfy the forward progress constraint.
*
* UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
* the check.
*/
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop); // 判断是否还存在活跃句柄
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) // 添加对evnet-loop运行模式的判断从而决定是否跳出event-loop
break;
}
/* The if statement lets gcc compile it to a conditional store. Avoids
* dirtying a cache line.
*/
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
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
当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本,它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick(),然后开始处理事件循环。
根据 nodejs 官方文档,在通常情况下,nodejs 中的事件循环根据不同的操作系统可能存在特殊的阶段,但总体是可以分为以下 6 个阶段:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 每个框被称为事件循环机制的一个阶段
- 每个阶段都有一个 FIFO 队列来执行回调
- 每个阶段都做了什么?
- 通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。
- 如何进入下一个阶段?
- 当该队列已用尽或达到回调限制,事件循环将移动到下一阶段
- 每次执行执行一个宏任务后就会清空微任务(执行顺序和浏览器一致,在 node11 版本以上)
- process.nextTick 是优先级最高的 node 中的微任务,会被放在当前执行栈的底部,优先级比 promise 要高
# timers
重要这个阶段执行 setTimeout 和 setInterval 调度的回调函数,底层实现用到的数据结构是二叉堆,最快到期的节点在最上面参考 (opens new window)
至于定时器合适执行,是在 poll 阶段进行控制的
# pending callbacks 阶段
大部分 I/O 回调任务都是在 poll 阶段执行的,但是也会存在一些上一次事件循环遗留的被延时的 I/O 回调函数 ,那么此阶段就是为了调用之前事件循环延迟执行的 I/O 回调函数。
# idle,prepare 阶段
仅系统内部使用,只需要知道有这 2 个阶段就可以
# poll 阶段
重要poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了 setTimeout
、setInterval
、setImmediate
以及一些因为 exception
意外关闭产生的回调)
通常,随着代码执行,事件循环最终会命中 poll 阶段,在那等待传入连接、请求等。但是,如果回调已使用 setImmediate()调度过,并且 poll 阶段变为空闲状态,则它将结束 poll 阶段,并继续到 check 阶段而不是继续等待轮询事件
poll 阶段其实主要做了两件事:
- 计算应该阻塞和轮询 I/O 的时间
- 处理 poll 队列里的事件
这个阶段的主要流程如下图所示
当事件循环进入 poll 阶段且 没有被调度的计时器时 ,将发生以下两种情况之一:
如果 poll 队列 不是空的 ,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
如果 poll 队列 是空的 ,还有两件事发生:
如果脚本被 setImmediate() 调度,则事件循环将结束 poll 阶段,并继续 check 阶段以执行那些被调度的脚本。
如果脚本 未被 setImmediate()调度,则事件循环将等待回调被添加到队列中,然后立即执行。
一旦 poll 队列为空,事件循环将检查 已达到时间阈值的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回 timer 阶段以执行这些计时器的回调。
# check 阶段
重要当 poll 阶段回调函数队列为空且脚本使用 setImmediate() 后被排列在队列中的时候,开始进入 check 阶段,主要执行 setImmediate 回调函数
# close callbacks
执行注册 close 事件的回调函数
# setImmediate 和 setTimeout
一下内容来自彻底理解 Event Loop (opens new window)
# 案例一
setTimeout(() => {
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
}, 0
// 结果
'setImmediate'
'setTimeout'
2
3
4
5
6
7
8
9
10
11
12
流程分析
- 外层是一个 setTimeout,所以执行他的回调的时候已经在 timers 阶段了
- 处理里面的 setTimeout,因为本次循环的 timers 正在执行,所以他的回调其实加到了下个 timers 阶段
- 处理里面的 setImmediate,将它的回调加入 check 阶段的队列
- 外层 timers 阶段执行完,进入 pending callbacks,idle, prepare,poll,这几个队列都是空的,所以继续往下
- 到了 check 阶段,发现了 setImmediate 的回调,拿出来执行
- 然后是 close callbacks,队列是空的,跳过
- 又是 timers 阶段,执行我们的 console
# 案例二
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
// 输出结果不一定
2
3
4
5
6
7
8
解析
node.js 里面 setTimeout(fn, 0)会被强制改为 setTimeout(fn, 1),浏览器里面 setTimeout 最小的时间限制是 4ms
- 外层同步代码一次性全部执行完,遇到异步 API 就塞到对应的阶段
- 遇到 setTimeout,虽然设置的是 0 毫秒触发,但是被 node.js 强制改为 1 毫秒,塞入 times 阶段
- 遇到 setImmediate 塞入 check 阶段
- 同步代码执行完毕,进入 Event Loop
- 先进入 times 阶段,检查当前时间过去了 1 毫秒没有,如果过了 1 毫秒,满足 setTimeout 条件,执行回调,如果没过 1 毫秒,此时 timers 队列为空,跳过进入下一阶段
- 跳过空的阶段,进入 check 阶段,执行 setImmediate 回调
# 案例三
process.nextTick(function () {
console.log('1')
})
process.nextTick(function () {
console.log('2')
setImmediate(function () {
console.log('3')
})
process.nextTick(function () {
console.log('4')
})
})
setImmediate(function () {
console.log('5')
process.nextTick(function () {
console.log('6')
})
setImmediate(function () {
console.log('7')
})
})
setTimeout((e) => {
console.log(8)
new Promise((resolve, reject) => {
console.log(8 + 'promise')
resolve()
}).then((e) => {
console.log(8 + 'promise+then')
})
}, 0)
setTimeout((e) => {
console.log(9)
}, 0)
setImmediate(function () {
console.log('10')
process.nextTick(function () {
console.log('11')
})
process.nextTick(function () {
console.log('12')
})
setImmediate(function () {
console.log('13')
})
})
console.log('14')
new Promise((resolve, reject) => {
console.log(15)
resolve()
}).then((e) => {
console.log(16)
})
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
54
55
56
57
输出结果如下:
14
15
1
2
4
16
8
8 + promise
8 + promise + then
9
5
6
10
11
12
3
713
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
解析:
同步代码先执行,输出 14 15
nextTick 执行,回调直接插入当前执行栈,输出 1 2 4
可以将 Promise 的 then 方法理解成 process.nextTick,只不过优先级要低于 nextTick