Node.js中的事件循环

看到很多文件介绍关于Node.js中的事件循环,但是总是有些地方不是很理解,最近无意中看到了Node官方文档中对事件循环(Event Loop)的介绍后,感觉有一种豁然开朗的感觉,但是其文档是英文版,在此,根据个人理解,进行翻译。
原文地址:事件循环: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

翻译名词解释

  1. Event Loop: 事件循环
  2. poll phase: 轮训阶段

翻译内容

事件循环

事件循环通过尽可能的将相应操作分担给系统内核,从而让单线程的Javascript语言提供了非阻塞I/O操作。
因为目前主流的内核都是多线程的,它们可以同时在后台执行多个操作。当其中的某个操作完成时,系统就会通知Node.js与其相关的回调函数添加到轮训队列(poll queue)中,最后被执行。这将在后续中详细讨论。

事件循环介绍

当Node.js运行,将会初始化一个事件循环,处理那些通过异步api调用,定时器,或者调用process.nextTick()提供的script(或者输入到REPL中的script)。
下图展示了事件循环的操作顺序的概要。

Event Loop

注意:图中每一个box代表事件循环的一个阶段。

每个阶段都会维持一个先进先出的可执行回调函数队列。当然每个阶段都有自己特殊的行为方式,即当事件循环进入一个给定的阶段,它执行这个阶段的任何操作,然后执行这个阶段队列中的回调函数直到队列为空,或者回调函数调用次数达到上限。当满足这两个条件后,事件循环会进入下一个阶段。

由于任何操作都有可能规划更多的操作,这操作都会添加到对应阶段的队列中进行排队(并非原文翻译,个人解读)。因此,需要占用大量时间运行的回调会让poll阶段运行更长的事件,设置超过定时器规定的时限。下面内容会详细说明这种情况。

注意:在Windows和Unix/Linux实现中有轻微的差异,但是并不重要。这里讲解最重要的部分。

各个阶段介绍

  1. timers: 这个阶段执行通过setTimeout()和setInterval()安排的回调函数。
  2. I/O callback: 这个阶段执行关于close callback(如关闭socket调用的回调), 定时器安排的回调,调用setImmediate()设置的回调中产生的异常后调用的回调函数。
  3. idle: 内部使用。
  4. poll: I/O事件回调;在这个阶段node会根据实际情况进行堵塞。
  5. check: 由setImmediate()设置的回调函数。
  6. close callbacks: 如socket.on('close', ...)设置的回调。

在事件循环执行过程中,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(() => {
  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
  }
});
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()完成了文件读取,而它的加入到poll队列中回调函数需要10ms完成执行。当回调函数执行完成后,poll队列中已经没有其他回调函数,所以时间循环检查时间最近的那个定时器的时限是否已经到达,然后回到timers阶段来执行定时器的回调函数。在这个例子中,你将看到从定时器设置到回调的执行总共延迟将会是105ms。

注意:为了防止poll阶段耗尽事件循环,libuv(实现Node.js事件循环以及所有该平台异步行为的C类库)设置了精确的最大值(具体值取决于系统)用于停止轮训更多的事件。

I/O回调函数

这个阶段执行一些系统操作如TCP错误调用的回调函数。例如:如果TCP socket尝试连接时,接收到ECONNREFUSED,一些*nix系统等待报告这个错误。这将在I/O callback阶段排队执行。

poll

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

  1. 为到达时限的定时器,执行脚本(不准确,其实是在poll队列中轮空时,检查定时器是否到达时限,如果到达了,则回到timers阶段执行定时器回调函数)
  2. 执行poll队列中的事件回调函数

当事件循环进入poll阶段,并且此时没有设置定时器,将会发生下面两种情况:

  1. 如果poll队列不是空的,事件循环将同步迭代执行队列中的回调函数,直到poll队列为空,或者达到执行上限。
  2. 如果poll队列为空的,将会发生下面两种情况:
    1)如果脚本通过setImmediate()设置,事件循环会结束poll阶段,然后进入check阶段来执行这些脚本。
    2)如果此时没有通过setImmediate()设置的脚本,事件循环将停留在poll阶段,等待回调函数添加到队列中,然后立即执行。

一旦poll队列为空,事件循环将检查已经达到时限的定时器。如果一个或多个定时器已经准备就绪,时间循环将回到timers阶段,执行这些定时器回调函数。

check

这个阶段,在poll阶段完成后,允许立即执行回调函数。如果poll阶段闲置,且存在setImmediate()设置的队列,事件循环将会进入check阶段,而不是继续等待。

