Node 定時器詳解
來源:阮一峰的網路日誌,作者:阮一峰,微博@ruanyf
連結:ruanyifeng.com/blog/2018/02/node-event-loop.html(點選尾部閱讀原文前往)
JavaScript 是單執行緒執行,非同步操作特別重要。
只要用到引擎之外的功能,就需要跟外部互動,從而形成非同步操作。由於非同步操作實在太多,JavaScript 不得不提供很多非同步語法。這就好比,有些人老是受打擊, 他的抗打擊能力必須變得很強,否則他就完蛋了。
Node 的非同步語法比瀏覽器更復雜,因為它可以跟核心對話,不得不搞了一個專門的庫 libuv 做這件事。這個庫負責各種回撥函式的執行時間,畢竟非同步任務最後還是要回到主執行緒,一個個排隊執行。
為了協調非同步任務,Node 居然提供了四個定時器,讓任務可以在指定的時間執行。
setTimeout()
setInterval()
setImmediate()
process.nextTick()
前兩個是語言的標準,後兩個是 Node 獨有的。它們的寫法差不多,作用也差不多,不太容易區別。
你能說出下面程式碼的執行結果嗎?
// test.js
setTimeout(() => console.log(1));
setImmediate(() =
> console.log(2));process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
(() => console.log(5))();
執行結果如下。
$ node test.js
5
3
4
1
2
如果你能一口說對,可能就不需要再看下去了。本文詳細解釋,Node 怎麼處理各種定時器,或者更廣義地說,libuv 庫怎麼安排非同步任務在主執行緒上執行。
一、同步任務和非同步任務
首先,同步任務總是比非同步任務更早執行。
前面的那段程式碼,只有最後一行是同步任務,因此最早執行。
(() => console.log(5))();
二、本輪迴圈和次輪迴圈
非同步任務可以分成兩種。
追加在本輪迴圈的非同步任務
追加在次輪迴圈的非同步任務
所謂"迴圈",指的是事件迴圈(event loop)。這是 JavaScript 引擎處理非同步任務的方式,後文會詳細解釋。這裡只要理解,本輪迴圈一定早於次輪迴圈執行即可。
Node 規定,process.nextTick和Promise的回撥函式,追加在本輪迴圈,即同步任務一旦執行完成,就開始執行它們。而setTimeout、setInterval、setImmediate的回撥函式,追加在次輪迴圈。
這就是說,文首那段程式碼的第三行和第四行,一定比第一行和第二行更早執行。
// 下面兩行,次輪迴圈執行
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
// 下面兩行,本輪迴圈執行
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
三、process.nextTick()
process.nextTick這個名字有點誤導,它是在本輪迴圈執行的,而且是所有非同步任務裡面最快執行的。
Node 執行完所有同步任務,接下來就會執行process.nextTick的任務佇列。所以,下面這行程式碼是第二個輸出結果。
process.nextTick(() => console.log(3));
基本上,如果你希望非同步任務儘可能快地執行,那就使用process.nextTick。
四、微任務
根據語言規格,Promise物件的回撥函式,會進入非同步任務裡面的"微任務"(microtask)佇列。
微任務佇列追加在process.nextTick佇列的後面,也屬於本輪迴圈。所以,下面的程式碼總是先輸出3,再輸出4。
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
// 3
// 4
注意,只有前一個佇列全部清空以後,才會執行下一個佇列。
process.nextTick(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
// 1
// 3
// 2
// 4
上面程式碼中,全部process.nextTick的回撥函式,執行都會早於Promise的。
至此,本輪迴圈的執行順序就講完了。
同步任務
process.nextTick()
微任務
五、事件迴圈的概念
下面開始介紹次輪迴圈的執行順序,這就必須理解什麼是事件迴圈(event loop)了。
Node 的官方文件是這樣介紹的。
這段話很重要,需要仔細讀。它表達了三層意思。
首先,有些人以為,除了主執行緒,還存在一個單獨的事件迴圈執行緒。不是這樣的,只有一個主執行緒,事件迴圈是在主執行緒上完成的。
其次,Node 開始執行指令碼時,會先進行事件迴圈的初始化,但是這時事件迴圈還沒有開始,會先完成下面的事情。
同步任務
發出非同步請求
規劃定時器生效的時間
執行process.nextTick()等等
最後,上面這些事情都幹完了,事件迴圈就正式開始了。
六、事件迴圈的六個階段
事件迴圈會無限次地執行,一輪又一輪。只有非同步任務的回撥函式佇列清空了,才會停止執行。
每一輪的事件迴圈,分成六個階段。這些階段會依次執行。
timers
I/O callbacks
idle, prepare
poll
check
close callbacks
每個階段都有一個先進先出的回撥函式佇列。只有一個階段的回撥函式佇列清空了,該執行的回撥函式都執行了,事件迴圈才會進入下一個階段。
下面簡單介紹一下每個階段的含義,詳細介紹可以看官方文件,也可以參考 libuv 的原始碼解讀。
(1)timers
這個是定時器階段,處理setTimeout()和setInterval()的回撥函式。進入這個階段後,主執行緒會檢查一下當前時間,是否滿足定時器的條件。如果滿足就執行回撥函式,否則就離開這個階段。
(2)I/O callbacks
除了以下操作的回撥函式,其他的回撥函式都在這個階段執行。
setTimeout()和setInterval()的回撥函式
setImmediate()的回撥函式
用於關閉請求的回撥函式,比如socket.on('close', ...)
(3)idle, prepare
該階段只供 libuv 內部呼叫,這裡可以忽略。
(4)Poll
這個階段是輪詢時間,用於等待還未返回的 I/O 事件,比如伺服器的迴應、使用者移動滑鼠等等。
這個階段的時間會比較長。如果沒有其他非同步任務要處理(比如到期的定時器),會一直停留在這個階段,等待 I/O 請求返回結果。
(5)check
該階段執行setImmediate()的回撥函式。
(6)close callbacks
該階段執行關閉請求的回撥函式,比如socket.on('close', ...)。
七、事件迴圈的示例
下面是來自官方文件的一個示例。
const fs = require('fs');
const timeoutScheduled = Date.now();
// 非同步任務一:100ms 後執行的定時器
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms`);
}, 100);
// 非同步任務二:至少需要 200ms 的檔案讀取
fs.readFile('test.js', () => {
const startCallback = Date.now();
while (Date.now() - startCallback < 200) {
// 什麼也不做
}
});
上面程式碼有兩個非同步任務,一個是 100ms 後執行的定時器,一個是至少需要 200ms 的檔案讀取。請問執行結果是什麼?
指令碼進入第一輪事件迴圈以後,沒有到期的定時器,也沒有已經可以執行的 I/O 回撥函式,所以會進入 Poll 階段,等待核心返回檔案讀取的結果。由於讀取小檔案一般不會超過 100ms,所以在定時器到期之前,Poll 階段就會得到結果,因此就會繼續往下執行。
第二輪事件迴圈,依然沒有到期的定時器,但是已經有了可以執行的 I/O 回撥函式,所以會進入 I/O callbacks 階段,執行fs.readFile的回撥函式。這個回撥函式需要 200ms,也就是說,在它執行到一半的時候,100ms 的定時器就會到期。但是,必須等到這個回撥函式執行完,才會離開這個階段。
第三輪事件迴圈,已經有了到期的定時器,所以會在 timers 階段執行定時器。最後輸出結果大概是200多毫秒。
八、setTimeout 和 setImmediate
由於setTimeout在 timers 階段執行,而setImmediate在 check 階段執行。所以,setTimeout會早於setImmediate完成。
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
上面程式碼應該先輸出1,再輸出2,但是實際執行的時候,結果卻是不確定,有時還會先輸出2,再輸出1。
這是因為setTimeout的第二個引數預設為0。但是實際上,Node 做不到0毫秒,最少也需要1毫秒,根據官方文件,第二個引數的取值範圍在1毫秒到2147483647毫秒之間。也就是說,setTimeout(f, 0)等同於setTimeout(f, 1)。
實際執行的時候,進入事件迴圈以後,有可能到了1毫秒,也可能還沒到1毫秒,取決於系統當時的狀況。如果沒到1毫秒,那麼 timers 階段就會跳過,進入 check 階段,先執行setImmediate的回撥函式。
但是,下面的程式碼一定是先輸出2,再輸出1。
const fs = require('fs');
fs.readFile('test.js', () => {
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
});
上面程式碼會先進入 I/O callbacks 階段,然後是 check 階段,最後才是 timers 階段。因此,setImmediate才會早於setTimeout執行。
九、參考連結
The Node.js Event Loop, Timers, and process.nextTick(), by Node.js
Handling IO -- NodeJS Event Loop, by Deepal Jayasekara
setImmediate() vs nextTick() vs setTimeout(fn,0) - in depth explanation, by Paul Shan
Node.js event loop workflow & lifecycle in low level, by Paul Shan