前言
根據上一篇文章,我們可知,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()來替代它。
這里先創建了一個Timeout對象,然后調用active函數使他生效
這里調用insert方法把當前Timeout對象插入到了一個地方
這個insert方法比較有意思,list是一個Timer對象,通過調用它的start方法可以使定時器生效,同時它又是個雙向鏈表,這iterm就是被插入到了這個雙向鏈表中。這是為什么?
其實,代碼里面已經給出了解釋
原來因為實際開發過程中,經常會出現很多的socket會被設置為相同的超時時間,如果為每一個這樣的超時請求都設置一個watcher,那就太浪費系統資源了,系統負載也會變得很高,性能變差。因為,這里用了一個非常巧妙的方法,那就是把超時時間相同的Timeout對象都扔到同一個鏈表中,然后再把這個Timer鏈表作為一個獨立的超時單位啟動。
這里調用了uv_timer_start(不同系統實現方式不同,這里的源碼是unix的)
原來這個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()更高效。
由以上代碼可知,nextTick函數,會將callback封裝為一個obj對象,并且插入到nextTickQueue隊列(數組)中。
由上述代碼可知,每次nextTick回調,都會nextTickQueue數組中的回調全部跑完!
◇setImmediate
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版本的
根據以上代碼可知,在0.10.13的代碼中,每次tick處理immediate時,只會取一個回調出來進行處理
4.x版本的
根據以上代碼可知,在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這樣穩定健壯的系統是不太可能允許這種不可控的隨機性的,我們回過頭去看前面的代碼,發現了這樣一行:
意思很明顯,如果沒有設置這個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