6

前言

Node.js以异步I/O和事件驱动的特性著称,但异步I/O是怎么实现的呢?其中核心的一部分就是event loop,下文中内容基本来自于Node.js文档,有不准确地方请指出.

什么是Event loop

event loop能让Node.js的I/O操作表现得无阻塞,尽管JavaScript是单线程的但通过尽可能的将操作放到操作系统内核.

由于现在大多数内核都是多线程的,它们可以在后台执行多个操作. 当这些操作完成时,内核通知Node.js应该把回调函数添加到poll队列被执行.我们将在接下来的话题里详细讨论.

Event Loop 说明

当Node.js开始时,它将会初始化event loop,处理提供可能造成异步API调用,timers任务,或调用process.nextTick()的脚本(或者将它放到[REPL][]中,这篇文章中将不会讨论),然后开始处理event loop.

下面是一张event loop操作的简单概览图.

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

注意: 每一个方框将被简称为一个event loop的阶段.

每一个阶段都有一个回调函数的FIFO队列被执行.每一个阶段都有自己特有的方式,通常even loop进入一个给定的阶段时,它将执行该阶段任何的特定操作,然后执行该阶段队列中的回调函数,直到执行完所有回调或执行了最大回调的次数.当队列中的回调已被执行完或者到达了限制次数,eventloop将会从下一个阶段开始依次执行.

由于这些操作可能造成更多的操作,并且在poll阶段中产生的新事件被内核推入队列,所以poll事件可以被推入队列当有其它poll事件正在执行时.因此长时间执行回调可以允许poll阶段超过timers设定的时间.详细内容请看timerspoll章节.

ps: 个人理解-在轮询阶段一个回调执行可能会产生新的事件处理,这些新事件会被推入到轮询队列中,所以poll阶段可以一直执行回调,即使timers的回调已到时间应该被执行时.

注意: Windows和Unix/Linux在实现时有一些细微的差异,但那都不是事儿.重点是: 实际上有7或8个步骤,Node.js实际上使用的是它们所有.

阶段概览

  • timers: 这个阶段执行setTimeout()setInterval()产生的回调.
  • I/O callbacks: 执行大多数的回调,除了close callbacks,timers和setImmediate()的回调.
  • idle, prepare: 仅供内部使用.
  • poll: 获取新的I/O事件;node会在适当时候在这里阻塞.
  • check: 执行setImmediate()回调.
  • close callbacks: e.g. socket.on('close', ...).

在每次event loop之间,Node.js会检查它是否正在等待任何异步I/O或计时器,如果没有就会完全关闭.

阶段详情

timers

一个定时器指定的是执行回调函数的阈值,而不是确定的时间点.定时器的回调将在规定的时间过后运行;然而,操作系统调度或其他回调函数的运行可能会使执行回调延迟.

注意: 技术上,poll 阶段控制了timers被执行.

例如, 你要在100ms的延时后在回调函数并且执行一个耗时95ms的异步读取文本操作:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  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);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(function() {

  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }

});

// 输出: 105ms have passed since I was scheduled

当event loop进入poll阶段时,它是一个空的队列(fs.readFile()还没有完成),所以它会等待数毫秒等待timers设定时间的到达.直到等待95 ms过后, fs.readFile()完成文件读取然后它的回调函数会被添加至poll队列然后执行.当执行完成后队列中没有其他回调,所以event loop会查看定时器设定的时间已经到达然后回撤到timers阶段执行timers的回调函数.在例子里你会发现,从定时器被记录到执行回调函数耗时105ms.

注意: 为了防止poll阶段阻塞死event loop, [libuv]
(http://libuv.org/) (实现Node.js事件循环的C库和平台的所有异步行为)
也有一个固定最大值(系统依赖).

I/O callbacks

这个阶段执行一些系统操作的回调,例如TCP错误等类型.例如TCP socket 尝试连接时收到了ECONNREFUSED,一些*nix系统想等待错误日志记录.这些都将在I/O callbacks阶段被推入队列执行.

poll

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

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

当event loop进入poll阶段并且没有timers任务时会执行下面某一条操作:

  • 如果poll队列不为空,则event loop会同步的执行回调队列,直到执行完回调或达到系统最大限制.
  • 如果poll队列为空,会执行下面某一条操做:

    • 如果脚本被setImmediate()执行,则event loop会结束 poll阶段,继续向下进入到check阶段执行setImmediate()的脚本.
    • 如果脚本不是被setImmediate()执行,event loop会等待回调函数被添加至队列,然后立刻执行它们.

一旦poll队列空了,event loop会检查timers是否有以满足条件的定时器,如果有一个以上满足执行条件的定时器,event loop将会撤回至timers阶段去执行定时器的回调函数.

check

这个阶段允许立刻执行一个回调在poll阶段完成后.如果poll阶段已经执行完成或脚本已经使用setImmediate(),event loop 可能就会继续到check阶段而不是等待.

setImmediate()实际是在event loop 独立阶段运行的特殊定时器.它使用了libuv API来使回调函数在poll阶段后执行.

通常在代码执行时,event loop 最终会到达poll阶段,等待传入连接,请求等等.然而,如果有一个被setImmediate()执行的回调,poll阶段会变得空闲,它将会结束并进入check阶段而不是等待新的poll事件.

close callbacks

如果一个socket或者操作被突然关闭(例如.socket.destroy()),这个close事件将在这个阶段被触发.否则它将会通过process.nextTick()被触发.

setImmediate() vs setTimeout()

setImmediatesetTimeout() 是很相似的,但是它们的调用方式不同导致了会有不同的表现.

  • setImmediate() 会中断poll阶段,立即执行..
  • setTimeout() 将在给定的毫秒后执行设定的脚本.

timers的执行顺序会根据它们被调用的上下文而变化.如果两个都在主模块内被调用,则时序将受到进程的性能的限制(可能受机器上运行的其他应用程序的影响).

例如,我们执行下面两个不在I/O周期内(主模块)的脚本,这两个timers的执行顺序是不确定的,它受到进程性能的影响:

// 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周期内,则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()setTimeout()的好处是setImmediate()在I/O周期内总是比所有timers先执行,无论有多少timers存在.

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阶段.

为什么这种操作会被允许呢?

部分原因是一个API应该是异步事件尽管它可能不是异步的.看看下面代码片段:

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

代码里对参数做了校验,如果不正确,它将会在回调函数中抛出错误.API最近更新,允许传递参数给 process.nextTick() ,process.nextTick()可以接受任何参数,回调函数被当做参数传递给回调函数后,你就不必使用嵌套函数了.

我们所做的就是将错误回传给用户当用户的其它代码执行后.通过使用process.nextTick()我们确保apiCall()执行回调函数在用户的代码之后,在event loop运行的阶段之前.为了实现这一点,JS调用的堆栈被允许释放掉,然后立刻执行提供的回调函数,回调允许用户递归的调用process.nextTick()直到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()的示例:

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', () => {});

仅当端口可用时端口立即被绑定.所以'listening'的回调函数能立即被调用.问题是那时候不会设置.on('listening').

为了解决这个问题,'listening'事件被放入nextTick()队列来允许代码执行完.这会允许用户设置他们想要的任何事件处理.

process.nextTick() vs setImmediate()

我们有两个直到现在用户都关心的相似的调用,但他们的名字令人困惑.

  • process.nextTick() 在同一阶段立即触发
  • setImmediate() 在以下迭代器或者event loop的'tick'中触发

本质上,这两个名字应该交换.process.nextTick()setImmediate()触发要快但这是一个不想改变的历史的命名.做这个改变会破坏npm上大多数包.每天都有新模块被增加,意味着每天我们都在等待更多的潜在错误发生.当他们困惑时,这个名字就不会被改变.

我们建议开发者使用setImmediate()因为它更容易被理解(并且它保持了更好的兼容性,例如浏览器的JS)

为什么使用process.nextTick()?

有两个主要原因:

  1. 允许用户处理错误,清除任何不需要的资源,或者可能在事件循环继续之前再次尝试该请求.
  2. 同时有必要允许回调函数执行在调用堆栈释放之后但在event loop继续之前.

一个满足用户期待的简单例子:

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

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

listen()在event loop开始时执行,但是listening的回调函数被放在一个setImmediate()中.现在除非主机名可用于绑定端口会立即执行.现在为了event loop继续执行,它必须进入poll阶段,意味着在监听事件前且没有触发允许连接事件时没有接收到请求的可能.

另一个例子是运行一个函数构造函数,例如,继承自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!');
});

部分个人理解

前面基本是基于文档的翻译(由于英文能力问题,很多地方都模模糊糊,甚至是狗屁不通[捂脸]),下面写一些重点部分的理解

几个概念

  1. event loop是跑在主进程上的一个while(true) {}循环.
  2. timers阶段包括setTimeout(),setInterval()两个定时器,回调执行时间等于或者晚于定时器设定的时间,因为在poll阶段会执行其它回调函数,在空闲时才回去检查定时器(event loop的开始和结束时检查).
  3. 在I/O callback阶段,虽然在阶段介绍里说的是执行除timers,Immediate,close之外的所有回调,但后面详细介绍中又说了,这里执行的大多是stream, pipe, tcp, udp通信错误的回调,例如fs产生的回调应该还是在poll阶段执行的.
  4. poll阶段应该才是真正的执行了除timers,Immediate,close外的所有回调.
  5. process.nextTick()没有在任何一个阶段执行,它执行的时间应该是在各个阶段切换的中间执行.

几段代码

const fs = require('fs');

fs.readFile('../mine.js', () => {
    setTimeout(() => { console.log("setTimeout") }, 0);
    process.nextTick(() => { console.log("process.nextTick") })
    setImmediate(() => { console.log("setImmediate") })
});
/*log -------------------
process.nextTick
setImmediate
setTimeout
*/
  1. 当文件读取完成后在poll阶段执行回调函数
  2. setTimeout添加至timers队列,解析process.nextTick()回调函数,将setImmediate添加至check队列
  3. poll队列为空,有setImmediate的代码,继续向下一个阶段.
  4. 在到达check阶段前执行process.nextTick()回调函数
  5. check阶段执行setImmediate
  6. timers阶段执行setTimeout回调
const fs = require('fs');

const start = new Date();
fs.readFile('../mine.js', () => {
    setTimeout(() => { console.log("setTimeout spend: ", new Date() - start) }, 0);
    setImmediate(() => { console.log("setImmediate spend: ", new Date() - start) })
    process.nextTick(() => { console.log("process.nextTick spend: ", new Date() - start) })
});
setTimeout(() => { console.log("setTimeout-main spend: ", new Date() - start) }, 0);
setImmediate(() => { console.log("setImmediate-main spend: ", new Date() - start) })
process.nextTick(() => { console.log("process.nextTick-main spend: ", new Date() - start) })
/* log ----------------
process.nextTick-main spend:  9
setTimeout-main spend:  12
setImmediate-main spend:  13
process.nextTick spend:  14
setImmediate spend:  15
setTimeout spend:  15
*/

这里没有搞懂为什么主进程内的setTimeout总是比setImmediate先执行,按文档所说,两个应该是不确定谁先执行.


Leo_
669 声望22 粉丝

learning...