本文由 sugerpocket 首发于 IMWeb 社区网站 imweb.io。点击阅读原文查看 IMWeb 社区更多精彩文章。
众所周知,javascript 是单线程的,其通过使用异步而不阻塞主进程执行。那么,他是如何实现的呢?本文就浏览器与nodejs环境下异步实现与event loop进行相关解释。
浏览器环境下,会维护一个任务队列,当异步任务到达的时候加入队列,等待事件循环到合适的时机执行。
实际上,js 引擎并不只维护一个任务队列,总共有两种任务
setTimeout
, setInterval
, setImmediate
, I/O
, UI rendering
Promise
, process.nextTick
, Object.observe
, MutationObserver
, MutaionObserver
那么两种任务的行为有何不同呢?
实验一下,请看下段代码
setTimeout(function() { console.log(4);}, 0);var promise = new Promise(function executor(resolve) { console.log(1); for (var i = 0; i < 10000; i++) { i == 9999 && resolve(); } console.log(2);}).then(function() { console.log(5);});console.log(3);
输出:
1 2 3 5 4
这说明 Promise.then
注册的任务先执行了。
我们再来看一下之前说的 Promise
注册的任务属于 microTask
, setTimeout
属于 Task,两者有何差别?
实际上, microTasks
和 Tasks
并不在同一个队列里面,他们的调度机制也不相同。比较具体的是这样:
也就是说,microTasks 队列在一次事件循环里面不止检查一次,我们做个实验
// 添加三个 Task// Task 1setTimeout(function() { console.log(4);}, 0);// Task 2setTimeout(function() { console.log(6); // 添加 microTask promise.then(function() { console.log(8); });}, 0);// Task 3setTimeout(function() { console.log(7);}, 0);var promise = new Promise(function executor(resolve) { console.log(1); for (var i = 0; i < 10000; i++) { i == 9999 && resolve(); } console.log(2);}).then(function() { console.log(5);});console.log(3);
输出为
1 2 3 5 4 6 8 7
microTasks
会在每个 Task
执行完毕之后检查清空,而这次 event-loop
的新 task
会在下次 event-loop
检测。
实际上,node.js环境下,异步的实现根据操作系统的不同而有所差异。而不同的异步方式处理肯定也是不相同的,其并没有严格按照js单线程的原则,运行环境有可能会通过其他线程完成异步,当然,js引擎还是单线程的。
node.js使用了Google的V8解析引擎和Marc Lehmann的libev。Node.js将事件驱动的I/O模型与适合该模型的编程语言(Javascript)融合在了一起。随着node.js的日益流行,node.js需要同时支持windows, 但是libev只能在Unix环境下运行。Windows 平台上与kqueue(FreeBSD)或者(e)poll(Linux)等内核事件通知相应的机制是IOCP。libuv提供了一个跨平台的抽象,由平台决定使用libev或IOCP。
关于event loop,node.js 环境下与浏览器环境有着巨大差异。
先来一张图
一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。
注意:技术上来说,poll 阶段控制 timers 什么时候执行。
I/O callbacks 这个阶段执行一些系统操作的回调。比如TCP错误,如一个TCP socket在想要连接时收到ECONNREFUSED, 类unix系统会等待以报告错误,这就会放到 I/O callbacks 阶段的队列执行。
poll 阶段的功能有两个
如果进入 poll 阶段,并且没有 timer 阶段加入的任务,将会发生以下情况
这个阶段在 poll 结束后立即执行,setImmediate 的回调会在这里执行。
一般来说,event loop 肯定会进入 poll 阶段,当没有 poll 任务时,会等待新的任务出现,但如果设定了 setImmediate,会直接执行进入下个阶段而不是继续等。
close 事件在这里触发,否则将通过 process.nextTick 触发。
var fs = require('fs');function someAsyncOperation (callback) { // 假设这个任务要消耗 95ms fs.readFile('/path/to/file', callback);}var timeoutScheduled = Date.now();setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled");}, 100);// someAsyncOperation要消耗 95 ms 才能完成someAsyncOperation(function () { var startCallback = Date.now(); // 消耗 10ms... while (Date.now() - startCallback < 10) { ; // do nothing }});
当event loop进入 poll 阶段,它有个空队列(fs.readFile()尚未结束)。所以它会等待剩下的毫秒, 直到最近的timer的下限时间到了。当它等了95ms,fs.readFile()首先结束了,然后它的回调被加到 poll 的队列并执行——这个回调耗时10ms。之后由于没有其它回调在队列里,所以event loop会查看最近达到的timer的 下限时间,然后回到 timers 阶段,执行timer的回调。
所以在示例里,回调被设定 和 回调执行间的间隔是105ms。
现在我们应该知道两者的不同,他们的执行阶段不同,setImmediate() 在 check 阶段,而settimeout 在 poll 阶段执行。但,还不够。来看一下例子。
// timeout_vs_immediate.jssetTimeout(function timeout () { console.log('timeout');},0);setImmediate(function immediate () { console.log('immediate');});
$ node timeout_vs_immediate.jstimeoutimmediate$ node timeout_vs_immediate.jsimmediatetimeout
结果居然是不确定的,why?
还是直接给出解释吧。
那我们再来一个
// timeout_vs_immediate.jsvar fs = require('fs')fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout') }, 0) setImmediate(() => { console.log('immediate') })})
输出始终为
$ node timeout_vs_immediate.jsimmediatetimeout
这个就很好解释了吧。 fs.readFile 的回调执行是在 poll 阶段。当 fs.readFile 回调执行完毕之后,会直接到 check 阶段,先执行 setImmediate 的回调。
nextTick 比较特殊,它有自己的队列,并且,独立于event loop。 它的执行也非常特殊,无论 event loop 处于何种阶段,都会在阶段结束的时候清空 nextTick 队列。
👇点击阅读原文获取更多参考资料