【nodejs原理&源码赏析(7)】【译】Node.js中的事件循环,定时器和process.nextTick

示例代码托管在:http://www.github.com/dashnowords/blogs前端

博客园地址:《大史住在大前端》原创博文目录node

华为云社区地址:【你要的前端打怪升级指南】git

原文地址:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttickgithub

若是你常年游走于Nodejs中文网,可能已经错过了官方网站上的第一手资料,Nodejs中文网并无翻译这些很是高质量的核心文章,只提供了中文版的API文档(已经很不容易了,没有任何黑它的意思,我也是中文网的受益者),它们涵盖了Node.js中从核心概念到相关工具等等很是重要的知识,下面是博文的目录,你知道该怎么作了。npm

Event Loop 是什么?

事件循环是Node.js可以实现非阻塞I/O的基础,尽管JavaScript应用是单线程运行的,可是它能够将操做向下传递到系统内核去执行。api

大多数现代系统内核都是支持多线程的,它们能够同时在后台处理多个操做。当其中任何一个任务完成后,内核会通知Node.js,这样它就能够把对应的回调函数添加进poll队列,回调函数最终就可以被执行,后文中咱们还会进行更详细的解释。浏览器

Event Loop 基本解释

Node.js开始运行时,它就会初始化Event Loop,而后处理脚本文件(或者在REPLread-eval-print-loop)环境中执行,本文不作深刻探讨)中的异步API调用,定时器,或process.nextTick方法调用,而后就会开始处理事件循环(Event Loop)。多线程

下图展现了事件循环的各个阶段(每个盒子被称为事件循环中一个“阶段”):异步

每个阶段都维护了一个先进先出的待执行回调函数队列,尽管每个阶段都有本身独特的处理方式,但整体来讲,当事件循环进入一个具体的阶段时,它将处理与这个阶段有关的全部操做,而后执行这个阶段对应队列中的回调函数直到队列为空,或者达到了该阶段容许运行函数的数量的最大值,当知足任何一个条件时,事件循环都会进入下一个阶段,以此类推。

由于任何阶段相关的操做均可能致使更多的待执行操做产生,而新事件会被内核添加进poll队列中,当poll队列中的回调函数被执行时容许继续向当前阶段的poll队列中添加新的回调函数,因而长时间运行的回调函数可能就会致使事件循环在poll阶段停留时间过长,你能够在后文的timerspoll章节查看更多的内容。

提示:Windows和Unix/Linux在实现上有细小的差异,但并不影响本文的演示,不一样的系统可能会存在7-8个阶段,可是最终要的阶段上图中已经展现了,这些是Node.js实际会使用到的。

事件循环阶段概览

  • timers-本阶段执行经过setTimeout( )setInterval( )添加的已经到时的计划任务
  • pending callbacks-将一些I/O回调函数延迟到下一循环执行(这里不是很肯定)
  • idle,prepare-内部使用的阶段
  • poll-检查新的I/O事件;执行相关I/O的回调(除了“close回调”,“定时器回调”和setImmediate( )添加的回调外几乎全部其余回调函数);node有可能会在这里产生阻塞
  • check-执行setImmediate( )添加的回调函数
  • close callbacks-用于关闭功能的回调函数,例如socket.on('close',......)

在每轮事件周期之间,Node.js会检查是否有处于等待中的异步I/O或定时器,若是没有的话就会关闭当前程序。

事件循环细节

timers

一个timer会明确一个时间点,回调函数会在时间超过这个时间点后被执行,而不是开发者但愿的精确时间。一旦定时器时间过时,回调函数就会尽量早地被调度执行,然而操做系统的调度方式和其余的回调函数都有可能会致使某个定时器回调函数被延迟。

提示:技术上来讲,poll阶段控制着timers如何被执行。

下面的示例中,你使用了一个100ms后过时的定时器,接着花费了95ms使用异步文件读取API异步读取了某个文件:

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

