浏览器和 Node.js 的事件循环机制

Libuv

Libuv 是 Node.js 关键的组成部分. 它包含了定时器, 非阻塞的网络 I/O, 异步文件系统访问, 子进程等功能. 它封装了 Libev, Libeio 以及 IOCP, 保证了跨平台的通用性. Libuv 的组成大致如下:

libuv_architecture

Node.js 事件循环

事件循环(event loop)是 Node.js 实现非阻塞 I/O 的关键. 尽管 JavaScript 的执行是单线程的, 但事件循环将操作尽可能的交由系统内核去执行. 而由于现代系统内核都是多线程的, 因此可以在后台同时处理多个操作. 当操作中的一个任务完成时, 系统内核告诉 Node.js 将回调添加到轮询队列中以等待执行.

Node.js 的事件循环可以用下图表示

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

上面的每一阶段都有一个先进先出的回调队列等待执行. 当事件循环进入该阶段时, 它将执行该阶段应该做的操作, 然后执行该阶段的队列中的回调, 直到队列耗尽或最大数量的回调执行. 之后事件循环将进入下一阶段.

长时间的回调的执行可能使得 poll 阶段的运行时间比 timers 的阀值更长.

  • timers

    this phase executes callbacks scheduled by setTimeout() and setInterval().

    该阶段会执行到期的定时器如 setTimeout 和 setInterval 的回调.

  • I/O callbacks

    executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate().

    该阶段执行某些系统操作的回调比如 TCP 错误. 当 TCP socket 连接出现 CONNREFUSED 错误时, *nix 系统会把它加入到 I/O callbacks 等待处理.

  • idle, prepare

    only used internally.

  • poll

    retrieve new I/O events; node will block here when appropriate.

    poll 阶段有两个重要任务:

    1. 执行到达阀值的计时器的回调
    2. 处理轮询队列(poll 队列)中的事件

    该阶段会首先检查计时器, 如果没有计时器需要被调度, 则

    • 如果轮询队列不为空, 事件循环将循环遍历轮询队列, 并同步的执行回调直到队列为空或者达到系统限制.
    • 如果轮询队列为空
    • 如果 immediateList 队列不为空, 则进入 check 阶段来执行 setImmediate 的任务
    • 如果 immediateList 队列为空, 则等待任务被加入到轮询队列然后执行, 一旦检测到则立即执行其回调

    当轮询队列为空时, 事件循环会去检查那些已经到达阀值的定时器, 如果有定时器已经准备好, 则事件循环将重新进入 timer 阶段去执行定时器回调.

  • check

    setImmediate() callbacks are invoked here.

    该阶段允许用户在 poll 阶段后立即执行一段回调. 如果 poll 阶段变为空闲并且脚本已经通过 setImmediate 排队, 那么事件循环会继续进入 check 阶段而不是继续等待

    setImmediate 实际上是一个在事件循环中的一个单独阶段运行的特殊定时器, 它使用了 libuv 的 API 来调度回调在轮询阶段完成后执行.

    一般的, 事件循环将进入 poll 阶段进行等待, 等待传入连接, 请求等等. 除非有任务通过 setImmediate 调度并且当前 poll 阶段为空闲, 事件循环将结束 poll 阶段进入 check 阶段.

  • close callbacks

    e.g. socket.on('close', ...).

    如果一个 socket 被突然关闭, 例如通过 socket.destroy(), 那么 close 事件将会在该阶段被触发. 否则它将通过 process.nextTick() 发出.

因此, 理解每个阶段的任务之后, 我们来理解 setTimeout, setImmediate 的执行时机就不难了.

  • setTimeout/setInterval 会在 timers 阶段执行, poll 阶段为空闲时且 immediateList 队列为空时. 它会检测 timer, 如果有准备好的 timer, 则切入 timers 阶段去执行. setTimeout 是被设计在超过阀值时后要运行的脚本.
  • setImmediate 只在 check 阶段执行, 通常是 poll 阶段为空闲时且 immediateList 不为空时切入 check 阶段执行. setImmediate 被设计用来在 poll 阶段执行完成后执行一段用户代码.

如果我们在非 I/O 回调中执行以下脚本, 那么这两个定时器(setImmediate 是是一个在事件循环中的一个单独阶段运行的特殊定时器)的执行顺序是不确定的. 因为它受到进程性能的限制.

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

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

但在 I/O 回调中他们的顺序就是确定的, setImmediate 总会先于 setTimeout 先执行. 因为 poll 阶段同步的执行 poll 队列的回调, 此时 setTimeout 和 setImmediate 各自注册了定时器, 之后当轮询队列为空时, 检查 immediateList 队列从而进入 check 阶段执行 immediateTask, 而后回到 poll 阶段之后才检测 timer 并进入 timers 阶段.

process.nextTick 是比较特殊的, 因为它并不是事件循环的一部分. 事实上, nextTickQueue 会在当前操作处理后执行, 无论它当前处在事件循环的哪一个阶段. process.nextTick 的问题在于他可能会导致其他阶段的任务被饿死, 并阻止事件循环进入到 poll 阶段. 既然如此, 为什么要设计这样一个 API 呢?

