likes
comments
collection

NodeJS中的Event Loop(事件循环)机制

作者站长头像
站长
· 阅读数 16

前言

在阅读本文之前可以将事件循环规范作为导读,无论浏览器还是NodeJS里的事件循环机制规定都来源于此。

NodeJS EventLoop

本节翻译自The Node.js Event Loop, Timers, and process.nextTick(),翻译内容加入了自己的理解。

NodeJS中的EventLoop使NodeJS能够进行非阻塞的I/O操作,尽管JavaScript是单线程的,可以通过将I/O操作交给系统内核去执行。

当某个I/O操作结束的时候,内核会将对应的callback函数元信息交给NodeJS中的poll queue(轮询队列)。

概述

当NodeJS开始工作的时候就会初始化EventLoop机制,处理我们的JavaScript脚本。当我们的脚本中调用了异步函数,计时器或者process.nextTick()的时候,EventLoop机制就会开始介入。

下图简明地展示了EventLoop的操作顺序:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

上面的每个方格展示了EventLoop的不同阶段。对应上文中的task queue,代表不同的taskQueue在不同阶段的处理机制。

每个阶段都维持着一个先进先出的回调函数队列。每个阶段都有特殊的职能,当EventLoop执行到该阶段的时候,会将该时刻在该阶段的队列中存在的所有回调函数执行完或者执行到回调函数数量的上限(NodeJS自己规定的参数),然后跳转到下一个阶段。

由于脚本中的某些操作会调度更多的操作,在poll(轮询)阶段新事件是由内核排队。因此在处理poll阶段事件的时候可以将其他的事件加入到poll阶段中。因此,长时间运行回调函数会让poll阶段的运行时间比计时器规定的阈值长地多,我们设定的计时阈值不一定准确在该时刻执行。

阶段描述

timers(计时阶段):这个阶段执行由setTimeout() 和 setInterval() 调度的回调。

pending callbacks(回调待处理阶段):这个阶段执行延迟到下一次循环迭代的I/O回调。

idle, prepare(闲置阶段):仅仅被NodeJS内部使用。(这里是根据官方文档描述的,不能甚解)

poll(轮询阶段):这个阶段检索I/O事件,执行I/O事件对应的回调函数(除了关闭回调、计时器调度的回调和 setImmediate() 之外的几乎所有异步操作),都将会在这个阶段被处理。

check(检测阶段):setImmediate()函数的回调函数将会在这里被处理。

close callbacks(关闭回调阶段):例如:socket.on('close', ...)

每个阶段都有对应的异步操作,当异步操作需要触发回调函数的时候是先将回调函数放在对应阶段的queue中,等待EventLoop机制进入到该阶段再去执行queue中排好队的回调函数。放入队列中的是回调函数的元信息。

timers(计时阶段)

计时器指定可以执行提供的回调的阈值,但是这个阈值不一定是人们希望执行的确切时间。当执行到setTimeout() 和 setInterval(),经过指定的时间阈值之后,Timers queue中对应的回调函数将会尽快被执行。可是,操作系统的调度机制和其他回调函数都有可能延迟执行Timers queue中的回调函数。

事实上,poll阶段决定着什么时候执行timers queue中的回调。

如下代码所示:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

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

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

fs.readFile('/path/to/file', callback);会使EventLoop进入poll阶段,由于fs.readFile()没有执行结束,此时poll queue中的回调函数为空,因此在没有意外的情况下等待一段时间等到timers阶段规定的时间阈值100ms去执行timers阶段对应的回调函数。但是在95ms的时候。fs.readFile()执行完毕,文件读取结束,接下来会将fs.readFile()对应的回调函数添加进poll queue并执行10ms。当poll queue中fs.readFile()对应的回到函数执行结束以后,EventLoop会查看在poll queue中是否有其他的回调函数,目前查看暂无,于是EventLoop将进入timers queue中按照队列的弹出顺序执行对应的回调函数。在本例中,setTimeout()对应的回调函数将会在105ms后执行。

pending callbacks(回调待处理阶段)

这个阶段执行操作系统的一些操作,例如TCP errors。例如,如果 TCP 套接字在尝试连接时收到 ECONNREFUSED,则某些 *nix 系统会将该错误挂起在pending callback queue中排队执行。

poll(轮询阶段)

轮询阶段有两个主要的职能:

  • 计算I/O操作应该阻塞和轮询的时间
  • 在poll queue中处理事件。

当EventLoop进入poll街二段并且当前timers queue为空,将会:

  • 如果poll queue不为空,EventLoop将会一直执行poll queue中的回调函数,直到poll queue为空或者到了NodeJS的硬限制。

  • 如果poll queue为空,将会:

    • 如果调用了setImmediate()函数,EventLoop会结束poll阶段的执行,进入check阶段执行setImmediate()对应的回调函数。
    • 如果没有调用setImmediate()函数,EventLoop会停留在poll阶段,当poll queue中添加了回到函数的时候会被立即执行。

一旦poll queue为空,EventLoop会立即检查timers queue中是否有回调函数,如果有将会立刻从poll阶段返回timers阶段去执行timers queue中的回到函数。

check(检测阶段)

这个阶段允许用户立即执行回调函数(在poll queue为空的情况)。setImmediate() 实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。 它使用 libuv API 来安排在轮询阶段完成后执行的回调。

通常,随着代码的执行,EventLoop最终会进入poll阶段,在那里它将等待传入的连接、请求等。但是,如果使用 setImmediate() 安排了回调并且poll阶段变为空闲,则EventLoop将结束并进入check阶段,而不是等待轮询事件。

close callbacks(关闭回调阶段)

如果套接字或句柄突然关闭(例如 socket.destroy()),则将在此阶段发出 'close' 事件。 否则它将通过 process.nextTick() 发出。

参考文章