setImmediate()实际上是一个特殊的定时器,在事件循环中占据独立的阶段。它使用libuv API设置poll阶段完成后的执行回调函数。

总体上,伴随着代码执行完,事件循环将会进入poll阶段来等待即将到达的网络连接,请求等。然而,如果此时有使用setImediate()设置的回调函数,并且poll阶段闲置,事件循环结束poll阶段,进入check阶段而不是等待poll事件。

close callbacks

如果一个sokect忽然关闭(如:socket.destroy()),'close'事件将会在这个阶段触发。process.nextTick()也会触发(个人理解:这个不需要强行理解,下面有process.nextTick()具体介绍)。

setImmediate() vs setTimeout()

setImmediate()和setTimeout()两者相似,但是调用时机不同。

  1. setImmediate()设计用来当当前poll阶段完成是执行脚本。
  2. setTimeout()经过给定时间后执行脚本

这两个定时器的执行顺序非常的依赖调用上下文。如果两个都是在主模块中调用,定时器将会受到执行过程的约束(可能会收到机器上运行的其他应用影响)。

如,如果我们执行下面的脚本,这两个定时器的执行顺序是不确定的,因为它受到执行过程的约束。

// 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回调中setImmediate()不管当前有多少定时器,总是比其他定时器先执行。

process.nextTick()

理解process.nextTick()

你可能意识到process.nextTick()并没有出现在事件循环的图中,即便它是异步API的一部分。这是因为process.nextTick()严格意义上来说,并不属于事件循环。取而代之的是,nextTickQueue将会忽略当前事件循环的阶段,在当前操作完成之后执行。

回顾事件循环结构图,在一个特定阶段的任何时间调用process.nextTick(),所有传入process.nextTick()的回调函数将会在事件循环继续之前执行。这可能会造成一些不良情况,因为这允许你通过迭代调用process.nextTick()耗尽I/O,从而使得事件循环不能进入poll阶段。

为什么允许上述情况

为什么会允许上述情况出现在Node.js中?Node.js的设计思想是尽可能的异步,即使并不需要异步。如下代码片段:

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

这个片段进行了参数类型的检测,如果类型不一致,就传递一个err到回调函数中。其中,需要传入回调函数中的参数,直接写在回调函数的后面即可。

我们这里做的事情是,传递一个error给回调函数,但是这个回调会在用户的剩余代码执行完之后才会执行。通过使用process.nextTick(),我们保证apiCall()总是在剩余代码执行之后事件循环继续之前执行回调函数。为了实现这些,允许JS调用栈展开,然后立即执行process.nextTick()提供的回调函数。这个回调中允许迭代调用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()具有异步的特征,但是它其实是一个通过操作。当它被调用,回调函数提供someAsyncApiCall()会在当前事件循环阶段被调用,因为someAsyncApiCall()其实没有做任何异步处理。结果,回调函数尝试引用bar,即使此时bar还没有进行赋值(此时代码还没有运行到bar = 1;这条语句)。

通过将回到函数设置在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', () => {});

当listen()函数中,只传递端口号时,端口后会立马进行设置。所以,'listening'回调函数将会立马被调用。问题是.on('listening)回调函数将不会那个时候设置。
为了绕过这个,'listening'事件在nextTick()中排队,从而运行脚本执行完成后在触发。这允许开发者设置任意设置事件回调函数。

process.nextTick() vs setImmediate()

就用户而言,这两个函数相似,但是它们的名字很令人迷惑。

  1. process.nextTick()在同一个阶段执行
  2. setImmediate()在事件循环的下一个阶段或者'tick'中执行

本质上,它们的名字需要交换一下。process.nextTick()比setImmediate()更快被执行,但是这是过去的产物,很难修改。修改这个问题将会导致一大部分的npm包出现损坏。每天有更多的模块被添加,这意味着更多潜在的损坏会出现。因此即使它们的名字令人迷惑,名字不会修改。

我们提倡开发者使用setImmediate(),因为setImmediate()具有更好的兼容性,如在浏览器中。

为什么使用process.nextTick()

这里有两个主要的原因:

  1. 在事件循环继续之前允许用户处理错误,清除任何之后不需要的资源,或者可能再次请求等。
  2. 需要回调函数在调用堆栈上但是在事件循环继续之前调用。

下面例子符合用户的期望,如下:

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

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

listen()在第一轮事件循环执行,但是listening事件的回调函数是通过setImmediate()设置的(与前面描述不一致,但是大致可以理解为,触发listening事件的语句放置在一个异步api,当其触发listening事件时,此时server.on('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', () => {
  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!');
});
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容