nodejs事件循环,Timer和process.nextTick()

avatarplhDigital nomad

什么是node.js事件循环??

这个事件循环是一个允许nodejs表现的不阻塞的I/O操作 -- 尽管事实上JavaScript是一个单线程 -- 通过尽可能将操作系统写在到系统内核。 直至今日,大多数现代内核是多线程,他们可以在后台处理多个操作的执行。当一种一个完成,内核告诉nodejs,所以适当的回调可以将他重新添加到池队列中,并且最终执行。我们将要在这个主题的最后解释他。

event循环解释

当Node.js开始,它初始化事件循环,进程提供输入脚本,这让异步API调用,计划定时器。或者调用process.nextTick(),接着开始进程的事件循环 下面这个图形展示了一个简单的,大概的事件循环概述。

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
注意,每个盒子都是事件循环的一个阶段。

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

阶段描述

  • timers:这个阶段执行setTimeout(),setInterval().
  • I/O callbacks:执行几乎所有的回调函数,除了关闭回掉函数,timers预定的以及setImmediate();
  • idle,prepare:只在内部使用。
  • poll:检索新的I/O事件。node可能会在这里阻塞。
  • check:setImmediate()将会在这调用
  • 关闭调用:例如socket.on('close',...);

阶段详细说明

timers 一个定时器在之后可以执行提供的回调后,指定阙值。定时器将在指定的时间经过计划运行。然而,操作系统调整或者其他回调的运行可能延迟他们。 注意:从技术上说,轮询阶段控制何时执行定时器。 例如:假设你计划一个超过100ms阙值后执行超市,那么你的脚本开始异步都需一个文件需要95ms

const fs = require('fs');
function someAsyncOperaTion(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('./README.md', 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) {
    const delay = Date.now() - timeoutScheduled;
    // do nothing
    console.log(delay);
  }
});

每当事件循环进入轮询期,它有一个空的队列(fs.readFile())没有完成,所以它会等待剩余的ms数,知道达到最快的定时器阙值,当等待95ms时候通过。fs.readFile()完成读取文件并且他会调用一个花了10ms去完成,这个将会被加入队列池并执行,当这个调用完成,事件循环将会看到已经达到最快计时器的扩至,然后回到计时器阶段以执行计时器的回调,在这个例子,你将会看到总延迟(计时器开始计划的时候 - 他的回调被执行将会是105ms)。

注意:来阻止轮询期饥饿事件循环,libuv(c包,实现了nodejs事件循环和所有的异步行为的平台)也有一个困难的最大(系统依赖)再它停止轮询更多事件的时候

I/O调用

这个期间执行回调,为一些一同操作,例如TPC错误类型ECONNERFUSED,当试图连接,一些*nix系统,想要等待来报错,这将会队列的执行I/O调用时期。

poll(轮询)

这个轮询期有两个主要函数

  • 为阙值过期的定时器执行脚本。
  • 处理轮询队列中的事件

当事件循环进入轮询期并且这里没有定时器被计划,下面这两件事情要发生,

  • 如果轮询队列不为空,事件循环将要遍历其,通过他的队列被调用同步执行他们,直到队列执行完毕。或者达到硬件限制。
  • 如果轮询队列为空,这里有两件事会发生,
    • 如果降本被setImmediate计划的,这个事件循环将要结束轮询期,并继续检查执行这些预设脚本。
    • 如果脚本没有被setImmediate计划的,事件循环将要等待调用,来将他们添加到队列,并立即执行他们。

一旦轮询队列为空,事件循环将要检查计时器(时间阙值已经达到),如果一个或多个计时器被准备,这个时间循环将要被计时器回调。

check

这个阶段允许一个人再轮询阶段结束后,立即执行回调。如果轮询阶段变得空闲,被setImmediate()调用的脚本在队列中,这件循环也许会继续检查轮询而不是等待。

setImmediate()是一个真实的特殊计时器,它在事件循环单独一个阶段来运行。它使用一个libuv的API,这个被计划调用来执行,在轮询完成之后。

一般来说,作为一个被执行的代码,这个事件轮询终将会到达轮询阶段,这里它将要会等待一个增长连接,请求等...,然而,如果一个回调已经被计划setImmediate()并且这个投标阶段变得空闲,它将会结束并且继续去到检查阶段而不是等待轮询事件。

关闭调用

如果一个socket或者一个handle被关闭,(例如:sockety.destroy()),这个close事件将会发射在这个阶段,否则,它将会通过process.nextTick()发射.

setImmediate()vssetTimeout()

setImmediate和setTimeout是相似的,但是行为的不同取决于他们什么时候被调用。

  • setImmediate()是一个用于在当前轮询阶段完成后执行脚本。,所以他后面不用带时间参数,轮询阶段完成后立即执行
  • setTimeout计划脚本在经过最小阙值后运行,他的后面要带时间参数。

这个定时器被执行的顺序取决于他被调用的上下文。如果两个被调用来自于主模块内,这个定时器将会被执行顺序约束(可能会受到机器上运行的其他应用程序的影响)。 例如,如果我们运行如下脚本,他们不在I/O循环内(例如主模块),这两个定时器的执行顺序是非确定性的,他受性能的影响。(ps:翻译到这里,就非常有意思了。)

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总是先执行,不管有几个定时器。

fs.readFile('./README.md', () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('Immediate');
  });
});

process.nextTick()

理解 process.nextTick()

你也许注意过process.nextTick()没有出现在上面那个对话图中,甚至,他是异步API的一部分,这是因为``process.nextTick()在技术上不是循环事件的一部分(高级语法。。。技术地,真尼玛难翻译),相反,nextTickQueue将在当前操作完成后处理,无论当前阶段是不是事件循环 回看上面我们画的那个对画图,任何时候你调用process.nextTick()在给定阶段。所有的调用传递给process.nextTick()·将会在事件循环被解决。这也能创造一样坏的情况,因为它允许你通过递归你的I/O事件.他和能创造一些坏的场景应为它允许你让你的I/O坏死,通过process.nextTick()`的调用,,这阻止事件循环到达轮询阶段。

为什么这要被允许。

为什么一些像这样的被包括在nodejs里面,一部分是因为他是一个设计哲学,一个API应该异步,甚至他不需要这样,以下面代码片段为例子:

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

这个片段应该是一个参数检查,并且如果他是部队的,他会传递出error作为回调。最近更新的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(),因为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', () => {});

只有当唯一的port传递过去之后,这个端口是立即绑定。所以,这个listening立即回调,这个问题是.on('listening')调用这个时候还没有被设置。 为了得到这个,这个listening事件队列在一个nextTick()来允许脚本运行完成,这个允许用户来设置一些事件

process.nextTick() vs setImmediate()

我们有两个调用,他们是相似的,名字不一样

  • process.nextTick()在同一个时期立即运行
  • setImmediate()运行在接下来的迭代,或者**事件循环

本质上,这个名字应该换,process.nextTick()运行更快,但这个是一个人造品,来传递,制造这个开关会打破大部分npm包。每天更多模块正在添加。这意味着我们等的每天,更多潜在破损在发生。当他们被拒绝的,这个名字自己将不会改变。 我们认识到开发者使用setImmediate()在所有的例子中,因为它更容易推理。

为什么使用process.nextTick()?

这有两个主要理由:

  • 允许用户处理错误,清理他们的不需要的资源,或者,在事件循环阶段再次尝试请求。
  • 有时需要在调用堆栈解除之后但事件循环继续之前允许回调运行。

一个例子是匹配用户意图。例如:

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

Reference: