node.js事件循环

事件循环是 Node.js 处理非阻塞 I/O 操作的机制,node.js是单线程的,同一时间只会执行一份js代码。但这并不意味着node.js很慢,遇到耗时任务,我们不可能阻塞单线程的运行,而是把此任务扔给系统内核去处理,主线程继续处理请求服务,这样可以高并发运行业务。而在系统内核中,会有多线程操作来处理node.js任务,当处理完毕后,扔回事件循环队列里。等到主线程处理完所有的同步的js代码后,会从事件循环队列里取出任务,执行对应的回调函数。

事件轮询机制解析

node.js是基于V8引擎的javascript运行环境,基于事件驱动,底层用libuv库进行异步事件处理。

从上图可以看到事件循环的过程,接下来根据官网文档,具体分析一下。

在node.js启动后,会初始化事件循环
下面的图表显示了事件循环的概述以及操作顺序,从图中看到事件循环有6个阶段,每个阶段对应一个先进先出的回调队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
  • timers(定时器): 本阶段执行已经安排的 setTimeout() 和 setInterval() 的回调函数。
  • pending callbacks(待定回调): 执行延迟到下一个循环迭代的 I/O 回调,除了close事件的callbacks、被timers(定时器,setTimeout、setInterval等)设定的callbacks、setImmediate()设定的callbacks之外的callbacks;
  • idle, prepare: 仅系统内部使用
  • pool(轮询):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,它们由计时器和 setImmediate() 排定的之外),其余情况 node 将在此处阻塞。
  • check(检测):setImmediate() 回调函数在这里执行
  • close callbacks(关闭的回调函数): 一些准备关闭的回调函数,如:socket.on(‘close’, …)

timers(定时器)

一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。

Note: 轮询阶段控制何时定时器执行。

pending callbacks(待定回调)

此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 *nix 的系统希望等待报告错误。这将被排队以在此阶段执行。

pool(轮询)

轮询阶段有2个重要的功能:

  1. 执行下限时间已经达到的timers的回调
  2. 然后,处理轮询队列里的事件

当事件循环进入poll阶段,且没有发现timers(定时器)时:

  • 如果轮询队列不空,事件循环会遍历队列并同步执行回调,直到队列清空或执行的回调数达到系统上限;
  • 如果轮询队列为空,则发生以下两件事之一:
    • 如果有setImmediate()定义了回调,那么事件循环会终止轮询阶段并进入检查阶段去执行定时器回调
    • 如果没有setImmediate(),事件回调会等待回调被加入队列并立即执行

如果事件循环进入poll阶段,且有设定的timers,一旦poll队列为空:

  • 事件循环将检查timers,如果有1个或多个timers的下限时间已经到达,事件循环将回到timers(定时器)阶段执行回调。

Q:

  1. poll队列不空,达到执行上限后去哪里?执行主线程同步代码,执行完毕后,继续执行poll阶段队列。
  2. poll阶段,有timers,但下限时间未到达,回到那个阶段?检测阶段,等待队列,处理队列…时间到,满足条件后回到timers阶段。

check(检测)

此阶段在poll阶段结束后立即执行回调(poll空闲,且有被setImmediate()设定的回调),事件循环会转到check(检测)阶段而不是继续等待。

根据官网所说,setImmediate是一个特殊的timer,跑在事件循环中一个独立的阶段。使用libuv的API来安排回调在轮询阶段完成后执行。

close callbacks(关闭的回调函数)

如果套接字或处理函数突然关闭(例如 socket.destroy()),则’close’ 事件将在这个阶段发出。否则它将通过 process.nextTick() 发出。

setTimeout 与 setImmediate

  • setImmediate()被设计在 poll 阶段结束后立即执行回调;
  • setTimeout()被设计在指定下限时间到达后执行回调;

执行计时器的顺序将根据调用它们的上下文而异。如果二者都从主模块内调用,则计时将受进程性能的约束(这可能会受到计算机上运行的其它应用程序的影响)。下面脚本的执行顺序就是非确定性的,受进程性能的约束:

无I/O处理情况下:

1
2
3
4
5
6
7
setTimeout(function timeout () {
console.log('timeout');
},0);

setImmediate(function immediate () {
console.log('immediate');
});

如果你把这两个函数放入一个 I/O 循环内调用,setImmediate 总是被优先调用:

1
2
3
4
5
6
7
8
9
10
11
// timeout_vs_immediate.js
const fs = require('fs');

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

setImmediate() 比 setTimeout() 优势的地方是 setImmediate() 在 I/O 循环中总是先于任何定时器,不管已经定义了多少定时器。

process.nextTick()

在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前”

关于 setTimeout(), setImmediate(), process.nextTick():

  • setTimeout() 在某个时间值过后尽快执行回调函数;
  • setImmediate() 一旦轮询阶段完成就执行回调函数;
  • process.nextTick() 在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前” ;

优先级顺序从高到低: process.nextTick() > setImmediate() > setTimeout()
注:这里只是多数情况下,即轮询阶段(I/O 回调中)。比如之前比较 setImmediate() 和 setTimeout() 的时候就区分了所处阶段/上下文。


参考:
https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/
https://segmentfault.com/a/1190000012258592