Node.js 中的 Event Loop

注意区分:

  • nodejsevent是基于libuv,而浏览器的event loop则在html5的规范 (opens new window)中明确定义
  • libuv已经对event loop作出了实现,而html5规范中只是定义了浏览器中event loop的模型,具体实现留给了浏览器厂商。

# NodeJS中的event loop

关于nodejs中的event loop有两个地方可以参考,一个是nodejs官方的文档 (opens new window);另一个是libuv官方的文档 (opens new window),前者已经对nodejs有一个比较完整的描述,而后者则有更多细节的描述。

看图:

image from dependency

  • 首先我们能看到我们的js代码(APPLICATION)会先进入v8引擎,v8引擎中主要是一些setTimeout之类的方法。
  • 其次如果我们的代码中执行了nodeApi,比如require('fs').read()node就会交给libuv库处理,这个libuv库是别人写的,他就是node的事件环。
  • libuv库是通过单线程异步的方式来处理事件,我们可以看到work threads是个多线程的队列,通过外面event loop阻塞的方式来进行异步调用。
  • 等到work threads队列中有执行完成的事件,就会通过EXECUTE CALLBACK回调给EVENT QUEUE队列,把它放入队列中。
  • 最后通过事件驱动的方式,取出EVENT QUEUE队列的事件,交给我们的应用

# 一、nodeJSEvent Loop阶段

Node.js启动时,会做这几件事:

  1. 初始化event loop
  2. 开始执行脚本(或者进入REPL)。这些脚本有可能会调用一些异步API、设定定时器或者调用process.nextTick()
  3. 开始处理event loop

处理event loop的过程如图所示:

image from dependency

图中每个方框都是event loop中的一个阶段。

每个阶段都有一个先入先出队列,这个队列存有要执行的回调函数地址。不过每个阶段都有其特有的使命。一般来说,当event loop 达到某个阶段时,会在这个阶段进行一些特殊的操作,然后执行这个阶段的队列里的所有回调。 下列两种情况之一会停止止执行这些回调:

  • 队列的操作全被执行完了
  • 执行的回调数目到达指定的最大值 然后,event loop 进入下一个阶段,然后再下一个阶段

一方面,上面这些操作都有可能添加计时器;另一方面,操作系统会向poll队列中添加新的事件,当poll 队列中的事件被处理时可能会有新的poll事件进入poll 队列。结果,耗时较长的回调函数可以让event looppoll 阶段停留很久,久到错过了计时器的触发时机。

注意,Windows的实现和Unix/Linux 的实现稍有不同,不过对本文内容影响不大。本文囊括了event loop最重要的部分,不同平台可能有七个或八个阶段,但是上面的几个阶段是我们真正关心的阶段,而且是Node.js 真正用到的阶段。

# 二、各阶段概览

  • timers阶段:这个阶段执行setTimeoutsetInterval的回调函数。
  • I/O callbacks阶段:不在timers阶段、close callbacks阶段和check 阶段这三个阶段执行的回调,都由此阶段负责,这几乎包含了所有回调函数。
  • idle, prepare阶段:event loop内部使用的阶段。
  • poll阶段:获取新的I/O事件。在某些场景下Node.js 会阻塞在这个阶段。
  • check阶段:执行setImmediate()的回调函数。
  • close callbacks阶段:执行关闭事件的回调函数,如socket.on('close', fn)里的fn

一个Node.js程序结束时,Node.js会检查event loop 是否在等待异步I/O 操作结束,是否在等待计时器触发,如果没有,就会关掉event loop

# 三、各阶段详解

# 3.1 timers阶段

计时器指定多久以后可以执行某个回调函数。当指定的时间达到后,计时器的回调函数会尽早被执行。如果操作系统很忙,或者Node.js正在执行一个耗时的函数,那么计时器的回调函数就会被推迟执行。

注意,从原理上来说,poll 阶段能控制计时器的回调函数什么时候被执行。

举例来说,你设置了一个计时器在100 毫秒后执行,然后你的脚本用了95毫秒来异步读取了一个文件:

const fs = require('fs');

