原文

什么是Event Loop

尽管事实上JavaScript是单线程的,Event Loop通过尽可能的将操作卸载到内核,让Node.js实现非阻塞的I/O操作。(其实这段话表达了JavaScript的执行是在单一线程上的,但是Node.js可能需要借助其他线程(将操作卸载到内核)来帮助实现异步)

因为大多数主流内核都是支持多线程操作的,可以在后台处理多个操作的执行。当其中一个操作完成,内核会通知Node.js,以便将合适的回调添加到poll队列中,并最终被执行。

Event Loop解释说明

Node.js一启动,变回初始化事件循环,处理输入的脚本(放入REPL的不在本文中说明),脚本可能调用异步API,可能调度定时器,也可能调用process.nextTick(),然后便开始了处理时间循环

下面的图标简单的展示了事件循环操作的顺序:

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

注:每个块都被成为事件循环的一个阶段。

每一个阶段都有一个回调的FIFO(first in first out先进先出)队列要执行。每个阶段都有其特殊的方式,通常情况下,当事件循环进入特定的阶段,将执行特定于该阶段的所有操作,然后在执行该阶段的回调,直到队列全部执行完,或者到达最大执行数量。当队列耗尽或达到最大执行数量,将开始下一个阶段,依次循环。

由于这些操作中的任意一个操作,都有可能调度更多其他的操作,在poll阶段被处理的新的事件被内核加入队列,当轮训事件被处理时可以列起来。所以一个长时间执行的回调会让poll阶段占用的时间超过定时器的阈值。(这一段简直就是绕口令,放到现实中,大概就是某个异步操作的回调中,开启了另一个异步操作,当这一个异步完成时,会有新的任务加入到队列中)

注:在Windows和Unix/Linux实现下有轻微的差异,但是并不重要。也许有七八个步骤,单实际重要的就是上面的流程。

阶段概述

timers:这个阶段执行setTimeout和setInterval调度的回调。即setTimeout和setInterval到点儿了要执行的函数

pending callbacks:被延迟到下一个迭代周期的那些回调

idel,prepare:仅在内部使用,并没有任何业务逻辑在这个阶段被执行

poll:回收新的I/O事件;执行I/O相关的回调(除了关闭回调,定时器回调和setImmediate回调)。node会在特定情况下在这一阶段被阻塞。(原文中almost all with the exception of表达的应该是整体中剔除,并不是异常)

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阶段,队里暂时是空的,此时文件并没有读取完,所以会等待一段时间,这个时间差等于最近定时器到期的剩余时间。等了95ms后,fs.readFile()结束,他的回调被加入到poll队列且被执行,这是一个需要耗时10ms的回调。这个回调执行完后,暂时poll队列空了,事件循环会查看最近的已经到时间的定时器设定的阈值,然后到timer阶段执行定时器的回调。在这个例子中,你会发现从设置定时器开始,到定时器回调被执行,一共花费了95ms+10ms,这超过了设定的100ms。

注:为了防止poll阶段使整个事件循环卡住,libuv有一个硬性最大值限制(根据不同的操作系统),要求poll阶段在处理一定事件后停下。

setTimeout设置一个100ms后执行的回调,然后执行fs.readFile(),读取完成后执行一个耗时10ms的回调。这两个过程之间的时间我们忽略不计,setTimeout和fs.readFile在时间轴上几乎同时开始。到达95ms时,I/O率先完成,到poll阶段了就把I/O回调执行掉。这个回调在跑过5ms时,也就是时间轴到达100ms,定时器到点儿,timer阶段的队列被加入一个回调等待执行,然后poll阶段没有完成。等到105ms是poll阶段的回调执行干净了,来到timer阶段,将队列中的回调执行,这时候从定时器设置的回调中观察,延迟了105ms而不是设定的100ms。poll阶段占用的时间,直接作用在timer的延迟上。当然Node.js也不会让poll阶段卡死整个流程,所以poll阶段也会将一些本次做不完的任务推到下一次。

pending callbacks