当事件循环进入poll阶段时,它的待执行队列是空的(fs.readFile( )尚未完成),因此它将等待必定时间(当前时间距离最快到期的定时器到期时间之间的差值)。95ms过去后,fs.readFile( )完成了文件读取,并花费了10ms将回调函数添加进poll的执行队列是它被执行。当回调函数执行完毕后,队列中没有更多的回调函数了,事件循环就会再次检查下一个待触发的timer是否已经到期,若是是,则事件循环就会绕回timers阶段去执行到期timer的回调函数。在这个示例中,你会看到timer从设置定时器到回调函数被触发一共花费了105ms.

注意:为了不在poll阶段阻塞事件循环,libuv(Node.js底层用于实现事件循环和异步特性的C语言库)设置了一个硬上限值(该值会根据系统不一样而有变化),使得poll阶段只能将有限数量的回调函数添加进poll队列。

pending callbacks

这个阶段会执行一些系统操做的回调函数,例如一些TCP的错误。好比一个TCP的socket对象尝试链接另外一个socket时收到了ECONNREFUSED,一些Linux系统会但愿汇报这类错误,这类回调函数就会被添加在pending callbacks阶段的待执行队列中。

poll阶段

poll阶段有两个主要的功能:

  1. 计算须要阻塞的时长,以即可以将完成的I/O添加进待执行队列
  2. 执行poll队列中产生的事件

当事件循环进入poll阶段且此时并无待执行的timer时,会按照下述逻辑来判断:

  • 若是poll队列不为空,事件循环会以同步的方式逐个迭代执行队列中的回调函数直到队列耗尽,或到达系统设置的处理事件数量限制。
  • 若是poll队列为空,则按照下述逻辑继续判断:
    • 若是脚本中使用setImmediate( )方法添加了回调函数,事件循环就会结束poll阶段,并进入check阶段来执行这些添加的回调函数。
    • 若是没有使用setimmediate( )添加的回调,事件循环就会等待其余回调函数被添加进队列并当即执行添加的函数。

一旦poll队列为空,事件循环就会检查是否有已经到期的timers定时器,若是有一个或多个定时器到期,事件循环就会回到timers阶段来执行这些定时器的回调函数。

check

这个阶段容许开发者在poll阶段结束后当即执行一些回调函数。若是poll阶段出现闲置或者脚本中使用setImmediate( )添加了回调函数,事件循环事件循环就会主动进入check阶段而不会停下来等待。

setImmediate( )其实是一个运行在独立阶段的特殊定时器。它经过调用libuv提供的API添加那些但愿在poll阶段完成之后执行的回调函数。

一般,随着代码的执行,事件循环最终会到达poll阶段,它会在这里等待incoming connection,request等请求事件。然而,若是一个回调函数被setImmediate( )添加时poll阶段处于空闲状态,它就会结束并进入check阶段而不是继续等待poll事件。

close callbacks

若是一个socket或者句柄被忽然关闭(好比调用socket.destroy( )),close事件就会在这个阶段被发出。不然(其余形式触发的关闭)事件将会经过process.nextTick( )来发送。

 setImmediate( )和setTimeout( )

setImmediate( )setTimeout( )很是类似,可是表现却不相同。

  • setImmediate( )被设计来在当前poll阶段完成后执行一些脚本
  • setTimeout( )会把一个脚本添加为必定时间过去后才执行的“待执行任务”

这两种定时器被执行的顺序依赖于调用定时器的上下文。若是都是在主模块中调用,定时器就会与process的性能相关(这也意味着它可能被同一个机器上的其余应用影响)。

例以下面的脚本中,若是咱们一个不包含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( )的主要优点在于在I/O回调函数中调用时,不论程序中有多少timers,它添加的回调函数老是比其余timers更早执行。

proess.nextTick( )

理解 process.nextTick()

你可能已经注意到尽管一样做为异步API的一部分,process.nextTick( )并无展现在上面的图表中,由于技术层面来说它并非事件循环中的一部分。nextTickQueue队列将会在当前操做执行完后当即执行,不管当前处于事件循环的哪一个阶段,这里所说的操做是指底层的C/C++句柄到待执行JavaScript代码的过渡(这句怪怪的,不知道怎么翻译,原文是 an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed)。

再来看上面的图表,任什么时候候当你在某个阶段调用process.nextTick( ),全部传入的回调函数都会在event loop继续以前先被解析执行。这可能会形成很是严重的影响,由于它容许你阻塞经过递归调用process.nextTick( )而使得事件循环产生阻塞,是它没法到达poll阶段。