function someAsyncOperation(callback) {
  // 假设读取这个文件一共花费 95 毫秒
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}毫秒后执行了 setTimeout 的回调`);
}, 100);


// 执行一个耗时 95 毫秒的异步操作
someAsyncOperation(() => {
  const startCallback = Date.now();

  // 执行一个耗时 10 毫秒的同步操作
  while (Date.now() - startCallback < 10) {
    // 什么也不做
  }
});

event loop进入poll阶段,发现poll 队列为空(因为文件还没读完),event loop 检查了一下最近的计时器,大概还有100毫秒时间,于是event loop决定这段时间就停在poll阶段。在poll阶段停了95毫秒之后,fs.readFile操作完成,一个耗时10 毫秒的回调函数被系统放入poll队列,于是event loop 执行了这个回调函数。执行完毕后,poll队列为空,于是event loop去看了一眼最近的计时器, event loop发现,已经超时95 + 10 - 100 = 5毫秒了,于是经由check阶段、close callbacks阶段绕回到timers阶段,执行timers 队列里的那个回调函数。这个例子中,100 毫秒的计时器实际上是在105毫秒后才执行的。

注意:为了防止poll阶段占用了event loop 的所有时间,libuvNode.js用来实现event loop和所有异步行为的C语言写成的库)对poll 阶段的最长停留时间做出了限制,具体时间因操作系统而异。

# 3.2 I/O callbacks阶段

这个阶段会执行一些系统操作的回调函数,比如TCP 报错,如果一个TCP socket开始连接时出现了ECONNREFUSED 错误,一些*nix系统就会(向 Node.js)通知这个错误。这个通知就会被放入I/O callbacks队列。

# 3.3 poll阶段(轮询阶段)

poll阶段有两个功能:

  1. 如果发现计时器的时间到了,就绕回到timers 阶段执行计时器的回调。
  2. 然后再执行poll队列里的回调。

event loop进入poll阶段,如果发现没有计时器,就会:

  1. 如果poll队列不是空的,event loop 就会依次执行队列里的回调函数,直到队列被清空或者到达poll 阶段的时间上限。
  2. 如果poll队列是空的,就会:
  • 如果有setImmediate()任务,event loop就结束poll 阶段去往check阶段。
  • 如果没有setImmediate()任务,event loop 就会等待新的回调函数进入poll队列,并立即执行它。

一旦poll队列为空,event loop 就会检查计时器有没有到期,如果有计时器到期了,event loop 就会回到timers阶段执行计时器的回调。

# 3.4 check阶段

这个阶段允许开发者在poll阶段结束后立即执行一些函数。如果 poll阶段空闲了,同时存在setImmediate()任务,event loop就会进入check阶段。

setImmediate() 实际上是一种特殊的计时器,有自己特有的阶段。它是通过libuv 里一个能将回调安排在poll阶段之后执行的API实现的。

一般来说,当代码执行后,event loop最终会达到poll 阶段,等待新的连接、新的请求等。但是如果一个回调是由 setImmediate()发出的,同时poll阶段空闲下来了,event loop就会结束poll阶段进入check阶段,不再等待新的poll事件。

# 3.5 close callbacks阶段

如果一个socket或者handle被突然关闭(比如 socket.destroy()),那么就会有一个close 事件进入这个阶段。否则,这个close事件就会经过 process.nextTick()触发。

# 微任务和宏任务

每次执行栈的同步任务执行完毕,就会去任务队列中取出完成的异步任务,队列中又分为microtasks queues和宏任务队。等到把microtasks queues所有的microtasks都执行完毕,注意是所有的,他才会从宏任务队列中取事件。等到把队列中的事件取出一个,放入执行栈执行完成,就算一次循环结束,之后event loop继续循环,他会再去microtasks queues执行所有的任务,然后再从宏任务队列里面取一个,如此反复循环。

  • 同步任务执行完
  • 去执行microtasks,把所有microtasks queues清空
  • 取出一个macrotasks queues的完成事件,在执行栈执行
  • 再去执行microtasks
  • ...
  • ...
  • ...

# macrotasksmicrotasks的区别

  • macrotasks: setTimeoutsetIntervalsetImmediateI/OUI渲染
  • microtasks: Promiseprocess.nextTickObject.observeMutationObserver

Promise/A+的规范 (opens new window)中,Promise的实现可以是微任务,也可以是宏任务,但是普遍的共识表示(至少Chrome是这么做的),Promise 应该是属于微任务阵营的

看图:

image from dependency

绿色小块是macrotask(宏任务),macrotask 中间的粉红箭头是microtask(微任务)。

举个例子:

setTimeout(()=>{
    console.log('A');
},0);
var obj={
    func:function () {
        setTimeout(function () {
            console.log('B')
        },0);
        return new Promise(function (resolve) {
            console.log('C');
            resolve();
        })
    }
};
obj.func().then(function () {
    console.log('D')
});
console.log('E');
  1. 首先setTimeout A被加入到事件队列中==> 此时macrotasks中有[‘A’]
  2. obj.func()执行时,setTimeout B 被加入到事件队列中==> 此时macrotasks中有[‘A’,‘B’]
  3. 接着return一个Promise对象,Promise 新建后立即执行 执行console.log('C'); 控制台首次打印‘C’;
  4. 然后,then方法指定的回调函数,被加入到microtasks当前脚本所有同步任务执行完才会执行。 ==> 此时microtasks中有[‘D’]
  5. 然后继续执行当前脚本的同步任务,故控制台第二次输出‘E’
  6. 此时所有同步任务执行完毕,如上所述先检查microtasks其中所有任务,故控制台第三次输出‘D’
  7. 最后再执行macrotask的任务,并且按照入队列的时间顺序,控制台第四次输出‘A’,控制台第五次输出‘B’

# 四、setImmediate() vs setTimeout()

setImmediatesetTimeout 很相似,但是其回调函数的调用时机却不一样。

setImmediate()的作用是在当前poll 阶段结束后调用一个函数。 setTimeout() 的作用是在一段时间后调用一个函数。 这两者的回调的执行顺序取决于setTimeoutsetImmediate 被调用时的环境。

举例来说,如果在主模块中运行下面的脚本,那么两个回调的执行顺序是无法判断的:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

运行结果如下:

$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

setTimeout/setInterval的第二个参数取值范围是:[1, 2^31 - 1],如果超过这个范围则会初始化为1,即setTimeout(fn, 0) === setTimeout(fn, 1)。我们知道setTimeout的回调函数在timer阶段执行,setImmediate的回调函数在 check阶段执行,event loop的开始会先检查timer 阶段,但是在开始之前到timer 阶段会消耗一定时间,所以就会出现两种情况:

  1. timer前的准备时间超过1ms,满足loop->time >= 1,则执行timer阶段(setTimeout)的回调函数
  2. timer前的准备时间小于1ms,则先执行check 阶段(setImmediate)的回调函数,下一次event loop执行 timer阶段(setTimeout)的回调函数

但是,如果把上面代码放到I/O操作的回调里,setImmediate的回调就总是优先于setTimeout的回调:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

运行结果如下:

$ node timeout_vs_immediate.js
immediate
timeout

fs.readFile的回调函数执行完后:

  1. 注册setTimeout的回调函数到timer阶段
  2. 注册setImmediate的回调函数到check
  3. event looppool阶段出来继续往下一个阶段执行,恰好是check阶段,所以setImmediate的回调函数先执行
  4. 本次event loop结束后,进入下一次event loop,执行 setTimeout的回调函数

所以,在I/O Callbacks中注册的setTimeoutsetImmediate,永远都是setImmediate先执行。

# 五、process.nextTick()

在任何一个阶段调用process.nextTick(回调),回调都会在当前阶段继续运行前被调用。这种行为有的时候会造成不好的结果,因为可以递归地调用process.nextTick(),这样event loop 就会一直停在当前阶段不走,无法进入poll阶段。

比如如下代码:

setInterval(() => {
  console.log('setInterval')
}, 100)

process.nextTick(function tick () {
  process.nextTick(tick)
})

运行结果:setInterval永远不会打印出来。

process.nextTick会无限循环,使event loop停留在当前阶段,无法进入timers阶段。

解决方法通常是用setImmediate替代process.nextTick,如下:

setInterval(() => {
  console.log('setInterval')
}, 100)

setImmediate(function immediate () {
  setImmediate(immediate)
})

setImmediate内执行setImmediate会将immediate函数注册到下一次event loopcheck阶段,而不是当前正在执行的check阶段,所以给了event loop上其他阶段执行的机会。

# 六、process.nextTick() vs setImmediate()

process.nextTick()的回调会在当前event loop阶段「立即」执行。 setImmediate()的回调会在后续的event loop 周期(tick)执行。

推荐开发者在任何情况下都使用setImmediate(),因为它的兼容性更好,而且它更容易理解。

# 七、什么时候用process.nextTick()

使用的理由有两个:

  1. 让开发者处理错误、清除无用的资源,或者在event loop当前阶段结束前尝试重新请求资源
  2. 有时候有必要让一个回调在调用栈unwind之后,event loop进入下阶段之前执行

为了让代码更合理,我们可能会写这样的代码:

const server = net.createServer();
server.on('connection', (conn) => { });

server.listen(8080);
server.on('listening', () => { });

假设listen()event loop一启动的时候就执行了,而listening事件的回调被放在了setImmediate()里,listen 动作是立即发生的,如果想要event loop执行listening回调,就必须先经过poll阶段,当时poll 阶段有可能会停留,以等待连接,这样一来就有可能出现connect事件的回调比listening事件的回调先执行。这显然不合理,所以我们需要用 process.nextTick

再举一个例子,一个类继承了EventEmitter,而且想在实例化的时候触发一个事件:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

不能直接在构造函数里执行this.emit('event'),因为这样的话后面的回调就永远无法执行。把this.emit('event')放在process.nextTick()里,后面的回调就可以执行,这才是我们预期的行为:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);

  // use nextTick to emit the event once a handler is assigned
  process.nextTick(() => {
    this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

# 参考文献:

Event Loop、计时器、nextTick (opens new window)

原文: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/ (opens new window)

Event Loop 必知必会(六道题) (opens new window)

node基础面试事件环?微任务、宏任务?一篇带你飞 (opens new window)

微任务、宏任务与Event-Loop (opens new window)