做者简介:nekron 蚂蚁金服·数据体验技术团队html
一直以来,我对Event Loop的认知界定都是可知可不知的分级,所以仅仅保留浅显的概念,从未真正学习过,直到看了这篇文章——《这一次,完全弄懂 JavaScript 执行机制》。该文做者写的很是友好,从最小的例子展开,让我获益匪浅,但最后的示例牵扯出了chrome
和Node
下的运行结果迥异,我很好奇,我以为有必要对这一块知识进行学习。node
因为上述缘由,本文诞生,本来我计划全文共分3部分来展开:规范、实现、应用。但遗憾的是因为本身的认知尚浅,在如何根据Event Loop的特性来设想应用场景时,实在没有什么产出,致使有关应用的篇幅太小,故不在标题中做体现了。git
(本文全部代码运行环境仅包含Node v8.9.4以及 Chrome v63)github
由于Javascript设计之初就是一门单线程语言,所以为了实现主线程的不阻塞,Event Loop这样的方案应运而生。web
先来看一段代码,打印结果会是?面试
console.log(1)
setTimeout(() => {
console.log(2)
}, 0)
Promise.resolve().then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
console.log(5)
复制代码
不熟悉Event Loop的我尝试进行以下分析:chrome
一、5
带着困惑,我试着运行了一下代码,正确结果是:一、五、三、四、2
。api
那这究竟是为何呢?浏览器
看来须要先从规范定义入手,因而查阅一下HTML规范,规范着实详(luo)细(suo),我就不贴了,提炼下来关键步骤以下:bash
好家伙,问题还没搞明白,一会儿又多出来2个概念task和microtask,让懵逼的我更加凌乱了。。。
不慌不慌,经过仔细阅读文档得知,这两个概念属于对异步任务的分类,不一样的API注册的异步任务会依次进入自身对应的队列中,而后等待Event Loop将它们依次压入执行栈中执行。
task主要包含:setTimeout
、setInterval
、setImmediate
、I/O
、UI交互事件
microtask主要包含:Promise
、process.nextTick
、MutaionObserver
整个最基本的Event Loop如图所示:
这时候,回头再看下以前的测试(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)
})
})
复制代码
分析以下:
一、7
8
二、4
5
九、11
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)
})
复制代码
有了以前的积累,我这回自信的写下了答案:一、七、八、六、二、四、五、3
。
然而,帅不过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的优先级,因此答案应该是:
一、七、六、八、二、四、三、5
九、十一、十、12
然而,啪啪打脸。。。
我第一次执行,输出结果是:一、七、六、八、二、四、九、十一、三、十、五、12
(即两次task的执行混合在一块儿了)。我继续执行,有时候又会输出我预期的答案。
现实真的是如此莫名啊!啊!啊!
(啊,很差意思,血一时止不住)因此,这究竟是为何???
俗话说得好:
规范是人定的,代码是人写的。 ——无名氏
规范没法囊括全部场景,虽然chrome
和node
都基于v8引擎,但引擎只负责管理内存堆栈,API仍是由各runtime自行设计并实现的。
Timer是整个Event Loop中很是重要的一环,咱们先从timer切入,来切身体会下规范和实现的差别。
首先再来一个小测试,它的输出会是什么呢?
setTimeout(() => {
console.log(2)
}, 2)
setTimeout(() => {
console.log(1)
}, 1)
setTimeout(() => {
console.log(0)
}, 0)
复制代码
没有深刻接触过timer的同窗若是直接从代码中的延时设置来看,会回答:0、一、2
。
而另外一些有必定经验的同窗可能会回答:二、一、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.
而真正痛过的同窗会告诉你,答案是:一、0、2
。而且,不管是chrome
仍是node
下的运行结果都是一致的。
(错误订正:经屡次验证,node下的输出顺序依然是没法保证的,node的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
中,为何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的行为是为了向浏览器行为看齐。
上文的timer算一个小插曲,咱们如今回归本文核心——Event Loop。
让咱们聚焦在node
的实现上,blink
的实现本文不作展开,主要是由于:
chrome
行为目前看来和规范一致(略过全部研究过程。。。)
直接看结论,下图是node
的Event Loop实现:
补充说明:
Node
的Event Loop分阶段,阶段有前后,依次是
process.nextTick
注册的函数了解了实现,再回头看测试(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
的回调以后执行。
因为也是刚刚学习Event Loop,不管是依托于规范仍是实现,我能想到的应用场景还比较少。那掌握Event Loop,咱们能用在哪些地方呢?
正常状况下,咱们不会碰到很是复杂的队列场景。不过万一碰到了,好比执行顺序没法保证的状况时,咱们能够快速定位到问题。
那何时会有复杂的队列场景呢?好比面试,保不许会有这种稀奇古怪的测试,这样就能轻松应付了~
说回正经的,若是从规范来看,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
。
欢迎你们一同补充~
对团队感兴趣的同窗能够关注专栏或者发送简历至'tao.qit####alibaba-inc.com'.replace('####', '@'),欢迎有志之士加入~