为何会容许这种状况存在?

为何这种匪夷所思的状况要被包含在Node.js中呢?一部分是因为Node.js的设计哲学决定的,Node.js中认为API不管是否有必要,都应该异步执行,例以下面的代码示例片断:

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

这个示例对参数进行了检查,若是参数类型是错误的,它就会将这个错误传递给回调函数。这个API容许process.nextTick获取添加在callback以后的其余参数,并支持以冒泡的方式将其做为callback调用时传入的参数,这样你就没必要经过函数嵌套来实现了。

这里咱们作的事情是容许剩余的代码执行完毕后再传递一个错误给用户。经过使用process.nextTick( )就能够确保apiCall( )方法老是在剩余的代码执行完和事件循环继续进行这两个时间点之间来执行回调函数。为了达到这个目的,JS调动栈就会容许马上执行一些回调函数并容许用户在其中递归触发调用process.nextTick( ),可是却不会形成爆栈(超过JavaScript引擎设置的调用栈最大容量)。

这种设计哲学可能会致使一些潜在的状况。例以下面的示例:

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback){callback();}

// the callback is called before `someAsyncApiCall` completes
someAsyncApiCall(()=>{
    console.log('bar',bar);
});

bar = 1;

用户定义的someAsyncApiCall( )虽然从注释上看是异步的,但其实是一个同步执行的函数。当它被调用时,回调函数和someAsyncApiCall( )实际上处于事件循环的同一个阶段,这里并无任何实质上的异步行为,结果就是,回调函数尝试获取bar这个标识符的值尽管做用域中并无为这个变量赋值,由于脚本剩余的部分并无执行完毕。

若是将回调函数替换为process.nextTick( )的形式,脚本中剩余的代码就能够执行完毕,这就使得变量和函数的初始化语句能够优先于传入的回调函数而被执行,这样作的另外一个好处是它不会推进事件循环前进。这就使得用户能够在事件循环继续进行以前对一些可能的告警或者错误进行处理。好比下面的例子:

let bar;

function someAsyncApiCall(callback) {
    process.nextTick(callback);
}

someAsyncApiCall(()=>{
   console.log('bar',bar); 
});

bar = 1;

真实的场景中你会看到像下面这样的使用方式:

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

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

当端口号传入后,就会马上被绑定。因此listening回调就会当即被执行,问题是.on('listening')这个回调的设置看起来并无执行到。

这里实际上listening事件的发送就是被nextTick( )添加到待执行队列中的,这样后面的同步代码就能够执行完毕,这样的机制使得用户能够在后文设置更多的事件监听器。

process.nextTick( )对比setImmediate( )

这两个方法的命名令不少开发者感到迷惑。

  • process.nextTick( )会在事件循环的同一个阶段马上触发
  • setImmediate( )会在下一轮事件循环触发或者说事件循环的tick时触发

事实上它们实际作的事情和它们的命名应该交换一下。process.nextTick( )setTimeout( )添加的回调要更早触发,但这种历史问题是很难去修正的,它会致使一大批npm包没法正常运做。天天还有大量的新的模块发布,这就意味着每过一天都有可能引起更多的破坏,尽管它们会形成混淆,但只能将错就错了。

咱们推荐开发者在开发中坚持使用setImmediate( ),由于它的执行时机相对更容易推测(另外它也使得代码能够兼容更多的环境例如浏览器JS)。

为何使用process.nextTick()

两个最主要的理由是:

  1. 它容许用户优先处理错误,清理任何后续阶段再也不使用的资源,或者在事件循环继续进行以前尝试从新发送请求。
  2. 有时也须要在调用栈并不为空时去执行一些回调函数。

好比下面的示例:

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

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

设想listen()在事件循环开始时先执行,可是listening事件的监听函数由setImmediate()来添加。除非传入hostname,不然端口不会被绑定。对于事件循环来讲,它必定会到达poll阶段,若是此时已经有connection链接,那么connection事件就会在poll阶段被发出,但listening事件要等到check阶段可以被发出。

另外一个示例是执行一个构造函数,它继承了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!');
});
相关文章
相关标签/搜索