【Nodejs】731- Node.js 定時(shí)器詳解
作者@阮一峰 |?www.ruanyifeng.com/blog/2018/02/node-event-loop.html
JavaScript 是單線程運(yùn)行,異步操作特別重要。
只要用到引擎之外的功能,就需要跟外部交互,從而形成異步操作。由于異步操作實(shí)在太多,JavaScript 不得不提供很多異步語法。這就好比,有些人老是受打擊, 他的抗打擊能力必須變得很強(qiáng),否則他就完蛋了。
Node 的異步語法比瀏覽器更復(fù)雜,因?yàn)樗梢愿鷥?nèi)核對(duì)話,不得不搞了一個(gè)專門的庫 libuv 做這件事。這個(gè)庫負(fù)責(zé)各種回調(diào)函數(shù)的執(zhí)行時(shí)間,畢竟異步任務(wù)最后還是要回到主線程,一個(gè)個(gè)排隊(duì)執(zhí)行。

為了協(xié)調(diào)異步任務(wù),Node 居然提供了四個(gè)定時(shí)器,讓任務(wù)可以在指定的時(shí)間運(yùn)行。
setTimeout()
setInterval()
setImmediate()
process.nextTick()
前兩個(gè)是語言的標(biāo)準(zhǔn),后兩個(gè)是 Node 獨(dú)有的。它們的寫法差不多,作用也差不多,不太容易區(qū)別。
你能說出下面代碼的運(yùn)行結(jié)果嗎?
// test.js
setTimeout(()?=>?console.log(1));
setImmediate(()?=>?console.log(2));
process.nextTick(()?=>?console.log(3));
Promise.resolve().then(()?=>?console.log(4));
(()?=>?console.log(5))();
運(yùn)行結(jié)果如下。
$ node test.js
5
3
4
1
2
如果你能一口說對(duì),可能就不需要再看下去了。本文詳細(xì)解釋,Node 怎么處理各種定時(shí)器,或者更廣義地說,libuv 庫怎么安排異步任務(wù)在主線程上執(zhí)行。
一、同步任務(wù)和異步任務(wù)
首先,同步任務(wù)總是比異步任務(wù)更早執(zhí)行。
前面的那段代碼,只有最后一行是同步任務(wù),因此最早執(zhí)行。
(()?=>?console.log(5))();
二、本輪循環(huán)和次輪循環(huán)
異步任務(wù)可以分成兩種。
追加在本輪循環(huán)的異步任務(wù)
追加在次輪循環(huán)的異步任務(wù)
所謂"循環(huán)",指的是事件循環(huán)(event loop)。這是 JavaScript 引擎處理異步任務(wù)的方式,后文會(huì)詳細(xì)解釋。這里只要理解,本輪循環(huán)一定早于次輪循環(huán)執(zhí)行即可。
Node 規(guī)定,process.nextTick和Promise的回調(diào)函數(shù),追加在本輪循環(huán),即同步任務(wù)一旦執(zhí)行完成,就開始執(zhí)行它們。而setTimeout、setInterval、setImmediate的回調(diào)函數(shù),追加在次輪循環(huán)。
這就是說,文首那段代碼的第三行和第四行,一定比第一行和第二行更早執(zhí)行。
// 下面兩行,次輪循環(huán)執(zhí)行
setTimeout(()?=>?console.log(1));
setImmediate(()?=>?console.log(2));
// 下面兩行,本輪循環(huán)執(zhí)行
process.nextTick(()?=>?console.log(3));
Promise.resolve().then(()?=>?console.log(4));
三、process.nextTick()
process.nextTick這個(gè)名字有點(diǎn)誤導(dǎo),它是在本輪循環(huán)執(zhí)行的,而且是所有異步任務(wù)里面最快執(zhí)行的。

Node 執(zhí)行完所有同步任務(wù),接下來就會(huì)執(zhí)行process.nextTick的任務(wù)隊(duì)列。所以,下面這行代碼是第二個(gè)輸出結(jié)果。
process.nextTick(()?=>?console.log(3));
基本上,如果你希望異步任務(wù)盡可能快地執(zhí)行,那就使用process.nextTick。
四、微任務(wù)
根據(jù)語言規(guī)格,Promise對(duì)象的回調(diào)函數(shù),會(huì)進(jìn)入異步任務(wù)里面的"微任務(wù)"(microtask)隊(duì)列。
微任務(wù)隊(duì)列追加在process.nextTick隊(duì)列的后面,也屬于本輪循環(huán)。所以,下面的代碼總是先輸出3,再輸出4。
process.nextTick(()?=>?console.log(3));
Promise.resolve().then(()?=>?console.log(4));
// 3
// 4

注意,只有前一個(gè)隊(duì)列全部清空以后,才會(huì)執(zhí)行下一個(gè)隊(duì)列。
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的回調(diào)函數(shù),執(zhí)行都會(huì)早于Promise的。
至此,本輪循環(huán)的執(zhí)行順序就講完了。
同步任務(wù)
process.nextTick()
微任務(wù)
五、事件循環(huán)的概念
下面開始介紹次輪循環(huán)的執(zhí)行順序,這就必須理解什么是事件循環(huán)(event loop)了。
Node 的官方文檔是這樣介紹的。
這段話很重要,需要仔細(xì)讀。它表達(dá)了三層意思。
首先,有些人以為,除了主線程,還存在一個(gè)單獨(dú)的事件循環(huán)線程。不是這樣的,只有一個(gè)主線程,事件循環(huán)是在主線程上完成的。
其次,Node 開始執(zhí)行腳本時(shí),會(huì)先進(jìn)行事件循環(huán)的初始化,但是這時(shí)事件循環(huán)還沒有開始,會(huì)先完成下面的事情。
同步任務(wù)
發(fā)出異步請(qǐng)求
規(guī)劃定時(shí)器生效的時(shí)間
執(zhí)行process.nextTick()等等
最后,上面這些事情都干完了,事件循環(huán)就正式開始了。
六、事件循環(huán)的六個(gè)階段
事件循環(huán)會(huì)無限次地執(zhí)行,一輪又一輪。只有異步任務(wù)的回調(diào)函數(shù)隊(duì)列清空了,才會(huì)停止執(zhí)行。
每一輪的事件循環(huán),分成六個(gè)階段。這些階段會(huì)依次執(zhí)行。
timers
I/O callbacks
idle, prepare
poll
check
close callbacks
每個(gè)階段都有一個(gè)先進(jìn)先出的回調(diào)函數(shù)隊(duì)列。只有一個(gè)階段的回調(diào)函數(shù)隊(duì)列清空了,該執(zhí)行的回調(diào)函數(shù)都執(zhí)行了,事件循環(huán)才會(huì)進(jìn)入下一個(gè)階段。

下面簡單介紹一下每個(gè)階段的含義,詳細(xì)介紹可以看官方文檔,也可以參考 libuv 的源碼解讀。
(1)timers
這個(gè)是定時(shí)器階段,處理setTimeout()和setInterval()的回調(diào)函數(shù)。進(jìn)入這個(gè)階段后,主線程會(huì)檢查一下當(dāng)前時(shí)間,是否滿足定時(shí)器的條件。如果滿足就執(zhí)行回調(diào)函數(shù),否則就離開這個(gè)階段。
(2)I/O callbacks
除了以下操作的回調(diào)函數(shù),其他的回調(diào)函數(shù)都在這個(gè)階段執(zhí)行。
setTimeout()和setInterval()的回調(diào)函數(shù)
setImmediate()的回調(diào)函數(shù)
用于關(guān)閉請(qǐng)求的回調(diào)函數(shù),比如socket.on('close', ...)
(3)idle, prepare
該階段只供 libuv 內(nèi)部調(diào)用,這里可以忽略。
(4)Poll
這個(gè)階段是輪詢時(shí)間,用于等待還未返回的 I/O 事件,比如服務(wù)器的回應(yīng)、用戶移動(dòng)鼠標(biāo)等等。
這個(gè)階段的時(shí)間會(huì)比較長。如果沒有其他異步任務(wù)要處理(比如到期的定時(shí)器),會(huì)一直停留在這個(gè)階段,等待 I/O 請(qǐng)求返回結(jié)果。
(5)check
該階段執(zhí)行setImmediate()的回調(diào)函數(shù)。
(6)close callbacks
該階段執(zhí)行關(guān)閉請(qǐng)求的回調(diào)函數(shù),比如socket.on('close', ...)。
七、事件循環(huán)的示例
下面是來自官方文檔的一個(gè)示例。
const?fs?=?require('fs');
const?timeoutScheduled?=?Date.now();
// 異步任務(wù)一:100ms 后執(zhí)行的定時(shí)器
setTimeout(()?=>?{
??const?delay?=?Date.now()?-?timeoutScheduled;
??console.log(`${delay}ms`);
},?100);
// 異步任務(wù)二:至少需要 200ms 的文件讀取
fs.readFile('test.js',?()?=>?{
??const?startCallback?=?Date.now();
??while?(Date.now()?-?startCallback?<?200)?{
????// 什么也不做
??}
});
上面代碼有兩個(gè)異步任務(wù),一個(gè)是 100ms 后執(zhí)行的定時(shí)器,一個(gè)是至少需要 200ms 的文件讀取。請(qǐng)問運(yùn)行結(jié)果是什么?

