最近对Event loop比较感兴趣,所以了解了一下。但是发现整个Event loop尽管有很多篇文章,但是没有一篇可以看完就对它所有内容都了解的文章。大部分的文章都只阐述了浏览器或者Node二者之一,没有对比的去看的话,认识总是浅一点。所以才有了这篇整理了百家之长的文章。
1. 定义
Event loop:即事件循环,是JavaScript引擎处理异步任务的方式。说人话就是为了让单线程的JavaScript通畅的跑起来,所有的异步操作都要被合适的处理,这个处理逻辑就叫做Event loop。
我们在之前的文章中提到过,JavaScript引擎又称为JavaScript解释器,是JavaScript解释为机器码的工具,分别运行在浏览器和Node中。而根据上下文的不同,Event loop也有不同的实现:其中Node使用了libuv库来实现Event loop; 而在浏览器中,html规范定义了Event loop,具体的实现则交给不同的厂商去完成。
所以,浏览器的Event loop和Node的Event loop是两个概念,下面分别来看一下。
2. 意义
在实际工作中,了解Event loop的意义能帮助你分析一些异步次序的问题(当然,随着es7 async和await的流行,这样的机会越来越少了)。除此以外,它还对你了解浏览器和Node的内部机制有积极的作用;对于参加面试,被问到一堆异步操作的执行顺序时,也不至于两眼抓瞎。
3. 浏览器上的实现
在JavaScript中,任务被分为Task(又称为MacroTask,宏任务)和MicroTask(微任务)两种。它们分别包含以下内容:
MacroTask: script(整体代码), setTimeout, setInterval, setImmediate(node独有), I/O, UI rendering
MicroTask: process.nextTick(node独有), Promises, Object.observe(废弃), MutationObserver
需要注意的一点是:在同一个上下文中,总的执行顺序为同步代码—>microTask—>macroTask[6]。这一块我们在下文中会讲。
浏览器中,一个事件循环里有很多个来自不同任务源的任务队列(task queues),每一个任务队列里的任务是严格按照先进先出的顺序执行的。但是,因为浏览器自己调度的关系,不同任务队列的任务的执行顺序是不确定的。
具体来说,浏览器会不断从task队列中按顺序取task执行,每执行完一个task都会检查microtask队列是否为空(执行完一个task的具体标志是函数执行栈为空),如果不为空则会一次性执行完所有microtask。然后再进入下一个循环去task队列中取下一个task执行,以此类推。
注意:图中橙色的MacroTask任务队列也应该是在不断被切换着的。
本段大批量引用了《什么是浏览器的事件循环(Event Loop)》的相关内容,想看更加详细的描述可以自行取用。
4. Node上的实现
nodejs的event loop分为6个阶段,它们会按照顺序反复运行,分别如下:
timers:执行setTimeout() 和 setInterval()中到期的callback。
I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
idle, prepare:队列的移动,仅内部使用
poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
check:执行setImmediate的callback
close callbacks:执行close事件的callback,例如socket.on("close",func)
不同于浏览器的是,在每个阶段完成后,而不是MacroTask任务完成后,microTask队列就会被执行。这就导致了同样的代码在不同的上下文环境下会出现不同的结果。我们在下文中会探讨。
另外需要注意的是,如果在timers阶段执行时创建了setImmediate则会在此轮循环的check阶段执行,如果在timers阶段创建了setTimeout,由于timers已取出完毕,则会进入下轮循环,check阶段创建timers任务同理。
5. 示例
5.1 浏览器与Node执行顺序的区别
setTimeout(()=>{ console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) setTimeout(()=>{ console.log('timer2') Promise.resolve().then(function() { console.log('promise2') }) }, 0) 浏览器输出: time1 promise1 time2 promise2 Node输出: time1 time2 promise1 promise2
在这个例子中,Node的逻辑如下:
最初timer1和timer2就在timers阶段中。开始时首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样的步骤执行timer2,打印timer2;
至此,timer阶段执行结束,event loop进入下一个阶段之前,执行microtask队列的所有任务,依次打印promise1、promise2。
而浏览器则因为两个setTimeout作为两个MacroTask, 所以先输出timer1, promise1,再输出timer2,promise2。
更加详细的信息可以查阅《深入理解js事件循环机制(Node.js篇)》
为了证明我们的理论,把代码改成下面的样子:
setImmediate(() => { console.log('timer1') Promise.resolve().then(function () { console.log('promise1') }) }) setTimeout(() => { console.log('timer2') Promise.resolve().then(function () { console.log('promise2') }) }, 0) Node输出: timer1 timer2 promise1 或者 promise2 timer2 timer1 promise2 promise1
按理说setTimeout(fn,0)
应该比setImmediate(fn)
快,应该只有第二种结果,为什么会出现两种结果呢?
这是因为Node 做不到0毫秒,最少也需要1毫秒。实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况。如果没到1毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数。
另外,如果已经过了Timer阶段,那么setImmediate会比setTimeout更快,例如:
const fs = require('fs'); fs.readFile('test.js', () => { setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); });
上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate才会早于setTimeout执行。
具体可以看《Node 定时器详解》。
5.2 不同异步任务执行的快慢
setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); Promise.resolve().then(() => console.log(3)); process.nextTick(() => console.log(4)); 输出结果:4 3 1 2或者4 3 2 1
因为我们上文说过microTask会优于macroTask运行,所以先输出下面两个,而在Node中process.nextTick比Promise更加优先[3],所以4在3前。而根据我们之前所说的Node没有绝对意义上的0ms,所以1,2的顺序不固定。
5.3 MicroTask队列与MacroTask队列
setTimeout(function () { console.log(1); },0); console.log(2); process.nextTick(() => { console.log(3); }); new Promise(function (resolve, rejected) { console.log(4); resolve() }).then(res=>{ console.log(5); }) setImmediate(function () { console.log(6) }) console.log('end'); Node输出:2 4 end 3 5 1 6
这个例子来源于《JavaScript中的执行机制》。Promise的代码是同步代码,then和catch才是异步的,所以4要同步输出,然后Promise的then位于microTask中,优于其他位于macroTask队列中的任务,所以5会优于1,6输出,而Timer优于Check阶段,所以1,6。
6. 总结
综上,关于最关键的顺序,我们要依据以下几条规则:
同一个上下文下,MicroTask会比MacroTask先运行
然后浏览器按照一个MacroTask任务,所有MicroTask的顺序运行,Node按照六个阶段的顺序运行,并在每个阶段后面都会运行MicroTask队列
同个MicroTask队列下
process.tick()
会优于Promise
Event loop还是比较深奥的,深入进去会有很多有意思的东西,有任何问题还望不吝指出。
参考文档:
作者:tomc,原文地址
热门评论
5.1中的代码我拷贝下来跑了一下,nodejs中的结果与文中有点差别。我第一次执行时它跟浏览器就是一致的,后来我又多执行了几次,发现两种结果概率都挺高的。