Node 定時器詳解

阮一峰發表於2018-02-23

JavaScript 是單執行緒執行,非同步操作特別重要。

只要用到引擎之外的功能,就需要跟外部互動,從而形成非同步操作。由於非同步操作實在太多,JavaScript 不得不提供很多非同步語法。這就好比,有些人老是受打擊, 他的抗打擊能力必須變得很強,否則他就完蛋了。

Node 的非同步語法比瀏覽器更復雜,因為它可以跟核心對話,不得不搞了一個專門的庫 libuv 做這件事。這個庫負責各種回撥函式的執行時間,畢竟非同步任務最後還是要回到主執行緒,一個個排隊執行。

為了協調非同步任務,Node 居然提供了四個定時器,讓任務可以在指定的時間執行。

  • setTimeout()
  • setInterval()
  • setImmediate()
  • process.nextTick()

前兩個是語言的標準,後兩個是 Node 獨有的。它們的寫法差不多,作用也差不多,不太容易區別。

你能說出下面程式碼的執行結果嗎?

執行結果如下。

如果你能一口說對,可能就不需要再看下去了。本文詳細解釋,Node 怎麼處理各種定時器,或者更廣義地說,libuv 庫怎麼安排非同步任務在主執行緒上執行。

一、同步任務和非同步任務

首先,同步任務總是比非同步任務更早執行。

前面的那段程式碼,只有最後一行是同步任務,因此最早執行。

二、本輪迴圈和次輪迴圈

非同步任務可以分成兩種。

  • 追加在本輪迴圈的非同步任務
  • 追加在次輪迴圈的非同步任務

所謂”迴圈”,指的是事件迴圈(event loop)。這是 JavaScript 引擎處理非同步任務的方式,後文會詳細解釋。這裡只要理解,本輪迴圈一定早於次輪迴圈執行即可。

Node 規定,process.nextTickPromise的回撥函式,追加在本輪迴圈,即同步任務一旦執行完成,就開始執行它們。而setTimeoutsetIntervalsetImmediate的回撥函式,追加在次輪迴圈。

這就是說,文首那段程式碼的第三行和第四行,一定比第一行和第二行更早執行。

三、process.nextTick()

process.nextTick這個名字有點誤導,它是在本輪迴圈執行的,而且是所有非同步任務裡面最快執行的。

Node 執行完所有同步任務,接下來就會執行process.nextTick的任務佇列。所以,下面這行程式碼是第二個輸出結果。

基本上,如果你希望非同步任務儘可能快地執行,那就使用process.nextTick

四、微任務

根據語言規格,Promise物件的回撥函式,會進入非同步任務裡面的”微任務”(microtask)佇列。

微任務佇列追加在process.nextTick佇列的後面,也屬於本輪迴圈。所以,下面的程式碼總是先輸出3,再輸出4

注意,只有前一個佇列全部清空以後,才會執行下一個佇列。

上面程式碼中,全部process.nextTick的回撥函式,執行都會早於Promise的。

至此,本輪迴圈的執行順序就講完了。

  1. 同步任務
  2. process.nextTick()
  3. 微任務

五、事件迴圈的概念

下面開始介紹次輪迴圈的執行順序,這就必須理解什麼是事件迴圈(event loop)了。

Node 的官方文件是這樣介紹的。

“When Node.js starts, it initializes the event loop, processes the provided input script which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.”

這段話很重要,需要仔細讀。它表達了三層意思。

首先,有些人以為,除了主執行緒,還存在一個單獨的事件迴圈執行緒。不是這樣的,只有一個主執行緒,事件迴圈是在主執行緒上完成的。

其次,Node 開始執行指令碼時,會先進行事件迴圈的初始化,但是這時事件迴圈還沒有開始,會先完成下面的事情。

  • 同步任務
  • 發出非同步請求
  • 規劃定時器生效的時間
  • 執行process.nextTick()等等

最後,上面這些事情都幹完了,事件迴圈就正式開始了。

六、事件迴圈的六個階段

事件迴圈會無限次地執行,一輪又一輪。只有非同步任務的回撥函式佇列清空了,才會停止執行。

每一輪的事件迴圈,分成六個階段。這些階段會依次執行。

  1. timers
  2. I/O callbacks
  3. idle, prepare
  4. poll
  5. check
  6. 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', ...)

七、事件迴圈的示例

下面是來自官方文件的一個示例。

上面程式碼有兩個非同步任務,一個是 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完成。

上面程式碼應該先輸出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。

上面程式碼會先進入 I/O callbacks 階段,然後是 check 階段,最後才是 timers 階段。因此,setImmediate才會早於setTimeout執行。

九、參考連結

(完)

相關文章