理解EventLoop

EventLoop即事件循环,是JavaScript单线程运行时实现异步非阻塞的原理,掌握EventLoop是学习JavaScript必不可少的一个环节,本文将整理浏览器和NodeJS中的事件循环机制,并了解他们的差异。

<!--more-->

本文参考

1. 浏览器中的Event loop

1.1. 任务类别

参考:HTML系列:macrotask和microtask,文中提到了关于浏览器中的event loop,居然是在HTML规范中定义的...

JavaScript单线程执行,包含下面两个特性

  • 同一时间只能执行一个任务。
  • 任务一直执行到完成,不能被其他任务抢断。

这里提到的任务被分为同步任务异步任务

  • 同步任务会在执行栈中按照顺序等待主线程依次执行
  • 异步任务会在主线程空闲且异步任务结果已返回时,读取到执行栈内等待主线程执行
  • 同步任务总是比异步任务优先执行

浏览器中的异步任务又分为了两种:宏任务MacroTask和微任务MicroTask

  • 宏任务包含JS主线程,及一些其他的一些事件如页面加载、输入、网络请求和定时器。从浏览器的角度来看,macrotask代表一些离散的独立的工作

  • 微任务则是完成一些更新应用程序状态的较小任务,如处理promise的回调和监听DOM的修改,微任务应该以异步的方式尽可能地在浏览器重渲染前执行

根据上面的定义,可以总结浏览器中两类任务具体的API

  • 宏任务包括:setTimeoutsetIntervalI/O(如事件交互)、requestAnimationFrameUI Rendering
  • 微任务包括:Promise.thenMutationObserver

基于此,浏览器一般需要同时维护至少一个MacroTask队列和一个MicroTask队列。了解浏览器中的事件循环机制,就是要了解这两类异步任务的调用时机。

1.2. Event loop

浏览器中的事件循环模型为

  • 执行栈首先按顺序执行所有的同步任务,此阶段可能会注册一些点击事件、定时器、网络请求等回调,最后检查执行栈是否为空,
  • 如果执行栈为空,就会去检查微任务队列是否为空,
    • 如果不为空,则按顺序执行微任务队列中所有的微任务,最后将微任务队列置为空
    • 如果为空,则检查宏任务队列是否为空,如果有未执行的宏任务,则进行执行
  • 每个宏任务执行完毕后,就会重复第二步检查微任务队列是否为空
    • 如果此时微任务队列不为空,则会按顺序执行并清空微任务队列,然后执行下一个宏任务
    • 如果此时微任务队列为空,则直接执行下一个宏任务

因此在浏览器中,每执行完一个宏任务之后,都会在一次性执行完所有的微任务之后,才会执行下一个宏任务。下面这张图很形象的阐释了浏览器中的Event loop

1.3. 一些例子

接下来,我们通过分析一些例子来理解这个过程

// 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

2. Node 中的 Event Loop

事件循环也是 Node.js 最核心的概念之一,Node底层的 Libuv 维护一个 I/O 线程池(可以理解为一个任务队列),结合异步 I/O 的特性,使得单线程也能达到高并发。

2.1. 任务类别

Node中的异步任务也分为宏任务和微任务等

  • 宏任务包括:setTimeoutsetIntervalsetImmediateI/O 操作(如读取文件)等。
  • 微任务包括:Promise.then

此外还有一些特殊的API如

  • process.nextTick其行为与微任务相似

2.2. Event loop

下面这张图很详细阐述了NodeJS中的事件循环机制,[图片来源]([https://github.com/nswbmw/node-in-debugging/blob/master/3.6%20Event%20Loop.md](https://github.com/nswbmw/node-in-debugging/blob/master/3.6 Event Loop.md))

在NodeJS的Event loop一共分为6个阶段,每个细节具体如下:

  • timers:执行setTimeoutsetInterval中到期的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的锅

2.3. setTimeout 与 setImmediate

poll阶段是事件循环中最重要的一个阶段,其主要功能包括

  • 执行I/O回调
  • 处理轮询队列中的事件

当进入poll阶段时,会进行下面检测

  • 如果poll对应任务队列不为空,则先遍历队列并同步执行回调,直到队列清空或执行回调数达到系统上限。
  • 如果任务队列为空,这里有两种情况。
    • 如果代码已经被setImmediate设定了回调,那么事件循环直接结束poll阶段进入check阶段来执行check队列里的回调
    • 如果代码没有被设定setImmediate设定回调:
      • 如果有被设定的timers,那么此时事件循环会检查timers,如果有一个或多个timers下限时间已经到达,那么事件循环将绕回timers阶段,并执行timers的有效回调队列。
      • 如果没有被设定timers,这个时候事件循环是阻塞在poll阶段等待任务被加入poll队列。

因此在poll阶段任务队列为空的情况下,会优先检测setImmediate,然后才检测已到执行时间的timer,基于这个特许店,可以来解释一下setImmediatesetTimeout的区别

  • 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的回调永远先执行。

2.4. 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 ?

3. 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 环境的 执行结果不一致

4. 小结

本文整理了浏览器和NodeJS中的Event loop机制,需要注意他们二者的一些区别,在遇见事件循环的一些问题时,也要先考虑他们运行环境的差异带来的影响。主要理解

  • 在浏览器中,每执行完一个宏任务之后,都会在一次性执行完所有的微任务之后,才会执行下一个宏任务
  • 在Node中,每执行完一个宏任务之后,并不会立即执行微任务队列,而是继续执行宏任务,直到该阶段结束再执行process.nextTick及微任务队列

学习了事件循环的机制,相关的问题就能迎刃而解了。