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

Posted 前端神盾局

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[NodeJs系列][译]理解NodeJs中的Event LoopTimers以及process.nextTick()相关的知识,希望对你有一定的参考价值。

译者注:

  1. 为什么要翻译?其实在翻译这篇文章前,笔者有Google了一下中文翻译,看的不是很明白,所以才有自己翻译的打算,当然能力有限,文中或有错漏,欢迎指正。

  2. 文末会有几个小问题,大家不妨一起思考一下

什么是Event Loop?

尽管javascript是单线程的,通过Event Loop使得NodeJs能够尽可能的通过卸载I/O操作到系统内核,来实现非阻塞I/O的功能。

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

Event Loop解析

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

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

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

 
   
   
 
  1.   ┌───────────────────────┐

  2. ┌─>│        timers        

  3.  └──────────┬────────────┘

  4.  ┌──────────┴────────────┐

  5.       I/O callbacks    

  6.  └──────────┬────────────┘

  7.  ┌──────────┴────────────┐

  8.       idle, prepare    

  9.  └──────────┬────────────┘      ┌───────────────┐

  10.  ┌──────────┴────────────┐         incoming:  

  11.           poll          │<─────┤  connections,

  12.  └──────────┬────────────┘         data, etc.  

  13.  ┌──────────┴────────────┐      └───────────────┘

  14.          check          

  15.  └──────────┬────────────┘

  16.  ┌──────────┴────────────┐

  17. └──┤    close callbacks    

  18.   └───────────────────────┘

注:每一个框代表event loop中的一个阶段

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

由于任何这些阶段的操作可能产生更多操作,内核也会将新的事件推入到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会阻塞在这里(==什么情况下是适当的?==)

  • check: setImmediate回调在这里触发

  • close callbacks:比如 socket.on('close',...)

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

阶段细节

timers

一个定时器会指定阀值,并在达到阀值之后执行给定的回调,但通常来说这个阀值会超过我们预期的时间。定时器回调会尽可能早的执行,不过操作系统的调度和其他回调的执行时间会造成一定的延时。

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

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

 
   
   
 
  1. const fs = require('fs');


  2. function someAsyncOperation(callback) {

  3.  // 假定这里花费了95ms

  4.  fs.readFile('/path/to/file', callback);

  5. }


  6. const timeoutScheduled = Date.now();


  7. setTimeout(function() {


  8.  const delay = Date.now() - timeoutScheduled;


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

  10. }, 100);



  11. // 95ms后异步操作才完成

  12. someAsyncOperation(function() {


  13.  const startCallback = Date.now();


  14.  // 这里花费了10ms

  15.  while (Date.now() - startCallback < 10) {

  16.    // do nothing

  17.  }

  18. });

就本例而言,当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()

setImmediate和 setTimeout()看起来是比较相似,但它们有不同的行为,这取决于它们什么时候被调用。

  • setImmediate() 被设计成一旦完成poll阶段就会被立即调用

  • setTimeout() 则是在达到最小阀值是才会被触发执行

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

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

 
   
   
 
  1. // timeout_vs_immediate.js

  2. setTimeout(function timeout() {

  3.  console.log('timeout');

  4. }, 0);


  5. setImmediate(function immediate() {

  6.  console.log('immediate');

  7. });

 
   
   
 
  1. $ node timeout_vs_immediate.js

  2. timeout

  3. immediate


  4. $ node timeout_vs_immediate.js

  5. immediate

  6. timeout

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

 
   
   
 
  1. // timeout_vs_immediate.js

  2. const fs = require('fs');


  3. fs.readFile(__filename, () => {

  4.  setTimeout(() => {

  5.    console.log('timeout');

  6.  }, 0);

  7.  setImmediate(() => {

  8.    console.log('immediate');

  9.  });

  10. });

 
   
   
 
  1. $ node timeout_vs_immediate.js

  2. immediate

  3. timeout


  4. $ node timeout_vs_immediate.js

  5. immediate

  6. 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应该始终是异步的即使有些地方是没必要的。举个例子:

 
   
   
 
  1. function apiCall(arg, callback) {

  2.  if (typeof arg !== 'string')

  3.    return process.nextTick(callback,

  4.                            new TypeError('argument should be string'));

  5. }

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

我们(在这个例子)所做的是在保证了其余(同步)代码的执行完成后把错误传递给用户。通过使用 process.nextTick()我们可以确保 apiCall()的回调总是在其他(同步)代码运行完成后 eventloop开始前调用的。为了实现这一点,JS调用栈被展开(==什么是栈展开?==)然后立即执行提供的回调,那我们就可以对process.nextTick进行递归(==怎么做到的?==)调用而不会触发 RangeError:Maximumcall stack size exceededfromv8的错误。

