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

栏目: Node.js · 发布时间: 6年前

内容简介:虽然js是单线程的,但是事件循环会尽可能地将卸载操作(offloading operations)托付给系统内核,让node能够执行非阻塞的I/O操作由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中任意一个任务完成后,内核都会通知Node.js,以保证将相对应的回调函数推入poll队列中最终执行。稍后我们将在本文中详细解释这一点。当Node.js服务启动时,它就会初始化事件循环。每当处理到脚本(或者是放置到REPL执行的代码,本文咱不提及)中异步的API, 定时器,或者调用

虽然js是单线程的,但是事件循环会尽可能地将卸载操作(offloading operations)托付给系统内核,让node能够执行非阻塞的I/O操作

由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中任意一个任务完成后,内核都会通知Node.js,以保证将相对应的回调函数推入poll队列中最终执行。稍后我们将在本文中详细解释这一点。

事件循环的定义

当Node.js服务启动时,它就会初始化事件循环。每当处理到脚本(或者是放置到REPL执行的代码,本文咱不提及)中异步的API, 定时器,或者调用 process.nextTick() 都会触发事件循环,

下图简单描述了事件循环的执行顺序

┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
复制代码

注: 每个方框都是事件循环的一个阶段

每个阶段都有一个待执行回调函数的FIFO队列, 虽然每个阶段都不尽相同,总体上说,当事件循环到当前阶段时,它将执行特定于该阶段的操作,然后就会执行被压入当前队列中的回调函数, 直到队列被清空或者达到最大的调用上限。 当队列被清空或者达到最大的调用上限时,事件循环就会进入到下一阶段,如此反复。

因为任意阶段的操作都有可能调用更多的任务和触发新的事件,这些事件都最终会由内核推入poll阶段,poll事件可以在执行事件的时候插入队列。所以调用栈很深的回调允许poll阶段运行时间比定时器的阀值更久,详细部分请查看定时器和poll部分的内容。

注:Windows和Unix/Linux实现之间存在细微的差异,但这对于本文来说并不重要,最重要的部分在文中会一一指出。 实际上事件循环一共有七到八个步骤, 但是我们只需要关注Node.js中实际运用到的,也就是上文所诉的内容

阶段概览

  • timers: 这个阶段将会执行 setTimeout()setInterval() 的回调函数
  • pending callbacks: 执行延迟到下一个循环迭代的I/O回调
  • idle, prepare: 只会在内核中调用
  • poll: 检索新的I/O事件,执行I/O相关的回调(除了结束回调之外,几乎所有的回调都是由计时器和 setimmediation() 触发的); node将会在合适的时候阻塞在这里
  • check: setImmediate() 的回调将会在这里触发
  • close callbacks: 一些关闭事件的回调, 比如 socket.on("close", ...)

在任意两个阶段之间,Node.js都会检查是否还有在等待中的异步I/O事件或者定时器,如果没有就会干净得关掉它。

阶段的细节

timers

定时器将会在一个特定的时间之后执行相应的回调,而不是在一个通过开发者设置预期的时间执行。定时器将会在超过设定时间后尽早地执行,然而操作系统的调度或者运行的其他回调将会将之滞后。

注: 从技术上讲,poll阶段会控制定时器什么时候执行

比如说,你设定了一个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
  }
});

复制代码

当事件循环进入到poll阶段,它将会声明一个空的队列(fs.readFile()还暂时没有完成),所以它将会等待一段时间来尽早到达定时器的阀值。当等待了95ms过后, fs.readFile() 结束读取文件的任务并且再花费10ms的时间去完成被推入poll队列中的回调,当回调结束,此时在队列中没有其他回调,这个时候事件循环将会看到定时器的阀值已经过了,并且是可以尽快执行的时机,这个时候回到timers阶段去执行定时器的回调。这样来说,你将会看到定时器从开始调度到被执行间隔105ms。

注: 为了保证poll阶段不出现轮训饥饿,libuv(一个 c语言 库,由他来实现Node.js的事件循环和所有平台的异步操作)会提供一个触发最大值(取决于系统),在达到最大值过后会停止触发更多事件。

pending callbacks

这个阶段将会执行操作系统的一些回调如同TCP的错误捕获一样。比如如果一个TCP 套接字接收到了 ECONNREFUSED 在尝试建立链接的时候,一些*nix系统就会上报当前错误,这个上报的回调就会被推入pending callback的执行队列中去。

poll

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

  1. 计算什么时候阻塞或者轮询更多的I/O
  2. 执行在poll队列中的回调

当事件循环进入到poll阶段并且没有定时器在被调度中的时候,下面两种情况中的一种会发生:

  • 当poll队列不为空,事件循环将会遍历它的队列并且同步执行他们,直到队列被清空或者达到系统执行回调的上限
  • 如果poll队列为空,将要发生的另外两件事之一:
    • 如果系统调度过 setImmediate() ,那么事件循环将会结束poll阶段然后继续到check阶段去执行 setImmediate() 的回调
    • 如果系统没有调度过 setImmediate() , 那么事件循环将等待回调被推入队列,然后立即执行它

一旦poll阶段队列为空事件循环将会检查是否到达定时器的阀值,如果有定时器准备好了,那么事件循环将会回到timers阶段去执行定时器的回调

