Event Loop 的规范和实现
本文所有代码运行环境仅包含 Node v8.9.4 以及 Chrome v63
PART 1:规范
为什么要有 Event Loop?
因为 Javascript 设计之初就是一门单线程语言,因此为了实现主线程的不阻塞, Event Loop 这样的方案应运而生。
小测试(1)
先来看一段代码,打印结果会是?
console.log(1)
setTimeout(() => {
console.log(2)
}, 0)
Promise.resolve().then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
console.log(5)
不熟悉 Event Loop 的我尝试进行如下分析:
- 首先,我们先排除异步代码,先把同步执行的代码找出,可以知道先打印的一定是
1、5
- 但是,setTimeout 和 Promise 是否有优先级?还是看执行顺序?
- 还有,Promise 的多级 then 之间是否会插入 setTimeout?
带着困惑,我试着运行了一下代码,正确结果是: 1、5、3、4、2
。
那这到底是为什么呢?
定义
看来需要先从规范定义入手,于是查阅一下 HTML 规范 ,规范着实详(luo)细(suo),我就不贴了,提炼下来关键步骤如下:
- 执行最旧的 task(一次)
- 检查是否存在 microtask,然后不停执行,直到清空队列(多次)
- 执行 render
好家伙,问题还没搞明白,一下子又多出来 2 个概念 task 和 microtask ,让懵逼的我更加凌乱了。。。
不慌不慌,通过仔细阅读文档得知,这两个概念属于对异步任务的分类,不同的 API 注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行。
task 主要包含: setTimeout
、 setInterval
、 setImmediate
、 I/O
、 UI 交互事件
microtask 主要包含: Promise
、 process.nextTick
、 MutaionObserver
整个最基本的 Event Loop 如图所示:
- queue 可以看做一种数据结构,用以存储需要执行的函数
- timer 类型的 API(setTimeout/setInterval)注册的函数,等到期后进入 task 队列(这里不详细展开 timer 的运行机制)
- 其余 API 注册函数直接进入自身对应的 task/microtask 队列
- Event Loop 执行一次,从 task 队列中拉出一个 task 执行
- Event Loop 继续检查 microtask 队列是否为空,依次执行直至清空队列
继续测试(2)
这时候,回头再看下之前的 测试(1)
,发现概念非常清晰,一下子就得出了正确答案,感觉自己萌萌哒,再也不怕 Event Loop 了~
接着,准备挑战一下更高难度的问题(本题出自 序 中提到的那篇文章,我先去除了 process.nextTick
):
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})
setTimeout(() => {
console.log(9)
new Promise(resolve => {
console.log(11)
resolve()
}).then(() => {
console.log(12)
})
})
分析如下:
- 同步运行的代码首先输出:
1、7
- 接着,清空 microtask 队列:
8
- 第一个 task 执行:
2、4
- 接着,清空 microtask 队列:
5
- 第二个 task 执行:
9、11
- 接着,清空 microtask 队列:
12
在 chrome
下运行一下,全对!
自信的我膨胀了,准备加上 process.nextTick
后在 node 上继续测试。我先测试第一个 task,代码如下:
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
})
process.nextTick(() => {
console.log(3)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})
process.nextTick(() => {
console.log(6)
})
有了之前的积累,我这回自信的写下了答案: 1、7、8、6、2、4、5、3
。
然而,帅不过 3 秒,正确答案是: 1、7、6、8、2、4、3、5
我陷入了困惑,不过很快明白了,这说明** process.nextTick
注册的函数优先级高于 Promise
**,这样就全说的通了~
接着,我再测试第二个 task:
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
})
process.nextTick(() => {
console.log(3)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})
process.nextTick(() => {
console.log(6)
})
setTimeout(() => {
console.log(9)
process.nextTick(() => {
console.log(10)
})
new Promise(resolve => {
console.log(11)
resolve()
}).then(() => {
console.log(12)
})
})
吃一堑长一智,这次我掌握了 microtask 的优先级,所以答案应该是:
- 第一个 task 输出:
1、7、6、8、2、4、3、5
- 然后,第二个 task 输出:
9、11、10、12
然而,啪啪打脸。。。
我第一次执行,输出结果是: 1、7、6、8、2、4、9、11、3、10、5、12
(即两次 task 的执行混合在一起了)。我继续执行,有时候又会输出我预期的答案。
现实真的是如此莫名啊!啊!啊!
啊,不好意思,血一时止不住 所以,这到底是为什么???
PART 2:实现
俗话说得好:规范是人定的,代码是人写的。 ——无名氏
规范无法囊括所有场景,虽然 chrome
和 node
都基于 v8 引擎,但引擎只负责管理内存堆栈,API 还是由各 runtime 自行设计并实现的。
小测试(3)
Timer 是整个 Event Loop 中非常重要的一环,我们先从 timer 切入,来切身体会下规范和实现的差异。
首先再来一个小测试,它的输出会是什么呢?
setTimeout(() => {
console.log(2)
}, 2)
setTimeout(() => {
console.log(1)
}, 1)
setTimeout(() => {
console.log(0)
}, 0)
没有深入接触过 timer 的同学如果直接从代码中的延时设置来看,会回答: 0、1、2
。
而另一些有一定经验的同学可能会回答: 2、1、0
。因为 MDN 的 setTimeout 文档 中提到 HTML 规范最低延时为 4ms:
(补充说明:最低延时的设置是为了给 CPU 留下休息时间)
In fact, 4ms is specified by the HTML5 spec and is consistent across browsers released in 2010 and onward. Prior to (Firefox 5.0 / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms.
而真正痛过的同学会告诉你,答案是: 1、0、2
。并且,无论是 chrome
还是 node
下的运行结果都是一致的。
(错误订正:经多次验证,node 下的输出顺序依然是无法保证的,node 的 timer 真是一门玄学~)
Chrome 中的 timer
从 测试(3)
结果可以看出,0ms 和 1ms 的延时效果是一致的,那背后的原因是为什么呢?我们先查查 blink
的实现。
(Blink 代码托管的地方我都不知道如何进行搜索,还好文件名比较明显,没花太久,找到了答案)
(我直接贴出最底层代码,上层代码如有兴趣请自行查阅)
// https://chromium.googlesource.com/chromium/blink/+/master/Source/core/frame/DOMTimer.cpp#93
double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);
这里 interval 就是传入的数值,可以看出传入 0 和传入 1 结果都是 oneMillisecond,即 1ms。
这样解释了为何 1ms 和 0ms 行为是一致的,那 4ms 到底是怎么回事?我再次确认了 HTML 规范 ,发现虽然有 4ms 的限制,但是是存在条件的,详见规范第 11 点:
If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
并且有意思的是, MDN 英文文档 的说明也已经贴合了这个规范。
我斗胆推测,一开始 HTML5 规范确实有定最低 4ms 的规范,不过在后续修订中进行了修改,我认为甚至不排除规范在向实现看齐,即逆向影响。
Node 中的 timer
那 node
中,为什么 0ms 和 1ms 的延时效果一致呢?
(还是 github 托管代码看起来方便,直接搜到目标代码)
// https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js#L456
if (!(after >= 1 && after <= TIMEOUT_MAX))
after = 1; // schedule on next tick, follows browser behavior
代码中的注释直接说明了,设置最低 1ms 的行为是为了向浏览器行为看齐。
Node 中的 Event Loop
上文的 timer 算一个小插曲,我们现在回归本文核心—— Event Loop 。
让我们聚焦在 node
的实现上, blink
的实现本文不做展开,主要是因为:
chrome
行为目前看来和规范一致- 可参考的文档不多
- 不会搜索,根本不知道核心代码从何找起。
略过所有研究过程。
补充说明:
Node
的 Event Loop 分阶段,阶段有先后,依次是- expired timers and intervals ,即到期的 setTimeout/setInterval
- I/O events ,包含文件,网络等等
- immediates ,通过 setImmediate 注册的函数
- close handlers ,close 事件的回调,比如 TCP 连接断开
- 同步任务及每个阶段之后都会清空 microtask 队列
- 优先清空 next tick queue ,即通过
process.nextTick
注册的函数 - 再清空 other queue ,常见的如 Promise
- 优先清空 next tick queue ,即通过
- 而和规范的区别,在于 node 会清空当前所处阶段的队列,即执行所有 task
重新挑战测试(2)
了解了实现,再回头看 测试(2)
:
// 代码简略表示
// 1
setTimeout(() => {
// ...
})
// 2
setTimeout(() => {
// ...
})
可以看出由于两个 setTimeout
延时相同,被合并入了同一个 expired timers queue ,而一起执行了。所以,只要将第二个 setTimeout
的延时改成超过 2ms(1ms 无效,详见上文),就可以保证这两个 setTimeout
不会同时过期,也能够保证输出结果的一致性。
那如果我把其中一个 setTimeout
改为 setImmediate
,是否也可以做到保证输出顺序?
答案是 不能 。虽然可以保证 setTimeout
和 setImmediate
的回调不会混在一起执行,但无法保证的是 setTimeout
和 setImmediate
的回调的执行顺序。
在 node
下,看一个最简单的例子,下面代码的输出结果是无法保证的:
setTimeout(() => {
console.log(0)
})
setImmediate(() => {
console.log(1)
})
// or
setImmediate(() => {
console.log(0)
})
setTimeout(() => {
console.log(1)
})
问题的关键在于 setTimeout
何时到期,只有到期的 setTimeout
才能保证在 setImmediate
之前执行。
不过如果是这样的 例子(2)
,虽然基本能保证输出的一致性,不过强烈不推荐:
// 先使用 setTimeout 注册
setTimeout(() => {
// ...
})
// 一系列 micro tasks 执行,保证 setTimeout 顺利到期
new Promise(resolve => {
// ...
})
process.nextTick(() => {
// ...
})
// 再使用 setImmediate 注册,“几乎”确保后执行
setImmediate(() => {
// ...
})
或者换种思路来保证顺序:
const fs = require('fs')
fs.readFile('/path/to/file', () => {
setTimeout(() => {
console.log('timeout')
})
setImmediate(() => {
console.log('immediate')
})
})
那,为何这样的代码能保证 setImmediate
的回调优先于 setTimeout
的回调执行呢?
因为当两个回调同时注册成功后,当前 node
的 Event Loop 正处于 I/O queue 阶段,而下一个阶段是 immediates queue ,所以能够保证即使 setTimeout
已经到期,也会在 setImmediate
的回调之后执行。
PART 3:应用
由于也是刚刚学习 Event Loop ,无论是依托于规范还是实现,我能想到的应用场景还比较少。那掌握 Event Loop ,我们能用在哪些地方呢?
查 Bug
正常情况下,我们不会碰到非常复杂的队列场景。不过万一碰到了,比如执行顺序无法保证的情况时,我们可以快速定位到问题。
面试
那什么时候会有复杂的队列场景呢?比如面试,保不准会有这种稀奇古怪的测试,这样就能轻松应付了~
执行优先级
说回正经的,如果从规范来看,microtask 优先于 task 执行。那如果有需要优先执行的逻辑,放入 microtask 队列会比 task 更早的被执行,这个特性可以被用于在框架中设计任务调度机制。
如果从 node
的实现来看,如果时机合适,microtask 的执行甚至可以阻塞 I/O,是一把双刃剑。
综上,高优先级的代码可以用 Promise
/ process.nextTick
注册执行。
执行效率
从 node
的实现来看, setTimeout
这种 timer 类型的 API ,需要创建定时器对象和迭代等操作,任务的处理需要操作小根堆,时间复杂度为 O(log(n)) 。而相对的, process.nextTick
和 setImmediate
时间复杂度为 O(1) ,效率更高。
如果对执行效率有要求,优先使用 process.nextTick
和 setImmediate
。
参考
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论