1. 程式人生 > >setTimeout/setImmediate/process.nextTick的差別

setTimeout/setImmediate/process.nextTick的差別

前言

根據上一篇文章,我們可知,node對回撥事件的處理完全是基於事件迴圈的tick的,因此具有幾大特徵:

1、在應用層面,JS是單執行緒的,業務程式碼中不能存在耗時過長的程式碼,否則可能會嚴重拖後續程式碼(包括回撥)的處理。如果遇到需要複雜的業務計算時,應當想辦法啟用獨立程序或交給其他服務進行處理。

2、回撥是不精確,因為前面的原因,setTimeout並不能得到準確的超時回撥。

3、不同型別的觀察者,處理的優先順序不同,idle觀察者最先,I/O觀察者其次,check觀察者最後。

那麼本文主要要分析的是基於tick的幾個主要回調實現,setTimeout/setInterval/process.nextTick/setImmediate,這幾個屬於js非同步回撥的比較特殊的,因為他們並不是像普通I/O操作那樣真的需要等待事件非同步處理結束再進行回撥,而是出於定時或延遲處理的原因才設計的。分析起來相對簡單,因此我們就從它們入手,逐步揭開事件迴圈的祕密。

區別及原始碼分析

◇setTimeout/setInterval

setTimeout和setInterval的表現和實現其實基本相同,不同的只是setInterval會不斷重複。在底層實現上他們是建立了一個Timeout的中間物件,並且放到了實現定時器的紅黑樹中,每一次tick開始時,都會到這個紅黑樹中檢查是否存在超時的回撥,如果存在,則一一按照超時順序取出來進行回撥。因此,我們可以得出這樣一個結論:

js的定時器是不可靠的。因此單執行緒的原因,它是基於tick的,每次tick開始時才開始檢查是否有超時,如果一個tick耗時過長,在它之後出發的定時回撥都將被延遲。因此才會出現像“問題1”這樣的情況。

setTimeout第二個引數設定為0或者不設定,意思不是立即執行回撥,而是在下次tick時立即執行(當然,實際上,這裡有點小問題,後面會講到)!這setTimeout也解釋了Promise的實現中,resolve方法裡為什麼有些要用setTimeout(..., 0),這是為了解決在碰到同步程式碼時,resolve先於then執行的問題。但是它有一個嚴重的問題,就是回撥依然被送入定時器的紅黑樹,存在一定的效能問題。因此,通常大家會用process.nextTick()或setImmediate()來替代它。

lib/timers.js

這裡先建立了一個Timeout物件,然後呼叫active函式使他生效

lib/timer.js

這裡呼叫insert方法把當前Timeout物件插入到了一個地方

lib/timer.js

這個insert方法比較有意思,list是一個Timer物件,通過呼叫它的start方法可以使定時器生效,同時它又是個雙向連結串列,這iterm就是被插入到了這個雙向連結串列中。這是為什麼?

其實,程式碼裡面已經給出瞭解釋

原來因為實際開發過程中,經常會出現很多的socket會被設定為相同的超時時間,如果為每一個這樣的超時請求都設定一個watcher,那就太浪費系統資源了,系統負載也會變得很高,效能變差。因為,這裡用了一個非常巧妙的方法,那就是把超時時間相同的Timeout物件都扔到同一個連結串列中,然後再把這個Timer連結串列作為一個獨立的超時單位啟動。

src/timer_wrap.cc

這裡呼叫了uv_timer_start(不同系統實現方式不同,這裡的原始碼是unix的)

deps/uv/src/unix/timer.cc

原來這個uv_timer_start其實主要就是把這個Timer物件插入到了一顆紅黑樹上。

如果還記得我上文對事件迴圈的程式碼分析的話,你一定會注意在事件迴圈的while中,有uv__run_timers這一行,通過上面這段程式碼,就能看出來這個uv__run_timers就是從紅黑樹上取下所有超時的Timer物件,然後依次呼叫他們的回撥方法進行回撥。

◇process.nextTick

實際上,process.nextTick()方法的操作相對較為輕量,每次呼叫Process.nextTick()方法,只會將回調函式放入佇列中,在下一輪Tick時取出執行。定時器採用紅黑樹的操作時間複雜度為o(lg(n)),而nextTick()的時間複雜度為o(1)。相較之下,process.nextTick()更高效。

src/node.js

由以上程式碼可知,nextTick函式,會將callback封裝為一個obj物件,並且插入到nextTickQueue佇列(陣列)中。

src/node.js

由上述程式碼可知,每次nextTick回撥,都會nextTickQueue陣列中的回撥全部跑完!

◇setImmediate

lib/timers.js

setImmediate函式,首先把callback封裝成了一個immediate物件,然後把它插入到了immediateQueue佇列(陣列)中。

注意上面的那句process._immediateCallback = processImmediate,這行程式碼就是把process._immediateCallback設定成了processImmediate的別名,下次tick的時候就會呼叫這個函式進行回撥。

setImmediate()方法和process.nextTick()方法十分類似,都是將回調函式延遲在下一次立即執行。setImmediate是建立了一個叫為immediate的中間物件,並且放入到了immediateQueue佇列中在Node v0.9.1之前,setImmediate()還沒有實現,那時候實現類似的功能主要是通過process.nextTick()來完成。

但兩者之間其實是有差別的。區別表現為兩點:

1、process.nextTick中回撥函式的優先順序高於setImmediate,根據我前面寫的那篇文章可知,原因在於事件迴圈對觀察者的檢查是有先後順序的,process.nextTick屬於idle觀察者,setImmediate屬於check觀察者。在每一輪迴圈檢查中,idle觀察者先於I/O觀察者,I/O觀察者先於check觀察者。

而且,這裡最有意思的是下面這段程式碼的執行結果,大家以為會是什麼樣的輸出?

他的實際輸出是:

nextTick 1

nextTick 2

timeout

immediate

上面程式碼中表明,由於process.nextTick方法指定的回撥函式,總是在當前"執行棧"的尾部觸發,所以不僅函式A比setTimeout指定的回撥函式timeout先執行,而且函式B也比timeout先執行。這說明,如果有多個process.nextTick語句(不管它們是否巢狀),將全部在當前"執行棧"執行。這裡具體為什麼這樣,其實我現在也搞不懂,以後有機會可以慢慢在讀讀程式碼,如果有知道的朋友,可以告訴我一下,謝謝了。

我們由此得到了一個重要區別:多個process.nextTick語句總是一次執行完,多個setImmediate則需要多次才能執行完。事實上,這正是Node.js 10.0版新增setImmediate方法的原因,否則像下面這樣的遞迴呼叫process.nextTick,將會沒完沒了,主執行緒根本不會去讀取"事件佇列"!

由於process.nextTick指定的回撥函式是在本次"事件迴圈"觸發,而setImmediate指定的是在下次"事件迴圈"觸發,所以很顯然,前者總是比後者發生得早,而且執行效率也高(因為不用檢查"任務佇列")。

2、在實現上,process.nextTick的回撥函式儲存在一個數組中,setImmediate則儲存在一個連結串列中。順便這裡丟擲一個樸靈老師在《深入淺出Node.js》中對process.nextTick和setImmediate的不夠準確的描述:“在行為上,process.nextTick在每輪迴圈中將陣列中的回撥函式全部執行完,而setImmediate在每輪迴圈中執行連結串列中的一個回撥函式。” 並且用了一段程式碼進行作證:

樸靈老師書裡面說的結果是:

正常執行

nextTick延遲執行1

nextTick延遲執行2

setImmediate延遲執行1

強勢插入

setImmediate延遲執行2

但我跑出來的真實結果卻是:

正常執行

nextTick延遲執行1

nextTick延遲執行2

setImmediate延遲執行1

setImmediate延遲執行2

強勢插入

我相信樸老師一定是驗證過那段程式碼的,也就是說當時他測試應該是正確的。為了印證為什麼我測試的結果為什麼跟樸老師給的結果存在差異,我做了兩件事情,一是在不同的node版本下執行這段程式碼(樸老師寫那本書的時候,node最新版本為0.10.13,而我的版本是4.2.4),二是去翻閱node的原始碼實現,通過底層原理來描述這件事情。

首先,我測試了在不同版本下node執行的差異:

通過這個測試,我們可以發現,從設計邏輯出發,setImmediate每次只執行連結串列中的一個回撥應該是早期node版本中是一個bug,這在後面的版本中修復了。所以,才出現了樸老師的書裡描述的結果跟實際測試的不同的現象。

然後,我分別對比了node在這兩個版本下的程式碼的差異:

0.10.13版本的

lib/timers.js

根據以上程式碼可知,在0.10.13的程式碼中,每次tick處理immediate時,只會取一個回調出來進行處理

4.x版本的

lib/timers.js

根據以上程式碼可知,在4.x版本的程式碼中,每次tick處理immediate時,會使用while迴圈,把所有的immediate回撥取出來依次進行處理。

3、setImmediate可以使用clearImmediate清除(沒搞懂這個到底能幹嗎,誰明白請告訴我一下),process.nextTick不能被清除

觀察者優先順序

在每次輪訓檢查中,各觀察者的優先順序分別是:

idle觀察者 > I/O觀察者 > check觀察者。

idle觀察者:process.nextTick

I/O觀察者:一般性的I/O回撥,如網路,檔案,資料庫I/O等

check觀察者:setImmediate,setTimeout

上面的結果顯示timeout1甚至優於immediate執行,原因應該在於距離下次tick啟動至檢查定時器的時間超過了10ms,因此timeout1那個時候其實已經超時了。

說到這裡,順便談個問題。知乎上曾有人貼過一段關於setImmediate和setTimeout(xxx,0)的程式碼,得出了一個這樣的結論:“而在執行setImmedia時,setTimeout是隨機的插入在setImmediate的順序中的”。我對這個結論是持懷疑態度的,一個像node這樣穩定健壯的系統是不太可能允許這種不可控的隨機性的,我們回過頭去看前面的程式碼,發現了這樣一行:

lib/timers.js

意思很明顯,如果沒有設定這個after,或者小於1,或者大於TIMEOUT_MAX(2^31-1),都會被強制設定為1ms。也就是說setTimeout(xxx,0)其實等同於setTimeout(xxx,1)。

那就很容易理解知乎這位作者的給出的程式碼為什麼是這樣的結果了。因此:setTimeout的優先順序高於setImmediate,但是因為setTimeout的after被強制修正為1,這就可能存在下一個tick觸發時,耗時尚不足1ms,setTimeout的回撥依然未超時,因此setImmediate就先執行了!這可以通過在本次tick中加入一段耗時較長的程式碼來來保證本次tick耗時必須超過1ms來檢測:

測試顯示:不論執行多少次,得出的結果都一樣,都是如下:

由此可知,setTimeout是優先於setImmediate被處理的。

總結

要想真正理解很多why的問題,光看大量的案例和看文字解釋其實還是很難理解的,死記硬背也比較難記住。最好的方法還是通過閱讀底層程式碼實現,並思考為什麼這樣設計,應該就會好很多。這些程式碼分析並不完整,我個人的理解也不是非常深入,很多地方地方可能都沒有講清楚。以後應該還會有更多的文章出來進行分析。

通過上面的分析,我也簡單給出幾個結論:

優先順序順序:process.nextTick > setTimeout/setInterval > setImmediate

setTimeout需要使用紅黑樹,且after設定為0,其實會被node強制轉換為1,存在效能上的問題,建議替換為setImmediate

process.nextTick有一些比較難懂的問題和隱患,從0.8版本開始加入setImmediate,使用時,建議使用setImmediate