理解EventLoop
EventLoop
即事件循环,是JavaScript单线程运行时实现异步非阻塞的原理,掌握EventLoop
是学习JavaScript必不可少的一个环节,本文将整理浏览器和NodeJS中的事件循环机制,并了解他们的差异。
本文参考
浏览器中的Event loop
任务类别
参考:HTML系列:macrotask和microtask,文中提到了关于浏览器中的event loop,居然是在HTML规范中定义的...
JavaScript单线程执行,包含下面两个特性
- 同一时间只能执行一个任务。
- 任务一直执行到完成,不能被其他任务抢断。
这里提到的任务被分为同步任务和异步任务,
- 同步任务会在执行栈中按照顺序等待主线程依次执行
- 异步任务会在主线程空闲且异步任务结果已返回时,读取到执行栈内等待主线程执行
- 同步任务总是比异步任务优先执行
浏览器中的异步任务又分为了两种:宏任务MacroTask
和微任务MicroTask
。
宏任务包含JS主线程,及一些其他的一些事件如页面加载、输入、网络请求和定时器。从浏览器的角度来看,macrotask代表一些离散的独立的工作
微任务则是完成一些更新应用程序状态的较小任务,如处理promise的回调和监听DOM的修改,微任务应该以异步的方式尽可能地在浏览器重渲染前执行
根据上面的定义,可以总结浏览器中两类任务具体的API
- 宏任务包括:
setTimeout
、setInterval
、I/O
(如事件交互)、requestAnimationFrame
、UI Rendering
等 - 微任务包括:
Promise.then
、MutationObserver等
基于此,浏览器一般需要同时维护至少一个MacroTask队列和一个MicroTask队列。了解浏览器中的事件循环机制,就是要了解这两类异步任务的调用时机。
Event loop
浏览器中的事件循环模型为
- 执行栈首先按顺序执行所有的同步任务,此阶段可能会注册一些点击事件、定时器、网络请求等回调,最后检查执行栈是否为空,
- 如果执行栈为空,就会去检查微任务队列是否为空,
- 如果不为空,则按顺序执行微任务队列中所有的微任务,最后将微任务队列置为空
- 如果为空,则检查宏任务队列是否为空,如果有未执行的宏任务,则进行执行
- 在每个宏任务执行完毕后,就会重复第二步检查微任务队列是否为空
- 如果此时微任务队列不为空,则会按顺序执行并清空微任务队列,然后执行下一个宏任务
- 如果此时微任务队列为空,则直接执行下一个宏任务
因此在浏览器中,每执行完一个宏任务之后,都会在一次性执行完所有的微任务之后,才会执行下一个宏任务。
一些例子
接下来,我们通过分析一些例子来理解这个过程
// example 1
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject)=>{
console.log('promise')
resolve()
}).then(function () {
console.log('promise1');
}).then(function () {
console.log('promise2');
});
console.log('script end');
// script start、promise、script end、promise1、promise2、setTimeout
首先需要理解的是Promise构造参数resolver
方法是同步执行的,可以通过promise-polyfill源码中的doResolve
看见。
然后我们来分析一下整个代码的运行流程
- 第一次执行,
- 执行同步代码,将宏任务(Tasks)和微任务(Microtasks)划分到各自队列中。
- 宏任务包括setTimeout callback,微任务包括promise1.then
- 输出script start、promise、script end
- 第二次执行,
- 此时宏任务包括setTimeout callback,微任务(Microtasks)队列中不为空,同步代码执行完毕,先检查并执行微任务队列Promise1,
- 执行完成Promise1后,调用Promise2.then,放入微任务(Microtasks)队列中,再执行Promise2.then。
- 输出promise1、promise2
- 第三次执行,
- 当微任务(Microtasks)队列中为空时,执行宏任务(Tasks)队列,执行setTimeout callback。
- 输出setTimeout
同理,下面这个例子在浏览器的输出应该是
// example 2
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
// start end、 promise3、 timer1 promise1、 timer2 promise2
NodeJS 中的 Event Loop
事件循环也是 Node.js 最核心的概念之一,Node底层的 Libuv
维护一个 I/O 线程池(可以理解为一个任务队列),结合异步 I/O 的特性,使得单线程也能达到高并发。
任务类别
Node中的异步任务也分为宏任务和微任务等
- 宏任务包括:
setTimeout
、setInterval
、setImmediate
、I/O
操作(如读取文件)等。 - 微任务包括:
Promise.then
此外还有一些特殊的API如
process.nextTick
其行为与微任务相似
Event loop
下面这张图很详细阐述了NodeJS中的事件循环机制,图片来源
在NodeJS的Event loop一共分为6个阶段,每个细节具体如下:
- timers:执行
setTimeout
和setInterval
中到期的callback。 - pending callback: 上一轮循环中少数的callback会放在这一阶段执行。
- idle, prepare: 仅在内部使用。
- poll: 最重要的阶段,执行pending callback,在适当的情况下会阻塞在这个阶段。
- check: 执行
setImmediate
的callback。 - close callbacks: 执行close事件的callback,例如socket.on('close'[,fn])或者http.server.on('close, fn)。
因此,NodeJS中的事件循环模型大致如下
- 每个阶段都可能包含一个或多个宏任务队列,每当进入某一个阶段的时候,
- 都会从对应的回调队列中取出一个宏任务去执行。
- 在每执行完一个宏任务之后,并不会立即执行微任务队列,而是继续执行宏任务(与浏览器的主要差异)
- 当队列为空或者执行的回调函数数量到达系统设定的阈值。
- 先检测并执行
process.nextTick
,这个细节还是比较重要的 - 然后检测微任务队列并按顺序执行
- 最后会进入下一阶段
- 先检测并执行
- 按照上面阶段的顺序反复运行
以上面的example 2
为例
在浏览器中,microtask的任务队列是每个macrotask执行完之后执行,因此依次输出start、end、promise3、timer1、promise1、timer2、promise2
在node v11版本之前,microtask会在事件循环的各个阶段之间执行,因此上面依次输出start、end、promise3、timer1、timer2、promise1、promise2
但是!!!需要注意的,是在node v11版本之后,上面例子在node环境输出会与浏览器保持一致,这是因为在timer中增加了执行完一个宏任务之后,就调用process._tickCallback()
清空微任务队列导致的,其目的大概是为了与浏览器保持一致。
相关问题可以参考:又被node的eventloop坑了,这次是node的锅。
setTimeout 与 setImmediate
poll
阶段是事件循环中最重要的一个阶段,其主要功能包括
- 执行I/O回调
- 处理轮询队列中的事件
当进入poll
阶段时,会进行下面检测
- 如果poll对应任务队列不为空,则先遍历队列并同步执行回调,直到队列清空或执行回调数达到系统上限。
- 如果任务队列为空,这里有两种情况。
- 如果代码已经被
setImmediate
设定了回调,那么事件循环直接结束poll阶段进入check阶段来执行check队列里的回调 - 如果代码没有被设定
setImmediate
设定回调:- 如果有被设定的timers,那么此时事件循环会检查timers,如果有一个或多个timers下限时间已经到达,那么事件循环将绕回timers阶段,并执行timers的有效回调队列。
- 如果没有被设定timers,这个时候事件循环是阻塞在poll阶段等待任务被加入poll队列。
- 如果代码已经被
因此在poll阶段任务队列为空的情况下,会优先检测setImmediate
,然后才检测已到执行时间的timer,基于这个特点,可以来解释一下setImmediate
和setTimeout
的区别
- setImmediate 设计在poll阶段完成时执行,即check阶段;
- setTimeout 设计在poll阶段为空闲时,且设定时间到达后在timer阶段执行
看下面这个例子
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// immediate、timeout
上面的代码中,setImmediate永远先于setTimeout执行。根据前面提到的poll
阶段检测顺序,fs.readFile
的回调是在poll阶段执行的,当其回调执行完毕之后,poll队列为空,而setTimeout入了timers的队列,此时有代码被setImmediate(),于是事件循环先进入check阶段执行回调,之后在下一个事件循环再在timers阶段中执行有效回调。
再来看看这个例子
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
上面这一段代码反复执行,可能输出timeout、immediate的顺序,也可能输出immediate、timeout的顺序,原因在于:进入事件循环也是需要成本的,
- setTimeout传入0将会强制设置为1ms,如果准备时间过长超过1ms,则进入事件循环时在 timer 阶段就会直接执行 setTimeout 回调,
- 否则,事件循环跳过了timer阶段,先在check阶段执行了setImmediate回调,然后在下一个timer阶段再执行setTimeout
综上,我们可以总结:
- 如果两者都在主模块中调用,那么执行先后取决于进程性能,也就是随机。
- 如果两者都不在主模块调用(被一个异步操作包裹),那么setImmediate的回调永远先执行。
process.nextTick
process.nextTick
有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行
setImmediate(function(){
console.log(1);
},0);
setTimeout(function(){
console.log(2);
},0);
new Promise(function(resolve){
console.log(3);
resolve();
console.log(4);
}).then(function(){
console.log(5);
});
console.log(6);
process.nextTick(function(){
console.log(7);
});
console.log(8);
// 3 4 6 8 同步代码
// 7 、 5 // process.nextTick先于其他微任务先执行
// 2 、 1 // 前面执行了不少代码,大概率情况下准备时间超过1ms,进入事件循环时先在timer阶段执行已到期的计数器
// 使用一台性能超级好的机器可能会打印 1、2 ?
Async
由于async/await
也是由Promise实现的,因此也理解为是MicroTask微任务
下面再来看个包含async的例子
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
}).then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})
console.log('script end')
// script start 、async2 end、Promise、script end // 同步代码
// async1 end、promise1、promise2 // 微任务
// setTimeout // 宏任务
把await理解成
async function f() {
await p
console.log('ok')
}
// 等价于
function f() {
return RESOLVE(p).then(() => {
console.log('ok')
})
}
这样就比较容易理解了。最后再看一道题
async function a1() {
console.log("a1 start");
await a2();
console.log("a1 end");
}
async function a2() {
console.log("a2");
}
console.log("script start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise1");
});
a1();
let promise2 = new Promise(resolve => {
resolve("promise2.then");
console.log("promise2");
});
promise2.then(res => {
console.log(res);
Promise.resolve().then(() => {
console.log("promise3");
});
});
console.log("script end");
// 浏览器和node v11版本之后
// 同步阶段:script start、a1 start、a2、promise2、script end
// 微任务:promise1、a1 end、promise2.then、promise3
// 宏任务:setTimeout
// node v11版本之前
// script start、a1 start、a2、promise2、script end
// promise1、promise2.then、promise2.then、promise3、a1 end // 区别在于a1 end的触发时机
// setTimeout
这个应该是新老版本V8引擎导致的差异,可以移步这个问题:async/await 在chrome 环境和 node 环境的 执行结果不一致
小结
本文整理了浏览器和NodeJS中的Event loop机制,需要注意他们二者的一些区别,在遇见事件循环的一些问题时,也要先考虑他们运行环境的差异带来的影响。
在浏览器中,每执行完一个宏任务之后,都会在一次性执行完所有的微任务之后,才会执行下一个宏任务。
从 v11.0 开始,Node.js事件循环的行为开始向浏览器靠拢,特别是在微任务处理和 Promise 行为方面。但由于 Node.js 的服务器环境特性,它仍然保留了一些独特的机制,比如上面提到的几个阶段。
学习了事件循环的机制,相关的问题就能迎刃而解了。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。