Node.js 中的 Event Loop, Timers, 和 process.nextTick()

原文来自 https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

什么是 Event Loop(事件循环)?

事件循环是用来让 Node.js 执行非阻塞 I/O 操作的 —— 尽管 JavaScript 是单线程 —— 尽可能的向主流系统内核执行 offloading 操作。

因为大部分内核模型都是多线程的,他们可以在后台执行多个操作。当这些操作中的某一个完成时内核会通知 Node.js, 因此恰当的回调函数可能会被加入到 轮询 队列来被最后执行。我们稍后将更详细地解释这个主题。

事件循环的概念

当 Node.js 开始运行时,它就初始化了事件循环,并且进程提供了一种输入脚本机制(或者顺便进入 REPL,本文没有包括这个话题),可以被用来调用异步 API、计划定时器、或者是调用 process.nextTick(),然后开始执行事件循环。

下图简单的概述了事件循环的操作顺序。

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

note: 每一个盒子都被称为事件循环的一个"阶段".

每一个阶段都含有一个 FIFO(先进先出)的回调队列可以被执行。虽然每个阶段都有特殊性,但普遍地,当事件循环进入到一个设定好的阶段时,它将执行这个特定阶段的任何操作,这个阶段队列中的回调函数会一直被执行,直到这个队列再没有回调函数或者已执行的回调函数量超过最大阈值。当这个队列再没有回调函数或者已执行的回调函数量超过最大阈值时,时间循环会进到下一阶段,以此类推。

因为这些操作中的每一个都可能在 轮询 阶段被内核加入 更多 的操作或者新的事件进程到队列中,所以当轮询事件执行的时候,可能有其他轮询事件来排列等待执行。结果是,长时间的执行回调函数甚至可以比定时器的阈值执行时间还要长。可以查看 定时器轮询 章节来了解更多细节。

_NOTE: 这个地方在 Windows 和 Unix/Linux 的实现上有略微差异,但是对我们的范例并不重要。重要的是,这里实际上有七到八个步骤,但是我们只需要关心 Node.js 实际使用的,那就是上面所说的那些。

阶段概述

  • timers(定时器): 这个阶段执行已经计划好的 setTimeout()setInterval() 的回调函数。
  • pending callbacks(等待回调函数阶段): 被延迟到下一个循环迭代执行的 I/O 回调函数。
  • idle, perpare: 只在内部使用。
  • poll(轮询): 重新获取到新的 I/O 事件;执行 I/O 相关的回调函数(除了 close 类的 callbacks、已经计划好的定时器和 setImmediate() 之外的几乎所有);节点在恰当的时候会阻塞在这里。
  • check: 这个会调用 setImmediate() 的回调函数。
  • close callbacks: 一些关闭类的 callbacks,比如 socket.on('close', ...)

在每次事件循环运行之间,Node.js 会检查是否有正在等待执行的任何异步 I/O 操作或者定时器,如果没有了的话则会关闭。

阶段详情

timer(定时器)

一个定时器指定一个在其之后可能执行所提供的回调函数的 阈值,而不是在一个 准确 的时间点被执行。定时器的回调函数会在指定的时间过去之后尽可能早地运行,然后他们(定时器的回调函数)可能会因为操作系统正在计算或者执行其他回调函数而被延迟执行。

_: 从技术上讲,是 轮询 阶段 控制定时器何时被执行。

举个例子,比如你设定了一个定时器在 100ms 的阈值后执行,这时你的脚本开始异步读取一个耗时 95ms 的文件:

const fs = require('fs');

function someAsyncOperation(callback) {
   // 假设这个操作耗时 95ms 完成
   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);

// 95ms 完成后执行 someAsyncOperation 函数
someAsyncOperation(() => {
   const startCallback = Date.now();

   while (Date.now() - startCallback < 10) {
      // do nothing
   }
});

当事件循环进入 轮询 阶段时,它是一个空队列(fs.readFile() 还没有完成),所以它会等待最早最快的定时器阈值到达。等待了 95ms 后,fs.readFile() 完成了读文件的操作,它的回调函数加入到了 轮询 队列并消耗了 10ms 然后被执行。此时回调函数完成了,轮询中没有其他回调函数在队列中了,因此事件循环会查看是否有最快的定时器回调到达阈值了,然后绕回 定时器 阶段去执行定时器的回调函数。在这个例子中,你将会看到总的延迟时间是定时器的阈值和回调函数被执行的时间相加也就是 105 ms。

注:为了防止事件循环中 轮询 阶段一直执行(starving),libuv(实现了 Node.js 的事件循环机制和平台中所有异步行为的 C 语言仓库)会在停止轮询所有事件的之前有一个严格的最大阈值(阈值大小取决于系统)。

pending callbacks(等待回调)

这个阶段会执行一些系统操作(如各种类型的 TCP 错误)的回调函数。比如,如果在尝试连接一个 TCP 协议时接收到了 ECONNREFUSED 的报警,一些 *nix 系统想要等待报告这个错误。它将会被排在 pending callbacks 阶段执行。

poll(轮询)

poll 阶段主要有两个函数:

  1. 计算 I/O 操作应该会被阻塞和轮询多长时间,然后
  2. 执行 轮询 队列中的事件

当事件循环进入 轮询 阶段并且没有计划完成的定时器时,下面两个事情会执行其中一个:

  • 如果 轮询 队列不是空的,事件循环将会同步的迭代执行所有队列中的回调函数直到队列中的回调函数都执行完成或者到达系统设定的执行数最大阈值。
  • 如果 轮询 队列空了,下面两个事情会执行其中一个:
    • 如果脚本中会计划好的 setImmediate() 函数,事件循环会跳出 轮询 阶段并进入 check(检查) 阶段执行计划好的脚本。
    • 如果脚本中 没有 计划好的 setImmediate() 函数,事件循环将会等待回调函数被加入到队列中,然后立即执行它们。

一旦 轮询 阶段空了,事件循环会检查定时器的时间阈值是否到了。如果定时器的时间阈值到了,事件循环会绕回到 定时器 阶段执行这些定时器的回调函数。

check(检查)

这个阶段会在 轮询 阶段完成后立即执行回调函数。如果 轮询 阶段闲置了并且 setImmediate 的回调函数已经被排到队列中了,事件循环已经不会等待直接进入 check 阶段。

setImmediate 实际上是一个特别的定时器,它被事件循环执行在一个单独的阶段。它使用了一个 libuv API,这个 API 设定了一个回调函数会在 轮询 阶段完成后执行。

通常,当代码被执行时,事件循环会最后执行 轮询 阶段(等待一个连接,请求,等等)。然而,如果有一个 setImmediate() 的回调函数被计划好了,并且 轮询 阶段是闲置的,那它将会结束并进入到 检查 阶段而不是一直等待 轮询 事件。

close callbacks(结束类的回调函数)

如果一个 socket 或者 handle 突然被关闭了(比如 socket.destory()),那个 'close' 事件会在这个阶段被触发。另外它将会通过 process.nextTick() 被触发。

setImmediate() vs setTimeout()

setImmediatesetTimeout 是类似的,但是根据它们被调用的时机表现出不同的方式。

  • setImmediate() 被设计为一旦当前的 轮询 阶段完成就会被执行的脚本。

  • setTimeout() 被设计为经过一个经过多少 ms 最小时间阈值之后会执行的脚本。

计时器执行的顺序会根据调用它们的上下文而变化。如果两者都是从主模块中被调用的,那么计时将受到进程性能的制约(机器上运行的其他应用程序的影响)。

举个例子,如果我们执行下面的代码(不在 I/O 轮询中,即主模块),那么这两个定时器执行的顺序是不确定的,会受到进程性能的制约。

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

