JavaScript 異步編程指南 - 聊聊 Node.js 中的事件循環(huán)
事件循環(huán)是一種控制應(yīng)用程序的運(yùn)行機(jī)制,在不同的運(yùn)行時(shí)環(huán)境有不同的實(shí)現(xiàn),上一節(jié)講了瀏覽器中的事件循環(huán),它們有很多相似的地方,也有著各自的特點(diǎn),本節(jié)討論下 Node.js 中的事件循環(huán)。
了解 Node.js 中的事件循環(huán)
Node.js 做為 JavaScript 的服務(wù)端運(yùn)行時(shí),主要與網(wǎng)絡(luò)、文件打交道,沒(méi)有了瀏覽器中事件循環(huán)的渲染階段。
在瀏覽器中有 HTML 規(guī)范來(lái)定義事件循環(huán)的處理模型,之后由各瀏覽器廠商實(shí)現(xiàn)。Node.js 中事件循環(huán)的定義與實(shí)現(xiàn)均來(lái)自于 Libuv。
Libuv 圍繞事件驅(qū)動(dòng)的異步 I/O 模型而設(shè)計(jì),最初是為 Node.js 編寫(xiě)的,提供了一個(gè)跨平臺(tái)的支持庫(kù)。下圖展示了它的組成部分,Network I/O 是網(wǎng)絡(luò)處理相關(guān)的部分,右側(cè)還有文件操作、DNS,底部 epoll、kqueue、event ports、IOCP 這些是底層不同操作系統(tǒng)的實(shí)現(xiàn)。
圖片來(lái)源:http://docs.libuv.org/en/v1.x/_images/architecture.png
事件循環(huán)的六個(gè)階段
當(dāng) Node.js 啟動(dòng)時(shí),它會(huì)初始化事件循環(huán),處理提供的腳本,同步代碼入棧直接執(zhí)行,異步任務(wù)(網(wǎng)絡(luò)請(qǐng)求、文件操作、定時(shí)器等)在調(diào)用 API 傳遞回調(diào)函數(shù)后會(huì)把操作轉(zhuǎn)移到后臺(tái)由系統(tǒng)內(nèi)核處理。目前大多數(shù)內(nèi)核都是多線程的,當(dāng)其中一個(gè)操作完成時(shí),內(nèi)核通知 Node.js 將回調(diào)函數(shù)添加到輪詢隊(duì)列中等待時(shí)機(jī)執(zhí)行。
下圖左側(cè)是 Node.js 官網(wǎng)對(duì)事件循環(huán)過(guò)程的描述,右側(cè)是 Libuv 官網(wǎng)對(duì) Node.js 的描述,都是對(duì)事件循環(huán)的介紹,不是所有人上來(lái)都能去看源碼的,這兩個(gè)文檔通常也是對(duì)事件循環(huán)更直接的學(xué)習(xí)參考文檔,在 Node.js 官網(wǎng)介紹的也還是挺詳細(xì)的,可以做為一個(gè)參考資料學(xué)習(xí)。
左側(cè) Node.js 官網(wǎng)展示的事件循環(huán)分為 6 個(gè)階段,每個(gè)階段都有一個(gè) FIFO(先進(jìn)先出)隊(duì)列執(zhí)行回調(diào)函數(shù),這幾個(gè)階段之間執(zhí)行的優(yōu)先級(jí)順序還是明確的。
右側(cè)更詳細(xì)的描述了,在事件循環(huán)迭代前,先去判斷循環(huán)是否處于活動(dòng)狀態(tài)(有等待的異步 I/O、定時(shí)器等),如果是活動(dòng)狀態(tài)開(kāi)始迭代,否則循環(huán)將立即退出。
下面對(duì)每個(gè)階段分別討論。
timers(定時(shí)器階段)
首先事件循環(huán)進(jìn)入定時(shí)器階段,該階段包含兩個(gè) API setTimeout(cb, ms)、setInterval(cb, ms) 前一個(gè)是僅執(zhí)行一次,后一個(gè)是重復(fù)執(zhí)行。
這個(gè)階段檢查是否有到期的定時(shí)器函數(shù),如果有則執(zhí)行到期的定時(shí)器回調(diào)函數(shù),和瀏覽器中的一樣,定時(shí)器函數(shù)傳入的延遲時(shí)間總比我們預(yù)期的要晚,它會(huì)受到操作系統(tǒng)或其它正在運(yùn)行的回調(diào)函數(shù)的影響。
例如,下例我們?cè)O(shè)置了一個(gè)定時(shí)器函數(shù),并預(yù)期在 1000 毫秒后執(zhí)行。
- 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) {}
- }
當(dāng)調(diào)用 setTimeout 異步函數(shù)后,程序緊接著執(zhí)行了 someOperation() 函數(shù),中間有些耗時(shí)操作大約消耗 3000ms,當(dāng)完成這些同步操作后,進(jìn)入一次事件循環(huán),首先檢查定時(shí)器階段是否有到期的任務(wù),定時(shí)器的腳本是按照 delay 時(shí)間升序存儲(chǔ)在堆內(nèi)存中,首先取出超時(shí)時(shí)間最小的定時(shí)器函數(shù)做檢查,如果 **nowTime - timerTaskRegisterTime > delay** 取出回調(diào)函數(shù)執(zhí)行,否則繼續(xù)檢查,當(dāng)檢查到一個(gè)沒(méi)有到期的定時(shí)器函數(shù)或達(dá)到系統(tǒng)依賴的最大數(shù)量限制后,轉(zhuǎn)移到下一階段。
在我們這個(gè)示例中,假設(shè)執(zhí)行完 someOperation() 函數(shù)的當(dāng)前時(shí)間為 T + 3000:
- 檢查 timer1 函數(shù),當(dāng)前時(shí)間為 T + 3000 - T > 1000,已超過(guò)預(yù)期的延遲時(shí)間,取出回調(diào)函數(shù)執(zhí)行,繼續(xù)檢查。
- 檢查 timer2 函數(shù),當(dāng)前時(shí)間為 T + 3000 - T < 5000,還沒(méi)達(dá)到預(yù)期的延遲時(shí)間,此時(shí)退出定時(shí)器階段。
pending callbacks
定時(shí)器階段完成后,事件循環(huán)進(jìn)入到 pending callbacks 階段,在這個(gè)階段執(zhí)行上一輪事件循環(huán)遺留的 I/O 回調(diào)。根據(jù) Libuv 文檔的描述:大多數(shù)情況下,在輪詢 I/O 后立即調(diào)用所有 I/O 回調(diào),但是,某些情況下,調(diào)用此類回調(diào)會(huì)推遲到下一次循環(huán)迭代。聽(tīng)完更像是上一個(gè)階段的遺留。
idle, prepare
idle, prepare 階段是給系統(tǒng)內(nèi)部使用,idle 這個(gè)名字很迷惑,盡管叫空閑,但是在每次的事件循環(huán)中都會(huì)被調(diào)用,當(dāng)它們處于活動(dòng)狀態(tài)時(shí)。這一塊的資料介紹也不是很多。略...
poll
poll 是一個(gè)重要的階段,這里有一個(gè)概念觀察者,有文件 I/O 觀察者,網(wǎng)絡(luò) I/O 觀察者等,它會(huì)觀察是否有新的請(qǐng)求進(jìn)入,包含讀取文件等待響應(yīng),等待新的 socket 請(qǐng)求,這個(gè)階段在某些情況下是會(huì)阻塞的。
阻塞 I/O 超時(shí)時(shí)間
在阻塞 I/O 之前,要計(jì)算它應(yīng)該阻塞多長(zhǎng)時(shí)間,參考 Libuv 文檔上的一些描述,以下這些是它計(jì)算超時(shí)時(shí)間的規(guī)則:
- 如果循環(huán)使用 UV_RUN_NOWAIT 標(biāo)志運(yùn)行、超時(shí)為 0。
- 如果循環(huán)將要停止(uv_stop() 被調(diào)用),超時(shí)為 0。
- 如果沒(méi)有活動(dòng)的 handlers 或 request,超時(shí)為 0。
- 如果有任何 idle handlers 處于活動(dòng)狀態(tài),超時(shí)為 0。
- 如果有任何待關(guān)閉的 handlers,超時(shí)為 0。
如果以上情況都沒(méi)有,則采用最近定時(shí)器的超時(shí)時(shí)間,或者如果沒(méi)有活動(dòng)的定時(shí)器,則超時(shí)時(shí)間為無(wú)窮大,poll 階段會(huì)一直阻塞下去。
示例一
很簡(jiǎn)單的一段代碼,我們啟動(dòng)一個(gè) Server,現(xiàn)在事件循環(huán)的其它階段沒(méi)有要處理的任務(wù),它會(huì)在這里等待下去,直到有新的請(qǐng)求進(jìn)來(lái)。
- const http = require('http');
- const server = http.createServer();
- server.on('request', req => {
- console.log(req.url);
- })
- server.listen(3000);
示例二
結(jié)合階段一的定時(shí)器,在看個(gè)示例,首先啟動(dòng) app.js 做為服務(wù)端,模擬延遲 3000ms 響應(yīng),這個(gè)只是為了配合測(cè)試。再運(yùn)行 client.js 看下事件循環(huán)的執(zhí)行過(guò)程:
- 首先程序調(diào)用了一個(gè)在 1000ms 后超時(shí)的定時(shí)器。
- 之后調(diào)用異步函數(shù) someAsyncOperation() 從網(wǎng)絡(luò)讀取數(shù)據(jù),我們假設(shè)這個(gè)異步網(wǎng)路讀取需要 3000ms。
- 當(dāng)事件循環(huán)開(kāi)始時(shí)先進(jìn)入 timer 階段,發(fā)現(xiàn)沒(méi)有超時(shí)的定時(shí)器函數(shù),繼續(xù)向下執(zhí)行。
- 期間經(jīng)過(guò) pending callbacks -> idle,prepare 當(dāng)進(jìn)入 poll 階段,此時(shí)的 http.get() 尚未完成,它的隊(duì)列為空,參考上面 poll 阻塞超時(shí)時(shí)間規(guī)則,事件循環(huán)機(jī)制會(huì)檢查最快到達(dá)閥值的計(jì)時(shí)器,而不是一直在這里等待下去。
- 當(dāng)大約過(guò)了 1000ms 后,進(jìn)入下一次事件循環(huán)進(jìn)入定時(shí)器,執(zhí)行到期的定時(shí)器回調(diào)函數(shù),我們會(huì)看到日志 setTimeout run after 1003 ms。
- 在定時(shí)器階段結(jié)束之后,會(huì)再次進(jìn)入 poll 階段,繼續(xù)等待。
- // client.js
- const now = Date.now();
- setTimeout(() => log(`setTimeout run after ${Date.now() - now} ms`), 1000);
- someAsyncOperation();
- function someAsyncOperation() {
- http.get('http://localhost:3000/api/news', () => {
- log(`fetch data success after ${Date.now() - now} ms`);
- });
- }
- // app.js
- const http = require('http');
- http.createServer((req, res) => {
- setTimeout(() => { res.end('OK!') }, 3000);
- }).listen(3000);
當(dāng) poll 階段隊(duì)列為空時(shí),并且腳本被 setImmediate() 調(diào)度過(guò),此時(shí),事件循環(huán)也會(huì)結(jié)束 poll 階段,進(jìn)入下一個(gè)階段 check。
check
check 階段在 poll 階段之后運(yùn)行,這個(gè)階段包含一個(gè) API setImmediate(cb) 如果有被 setImmediate 觸發(fā)的回調(diào)函數(shù),就取出執(zhí)行,直到隊(duì)列為空或達(dá)到系統(tǒng)的最大限制。
setTimeout VS setImmediate
拿 setTimeout 和 setImmediate 對(duì)比,這是一個(gè)常見(jiàn)的例子,基于被調(diào)用的時(shí)機(jī)和定時(shí)器可能會(huì)受到計(jì)算機(jī)上其它正在運(yùn)行的應(yīng)用程序影響,它們的輸出順序,不總是固定的。
- setTimeout(() => log('setTimeout'));
- setImmediate(() => log('setImmediate'));
- // 第一次運(yùn)行
- setTimeout
- setImmediate
- // 第二次運(yùn)行
- setImmediate
- setTimeout
setTimeout VS setImmediate VS fs.readFile
但是一旦把這兩個(gè)函數(shù)放入一個(gè) I/O 循環(huán)內(nèi)調(diào)用,setImmediate 將總是會(huì)被優(yōu)先調(diào)用。因?yàn)?setImmediate 屬于 check 階段,在事件循環(huán)中總是在 poll 階段結(jié)束后運(yùn)行,這個(gè)順序是確定的。
- fs.readFile(__filename, () => {
- setTimeout(() => log('setTimeout'));
- setImmediate(() => log('setImmediate'));
- })
close callbacks
在 Libuv 中,如果調(diào)用關(guān)閉句柄 uv_close(),它將調(diào)用關(guān)閉回調(diào),也就是事件循環(huán)的最后一個(gè)階段 close callbacks。
這個(gè)階段的工作更像是做一些清理工作,例如,當(dāng)調(diào)用 socket.destroy(),'close' 事件將在這個(gè)階段發(fā)出,事件循環(huán)在執(zhí)行完這個(gè)階段隊(duì)列里的回調(diào)函數(shù)后,檢查循環(huán)是否還 alive,如果為 no 退出,否則繼續(xù)下一次新的事件循環(huán)。
包含 Microtask 的事件循環(huán)流程圖
在瀏覽器的事件循環(huán)中,把任務(wù)劃分為 Task、Microtask,在 Node.js 中是按照階段劃分的,上面我們介紹了 Node.js 事件循環(huán)的 6 個(gè)階段,給用戶使用的主要是 timer、poll、check、close callback 四個(gè)階段,剩下兩個(gè)由系統(tǒng)內(nèi)部調(diào)度。這些階段所產(chǎn)生的任務(wù),我們可以看做 Task 任務(wù)源,也就是常說(shuō)的 “Macrotask 宏任務(wù)”。
通常我們?cè)谡務(wù)撘粋€(gè)事件循環(huán)時(shí)還會(huì)包含 Microtask,Node.js 里的微任務(wù)有 Promise、還有一個(gè)也許很少關(guān)注的函數(shù) queueMicrotask,它是在 Node.js v11.0.0 之后被實(shí)現(xiàn)的,參見(jiàn) PR/22951。
Node.js 中的事件循環(huán)在每一個(gè)階段執(zhí)行后,都會(huì)檢查微任務(wù)隊(duì)列中是否有待執(zhí)行的任務(wù)。
Node.js 11.x 前后差異
Node.js 在 v11.x 前后,每個(gè)階段如果即存在可執(zhí)行的 Task 又存在 Microtask 時(shí),會(huì)有一些差異,先看一段代碼:
- setImmediate(() => {
- log('setImmediate1');
- Promise.resolve('Promise microtask 1')
- .then(log);
- });
- setImmediate(() => {
- log('setImmediate2');
- Promise.resolve('Promise microtask 2')
- .then(log);
- });
在 Node.js v11.x 之前,當(dāng)前階段如果存在多個(gè)可執(zhí)行的 Task,先執(zhí)行完畢,再開(kāi)始執(zhí)行微任務(wù)?;?v10.22.1 版本運(yùn)行結(jié)果如下:
- setImmediate1
- setImmediate2
- Promise microtask 1
- Promise microtask 2
在 Node.js v11.x 之后,當(dāng)前階段如果存在多個(gè)可執(zhí)行的 Task,先取出一個(gè) Task 執(zhí)行,并清空對(duì)應(yīng)的微任務(wù)隊(duì)列,再次取出下一個(gè)可執(zhí)行的任務(wù),繼續(xù)執(zhí)行。基于 v14.15.0 版本運(yùn)行結(jié)果如下:
- setImmediate1
- Promise microtask 1
- setImmediate2
- Promise microtask 2
在 Node.js v11.x 之前的這個(gè)執(zhí)行順序問(wèn)題,被認(rèn)為是一個(gè)應(yīng)該要修復(fù)的 Bug 在 v11.x 之后并修改了它的執(zhí)行時(shí)機(jī),和瀏覽器保持了一致,詳細(xì)參見(jiàn) issues/22257 討論。
特別的 process.nextTick()
Node.js 中還有一個(gè)異步函數(shù) process.nextTick(),從技術(shù)上講它不是事件循環(huán)的一部分,它在當(dāng)前操作完成后處理。如果出現(xiàn)遞歸的 process.nextTick() 調(diào)用,這將會(huì)很糟糕,它會(huì)阻斷事件循環(huán)。
如下例所示,展示了一個(gè) process.nextTick() 遞歸調(diào)用示例,目前事件循環(huán)位于 I/O 循環(huán)內(nèi),當(dāng)同步代碼執(zhí)行完成后 process.nextTick() 會(huì)被立即執(zhí)行,它會(huì)陷入無(wú)限循環(huán)中,與同步的遞歸不同的是,它不會(huì)觸碰 v8 最大調(diào)用堆棧限制。但是會(huì)破壞事件循環(huán)調(diào)度,setTimeout 將永遠(yuǎn)得不到執(zhí)行。
- fs.readFile(__filename, () => {
- process.nextTick(() => {
- log('nextTick');
- run();
- function run() {
- process.nextTick(() => run());
- }
- });
- log('sync run');
- setTimeout(() => log('setTimeout'));
- });
- // 輸出
- sync run
- nextTick
將 process.nextTick 改為 setImmediate 雖然是遞歸的,但它不會(huì)影響事件循環(huán)調(diào)度,setTimeout 在下一次事件循環(huán)中被執(zhí)行。
- fs.readFile(__filename, () => {
- process.nextTick(() => {
- log('nextTick');
- run();
- function run() {
- setImmediate(() => run());
- }
- });
- log('sync run');
- setTimeout(() => log('setTimeout'));
- });
- // 輸出
- sync run
- nextTick
- setTimeout
process.nextTick 是立即執(zhí)行,setImmediate 是在下一次事件循環(huán)的 check 階段執(zhí)行。但是,它們的名字著實(shí)讓人費(fèi)解,也許會(huì)想這兩個(gè)名字交換下比較好,但它屬于遺留問(wèn)題,也不太可能會(huì)改變,因?yàn)檫@會(huì)破壞 NPM 上大部分的軟件包。
在 Node.js 的文檔中也建議開(kāi)發(fā)者盡可能的使用 setImmediate(),也更容易理解。
總結(jié)
Node.js 事件循環(huán)分為 6 個(gè)階段,每個(gè)階段都有一個(gè) FIFO(先進(jìn)先出)隊(duì)列執(zhí)行回調(diào)函數(shù),這幾個(gè)階段之間執(zhí)行的優(yōu)先級(jí)順序還是明確的。
事件循環(huán)的每一個(gè)階段,有時(shí)還會(huì)伴隨著一些微任務(wù)而運(yùn)行,這里以 Node.js v11.x 版本為分界線會(huì)有一些差異,文中也都有詳細(xì)的介紹。
在上一篇介紹了瀏覽器的事件循環(huán)機(jī)制,本篇又詳細(xì)的介紹了 Node.js 中的事件循環(huán)機(jī)制,留給大家一個(gè)思考問(wèn)題,結(jié)合自己的理解,總結(jié)下瀏覽器與 Node.js 中事件循環(huán)的一些差異,這個(gè)也是常見(jiàn)的一個(gè)面試題,歡迎在留言區(qū)討論。
在 Cnode 上看到的兩篇事件循環(huán)相關(guān)文章,推薦給大家,文章很精彩,評(píng)論也更加精彩。
- https://cnodejs.org/topic/5a9108d78d6e16e56bb80882
- https://cnodejs.org/topic/57d68794cb6f605d360105bf
Reference
http://docs.libuv.org/en/v1.x/design.html
https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick