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

Posted 小柴的修仙之路

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了译Node.js 事件循环, 定时器, 和 process.nextTick()相关的知识,希望对你有一定的参考价值。

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

点击查看原文

什么是事件循环?

事件循环( Event Loop ) 通过尽可能地将操作下放到系统内核,使得 Node.js 可以执行非阻塞 I/O 操作 — 尽管 javascript单线程的。

大多数的现代内核都是多线程的,他们可以在后台处理多个操作的执行。当其中的某个操作执行完成时,内核会通知 Node.js ,对应的callback就可以被加入到 poll 队列中,并最终得以执行。我们稍候将在本篇中给予详细解释。

解释事件循环

Node.js 启动时会初始化 事件循环 , 处理提供的输入脚本 (或者进入 REPL, 本文未涉及) ,这些脚本可能将会调用 异步API,排定 定时器 或者调用 process.nextTick(), 然后开始处理事件循环。

下面的图表是一个简化的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 的一个 "阶段" .

每个阶段都有一个 待执行callback的 FIFO 队列。虽然每个阶段都有它的特殊性, 总体而言,当 event loop 进入某个阶段时,将会执行特定于该阶段的任何操作,然后执行该阶段 队列 中的回调,直到队列耗空 或者 执行的回调数达到最大限定数量,然后进入 下一个阶段,然后循环往复。

因为在这过程中的任何操作都有可能会安排 更多 操作 ,并且在poll阶段处理的新事件是由内核进行排队的,poll事件可以在polling事件正在处理中时被排队。这样导致的结果就是,长时间运行的callback会使得poll阶段运行时间远远超出定时器的定时期限。查看 timers 和poll 部分获取更多详情。

NOTE: 在Windows与Unix/Linux的实现中有轻微的差异, 但这对于这里的展示并没有重要的影响。 这里就是最重要的部分。事实上有七或八步, 但是我们关心的 — Node.js 实际上使用的 — 就是上面这些了

阶段 概览

  • timers: 这个阶段执行由 setTimeout() 和 setInterval()调度的回调。

  • I/O callbacks: 执行除了 close callbacks 、由timers调度的 和 setImmediate()之外的几乎全部的callback。

  • idle, prepare: 仅内部使用。

  • poll: 检索新的 I/O 事件;适当时 node 会在此阻塞。

  • check: setImmediate() 回调在此唤起。

  • close callbacks: e.g. socket.on('close',...).

在两轮事件循环之间, Node.js 会检查是否还有处于等待中的异步 I/O 或者 timers ,如果没有则干净利落地退出。

阶段 详情

timers

timer指定一个倒计时 阈值, 在此之后 ,它提供的callback就 可能被执行 ,而不是说,人们设定精确的时间耗尽后它就一定能够立即执行。 Timers callbacks 会在指定时间结束后尽可能早地被安排执行;但是,操作系统调度或者执行其他的callback将会使得它们被延后执行。

Note: 从技术上讲, poll 阶段 控制 timers 何时被执行。

举例而言,假如你指定在100ms后执行一个回调,然后又开启一个耗时95ms的异步读取文件操作:

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

  2. function someAsyncOperation(callback) {

  3.  // Assume this takes 95ms to complete

  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. // do someAsyncOperation which takes 95 ms to complete

  12. someAsyncOperation(function() {

  13.  const startCallback = Date.now();

  14.  // do something that will take 10ms...

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

  16.    // do nothing

  17.  }

  18. });

当事件循环进入 poll 阶段, 它的队列是空的 ( fs.readFile() 还没有完成), 所以它会等待定时器的消耗倒计时剩余的毫秒数。当它等到95ms时, fs.readFile() 完成文件读取,并且它的回调被加入到poll队列去执行,这个回调要花费10ms完成,完成后,队列里就没有callback了,于是 event loop 就会查看当下最快的倒计时结束的定时器,等待它计时完毕,事件循环就会返回timers 阶段去执行timer的callback。 在本例中,你会看到定时器启动到它的callback被执行一共间隔了105ms.

