首頁  >  文章  >  web前端  >  一文深入了解 Node 中的事件循環

一文深入了解 Node 中的事件循環

青灯夜游
青灯夜游轉載
2021-12-31 19:18:551513瀏覽

Node.js是單執行緒的語言,是透過事件循環處理非阻塞I/O操作的。以下這篇文章帶大家詳細了解 Node 中的事件循環,希望對大家有幫助!

一文深入了解 Node 中的事件循環

Node.js 當做為JavaScript 的服務端執行時,主要與網路、檔案打交道,沒有了瀏覽器中事件循環的渲染階段。

在瀏覽器中有 HTML 規格來定義事件循環的處理模型,之後由各瀏覽器廠商實作。 Node.js 中事件循環的定義與實作皆來自於 Libuv。

Libuv 圍繞著事件驅動的非同步 I/O 模型而設計,最初是為 Node.js 編寫的,提供了一個跨平台的支援庫。下圖展示了它的組成部分,Network I/O 是網路處理相關的部分,右邊還有檔案操作、DNS,底部 epoll、kqueue、event ports、IOCP 這些是底層不同作業系統的實作。

一文深入了解 Node 中的事件循環

事件循環的六個階段

#當Node.js 啟動時,它會初始化事件循環,處理提供的腳本,同步程式碼入棧直接執行,非同步任務(網路請求、檔案操作、定時器等)在呼叫API 傳遞回調函數後會把操作轉移到後台由系統核心處理。目前大多數核心都是多執行緒的,當其中一個操作完成時,核心通知 Node.js 將回調函數加入到輪詢佇列中等待時機執行。

下圖左側是Node.js 官網對事件循環過程的描述,右側是Libuv 官網對Node.js 的描述,都是事件循環的介紹,不是所有人上來都能去看源碼的,這兩份文檔通常也是對事件循環更直接的學習參考文檔,在Node.js 官網介紹的也還挺詳細的,可以做為一個參考資料學習。

一文深入了解 Node 中的事件循環

左側Node.js 官網展示的事件循環分為6 個階段,每個階段都有FIFO(先進先出)佇列執行回呼函數,這幾個階段之間執行的優先順序還是明確的。

右邊更詳細的描述了,在事件循環迭代前,先去判斷循環是否處於活動狀態(有等待的非同步I/O、定時器等),如果是活動狀態開始迭代,否則循環將立即退出。

下面對每個階段分別討論。

timers(定時器階段)

首先事件循環進入定時器階段,該階段包含兩個API setTimeout(cb, ms)、setInterval(cb, ms ) 前一個是只執行一次,後一個是重複執行。

這個階段檢查是否有到期的定時器函數,如果有則執行到期的定時器回呼函數,和瀏覽器中的一樣,定時器函數傳入的延遲時間總比我們預期的要晚,它會受到作業系統或其它正在運行的回調函數的影響。

例如,下例我們設定了一個定時器函數,並且預期在 1000 毫秒後執行。

const now = Date.now();
setTimeout(function timer1(){
  log(`delay ${Date.now() - now} ms`);
}, 1000);
setTimeout(function timer2(){
 log(`delay ${Date.now() - now} ms`);
}, 5000);
someOperation();

function someOperation() {
  // sync operation...
  while (Date.now() - now < 3000) {}
}

當呼叫setTimeout 非同步函數後,程式緊接著執行了someOperation() 函數,中間有些耗時操作大約消耗3000ms,當完成這些同步操作後,進入一次事件循環,首先檢查定時器階段是否有到期的任務,定時器的腳本是按照delay 時間升序存儲在堆內存中,首先取出超時時間最小的定時器函數做檢查,如果nowTime - timerTaskRegisterTime > delay 取出回調函數執行,否則繼續檢查,當檢查到一個沒有到期的定時器函數或達到系統依賴的最大數量限制後,轉移到下一階段。

在我們這個範例中,假設執行完someOperation() 函數的目前時間為T 3000:

檢查timer1 函數,目前時間為T 3000 - T > 1000,已超過預期的延遲時間,取出回調函數執行,繼續檢查。

檢查 timer2 函數,目前時間為 T 3000 - T

pending callbacks

定時器階段完成後,事件循環進入到 pending callbacks 階段,在這個階段執行上一輪事件循環遺留的 I/O 回呼。根據 Libuv 文件的描述:大多數情況下,在輪詢 I/O 後立即調用所有 I/O 回調,但是,在某些情況下,調用此類回調會推遲到下一次循環迭代。聽完更像是上一個階段的遺留。