这个阶段执行某些系统操作的回调,比如一些类型的TCP错误。比如TCP socket尝试连接时接到ECONNREFUSED,一些Unix系统会等到报告错误。这些会被放到pending阶段的队列。

poll

poll阶段主要有两个主要的作用:

  1. 计算需要阻塞多长时间并轮训I/O,然后
  2. 处理poll队列中的事件

当时间循环进入poll阶段并且没有设置定时器,以下两种情况会出现:

  • 如果poll队列不空,事件循环会迭代poll队列,同步执行回调直到队列清空,或者达到调用上限
  • 如果poll队列是空的,以下情况会出现:

    • 如果脚本有使用setImmediate()调度,事件循环会结束poll阶段进入check阶段来执行被调度的内容
    • 如果没有,事件循环会等待回调进入队列,且会立马执行加入的回调

一旦poll队列清空,事件循环会检查那些已经到达定时时间的定时器,如果有这样的已经到点儿的定时器,事件循环就会绕回到timers阶段来执行那些到点儿的定时器回调。

check

这个阶段可以在poll阶段一完成就立即执行回调,如果poll阶段已经空闲,且有脚本被setImmediate()压如队列,事件循环可能会直接进入check阶段而不是等待。

setImmediate()实际上是一个特殊的定时器,运行在事件循环以外的一个单独阶段。在poll阶段完成后,它使用一个libuv的API去调度执行回调。

一般来说,代码被执行了,事件循环最终会在poll阶段等待即将到来的连接或请求等。但是,如果有setImmediate()调度的回调,且poll阶段已经进入空闲,那么事件循环会继续运行到check阶段而不是等待。

close callbacks

如果socket或者句柄突然关闭,比如调用了socket.destroy(),‘close’事件在这个阶段被‘弹出’。否则将通过process.nextTick()使其‘弹出’。(我的理解应该是代码中on('close',function(){})这样的逻辑,监听socket的close事件的回调函数在这个阶段执行。)

setImmediate() VS setTimeout()

这两个函数功能类似,但是他们的行为存在差异,取决与他们何时被调用。

  • setImmediate()是用来在当前poll阶段一完成就立即执行回调
  • setTimeout()是调度一个脚本,在一个以毫秒为单位的阈值时间后执行。

定时器的执行顺序,会依赖于他们被调用的上下文。如果两者都是在主模块内调用,那么执行时机将由进程性能所影响(可能还会受到机器上其他应用程序的影响)。毕竟timers阶段和check阶段都在poll阶段之后,调用都在同一个poll阶段,执行就被当前的poll阶段所在的主线程影响。

举个例子,如果我们运行如下不包含在I/O周期内的脚本(也就是不包含在任何回调中,纯纯的就是在main.js),定时器的执行顺序其实是不确定的,因为受到主线程的性能影响:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

根据不同的文档说明,setTimeout第二个参数如果传入0或者不传,表达尽快执行,这个尽快执行和setImmediate()的尽快执行仍然在阶段上存在差异(浏览器环境下,没有其他阻塞影响,两个setTImeout之间的最小间隔是4ms,嵌套设定定时器会明显感觉出这个问题)。

但是如果你在I/O循环内调用,immediate回调总是先执行:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

setImmediate()比setTimeout的优势在于,在同一个I/O周期内,setImmediate()调度总是在任何定时器之前,check阶段比timers阶段优先,不受定时器多少的影响。

I/O操作的回调总是在poll阶段执行,而poll阶段总是更积极的向check阶段进行而不是timers阶段。

setTimeout的函数签名:setTimeout(callback, delay[, ...args]),其中delay大于2147483647 或小于 1 时, delay 将设置为 1。非整数被截断为整数。所以第一个例子中,setTImeout调度一个回调在1ms后执行。如果timers阶段1ms已经到期了,那肯定是先输出‘timeout’,如果没有,到poll阶段,更积极的向check阶段进行,则‘immediate’先输出。因为线程调度有开销,进程切换也有开销,并不能保证JavaScript主线程执行时过去了多久,所以才有了不确定执行顺序的结论。

process.nextTick()

理解process.nextTick()

