Node.js 指南(Node.js事件循环、定时器和process.nextTick())

Node.js事件循环、定时器和process.nextTick()

什么是事件循环?

事件循环容许Node.js执行非阻塞I/O操做 — 尽管JavaScript是单线程的 — 经过尽量将操做卸载到系统内核。node

因为大多数现代内核都是多线程的,所以它们能够处理在后台执行的多个操做,当其中一个操做完成时,内核会告诉Node.js,以即可以将相应的回调添加到轮询队列中以最终执行,咱们将在本主题后面进一步详细解释。npm

事件循环解释

当Node.js启动时,它初始化事件循环,处理提供的可能会进行异步API调用、调度定时器或调用process.nextTick()的输入脚本(或放入REPL,本文档未涉及),而后开始处理事件循环。segmentfault

下面的图解显示了事件循环操做顺序的简要概述。api

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

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

每一个阶段都有一个要执行的回调FIFO队列,虽然每一个阶段都以其本身的方式特殊,但一般状况下,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操做,而后在该阶段的队列中执行回调,直到队列耗尽或已执行最大回调数。当队列耗尽或达到回调限制时,事件循环将移至下一阶段,依此类推。多线程

因为任何这些操做均可以调度更多操做,而且在轮询阶段处理的新事件由内核排队,轮询事件能够在处理轮询事件时排队,所以,长时间运行的回调能够容许轮询阶段的运行时间远远超过定时器的阈值,有关详细信息,请参阅timerspoll部分。异步

注意:Windows和Unix/Linux实现之间存在轻微差别,但这对于此示范并不重要,最重要的部分在这里,实际上有七到八个步骤,但咱们关心的是 — Node.js实际使用的那些 — 是上面那些。socket

阶段概述

  • timers:此阶段执行由setTimeout()setInterval()调度的回调。
  • pending callbacks:执行延迟到下一个循环迭代的I/O回调。
  • idle, prepare:仅在内部使用。
  • poll:检索新的I/O事件;执行与I/O相关的回调(几乎全部,除了close callbacks、由定时器调度的一些和setImmediate());node将在适当的时候在这里阻塞。
  • check:这里调用setImmediate()回调函数。
  • close callbacks:一些关闭回调,例如socket.on('close', ...)

在事件循环的每次运行之间,Node.js检查它是否在等待任何异步I/O或定时器,若是没有,则完全关闭。async

阶段的细节

timers

定时器指定阈值,在该阈值以后能够执行提供的回调而不是人们但愿它执行的确切时间,定时器回调将在指定的时间事后能够调度,可是,操做系统调度或其余回调的运行可能会延迟它们。函数

注意:从技术上讲,轮询阶段控制什么时候执行定时器。

例如,假设你在100毫秒阈值后调度执行超时,那么你的脚本将异步读取一个耗时95毫秒的文件:

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()还没有完成),因此它将等待剩余的ms数,直到达到最快的定时器阈值,当它等待95毫秒经过,fs.readFile()完成了读取文件,其须要10毫秒完成的回调被添加到轮询队列并执行,当回调结束时,队列中再也不有回调,所以事件循环将看到已达到最快定时器的阈值而后回到定时器阶段以执行定时器的回调,在此示例中,你将看到正在调度的定时器与正在执行的回调之间的总延迟将为105毫秒。

注意:为了防止轮询阶段耗尽事件循环,libuv(实现Node.js事件循环的C库以及平台的全部异步行为)在中止轮询更多事件以前,还具备硬性最大值(取决于系统)。

pending callbacks

此阶段执行某些系统操做(例如TCP错误类型)的回调,例如,若是TCP socket在尝试链接时收到ECONNREFUSED,某些*nix系统要等待报告错误,这将在等待回调阶段排队执行。

poll

轮询阶段有两个主要功能:

  1. 计算它应该阻塞和轮询I/O的时间。
  2. 而后处理轮询队列中的事件。

当事件循环进入轮询阶段而且没有定时器被调度时,将发生如下两种状况之一:

  • 若是轮询队列不为空,则事件循环将遍历其同步执行它们的回调队列,直到队列已用尽,或者达到系统相关的硬限制。
  • 若是轮询队列为空,则会发生如下两种状况之一:

    • 若是setImmediate()已调度脚本,则事件循环将结束轮询阶段并继续执行检查阶段以执行这些调度脚本。
    • 若是setImmediate()还没有调度脚本,则事件循环将等待将回调添加到队列,而后当即执行它们。

轮询队列为空后,事件循环将检查已达到时间阈值的定时器,若是一个或多个定时器准备就绪,事件循环将回绕到定时器阶段以执行那些定时器的回调。

check

此阶段容许人员在轮询阶段完成后当即执行回调,若是轮询阶段变为空闲而且脚本已使用setImmediate()排队,则事件循环能够继续到检查阶段而不是等待。

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

一般,在执行代码时,事件循环最终将进入轮询阶段,在此阶段它将等待传入链接、请求等,可是,若是已使用setImmediate()调度回调而且轮询阶段变为空闲,则它将结束并继续到检查阶段,而不是等待轮询事件。

close callbacks

若是socket或handle忽然关闭(例如socket.destroy()),则在此阶段将发出'close'事件,不然它将经过process.nextTick()发出。

setImmediate()setTimeout()

setImmediate()setTimeout()相似,但行为方式不一样,取决于他们什么时候被调用。

  • 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周期内,则始终首先执行immediate回调:

// 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()的主要优势是setImmediate()将始终在任何定时器以前执行(若是在I/O周期内调度),与存在多少定时器无关。

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: Maximum call stack size exceeded from v8

这种理念可能会致使一些潜在的问题,以此片断为例:

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()的回调在事件循环的同一阶段被调用,由于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()在事件循环开始时运行,可是监听回调放在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!');
});

上一篇:阻塞与非阻塞概述

下一篇:不要阻塞事件循环(或工做池)

相关文章
相关标签/搜索