Event Loop - Node.js

263 阅读4分钟

Event Loop

Event Loop 目的

JavaScript 是单线程的,一个任务需要等待前一个任务结束才能执行,这种模式可以称为“同步”(synchronous I/O)或“阻塞”(blocking I/O)。

Event Loop 则提供了非阻塞(non-blocking I/O)的运行模式。

和多线程不一样,你可以想象 Event Loop 是一个服务员服务几个桌子,而多线程则是多个服务员服务多个桌子。

Event Loop 介绍

以下调用都会触发 event loop :

  • 异步 API 调用
  • 定时器安排
  • process.nextTick()

以下是 event loop 内部执行的图表:

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

每一次 event loop 的循环称为一个 tick

每一个阶段都有一个回调队列,当 event loop 到达某个阶段的时候,会以先进先出的原则执行队列中的回调,执行的数量会有个机制上限。

各阶段概述

  • timers:执行 setTimeout()setInterval() 所计划安排的回调
  • pending:执行一些有关系统操作(一般是 I/O 请求)的回调
  • idle, prepare:仅供 Node 自身使用
  • poll:寻找新的 I/O 事件
  • check:执行 setImmediate() 的回调
  • close:执行所有 .on('close') 事件回调

Node 会在每个 event loop 之间检查是否有异步操作或者计时器,如果没有的话 Node 会关闭。

计时器 timers

一个计时器明确了回调执行的(时间)起点,且这个回调会尽可能地按计划时间运行,但是大部分情况下是会延迟一点。回调并不会精确地按给定时间调用

实际上 poll 阶段会控制计时器何时执行。

举个例子:

const fs = require('fs');

setTimeout(() => {
    console.log('finish timeout')
}, 100)

// 假设 `fs.readFile()` 使用了 95 ms 读取文件
fs.readFile('./text', () => {
    // 假设回调的执行使用了 10 ms
    console.log('finish read file')
})

在这个例子中,安排了一个起始点为 100 ms 的 timeout 计时器,然后开始执行异步文件读取。当 event loop 进入 poll 阶段时,该阶段队列实际上是空的(fs.readFile() 还没有执行完),因此它会一直等到计时器起始点到达。95 ms 过去,fs.readFile() 结束了文件的读取,并将其回调加入了 poll 队列并执行。当回调使用 10 ms 执行完毕后,队列又是空的了,这个时候 event loop 又回到 timers 阶段去执行计时器的回调。这样下来计时器的回调会在 105 ms 开始执行。

为了防止 poll 阶段持续运行造成的 event loop “饥饿”,libuv(Node.js event loop 的实现)会设定一个最大值(取决于系统)来停止轮询更多的事件。

待执行回调 pending callbacks

这个阶段会执行一些有关系统操作(一般是 I/O 请求)的回调。

当执行异步操作(比如 fs.readFile)时,Node 会向系统发送 I/O 请求,当 I/O 操作结束了或者遇到了错误,该异步操作的回调会放进 pending 队列中,并会在下一个 pending callbacks 中执行。

轮询 poll

poll 阶段有两个主要功能:

  1. 计算它应当多久以后阻塞然后轮询 I/O ,然后
  2. 运行 poll 队列中的事件

当 event loop 进入 poll 阶段时且没有任何安排好的的计时器,以下两种会有一种发生:

  • 如果 poll 队列不是空的,event loop 将会在最大数量限制(和系统有关)下,以同步方式迭代执行队列中的回调
  • 如果 poll 队列是空的,以下两种会有一种发生:
    • 如果脚本被 setImmediate() 计划了,则会立即结束 poll 阶段,然后进入 check 阶段去执行 setImmediate() 的回调
    • 如果脚本没有被 setImmediate() 计划,event loop 会等待回调加入队列中,然后立即执行回调

检查 check

这个阶段允许开发者立即执行一些回调(在 poll 阶段之后),如果 poll 阶段闲置或者被 setImmediate() 堆积,则会继续 check 阶段而非等待(即上文所述)。

实际上 setImmediate() 是一个运行在单独阶段的特别的计时器。

总的来说(Node 文档里再三强调 ^_^ ),当代码执行时,event loop 最终会进入 poll 阶段来等待将要到来的连接、请求等等。但是,如果一个回调被 setImmediate() 计划安排了,poll 阶段会立即终止,然后进入 check 阶段。

事件关闭 close callbacks

如果 socket 或者句柄突然关闭了,close 事件会在这个阶段触发。除此以外也可以通过 process.nextTick() 触发。 `

setImmediate() vs setTimeout()

这两个函数很相似,但是不同的场景下会表现不同:

  • setImmediate() 被设计用来在 poll 阶段后执行
  • setTimeout() 用来一段时间后执行脚本

通常计时能力会受系统性能限制,在非 I/O 周期中(比如主模块),两种计时器的执行顺序是无法决定的:

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

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

但是在 I/O 周期中,setImmediate() 总是先执行:

const fs = require('fs')

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

process.nextTick()

技术上 process.nextTick() 并不是 event loop 的一部分。取而代之的是,nextTickQueue 会在当前操作执行完后运行,不管当前 event loop 是哪个阶段。这里的操作被定义为一个到 C/C++ 句柄的转变,然后去处理 JavaScript 所要执行的东西。

在任何阶段调用 process.nextTick() ,其回调都会在 event loop 继续运作之前执行。但这一机制的滥用可能会造成 I/O 操作的饥饿,阻止 event loop 进入 poll 阶段。

process.nextTick() vs setImmediate()

记住,process.nextTick() 会在当前阶段立即执行,会比 setImmediate() 更“立即”一点。

Node 建议在各种情况下都使用 setImmediate() ,因为推理起来容易一点。

为什么使用 process.nextTick()

  1. 允许开发者处理错误、清除不需要的资源,或者是在 event loop 继续运作之前再次尝试发起请求
  2. 一些必要的情况下需要在调用堆栈(call stack)展开之后、event loop 继续运作之前执行回调

参考

The Node.js Event Loop, Timers, and process.nextTick() - Node.js

什么是 Event Loop? - 阮一峰

A complete guide to the Node.js event loop - Piero Borrelli

What you should know to really understand the Node.js Event Loop - Daniel Khan

How the Event Loop Works in Node.js - heynode