腳本進(jìn)入第一輪事件循環(huán)以后,沒有到期的定時(shí)器,也沒有已經(jīng)可以執(zhí)行的 I/O 回調(diào)函數(shù),所以會(huì)進(jìn)入 Poll 階段,等待內(nèi)核返回文件讀取的結(jié)果。由于讀取小文件一般不會(huì)超過 100ms,所以在定時(shí)器到期之前,Poll 階段就會(huì)得到結(jié)果,因此就會(huì)繼續(xù)往下執(zhí)行。
第二輪事件循環(huán),依然沒有到期的定時(shí)器,但是已經(jīng)有了可以執(zhí)行的 I/O 回調(diào)函數(shù),所以會(huì)進(jìn)入 I/O callbacks 階段,執(zhí)行fs.readFile的回調(diào)函數(shù)。這個(gè)回調(diào)函數(shù)需要 200ms,也就是說,在它執(zhí)行到一半的時(shí)候,100ms 的定時(shí)器就會(huì)到期。但是,必須等到這個(gè)回調(diào)函數(shù)執(zhí)行完,才會(huì)離開這個(gè)階段。
第三輪事件循環(huán),已經(jīng)有了到期的定時(shí)器,所以會(huì)在 timers 階段執(zhí)行定時(shí)器。最后輸出結(jié)果大概是200多毫秒。
八、setTimeout 和 setImmediate
由于setTimeout在 timers 階段執(zhí)行,而setImmediate在 check 階段執(zhí)行。所以,setTimeout會(huì)早于setImmediate完成。
setTimeout(()?=>?console.log(1));
setImmediate(()?=>?console.log(2));
上面代碼應(yīng)該先輸出1,再輸出2,但是實(shí)際執(zhí)行的時(shí)候,結(jié)果卻是不確定,有時(shí)還會(huì)先輸出2,再輸出1。
這是因?yàn)?span style="margin-right: 2px;margin-left: 2px;padding: 2px 4px;border-width: 1px;border-style: solid;border-color: rgb(225, 225, 232);font-size: 14px;font-family: monospace;color: rgb(221, 17, 68);background-color: rgb(247, 247, 249);border-radius: 2px;word-break: break-word;">setTimeout的第二個(gè)參數(shù)默認(rèn)為0。但是實(shí)際上,Node 做不到0毫秒,最少也需要1毫秒,根據(jù)官方文檔,第二個(gè)參數(shù)的取值范圍在1毫秒到2147483647毫秒之間。也就是說,setTimeout(f, 0)等同于setTimeout(f, 1)。
實(shí)際執(zhí)行的時(shí)候,進(jìn)入事件循環(huán)以后,有可能到了1毫秒,也可能還沒到1毫秒,取決于系統(tǒng)當(dāng)時(shí)的狀況。如果沒到1毫秒,那么 timers 階段就會(huì)跳過,進(jìn)入 check 階段,先執(zhí)行setImmediate的回調(diào)函數(shù)。
但是,下面的代碼一定是先輸出2,再輸出1。
const?fs?=?require('fs');
fs.readFile('test.js',?()?=>?{
??setTimeout(()?=>?console.log(1));
??setImmediate(()?=>?console.log(2));
});
上面代碼會(huì)先進(jìn)入 I/O callbacks 階段,然后是 check 階段,最后才是 timers 階段。因此,setImmediate才會(huì)早于setTimeout執(zhí)行。
九、參考鏈接
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
以上鏈接請(qǐng)閱讀原文查看。

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
