NodeJs 的 Event loop 事件循环机制详解

什么是事件轮询

事件循环是 Node.js 处理非阻塞 I/O 操作的机制——尽管 JavaScript 是单线程处理的——当有可能的时候,它们会把操作转移到系统内核中去。

下面的图表显示了事件循环的概述以及操作顺序。

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

三大关键阶段

  1. timer:执行定时器时,如 setTimeout、setInterval,在 timers 阶段执行
  2. poll:异步操作,比如文件I/O,网络I/O等,通过'data'、 'connect'等事件通知 JS 主线程并执行回调的,此阶段就是 poll 轮询阶段
  3. check:这是一个比较简单的阶段,直接执行 setImmdiate 的回调。

注意,若 2 阶段结束后,当前存在到时间的定时器,那么拿出来执行,eventLoop 将再回到 timer 阶段

阶段流程概述

  • timers: 本阶段执行已经安排的 setTimeout() 和 setInterval() 的回调函数
  • IO / callbacks: 执行 I/O 异常的回调,如TCP 连接遇到 ECONNREFUSED
  • idle, prepare: 仅系统内部使用,只是表达空闲、预备状态(第2阶段结束,poll 未触发之前)
  • poll: 检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数),node 将在此处阻塞。
  • check: setImmediate() 回调函数在这里执行.
  • close callbacks: 一些准备关闭的回调函数,如:socket.on('close', ...)

在每次运行的事件循环之间,Node.js 检查它是否在等待任何异步 I/O 或计时器,如果没有的话,则关闭干净。

timers

timers 指定 可执行所提供回调 的 时间阈值,poll 阶段 控制何时定时器执行。

一旦 poll queue 为空,事件循环将检查 已达到时间阈值的timer计时器。如果一个或多个计时器已准备就绪,则事件循环将回到 timer 阶段以执行这些计时器的回调

pending callbacks

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

poll

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

  • 计算应该阻塞和 poll I/O 的时间。
  • 然后,处理 poll 队列里的事件。

当事件循环进入 poll阶段且 timers scheduled,将发生以下两种情况之一:

  • if the poll queue is not empty, 事件循环将循环访问其回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬限制
  • If the poll queue is empty,还有两件事发生
    • 如果脚本已按 setImmediate() 排定,则事件循环将结束 轮询 阶段,并继续 检查 阶段以执行这些计划脚本。
    • 如果脚本尚未按 setImmediate()排定,则事件循环将等待回调添加到队列中,然后立即执行。

一旦 poll queue 为空,事件循环将检查 已达到时间阈值的timer计时器。如果一个或多个计时器已准备就绪,则事件循环将回到 timer 阶段以执行这些计时器的回调。

check

通常,在执行代码时,事件循环最终会命中轮询阶段,等待传入连接、请求等。但是,如果回调已计划为 setImmediate(),并且轮询阶段变为空闲状态,则它将结束并继续到检查阶段而不是等待轮询事件。

setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调在 poll 阶段完成后执行。

close callbacks

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

setImmediate() 对比 setTimeout()

setImmediate() 和 setTimeout() 很类似,但何时调用行为完全不同。

  • setImmediate() 设计为在当前 轮询 阶段完成后执行脚本。
  • 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 循环内调用,setImmediate 总是被优先调用:

// 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() 在技术上不是事件循环的一部分,无论事件循环的当前阶段如何,都将在当前操作完成后处理 nextTickQueue。这里的一个操作被视作为一个从 C++ 底层处理开始过渡,并且处理需要执行的 JavaScript 代码。

回顾我们的关系图,任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前得到解决。这可能会造成一些糟糕的情况, 因为它允许您通过进行递归 process.nextTick() 来“饿死”您的 I/O 调用,阻止事件循环到达 轮询 阶段。

一个题目

// test.js
process.nextTick(function() {
  console.log('next tick');
});

setTimeout(function() {
  console.log('settimeout');
});

(async function() {
  console.log('async promise');
})();

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

$ node test.js
async promise
next tick
settimeout
setimmediate
  • 没有await,async那句其实是同步执行的,故而第一句输出。
  • next tick 在任何事件循环阶段继续之前得到解决,故而第二句
  • setTimeout 在主线程中与 setImmediate 的执行顺序是非确定性的
// test.js
setTimeout(function () {
  process.nextTick(function() {
    console.log('next tick');
  });
  
  setTimeout(function() {
    console.log('settimeout');
  });
  
  (async function() {
    console.log('async promise');
  })();
  
  setImmediate(function() {
    console.log('setimmediate');
  });
})

$ node test.js
async promise
next tick
setimmediate
settimeout

setimmediate 与 settimeout 放入一个 I/O 循环内调用,则 setImmediate 总是被优先调用

node >= 11 ?

setTimeout(()=>{
    console.log('timer1')
    setImmediate(function () { console.log('immd 1'); })
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    setImmediate(function () { console.log('immd 2'); })
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)

在 node 11 及以上版本打印得

timer1
promise1
timer2
promise2
immd 1
immd 2

在 node 版本为 8.11.2 打印

timer1
timer2
promise1
promise2
immd 1
immd 2

这是因为 < 11 得版本中

若第一个定时器任务出队并执行完,发现队首的任务仍然是一个定时器,那么就将微任务暂时保存,直接去执行新的定时器任务,当新的定时器任务执行完后,再一一执行中途产生的微任务。

nodejs 和 浏览器关于eventLoop的主要区别

两者最主要的区别在于浏览器中的微任务是在每个相应的宏任务中执行的,而nodejs中的微任务是在不同阶段之间执行的。