「翻译」Node.js 事件循环、计时器和process.nextTick()

什么是事件循环?

事件循环允许Node.js执行非阻塞I/O操作 - 尽管JavaScript是单线程的 - 只要可能就将操作卸载到系统内核。

由于大多数现代内核都是多线程的,他们可以在后台处理多个正在执行的操作。 当其中一个操作完成时,内核会通知Node.js,以便可以将相应的回调添加到轮询队列中得到最终执行。 我们将在本主题后面进一步详细解释这一点。

事件循环解释

当Node.js启动时,它就会初始事件循环,处理提供的输入脚本(或者进入REPL:Read-Eval-Print-Loop,本文不会提及。),这可能会导致异步API调用,调度定时器或调用process.nextTick(),然后开始处理事件循环。

下面的图表展示的是一个事件循环的操作顺序的简单概述:

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

注意:每个方框将被称为事件循环的一个“阶段”。

每个阶段有一个要执行的先进先出的回调队列。虽然每个阶段都有其特定的方式,但通常情况下,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或已执行回调的最大数量。当队列耗尽或达到回调限制时,事件循环将移至下一个阶段,依此类推。

由于这些操作中的任何一个都可以调度更多操作,并且在轮询阶段处理的新事件由内核排队,所以轮询事件可以在轮询事件正在处理时排队。 因此,长时间运行的回调可以使轮询阶段的运行时间远远超过计时器的阈值。 有关更多详细信息,请参阅定时器和轮询部分。

注意:Windows和Unix/Linux实现之间略有差异,但对这里的表述不重要。 最重要的部分就在这里。 实际上有七八个步骤,但我们关心的那些 - Node.js实际使用的那些 - 就是上述这些。

阶段概述

  • timers: 这个阶段执行由setTimeout()setInterval() 注册的回调。
  • I/O callbacks: 执行几乎所有的回调,除了关闭回调,由定时器注册的回调,和setImmediate()
  • idle, prepare: 只是内部使用
  • poll: 检索新的I/O事件;在占用时节点将会阻塞在这里。
  • check: setImmediate()回调会在这里被调用。
  • close callbacks: 比如socket.on('close', ...)

在事件循环的每次运行之间,Node.js会检查它是否正在等待任何异步I/O或定时器,并在没有时清除关闭。

阶段详解

计时器

计时器规定了一个阈值,这个阈值是注册的回调才可能被执行的时间,而不是人们希望执行的确切时间。 定时器回调会尽可能早的在指定的时间过后执行; 但是,操作系统调度或其他回调的运行可能会延迟它们。

注意:技术上来讲,当计时器被执行时,poll阶段会进行控制。

比如:假设你注册了一个延时是在100ms之后调用,然后,你的脚本开始是异步读取一个文件花了95ms:

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()尚未完成),因此它将等待剩余的毫秒数,直到计时器的阈值最早达到。 当等待了95ms后,fs.readFile()完成读取文件,并且需要10ms来完成添加回调到轮询队列并执行的操作。当回调完成时,队列中没有更多的回调了,所以事件循环会看到已经达到最快计时器的阈值,然后回到计时器阶段以执行计时器的回调。在这个例子中,你会看到被调度的定时器和它正在执行的回调之间的总延迟将是105ms。

注意:为防止轮询阶段时事件循环挨饿空闲,在停止轮询之前,为了执行更多的事件,libuv(实现Node.js事件循环和平台所有异步行为的C库)也有一个硬性最大值(取决于系统)。

I/O callbacks

此阶段是执行某些系统操作(如TCP错误类型)注册的回调。例如,如果尝试连接时TCP套接字收到ECONNREFUSED,则某些*nix系统要等待报告该错误。这将排队在I/O回调阶段执行。

poll

在轮询阶段有两个主要功能:
对阈值已到的定时器执行脚本,然后处理轮询队列中的事件。
当事件循环进入轮询阶段并且没有计时器时,会发生以下两件事之一:

  • 如果轮询队列不为空,则事件循环将遍历其回调队列,同步执行它们,直到队列耗尽或达到系统相关的强制限值。

  • 如果轮询队列为空,则还发生以下两件事之一:

    • 如果脚本已通过setImmediate()进行了调度,则事件循环将结束轮询阶段并继续执行检查阶段以执行这些被调度的脚本。
    • 如果脚本没有通过setImmediate()进行调度,则事件循环将等待将回调添加到队列中,然后立即执行它们。

一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。 如果一个或多个定时器准备就绪,则事件循环将回退到定时器阶段以执行这些定时器的回调。

check

此阶段允许在轮询阶段结束后立即执行回调。 如果轮询阶段变得空闲并且脚本已经通过setImmediate()排队,则事件循环可能会继续检查阶段而不是等待。

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