这种理念可能会导致一些潜在的问题。比如:

 
   
   
 
  1. let bar;


  2. // this has an asynchronous signature, but calls callback synchronously

  3. function someAsyncApiCall(callback) { callback(); }


  4. // the callback is called before `someAsyncApiCall` completes.

  5. someAsyncApiCall(() => {


  6.  // since someAsyncApiCall has completed, bar hasn't been assigned any value

  7.  console.log('bar', bar); // undefined


  8. });


  9. bar = 1;

用户定义了一个异步签名的函数 someAsyncApiCall()(函数名可以看出),但实际上操作是同步的。当它被调用时,其回调也在event loop中的同一阶段被调用了,因为 someAsyncApiCall()实际上并没有任何异步动作。结果,在(同步)代码还没有全部执行的时候,回调就尝试去访问变量 bar

通过把回调置于 process.nextTick(),脚本就能完整运行(同步代码全部执行完毕),这就使得变量、函数等可以先于回调执行。同时它也有阻止event loop继续执行的好处。有时候我们可能希望在event loop继续执行前抛出一个错误,这种情况下 process.nextTick()变的很有用。下面是对上一个例子的 process.nextTick()改造:

 
   
   
 
  1. let bar;


  2. function someAsyncApiCall(callback) {

  3.  process.nextTick(callback);

  4. }


  5. someAsyncApiCall(() => {

  6.  console.log('bar', bar); // 1

  7. });


  8. bar = 1;

这是一个实际的例子:

 
   
   
 
  1. const server = net.createServer(() => {}).listen(8080);


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

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

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

process.nextTick() 和 setImmediate()

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

  • process.nextTick() 会在同一个阶段执行

  • setImmediate() 会在随后的迭代中执行

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

我们建议开发者在所有情况中使用 setImmediate(),因为这可以让你的代码兼容更多的环境比如浏览器。

为什么要使用process.nextTick()?

这里又两个主要的原因:

  1. 让开发者处理错误、清除无用的资源或者在event loop继续之前再次尝试重新请求资源

  2. 有时需要允许回调在调用栈展开之后但在事件循环继续之前运行

下面这个例子会满足我们的期望:

 
   
   
 
  1. const server = net.createServer();

  2. server.on('connection', function(conn) { });


  3. server.listen(8080);

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

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

另一个例子是运行一个继承至 EventEmitter的构造函数,而这个构造函数中会发布一个事件。

 
   
   
 
  1. const EventEmitter = require('events');

  2. const util = require('util');


  3. function MyEmitter() {

  4.  EventEmitter.call(this);

  5.  this.emit('event');

  6. }

  7. util.inherits(MyEmitter, EventEmitter);


  8. const myEmitter = new MyEmitter();

  9. myEmitter.on('event', function() {

  10.  console.log('an event occurred!');

  11. });

你无法立即从构造函数中真正触发事件,因为脚本还没有运行到用户为该事件分配回调的位置。因此,在构造函数中,您可以使用 process.nextTick() 来设置回调以在构造函数完成后发出事件,从而提供预期的结果

 
   
   
 
  1. const EventEmitter = require('events');

  2. const util = require('util');


  3. function MyEmitter() {

  4.  EventEmitter.call(this);


  5.  // use nextTick to emit the event once a handler is assigned

  6.  process.nextTick(function() {

  7.    this.emit('event');

  8.  }.bind(this));

  9. }

  10. util.inherits(MyEmitter, EventEmitter);


  11. const myEmitter = new MyEmitter();

  12. myEmitter.on('event', function() {

  13.  console.log('an event occurred!');

  14. });

译者注(Q&A)

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

  1. poll阶段什么时候会被阻塞?

  2. 为什么在非I/O循环中, setTimeout和 setImmediate的执行顺序是不一定的?

  3. JS调用栈展开是什么意思?

  4. 为什么 process.nextTick()可以被递归调用?

以上是关于[NodeJs系列][译]理解NodeJs中的Event LoopTimers以及process.nextTick()的主要内容,如果未能解决你的问题,请参考以下文章

深入理解nodejs的HTTP处理流程

深入理解nodejs中的异步编程

nodejs中的异步和同步怎么理解

初探nodeJS

无法理解 async/await nodejs

nodejs 根据正则爬取本地文件夹里面内容