因为有时候我们并不希望一段代码立即被执行, 而是希望其在当前阶段处理完毕之后才执行. 我们通过以下代码来理解 process.nextTick 的用途:

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) { callback(); }

// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall has completed, bar hasn't been assigned any value
  console.log('bar', bar); // undefined
});

bar = 1;

我们期望在 someAsyncApiCall 中执行一个函数, 我们希望它是异步的行为, 但是事实上, someAsyncApiCall 并没有任何异步表现, 这段代码运行的记过就是 bar 会立即输出并且为 undefined. 因为当 someAsyncApiCall 的函数被执行时, bar 还没有被初始化值, 因为脚本还没有执行到那里.

如果我们把 callback 放到 process.nextTick 中, 那么 callback 将在当前脚本被执行完毕, 即变量初始化, 函数初始化等等完成之后才执行.

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

举个真实的例子:

const server = net.createServer(() => {}).listen(8080);

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

事实上, 第一行会被立即执行结束, 但 listening 事件不会立即被触发, 因为 server.on('listening') 还没执行呢, listening 事件会通过 process.nextTick 等待用户代码执行完成之后才执行.

有时候我们需要抛出错误, 但希望等待后面的代码执行完后再抛出, 也可以通过 process.nextTick 来处理.

那么 setImmediateprocess.nextTick 的区别也很明显的出来了, 相比 setImmediate 在 poll 阶段空闲后进入 check 阶段执行,process.nextTick 总是在当前事件轮询所处阶段执行完毕之后立即执行.

以上事件轮询部分大部分来源 event-loop-timers-and-nexttick, 这是 nodejs 官网的文章, 强烈建议去看一下.

浏览器环境的事件循环

说完了 Node.js 的事件轮询, 那么对于同样使用 V8 的 Chrome/Chromium, 其事件轮询又是怎样的呢?

浏览器的事件循环比较复杂, 因为涉及到 DOM. 我只是简单的介绍下.

根据 WHATWG 标准, 事件循环有两种, 一种用于浏览上下文环境, 一种用于 workers. 其事件循环模型如下:

event-loop

简单的说,大多数任务都是宏任务, 包括 EventTarget 指定的事件, HTML 解析, 回调执行, 网络请求, DOM 操作等等. 常见的微任务是 Promise (图片中把 DOM mutations 也归入了 microtask, 这个应该是错误的). 另外, process.nextTick 也是归入到微任务, 比如 Vue 就用 Promise 来模拟 nextTick.

如图所见, 事件循环, 每次执行一个宏任务, 然后执行多个微任务直到没有微任务为止.

浏览器事件循环和 Node.js 事件循环的差异

综上, 我们直到浏览器事件循环和 Node.js 事件循环是完全不一样的. 举个粟子:

setTimeout(() => {
  console.log('setTimeout1')
  Promise.resolve().then(() => console.log('promise1'))
})
Promise.resolve().then(() => {
  console.log('promise2')
  setTimeout(() => console.log('setTimeout2'))
})

如果这段代码在 Node.js 环境中执行, 结果有两种可能:

promise2 - setTimeout1 - setTimeout2 - promise1
promise2 - setTimeout1 - promise1 - setTimeout2

根据 Promise A+ 规范, Promise 在 resolve/reject 后, 其回调被加入到微任务中. 还没有看 Node.js 原生 Promise 的实现, 但我猜测它应该是通过 process.nextTick 加入到微任务中. 在上面这段代码执行到 Promise 时, 应该立即 resolved 了然后回调被加入到 nextTickQueue 中. 因此代码执行结束后, Promise 的回调会被调用. 因此先是输出了 promise2. 同时注册了一个新的计时器.

接着由于 nextTickQueue 为空, 进入事件循环下一阶段, 有 timer 就绪, 进入 timers 阶段执行了第一个计时器, 从而输出了 setTimeout1, 同时 Promise 立即执行, 也往 nextTickQueue 加入了一个任务. 但接下来, 我们可以看到结果有两种可能, 可能是 promise1 先输出, 也可能是 setTimeout2 先输出.

按照前面所说的, 第一个定时器结束后应该会立即执行第二个执行器(如果它已经准备好了), 但是我们可以看到它并不一定在当前事件循环的该阶段执行, 这是为什么呢? emmmmm.... 我也不知道, 而且如果把 setTimeout 全部改为 setImmediate, 那么只会有前面那种结果. 这里 todo 下...

在浏览器环境中, 根据一个宏任务多个微任务的说法, 那么结果自然就是

promise2 - setTimeout1 - promise1 - setTimeout2

无论是在 firefox 还是 chromium 都是这个结果.

这里选用 promise 和 setTimeout 这两个, 主要是因为这两个在 node 和浏览器都受支持.


参考资料:

  1. [timers] setImmediate executes after setTimeout
  2. Node.js 探秘:初识单线程的 Node.js
  3. event-loop-timers-and-nexttick
  4. Process.nextTick 和 setImmediate 的区别?
  5. WHATWG event-loops

标签: JavaScript, Node.js

知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。

添加新评论

This page loaded in 0.001244 seconds