[NodeJs系列][译]理解NodeJs中的Event Loop、Timers以及process.nextTick()

译者注:前端

  1. 为何要翻译?其实在翻译这篇文章前,笔者有Google了一下中文翻译,看的不是很明白,因此才有本身翻译的打算,固然能力有限,文中或有错漏,欢迎指正。
  2. 文末会有几个小问题,你们不妨一块儿思考一下
  3. 若是你对NodeJs系列感兴趣,欢迎关注微信公众号:前端神盾局或 github NodeJs系列文章

什么是Event Loop?

尽管JavaScript是单线程的,经过Event Loop使得NodeJs可以尽量的经过卸载I/O操做到系统内核,来实现非阻塞I/O的功能。node

因为大部分现代系统内核都是多线程的,所以他们能够在后台执行多个操做。当这些操做中的某一个完成后,内核便会通知NodeJs,这样(这个操做)指定的回调就会添加到poll队列以便最终执行。关于这个咱们会在随后的章节中进一步说明。git

Event Loop解析

当NodeJs启动时,event loop 随即会被初始化,然后会执行对应的输入脚本(直接把脚本放入REPL执行不在本文讨论范围内),这个过程当中(脚本的执行)可能会存在对异步API的调用,产生定时器或者调用process.nextTick(),接着开始event loop。github

译者注:这段话的意思是NodeJs优先执行同步代码,在同步代码的执行过程当中可能会调用到异步API,当同步代码和 process.nextTick()回调执行完成后,就会开始event loop

下图简要的概述了event loop的操做顺序:npm

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
注:每个框表明event loop中的一个阶段

每一个阶段都有一个FIFO(先进先出)的回调队列等待执行。虽然每一个阶段都有其独特之处,但整体而言,当event loop进入到指定阶段后,它会执行该阶段的任何操做,并执行对应的回调直到队列中没有可执行回调或者达到回调执行上限,然后event loop会进入下一阶段。api

因为任何这些阶段的操做可能产生更多操做,内核也会将新的事件推入到poll阶段的队列中,因此新的poll事件被容许在处理poll事件时继续加入队,这也意味着长时间运行的回调能够容许poll阶段运行的时间比计时器的阈值要长浏览器

注意:Windows和Unix/Linux在实现上有些差异,但这对本文并不重要。事实上存在7到8个步骤,但以上列举的是Node.js中实际使用的。

阶段概览

  • timers:执行的是setTimeout()setInterval()的回调
  • I/O callbacks:执行除了 close callbacks、定时器回调和setImmediate()设定的回调以外的几乎全部回调
  • idle, prepare:仅内部使用
  • poll:接收新的I/O事件,适当时node会阻塞在这里(==什么状况下是适当的?==)
  • checksetImmediate回调在这里触发
  • close callbacks:好比socket.on('close', ...)

在每次执行完event loop后,Node.js都会检查是否还有须要等待的I/O或者定时器没有处理,若是没有那么进程退出。微信

阶段细节

timers

一个定时器会指定阀值,并在达到阀值以后执行给定的回调,但一般来讲这个阀值会超过咱们预期的时间。定时器回调会尽量早的执行,不过操做系统的调度和其余回调的执行时间会形成必定的延时。多线程

注:严格意义上说,定时器何时执行取决于poll阶段

举个例子,假定一个定时器给定的阀值是100ms,异步读取文件须要95ms的时间异步

const fs = require('fs');

function someAsyncOperation(callback) {
  // 假定这里花费了95ms
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(function() {

  const delay = Date.now() - timeoutScheduled;

  console.log(delay + 'ms have passed since I was scheduled');
}, 100);


// 95ms后异步操做才完成
someAsyncOperation(function() {

  const startCallback = Date.now();

  // 这里花费了10ms
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

就本例而言,当event loop到达poll阶段,它的队列是空的(fs.readFile()还未完成),所以它会停留在这里直到达到最先的定时器阀值。fs.readFile()
花费了95ms读取文件,以后它的回调被推入poll队列并执行(执行花了10ms)。回调执行完毕后,队列中已经没有其余回调须要执行了,那么event loop就会去检查是否有定时器的回调能够执行,若是有就跳回到timer阶段执行相应回调。在本例中,你能够看到从定时器被调用到其回调被执行一共耗时105ms。

注:为了防止event loop一直阻塞在poll阶段,libuv( http://libuv.org/ 这是用c语言实现了Node.js event loop以及各个平台的异步行为的库)会指定一个硬性的最大值以阻止更多的事件被推入poll。

I/O callbacks阶段

这个阶段用于执行一些系统操做的回调,好比TCP错误。举个例子,当一个TCP socket 在尝试链接时接收到ECONNREFUSED的错误,一些*nix系统会想要获得这些错误的报告,而这都会被推到 I/O callbacks中执行。

poll阶段

poll阶段有两个功能:

  1. 执行已经达到阀值的定时器脚本
  2. 处理在poll队列中的事件

当event loop进入到poll阶段且此代码中为设定定时器,将会发生下面状况:

  1. 若是poll队列非空,event loop会遍历执行队列中的回调函数直到队列为空或达到系统上限
  2. 若是poll队列是空的,将会发生下面状况:

    • 若是脚本中存在对setImmediate()的调用,event loop将会结束poll阶段进入check阶段并执行这些已被调度的代码
    • 若是脚本中不存在对setImmediate()的调用,那么event loop将阻塞在这里直到有回调被添加进来,新加的回调将会被当即执行

一旦poll队列为空,event loop就会检查是否有定时器达到阀值,若是有1个或多个定时器符合要求,event loop将将会回到timers阶段并执行改阶段的回调.

check阶段

一旦poll阶段完成,本阶段的回调将被当即执行。若是poll阶段处于空闲状态而且脚本中有执行了setImmediate(),那么event loop会跳过poll阶段的等待进入本阶段。

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

一般而言,随着代码的执行,event loop最终会进入poll阶段并在这里等待新事件的到来(例如新的链接和请求等等)。可是,若是存在setImmediate()的回调而且poll阶段是空闲的,那么event loop就会中止在poll阶段漫无目的的等等直接进入check阶段。

close callbacks阶段

若是一个socket或者handle忽然关闭(好比:socket.destory()),close事件就会被提交到这个阶段。不然它将会经过process.nextTick()触发

setImmediate() 和 setTimeout()

setImmediatesetTimeout()看起来是比较类似,但它们有不一样的行为,这取决于它们何时被调用。

  • setImmediate() 被设计成一旦完成poll阶段就会被当即调用
  • setTimeout() 则是在达到最小阀值是才会被触发执行

其两者的调用顺序取决于它们的执行上下文。若是二者都在主模块被调用,那么其回调被执行的时间点就取决于处理过程的性能(这可能被运行在同一台机器上的其余应用影响)

好比说,若是下列脚本不是在I/O循环中运行,这两种定时器运行的顺序是不必定的(==这是为何?==),这取决于处理过程的性能:

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

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

$ node timeout_vs_immediate.js
immediate
timeout

可是若是你把上面的代码置于I/O循环中,setImmediate回调会被优先执行:

// 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()不属于event loop中的一部分,它会忽略event loop当前正在执行的阶段,而直接处理nextTickQueue中的内容。

回过头看一下图表,你在任何给定阶段调用process.nextTick(),在继续event loop以前,全部传入process.nextTick()的回调都会被执行。这可能会致使一些很差的状况,由于它容许你递归调用process.nextTick()从而使得event loop没法进入poll阶段,致使没法接收到新的 I/O事件

为何这会被容许?

那为何像这样的东西会被囊括在Node.js?部分因为Node.js的设计理念:API应该始终是异步的即便有些地方是不必的。举个例子:

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

这是一段用于参数校验的代码,若是参数不正确就会把错误信息传递到回调。最近process.nextTick()有进行一些更新,使得咱们能够传递多个参数到回调中而不用嵌套多个函数。

咱们(在这个例子)所作的是在保证了其他(同步)代码的执行完成后把错误传递给用户。经过使用process.nextTick()咱们能够确保apiCall()的回调老是在其余(同步)代码运行完成后event loop开始前调用的。为了实现这一点,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()(函数名能够看出),但实际上操做是同步的。当它被调用时,其回调也在event loop中的同一阶段被调用了,由于someAsyncApiCall()实际上并无任何异步动做。结果,在(同步)代码尚未所有执行的时候,回调就尝试去访问变量bar

经过把回调置于process.nextTick(),脚本就能完整运行(同步代码所有执行完毕),这就使得变量、函数等能够先于回调执行。同时它也有阻止event loop继续执行的好处。有时候咱们可能但愿在event loop继续执行前抛出一个错误,这种状况下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', () => {});

当只有一个端口做为参数传入,端口会被当即绑定。因此监听回调可能被当即调用。问题是:on('listening') 回调在那时还没被注册。

为了解决这个问题,把listening事件加入到nextTick() 队列中以容许脚本先执行完(同步代码)。这容许用户(在同步代码中)设置任何他们须要的事件处理函数。

process.nextTick() 和 setImmediate()

对于用户而言,这两种叫法是很类似的但它们的名字又让人琢磨不透。

  • process.nextTick() 会在同一个阶段执行
  • setImmediate() 会在随后的迭代中执行

本质上,这两个的名字应该互换一下,process.nextTick()setImmediate()更接近于当即,可是因为历史缘由这不太可能去改变。名字互换可能影响大部分的npm包,天天都有大量的包在提交,这意味这越到后面,互换形成的破坏越大。因此即便它们的名字让人困惑也不可能被改变。

咱们建议开发者在全部状况中使用setImmediate(),由于这可让你的代码兼容更多的环境好比浏览器。

为何要使用process.nextTick()?

这里又两个主要的缘由:

  1. 让开发者处理错误、清除无用的资源或者在event loop继续以前再次尝试从新请求资源
  2. 有时须要容许回调在调用栈展开以后但在事件循环继续以前运行

下面这个例子会知足咱们的指望:

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

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

假设listen()是在event loop开始前运行,可是监听回调是包裹在setImmediate中,除非指定hostname参数不然端口将被当即绑定(listening回调被触发),event loop必需要执行到poll阶段才会去处理,这意味着存在一种可能:在listening事件的回调执行前就收到了一个链接,也就是至关于先于listening 触发了connection事件。

另外一个例子是运行一个继承至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', function() {
  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(function() {
    this.emit('event');
  }.bind(this));
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
  console.log('an event occurred!');
});

译者注(Q&A)

翻译完本文,笔者给本身提了几个问题?

  1. poll阶段何时会被阻塞?
  2. 为何在非I/O循环中,setTimeoutsetImmediate的执行顺序是不必定的?
  3. JS调用栈展开是什么意思?
  4. 为何process.nextTick()能够被递归调用?

笔者将在以后的文章《Q&A之理解NodeJs中的Event Loop、Timers以及process.nextTick()》探讨这些问题,有兴趣的同窗能够关注笔者的公众号: 前端情报局-NodeJs系列获取最新情报

原文地址: https://github.com/nodejs/nod...

image

相关文章
相关标签/搜索