idle, prepare

idle, prepare 阶段是给系统内部使用,idle 这个名字很迷惑,尽管叫空闲,但是在每次的事件循环中都会被调用,当它们处于活动状态时。这一块的资料介绍也不是很多。略...

poll

poll 是一个重要的阶段,这里有一个概念观察者,有文件 I/O 观察者,网络 I/O 观察者等,它会观察是否有新的请求进入,包含读取文件等待响应,等待新的 socket 请求,这个阶段在某些情况下是会阻塞的。

阻塞 I/O 超时时间

在阻塞 I/O 之前,要计算它应该阻塞多长时间,参考 Libuv 文档上的一些描述,以下这些是它计算超时时间的规则:

如果循环使用 UV_RUN_NOWAIT 标志运行、超时为 0。

如果循环将要停止(uv_stop() 被调用),超时为 0。

如果没有活动的 handlers 或 request,超时为 0。

如果有任何 idle handlers 处于活动状态,超时为 0。

如果有任何待关闭的 handlers,超时为 0。

如果以上情况都没有,则采用最近定时器的超时时间,或者如果没有活动的定时器,则超时时间为无穷大,poll 阶段会一直阻塞下去。

示例一

很简单的一段代码,我们启动一个 Server,现在事件循环的其它阶段没有要处理的任务,它会在这里等待下去,直到有新的请求进来。

const http = require(&#39;http&#39;);
const server = http.createServer();
server.on(&#39;request&#39;, req => {
  console.log(req.url);
})
server.listen(3000);

示例二

结合阶段一的定时器,在看个示例,首先启动 app.js 做为服务端,模拟延迟 3000ms 响应,这个只是为了配合测试。再运行 client.js 看下事件循环的执行过程:

首先程序调用了一个在 1000ms 后超时的定时器。

之后调用异步函数 someAsyncOperation() 从网络读取数据,我们假设这个异步网路读取需要 3000ms。

当事件循环开始时先进入 timer 阶段,发现没有超时的定时器函数,继续向下执行。

期间经过 pending callbacks -> idle,prepare 当进入 poll 阶段,此时的 http.get() 尚未完成,它的队列为空,参考上面 poll 阻塞超时时间规则,事件循环机制会检查最快到达阀值的计时器,而不是一直在这里等待下去。

当大约过了 1000ms 后,进入下一次事件循环进入定时器,执行到期的定时器回调函数,我们会看到日志 setTimeout run after 1003 ms。

在定时器阶段结束之后,会再次进入 poll 阶段,继续等待。

// client.js
const now = Date.now();
setTimeout(() => log(`setTimeout run after ${Date.now() - now} ms`), 1000);
someAsyncOperation();
function someAsyncOperation() {
  http.get(&#39;http://localhost:3000/api/news&#39;, () => {
    log(`fetch data success after ${Date.now() - now} ms`);
  });
}

// app.js
const http = require(&#39;http&#39;);
http.createServer((req, res) => {
  setTimeout(() => { res.end(&#39;OK!&#39;) }, 3000);
}).listen(3000);

当 poll 阶段队列为空时,并且脚本被 setImmediate() 调度过,此时,事件循环也会结束 poll 阶段,进入下一个阶段 check。

check

check 阶段在 poll 阶段之后运行,这个阶段包含一个 API setImmediate(cb) 如果有被 setImmediate 触发的回调函数,就取出执行,直到队列为空或达到系统的最大限制。

setTimeout VS setImmediate

拿 setTimeout 和 setImmediate 对比,这是一个常见的例子,基于被调用的时机和定时器可能会受到计算机上其它正在运行的应用程序影响,它们的输出顺序,不总是固定的。

setTimeout(() => log(&#39;setTimeout&#39;));
setImmediate(() => log(&#39;setImmediate&#39;));

// 第一次运行
setTimeout
setImmediate

// 第二次运行
setImmediate
setTimeout

setTimeout VS setImmediate VS fs.readFile

但是一旦把这两个函数放入一个 I/O 循环内调用,setImmediate 将总是会被优先调用。因为 setImmediate 属于 check 阶段,在事件循环中总是在 poll 阶段结束后运行,这个顺序是确定的。

fs.readFile(__filename, () => {
  setTimeout(() => log(&#39;setTimeout&#39;));
  setImmediate(() => log(&#39;setImmediate&#39;));
})

close callbacks

在 Libuv 中,如果调用关闭句柄 uv_close(),它将调用关闭回调,也就是事件循环的最后一个阶段 close callbacks。

这个阶段的工作更像是做一些清理工作,例如,当调用 socket.destroy(),'close' 事件将在这个阶段发出,事件循环在执行完这个阶段队列里的回调函数后,检查循环是否还 alive,如果为 no 退出,否则继续下一次新的事件循环。

包含 Microtask 的事件循环流程图

在浏览器的事件循环中,把任务划分为 Task、Microtask,在 Node.js 中是按照阶段划分的,上面我们介绍了 Node.js 事件循环的 6 个阶段,给用户使用的主要是 timer、poll、check、close callback 四个阶段,剩下两个由系统内部调度。这些阶段所产生的任务,我们可以看做 Task 任务源,也就是常说的 “Macrotask 宏任务”。

通常我们在谈论一个事件循环时还会包含 Microtask,Node.js 里的微任务有 Promise、还有一个也许很少关注的函数 queueMicrotask,它是在 Node.js v11.0.0 之后被实现的,参见 PR/22951。

Node.js 中的事件循环在每一个阶段执行后,都会检查微任务队列中是否有待执行的任务。

一文深入了解 Node 中的事件循環

Node.js 11.x 前后差异

Node.js 在 v11.x 前后,每个阶段如果即存在可执行的 Task 又存在 Microtask 时,会有一些差异,先看一段代码:

setImmediate(() => {
  log(&#39;setImmediate1&#39;);
  Promise.resolve(&#39;Promise microtask 1&#39;)
    .then(log);
});
setImmediate(() => {
  log(&#39;setImmediate2&#39;);
  Promise.resolve(&#39;Promise microtask 2&#39;)
    .then(log);
});

在 Node.js v11.x 之前,当前阶段如果存在多个可执行的 Task,先执行完毕,再开始执行微任务。基于 v10.22.1 版本运行结果如下:

setImmediate1
setImmediate2
Promise microtask 1
Promise microtask 2

在 Node.js v11.x 之后,当前阶段如果存在多个可执行的 Task,先取出一个 Task 执行,并清空对应的微任务队列,再次取出下一个可执行的任务,继续执行。基于 v14.15.0 版本运行结果如下:

setImmediate1
Promise microtask 1
setImmediate2
Promise microtask 2

在 Node.js v11.x 之前的这个执行顺序问题,被认为是一个应该要修复的 Bug 在 v11.x 之后并修改了它的执行时机,和浏览器保持了一致,详细参见 issues/22257 讨论。

特别的 process.nextTick()

Node.js 中还有一个异步函数 process.nextTick(),从技术上讲它不是事件循环的一部分,它在当前操作完成后处理。如果出现递归的 process.nextTick() 调用,这将会很糟糕,它会阻断事件循环。

如下例所示,展示了一个 process.nextTick() 递归调用示例,目前事件循环位于 I/O 循环内,当同步代码执行完成后 process.nextTick() 会被立即执行,它会陷入无限循环中,与同步的递归不同的是,它不会触碰 v8 最大调用堆栈限制。但是会破坏事件循环调度,setTimeout 将永远得不到执行。

fs.readFile(__filename, () => {
  process.nextTick(() => {
    log(&#39;nextTick&#39;);
    run();
    function run() {
      process.nextTick(() => run());
    }
  });
  log(&#39;sync run&#39;);
  setTimeout(() => log(&#39;setTimeout&#39;));
});

// 输出
sync run
nextTick

将 process.nextTick 改为 setImmediate 虽然是递归的,但它不会影响事件循环调度,setTimeout 在下一次事件循环中被执行。

fs.readFile(__filename, () => {
  process.nextTick(() => {
    log(&#39;nextTick&#39;);
    run();
    function run() {
      setImmediate(() => run());
    }
  });
  log(&#39;sync run&#39;);
  setTimeout(() => log(&#39;setTimeout&#39;));
});

// 输出
sync run
nextTick
setTimeout

process.nextTick 是立即执行,setImmediate 是在下一次事件循环的 check 阶段执行。但是,它们的名字着实让人费解,也许会想这两个名字交换下比较好,但它属于遗留问题,也不太可能会改变,因为这会破坏 NPM 上大部分的软件包。

在 Node.js 的文档中也建议开发者尽可能的使用 setImmediate(),也更容易理解。

更多node相关知识,请访问:nodejs 教程!!

以上是一文深入了解 Node 中的事件循環的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.cn。如有侵權,請聯絡admin@php.cn刪除