你也许注意到图表中并没有展示process.nextTick(),尽管这是异步API的一部分。这是因为process.nextTick()技术上并不是事件循环的一部分。相反,nextTickQueue会在当前操作完成后被处理,无视当前处于事件循环的哪一个阶段。

回看图表,在某个阶段的任意时间调用process.nextTick(),给定的回调会在事件循环继续下一阶段之前被解决。这可能会引起一些不好的情况,因为这样可能会造成主线程空等,比如递归调用process.nextTick(),这会组织事件循环进入poll阶段。

为什么允许process.nextTick()这种特殊的行为

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

这里先介绍一下process.nextTick()函数,签名如下:

process.nextTick(callback[, ...args])

process.nextTick()将callback添加到“next tick queue”,这个队列会在当前JavaScript执行栈完成后,在事件循环将要进入下一个阶段之前,被完全处理掉。所以递归调用将产生死循环(不会到达v8引擎的最大调用栈限制)。

调用apiCall时,传入的arg不是string类型,会将一个错误传给指定的callback,但是在其他用户代码执行完毕后。

这种设定可能导致一些潜在的问题:

let bar;

// 函数名表达了这应当是一个异步操作,但是实际调用callback是同步调用的
function someAsyncApiCall(callback) { callback(); }

// 所以根据调用栈执行顺序,callback完成了之后,someAsyncApiCall才会调用完成
someAsyncApiCall(() => {
  // 此时bar并没有被赋值
  console.log('bar', bar); // undefined
});

bar = 1;

someAsyncApiCall是一个包含异步含义的函数前面,实际内容却是同步操作。这个方法被调用时,callback被提供给在同一个事件循环阶段被调用的someAsyncApiCall(),正是因为这个方法没有执行任何异步操作。结果解释,callback尝试引用bar变量,即使作用域内可能还没有值,也是因为同步的原因,执行栈并没有执行到为bar赋值的地方。

通过使用process.nextTick()调用callback,这段脚本就可以执行完,所有的变量和函数等,都可以在callback被调用前被初始化完成。而且还可以阻止事件循环进入下一阶段。在事件循环进入下一阶段之前,对用户提示错误也许是有用的。

let bar;

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

someAsyncApiCall(() => {
  console.log('bar', bar); // 这样就可以正常的输出bar被赋予的值了
});

bar = 1;

下面是另外一个例子:

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

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

当传入了端口,服务就会立即绑定到指定的端口,所以‘listening’回调会被立即调用。如果你调用"listen(8080)"是同步的,那么事件触发时,绑定监听的操作还没有执行,会导致监听不到。为了解决这个问题,“listening”事件被nextTick放到响应的队列中,让脚本有时间去绑定监听,而不是强行要求先绑定在监听。这更符合事件触发与监听的异步关系。

process.nextTick() VS setImmediate()

对用户来说这两个函数执行时机可能会产生困扰:

  • process.nextTick()会在同一阶段立即出发
  • setImmediate()会在紧跟着的下一个迭代周期执行,或者说事件循环的“下一跳”执行。

实际上名称应该交换(nextTick具有“立即”的含义,setImmediate却是“下一跳”的动作)。很明显,process.nextTick()要比setImmediate()更快的出发,名字都是遗留问题,就不改了吧。--一段废话

建议开发者任何情况下都使用setImmediate(),因为他的行为更容易被分析,并且更容易被环境兼容,比如浏览器环境下有setImmediate而没有nextTick

为什么使用process.nextTick()

两个主要的原因:

  1. 允许用户处理错误,清理不需要的资源,或者在事件循环继续之前尝试再次请求
  2. 又是需要让回调在调用栈清空之后,事件循环之前执行

一个例子:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  this.emit('event');
  // 如果使用nextTick将触发事件放在绑定结束,就能得到预期的结果
  // process.nextTick(()=>this.emit('event'));
}
util.inherits(MyEmitter, EventEmitter);

// 在构造方法中发射事件,脚本还没有处理到事件监听的操作
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
// 相当于在这里才emit,上面的on就能正常运作了

标签: none

添加新评论