Note: 为了防止 poll 阶段造成 event loop 处于饥饿状态, [libuv] (http://libuv.org/) (实现Node.js平台的事件循环和全部异步行为的 C library)这里还存在一个既定的停止事件轮询的最大次数(视平台而定)。

I/O callbacks

这个阶段执行一些诸如TCP错误之类的系统操作。比如一个 TCP socket 在尝试连接时 收到 ECONNREFUSED , 一些 *nix 系统会等着报告这些错误。这会被排到 I/O callbacks 阶段去执行。

poll

poll 阶段有两个主要功能:

  1. 执行倒计时完毕的计时器的脚本,然后

  2. 处理 poll 队列中的事件。

当事件循环进入 poll 阶段, 且此时没有排定的timers,则接下来会发生这两件事的其一:

  • 如果 poll 队列 不为空,事件循环讲会迭代这个callback队列,同步地执行它们,直到队列清空或者达到了基于系统的既定限制。


  • 如果 poll 队列 为空, 则接下来会发生这两件事的其一:


  • 如果有被 setImmediate()排定的脚本,那么 event loop 会结束 poll 阶段,然后接着到 check 阶段去执行这些排定的脚本。


  • 如果 没有 被 setImmediate()排定的脚本, event loop 会等待 callbacks 加入到队列中,然后立即执行它们。


一旦 poll 队列被清空,event loop 会检查 倒计时结束的 timer。如果有一个及以上的timer就绪了,event loop 就回到 timers 阶段去执行这些 timer 的回调。

check

这个阶段允许人们在poll结束时立即调用callback。 如果 poll 阶段闲置并且存在被 setImmediate()排定的脚本, event loop 就会继续到 check 阶段而不是继续等待。

setImmediate() 实际上是一个特殊的定时器,它在事件循环中有一个独立的阶段。它使用 libuv API 来排定脚本在 poll阶段结束时执行。

总的来说,当代码被执行后,事件循环最终都会进入 poll 阶段来等待 incoming connection, request, etc。 但是,如果一个 callback 被 setImmediate()排定,并且 poll 阶段正空闲,它就会结束并进入check 阶段而不是继续等待poll 事件。

close callbacks

如果一个 套接字(socket) 或 句柄(handle) 突然被关闭了 (e.g. socket.destroy()), 'close' 事件就会在这个阶段被发出。 否则它会通过 process.nextTick() 被发出。

setImmediate() vs setTimeout()

setImmediatesetTimeout() 是相似的,但是视它们何时被调用有不同的行为。

  • setImmediate() 被设计成 在当前的 poll 阶段完成时执行一段脚本。

  • setTimeout() 调度一个在最小阈值的ms耗尽后执行一段脚本。

这两个定时器设定的脚本的执行顺序会根据它们被调用的上下文的不同而有所差异。如果它们都在主模块里被调用,那么 定时会受到进程性能的约束 (这会受在设备上运行的其他应用的影响。)。

例如,如果我们运行下面的没有在I/O周期(i.e. the main module)中的脚本, 这两个定时器脚本的执行顺序是不确定的,因为会受到进程性能的约束:

 
   
   
 
  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 回调里, immediate callback 总是会先执行:

 
   
   
 
  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()的主要好处是,如果 setImmediate() 在 I/O 周期里排定了,那它始终会在任何timer之前执行,不论设定了多少个timer。

process.nextTick()

理解 process.nextTick()

你可能已经注意到, 尽管 process.nextTick() 是异步API的一部分,但它并没有在上面的图表中展示出来。这是因为技术上讲 process.nextTick() 不是 event loop 的一部分。 相反, nextTickQueue会在当前操作完成后被处理,不论event loop处于哪个阶段。

再回顾我们的图表,你在任何阶段调用 process.nextTick() ,传给 process.nextTick() 的全部回调都会在 event loop 往下进行前被解决。这可能会造成一些不好的情形,因为 你可以递归地调用 process.nextTick()来让I/O 处于饥饿状态,这会使得 event loop无法进入poll阶段。

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

为什么 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. }

