1. 首页

Event Loop的规范和实现

作者简介:nekron 蚂蚁金服·数据体验技术团队

一直以来,我对Event Loop的认知界定都是可知可不知的分级,因此仅仅保留浅显的概念,从未真正学习过,直到看了这篇文章——《这一次,彻底弄懂 JavaScript 执行机制》。该文作者写的非常友好,从最小的例子展开,让我获益匪浅,但最后的示例牵扯出了chromeNode下的运行结果迥异,我很好奇,我觉得有必要对这一块知识进行学习。

由于上述原因,本文诞生,原本我计划全文共分3部分来展开:规范、实现、应用。但遗憾的是由于自己的认知尚浅,在如何根据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. 首先,我们先排除异步代码,先把同步执行的代码找出,可以知道先打印的一定是1、5
  2. 但是,setTimeout和Promise是否有优先级?还是看执行顺序?
  3. 还有,Promise的多级then之间是否会插入setTimeout?

带着困惑,我试着运行了一下代码,正确结果是:1、5、3、4、2

那这到底是为什么呢?

定义

看来需要先从规范定义入手,于是查阅一下HTML规范,规范着实详(luo)细(suo),我就不贴了,提炼下来关键步骤如下:

  1. 执行最旧的task(一次)
  2. 检查是否存在microtask,然后不停执行,直到清空队列(多次)
  3. 执行render

好家伙,问题还没搞明白,一下子又多出来2个概念taskmicrotask,让懵逼的我更加凌乱了。。。

不慌不慌,通过仔细阅读文档得知,这两个概念属于对异步任务的分类,不同的API注册的异步任务会依次进入自身对应的队列中,然后等待Event Loop将它们依次压入执行栈中执行。

task主要包含:setTimeoutsetIntervalsetImmediateI/OUI交互事件

microtask主要包含:Promiseprocess.nextTickMutaionObserver

整个最基本的Event Loop如图所示:

  • queue可以看做一种数据结构,用以存储需要执行的函数
  • timer类型的API(setTimeout/setInterval)注册的函数,等到期后进入task队列(这里不详细展开timer的运行机制)
  • 其余API注册函数直接进入自身对应的task/microtask队列
  • Event Loop执行一次,从task队列中拉出一个task执行
  • Event Loop继续检查microtask队列是否为空,依次执行直至清空队列

规范.png | center | 585x357

继续测试(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. 同步运行的代码首先输出:1、7
  2. 接着,清空microtask队列:8
  3. 第一个task执行:2、4
  4. 接着,清空microtask队列:5
  5. 第二个task执行:9、11
  6. 接着,清空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

打脸3.png | left | 64x64

我陷入了困惑,不过很快明白了,这说明**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的执行混合在一起了)。我继续执行,有时候又会输出我预期的答案。

现实真的是如此莫名啊!啊!啊!

吐血1.jpg | left | 200x117

(啊,不好意思,血一时止不住)所以,这到底是为什么???

PART 2:实现

俗话说得好:

规范是人定的,代码是人写的。       ——无名氏

规范无法囊括所有场景,虽然chromenode都基于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行为目前看来和规范一致
  • 可参考的文档不多
  • 不会搜索,根本不知道核心代码从何找起。。。

原谅1.jpg | left | 264x250

(略过所有研究过程。。。)

直接看结论,下图是nodeEvent Loop实现:

node_event_loop.png | center | 832x460

补充说明:

  • NodeEvent 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
  • 而和规范的区别,在于node会清空当前所处阶段的队列,即执行所有task

重新挑战测试(2)

了解了实现,再回头看测试(2)


// 代码简略表示 // 1 setTimeout(() => { // ... }) // 2 setTimeout(() => { // ... }) 复制代码

可以看出由于两个setTimeout延时相同,被合并入了同一个expired timers queue,而一起执行了。所以,只要将第二个setTimeout的延时改成超过2ms(1ms无效,详见上文),就可以保证这两个setTimeout不会同时过期,也能够保证输出结果的一致性。

那如果我把其中一个setTimeout改为setImmediate,是否也可以做到保证输出顺序?

答案是不能。虽然可以保证setTimeoutsetImmediate的回调不会混在一起执行,但无法保证的是setTimeoutsetImmediate的回调的执行顺序。

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的回调执行呢?

因为当两个回调同时注册成功后,当前nodeEvent 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.nextTicksetImmediate时间复杂度为O(1),效率更高。

如果对执行效率有要求,优先使用process.nextTicksetImmediate

其他

欢迎大家一同补充~

参考

对团队感兴趣的同学可以关注专栏或者发送简历至’tao.qit####alibaba-inc.com’.replace(‘####’, ‘@’),欢迎有志之士加入~

原文地址:github.com/ProtoTeam/b…

看完两件小事

如果你觉得这篇文章对你挺有启发,我想请你帮我两个小忙:

  1. 关注我们的 GitHub 博客,让我们成为长期关系
  2. 把这篇文章分享给你的朋友 / 交流群,让更多的人看到,一起进步,一起成长!
  3. 关注公众号 「画漫画的程序员」,公众号后台回复「资源」 免费领取我精心整理的前端进阶资源教程

JS中文网是中国领先的新一代开发者社区和专业的技术媒体,一个帮助开发者成长的社区,目前已经覆盖和服务了超过 300 万开发者,你每天都可以在这里找到技术世界的头条内容。欢迎热爱技术的你一起加入交流与学习,JS中文网的使命是帮助开发者用代码改变世界

本文来源于网络,其版权属原作者所有,如有侵权,请与小编联系,谢谢!

转载请注明:文章转载自「 Js中文网 · 前端进阶资源教程 」https://www.javascriptc.com

标题:Event Loop的规范和实现

链接:https://www.javascriptc.com/2172.html

原文链接:https://juejin.im/post/5a6155126fb9a01cb64edb45

« 重构 – 代码整洁之道
如何学习一门编程语言或框架»
Flutter 中文教程资源

相关推荐

QR code