通常,随着代码的执行,事件循环将最终进入轮询阶段,在那里它将等待传入的连接,请求等。但是,如果使用setImmediate()注册了回调并且轮询阶段变为空闲,事件循环将继续进行检查阶段,而不是等待轮询事件。

close callbacks

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

setImmediate() vs setTimeout()

setImmediate and setTimeout() 很相似,但其行为方式是不一样的,这取决于它们何时被调用。

setImmediate()用于在当前轮询阶段完成后执行脚本。
setTimeout()在经过最小阈值(以毫秒为单位)后调度脚本运行。
定时器执行的顺序取决于它们被调用的上下文。 如果两者都是在主模块内调用的,那么时序将受到进程性能的限制(可能会受到计算机上运行的其他应用程序的影响)。

例如,如果我们运行以下不在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周期内移动这两个调用,则立即回调总是首先执行:

// 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

使用setImmediate()超过setTimeout()的主要优点是: 如果在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 should be string'));
}

代码进行参数检查,如果不正确,它会将错误传递给回调函数。最近更新的API允许将参数传递给process.nextTick(),允许它将回调后传递的任何参数作为参数传播给回调函数,这样就不必嵌套函数了。

我们正在做的是将错误传递给用户,但只有在我们允许执行用户其余的代码之后。通过使用process.nextTick(),我们就能保证apiCall()总是在剩余代码之后且在允许事件循环继续之前运行其回调。为了达到这个目的,JS调用堆栈允许展开,然后立即执行提供的回调,这样就允许了开发者对process.nextTick()进行递归调用,而不会出现RangeError错误:超出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(),因为这更容易理解。(而且使得代码在更多环境中兼容,比如浏览器JS中。)

为什么使用 process.nextTick()?

有两个主要原因:

1、允许用户处理错误,清理任何不需要的资源,或者可能在事件循环继续之前再次尝试请求

2、有时需要在调用堆栈解除之后但事件循环继续之前允许回调运行。

下面这个简单的例子就符合用户期望:

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!');
});

查看原文

译者补充

1、node的核心思想就是异步,异步的实现是基于一个C库--libuv,这个库其实是又一层封装,针对windows和nix两种系统进行了不同的处理。
2、Node的I/O异步
I/O操作主要包括http请求,文件读取等。下图就是整个异步I/O流程:

IOchart.png

这里有三个概念:请求对象,IO观察者,IOCP,线程池。IOCP,是Windows的内核对象,
nix是通过其他方法模拟实现。以下摘自wikipedia:

输入输出完成端口(Input/Output Completion Port,IOCP), 是支持多个同时发生的异步I/O操作的应用程序编程接口。

原理:通常的办法是,线程池中的工作线程的数量与CPU内核数量相同,以此来最小化线程切换代价。一个IOCP对象,在操作系统中可关联着多个Socket和(或)文件控制端。 IOCP对象内部有一个先进先出(FIFO)队列,用于存放IOCP所关联的输入输出端的服务请求完成消息。请求输入输出服务的进程不接收IO服务完成通知,而是检查IOCP的消息队列以确定IO请求的状态。 (线程池中的)多个线程负责从IOCP消息队列中取走完成通知并执行数据处理;如果队列中没有消息,那么线程阻塞挂起在该队列。这些现成从而实现了负载均衡。

3、Node的非I/O异步
非I/O的异步操作包括:setTimeout(), setInterval(), process.nextTick(), setImmediate()

以setTimeout()行为为例:


setTimeout.png

结合译文中那张时间循环阶段图,I/O的异步是在I/O callbacks阶段,setTimeout()setInterval()是在timer阶段,事件循环中每一次循环会经过那几个阶段,在I/O callbacks阶段中主要是I/O观察者接收通知获取其回调函数及请求结果,然后在此阶段执行回调函数。timer阶段主要是检查由定时器放入的handles是否到达时间,从而执行回调。

对于process.nextTick()setImmediate(), 前者是idle观察者,后者是check观察者,在每一次循环中,idle观察者先于I/O观察者,I/O观察者先于check观察者。同时,process.nextTick()的回调保存在一个数组中,每次循环会将数组中的回调全部执行完,而setImmediate()的回调保存在链表中,每次循环只执行链表中的一个回调节点。

执行顺序实例:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  const startCallback = Date.now();
  fs.readFile('/koa.js', () => {
    console.log(`${Date.now() - startCallback}ms readfile`);
  });
  callback();
}

const timeoutScheduled = Date.now();

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

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

process.nextTick(() => {
  console.log('next tick1');
});

process.nextTick(() => {
  console.log('next tick2');
});

someAsyncOperation(() => {
  setImmediate(() => {
    console.log('setImmediate');
    process.nextTick(() => {
      console.log('next tick');
    })
  });
});

运行结果:

next tick1
next tick2
2ms readfile
setImmediate
next tick
103ms have passed since I was scheduled
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容