这个片段检查了参数,如果参数不正确,它会把错误传给回调。这个API最近更新了,允许传递参数到 process.nextTick() 使它允许把排在在回调函数后面的参数传到回调函数里,这样你可以不用嵌套函数。

我们所做的就是把一个错误传递回用户,但是要在剩余的用户代码执行完毕。 通过使用 process.nextTick() 我们可以确认 apiCall() 始终在用户剩余代码(译注:即在调用apiCall函数的上下文中剩余的用户代码)执行,并且在event loop继续前,调用它的回调。为了实现这个特性,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() 有异步的签名,但实际上是同步操作的。结果是, 回调函数会去尝试引用 bar ,尽管它作用内还没有这个变量(译注:还没初始化), 因为脚本还没运行完(译注:即运行到最后一行给bar初始化)。

通过调用 process.nextTick()替代直接调用回调函数,脚本可以完全运行,允许在回调被调用前,初始化全部的变量,函数,等。 还有一个优点是,这时event loop 不被允许继续下去。这可能有实用性,因为用户可以在event loop之前提示一个错误。这里是一个使用 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', () => {});

当只有一个port传入的时候,port就被立即绑定了。所以 'listening' 回调可以立即调用。问题是, .on('listening') 在此时还没有被设定呢。

要避免这个问题, 'listening' 事件在 nextTick() 中排队,这样脚本就能先运行完毕。这使得用户可以任意设置事件处理回调。

process.nextTick() vs setImmediate()

现在有用户关心的两个相似调用,但是它们的名字令人困惑。

  • process.nextTick() 在同一个阶段立即启动

  • setImmediate() 在接下来的迭代或接下来的 event loop的'tick'中启动。

本质上,这两个名字应该替换下才对。 process.nextTick() 启动得比 setImmediate() 更早点, 但是这是历史产物,不大可能会变了。真要改变,npm的相当一部分包就都不能用了。随着时间流逝,更多npm包上传,改动的可能性就越来越小。虽然它们名字使人困惑,已经不会改变了。

我们建议开发者在任何场景下都使用 setImmediate() ,因为它更容易理解些 (并且这也能够让代码兼容更多环境,比如浏览器的JS环境。a)

为什么使用 process.nextTick()?

有两个主要原因:

  1. 它允许用户掌控错误, 清理任何不需要的资源,或者,在event loop继续前发起请求。


  2. 有时候,需要在调用栈清空时,event loop继续前调用一个回调。


看一个符合用户期望的例子:

 
   
   
 
  1. const server = net.createServer();

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

  3. server.listen(8080);

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

我们说 listen() 在event loop开始时启动,但是 listening callback 被放在 setImmediate()内(译注:listening事件发射是在 process.nextTick()里,如前文所说)。现在,除非传入hostname,否则port绑定就会立即发生。这时,对于 event loop 的进行, 它必须进入到 poll 阶段,这也就意味着,没有一点机会在 listening event 之前接收到一个connection并发射一个 connection event(译注:保证了先绑端口再连接的用户期望)。

另一个例子,运行一个继承自 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() 来设定一个回调,在构造器完成后(译注:按上下文看,其实也是在绑定handle回调后)再发射事件,这是符合预期的:

 
   
   
 
  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. });


以上是关于译Node.js 事件循环, 定时器, 和 process.nextTick()的主要内容,如果未能解决你的问题,请参考以下文章

Node.js-提供了四种形式的定时器

极简 Node.js 入门 - 2.4 定时器

JavaScript:彻底理解同步异步和事件循环(Event Loop) (转)

Node.js 事件循环

Node.js 事件循环

Node.js 事件循环