check

这个阶段允许开发者在poll阶段执行完成后立即执行回调函数。 如果poll阶段变为空闲状态并且还有 setImmediate() 回调,那么事件循环将会直接来到check阶段而不是继续在poll阶段等待

setImmediate() 实际上是运行在事件循环各个分离阶段的特殊定时器,它直接使用libuv的API去安排回调在poll阶段完成后执行

通常上来说,在执行代码时,事件循环最终会进入轮询阶段,等待传入连接、请求等。但是,如果还有 setImmediate() 回调,并且轮询阶段变为空闲状态,则它将结束并继续到check阶段而不是等待poll事件。

close callbacks

如果一个socket连接突然关闭(比如socket.destroy()),‘close’事件将会被推入这个阶段的队列中,否则它将通过process.nextTick()触发。

setImmediate()和setTimeout()有什么不同

setImmediatesetTimeout 相似,但是他们在被调用的时机上是不同的。

setImmediate
setTimeout

定时器执行的时机依赖于它们被调用时的上下文环境, 如果他们在主模块中同时被调用,那么他们的执行顺序会被程序(被运行在同一台机子上的应用所影响)的性能所约束

举个例子,如果我们在非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() 而不是 setTimeout() 的主要优点是 setImmediate() 将始终在任何定时器之前执行(如果在I / O周期内调度),与存在多少定时器无关。

process.nextTick()

什么是 process.nextTick()

你可能注意到了 process.nextTick() 不在上面展示的图示里,甚至它不是一个异步调用API,从技术上说, process.nextTick() 并不属于事件循环。 相反的, nextTickQueue 会在当前的操作执行完成后运行,而不必在乎是在某一个特定的阶段

回到我的图示,每次你在一个阶段中调用 process.nextTick() 的时候,所有的回调都会在事件循环进入到下一个阶段的时候被处理完毕。但是这会造成一个非常坏的情况,那就是饥饿轮训,即递归调用你的 process.nextTick() ,这样就会阻止事件循环进入到poll阶段

为什么这种情况会被允许

为什么这样的事情会包含在 Node.js 中?设计它的初衷是这个API 应该始终是异步的,即使它不必是。以此代码段为例:

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

复制代码

上诉代码段进行参数检查。如果不正确,则会将错误传递给回调函数。最近对 API 进行了更新,允许将参数传递给 process.nextTick(),允许它在回调后传递任何参数作为回调的参数传播,这样您就不必嵌套函数了。

上述函数做的是将错误传递给用户,而且是在用户其他代码执行完毕过后。通过使用process.nextTick(),apiCall() 可以始终在用户代码的其余部分之后 运行其回调函数,并在允许事件循环之前继续进行。为了实现这一点,JS 调用栈被允许展开,然后立即执行提供的回调,并且允许进行递归调用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,即使它在范围内可能还没有该变量,因为脚本无法按照预料中完成。

将回调用 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() 对比 setImmediate()

就用户而言我们有两个类似的调用,但它们的名称令人费解。

  • process.nextTick() 在同一个阶段立即执行。
  • setImmediate() 在接下来的迭代中或是事件循环上的"tick" 上触发。

实质上,应该交换名称。process.nextTick() 比 setImmediate() 触发得更直接,但这是过去遗留的,所以不太可能改变。进行此操作将会破坏 npm 上的大部分软件包。每天都有新的模块在不断增长,如果这样做了,这意味着我们每天都会有的潜在破损在增长。 虽然他们很迷惑,但名字本身不会改变。

我们建议开发人员在所有情况下都使用 setImmediate() ,因为它更让人理解(并且它导致代码与更广泛的环境,如浏览器 JS 所兼容。)

为什么使用 process.nextTick()

主要有两个原因:

  • 允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前重试请求。

  • 有时在调用堆栈已解除但在事件循环继续之前,必须允许回调运行。

下面就是一个符合用户预期的例子:

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

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

复制代码

假设 listen() 在事件循环开始时运行,但回调被放置在 setImmediate()中。除非通过主机名,否则将立即绑定到端口。事件循环进行时,会命中轮询阶段,这意味着可能会收到连接请求,从而允许在回调事件之前激发连接事件。

另一个示例运行的函数继承于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!');
});
复制代码

这里并不能立即从构造函数中触发 event 事件。因为在此之前用户并没有给 event 事件添加回调。但是,在构造函数本身中可以使用 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!');
});

复制代码

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

永无止境

永无止境

[美] 道格拉斯•艾德华兹 / 刘纯毅 / 中信出版社 / 2012-12-15 / 59.00元

★ 值得中国初创公司反复思考的企业传记 ★ 互联网行业必读书 ★ Google高管揭开Google的神秘面纱 ★ 探寻“G力量”重塑人类知识景观的心路历程 ★ Google走过的路,Google未来的路 ★ 编辑推荐: 它是目前被公认为全球最大的搜索引擎!它是互联网上五大最受欢迎的网站之一! 它在操作界面中提供多达30余种语言选择,在全球范围内拥有无数用户......一起来看看 《永无止境》 这本书的介绍吧!

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

URL 编码/解码
URL 编码/解码

URL 编码/解码

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具