setImmediate(() => {
   console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

然而,如果你在一个 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

$ node timeout_vs_immediate.js
immediate
timeout

与使用 setTimeout 相比 setImmediate 主要的优势是,如果在一个 I/O 周期调用 setImmediate(),那么它总是比任何一个定时器都先执行,并不受存在多少个定时器的影响。

process.nextTick()

了解 process.nextTick()

你可能注意到了,process.nextTick() 并没有在图例中出现,虽然它是异步 API 的一部分。这是因为 process.nextTick() 在技术上并不是事件循环的一部分。反而,nextTickQueue 会在当前操作完成之后执行,无论事件循环正处于什么阶段。回头再看一下我们的图例,在事件循环给定的任意一个阶段中使用 process.nextTick(),所有通过 process.nextTick() 的回调函数都会在事件循环要继续之前被执行。这也会创造出一些不好的情况,因为 它允许你通过制造递归的 process.nextTick 方法来使你的 I/O 一直处于"饥饿"状态,即阻止事件循环到达 轮询 阶段。

为什么这种情况被允许

为什么 Node.js 会包含这种情况?它的这一部分是一种设计理念,即 API 应该一直是异步的,尽管它不应该是。那下面的代码举个例子:

function apiCall(arg, callback) {
   if (typeof arg !== 'string')
      return process.nextTick(callback, new TypeError('argument shoud be string'));
}

这段代码执行了一个参数检查,并且如果类型不正确会把错误传递给回调函数中。API 最近进行了更新,允许给 process.nextTick() 传递参数,通过在回调函数后面传递其他任何参数到回调函数中,这样就不需要嵌套函数了。

我们要做的是将一个错误返回给用户,但是这仅仅是在我们已经允许用户的其余代码执行 之后 了。通过使用 process.nextTick() 我们确保 apiCall() 总是在用户的其余代码 之后 执行它的回调函数,并且在事件循环被允许进行的 之前。要做到这一点,JS 调用栈就会允许立即执行函数提供一个回调函数,这个回调函数允许递归的使用 process.nextTick() 而不是抛出 RangeError: Maximum call stack size exceeded from v8

这种设计会导致一些潜在的问题状况。拿下面的代码举例:

let bar;

// 这是一个异步的函数,但是执行一个同步的回调函数
function someAsyncApiCall(callback) { callback(); }

// 这个回调在 `someAsyncApiCall` 完成前被执行了
someAsyncApiCall(() => {
   // 在 someAsyncApiCall 完成时, bar 没有拿到任何值
   console.log('bar', bar); // undefined
});

bar = 1;

用户定义了 someAsyncApiCall() 有一个异步的标记,但是实际上操作是同步的。当它运行时,回调函数就提供给了 someAsyncApiCall() 来执行在相同的事件循环阶段因为 someAsyncApiCall() 实际上没有任何的异步操作。因此,回调函数尝试引用 bar ,虽然在作用域中可能还有这个变量,因为脚本还没有运行完成。

通过在回调函数替换到 process.nextTick() 中执行,脚本仍然可以完成运行,允许所有的定义变量,函数,等等,初始化变量会优先回调函数执行。它还有一个优势是可以不允许事件循环继续执行。这一点可能会帮助用户在允许事件循环继续执行前抛出一个错误。下面是使用 process.nextTick() 实现刚才的示例:

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' 的回调函数会立即被调用。但是问题是 .on('listening') 在那时候还没有被设定。

为了解决这个问题, 'listening' 事件要在一个 nextTick() 中排队来允许脚本完成后再执行。这允许用户设置任何他们想要的事件处理程序。

process.nextTick() vs setImmediate

就用户而言,我们有两个类似的调用,但是它们的命名令人困惑。

  • process.nextTick() 在同一阶段立即被调用

  • setImmediate 在事件循环的迭代序列中或者在 'tick' 中被调用

本质上,它们的命名应该相互交换。process.nextTick() 会比 setImmediate 先执行,但是这是历史中无法改变的事实。改变它将会破坏 npm 中相当大比例的包。每天都会加入大量新的模块,意味着我们要等待每天大量潜在的破坏会发生。所以尽管这两者比较混乱但是它们的命名也不会被改变。

我们推荐开发者在所有的情况下使用 setImmediate() 因为它更容易推理 (并且它在更宽泛的环境中兼容性更高,比如浏览器脚本。)

为什么使用 process.nextTick()?

主要有两点原因:

  1. 允许使用者抛出错误,清理不需要的资源,或者尝试在事件循环继续之前重新发送请求。

  2. 有时,确实需要在调用栈解除后但在事件循环继续之前执行回调。

这个例子很符合用户的期望。比如:

const net = require('net');

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

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

listen() 是运行在事件循环的一开始的,但是 listening 的回调函数被放在 setImmediate 中。除非一个端口号被传入,绑定的端口号会立即生效。为了使事件循环继续,它需要到达 轮询 阶段,这意味着有这样的可能性:连接可能已经接收到在监听事件之前的连接事件调用方法。

另一个例子是运行一个构造函数,它继承自 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!');
});

你不能立即从构造函数中触发一个事件,因为脚本还没有处理到用户为这个事件设置一个回调函数的位置。因此,在这个构造函数内部,你可以使用 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!');
});
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容