前言
本篇也是過渡篇,主要補充NodeJS
的重點和難點。
嘛,廢話不多說,正文開始。
NodeJS
首先,理解 NodeJS 有一個很重要的前提,就是知道它究竟是 單線程的,還是 多線程 的?
還是如以往一樣,越是簡單的問題答案就每個人的答案就越是令人迷惑;
實踐是檢驗真理的唯一標準,最後看看 node
線程數就知道了——7
個,所以它是多線程的。
但是爲何有人說它是 單線程 呢?
單線程這個說法應該被嚴重曲解的,導致出現了一些誤導性; 完整說法應該是 NodeJS運行Javascript代碼只有一個線程, 這個特徵與瀏覽器如出一轍,瀏覽器運行Javascript也是單線程的。然而卻不能說瀏覽器就是單線程,同樣的NodeJS也是這個道理。
所以從根本上來說,NodeJS只有一個運行JS的主線程,它也是負責事件循環的線程; 與其他異步模型一般,它永遠不會阻塞。 Node官網的事件循環,太過簡潔了在細節方面做得不夠詳盡,因此只能參考實現它的組件——libuv
(但這個細節也太多了)。
NodeJS 的事件循環其實是由 libuv
實現的,所以只有理解了 libuv 的事件循環才能算徹底理解了 nodejs 的事件循環。libuv 實現機制要細說三天三夜也講不完, 所以長話短說——
- libuv 負責收集所有事件(它也可能會來自外部、也包括內核的)
- 針對這些事件註冊回調函數
- 事件發生後被執行回調函數。
其實原理與瀏覽器環境大同小異,都是採用消息傳遞的異步通信方式,並且都是基於事件循環來執行代碼的。不過要注意的是Node 程序處理最多的不是 頁面渲染, 而是 輸入和輸出,統稱作IO, 例如文件讀寫、網絡請求等等,這類操作統一特點都是阻塞(響應時間超長)的,傳統的讀寫事件都是同步的,也就是說在獲取文件全部內容之前,根本無法做任何事情,自然也效率低下,我們的任務就是狠狠的壓榨CPU性能。
libuv便是上面問題的一個解決方案,基本思路是將一些阻塞任務(主要是IO,網絡請求.etc)這類阻塞任務分配到工作線程中等待稍後執行,這些工作線程會統一保存在線程池中;然後開啓事件循環一一處理。 至於一次能處理多少事件(可能一次事件循環爆發式接收到500
個請求),取決於計算機硬件。
所以,理解 nodejs 事件循環, 還是要從 libuv事件循環開始。
下面是官網給出的事件循環示意圖:
libuv 概覽圖:
理解事件循環難點:
- 在主模塊代碼結束後,事件註冊纔會完成。一旦有註冊的事件,就必然會產生一個
handler
,它可以標識任務的上下文(術語:事件句柄、文件描述符)但這時還沒有正式開啓事件循環。 - 當有
handler
存在,初始化線程池。然後將已經註冊事件的handler
分配到線程池的對應workthread
中。例如setTimeout
的handlers
(Timer)會分配到Timer
,setImmediate
的handler
(CheckHandler
)分配到Check
, 其他事件分配到poll
中統一處理。 - 開啓事件循環,然後更新事件循環的
handler
計數 ; 現在能夠看出,事件循環的每個phase
其實都保存在了線程池中。 - 也正因爲如此,
phase
並不是嚴守順序的;或是說它是跳躍性的。例如當第一次進行事件循環時,Timer
沒有到期,所以不會執行回調。但反過來說一旦到期就一定會執行回調。由於沒有需要異步完成的IO Callback
,所以不會進入pending callback
。 - 因此,首次事件循環會直接跳躍到
poll
階段完成IO Callback
。poll
階段極其特殊,它有一個阻塞時長,如果Callback
在阻塞時長內沒有完成,相應的io callback
將異步完成,即在下一個事件循環的pending callback
處理完所有剩餘的callback。同樣的道理,如果在阻塞時長內存在setImmediate
的回調,那麼就會立即進入到check
階段(不用在意幾回合事件循環)。 - 每個事件循環跳躍到下一個事件循環前,都會有一個Close Handler過程,它會清理掉完成狀態(
done=true
)的handler
,更新事件循環的引用計數等等。
事件循環的核心,poll
階段的總結就是(有點都合主義的味道):
- 計算
Poll
階段的阻塞時間(即Timeout
) - 執行已發生事件的回調函數,但還有:
- 是否有
nextTick
和microtasks
? 如果有則執行,否則繼續 - 檢查是否有
setImmediate
回調,如果有執行回調然後進入Check
階段。 - 若無,繼續。
- 是否有
- 在阻塞時間內等待事件發生,如果存在
Timer
,那麼阻塞時間是最近的Timer
到期時間,否則不限制阻塞時間:- 若有事件發生,立即執行相應的回調函數,此步驟完全等價於
(2)
- 若無則繼續等待下去(直至node 停止監聽、或沒有活躍的
handler
)。 - 如果在阻塞時間內仍有未回調完成的任務(
handler
),掛起在下一個事件循環的pending callback
階段排隊處理。
- 若有事件發生,立即執行相應的回調函數,此步驟完全等價於
- 超過阻塞時間後,進入下一個事件循環,執行到期
Timer
。- 此時不再有可用
setImmediate回調
,因此前移。 - 阻塞時間是最近的
Timer
到期時間
- 此時不再有可用
特別注意:
(2)
實際上是(3)
其中的一個步驟,但是分離出來更容易理解一些。- 雖然官網說是繞回,但是我覺得這裏應該是前移到下一個事件循環中更準確一點。這是和nodejs官網矛盾點。
想要了解libuv
事件循環所有細節的,可以直接跳到後面。
setTimeout VS setImmediate
這是很令人興奮的話題,它們到底誰更優先? 事實上它們並沒有優先級關係,因爲事件循環沒辦法用順序去理解,因此不要用誰優先誰就執行原則,而是誰觸發誰就必須執行原則。
注意兩點:
setTimeout
是到期立即執行setImmediate
是進入到check
階段後執行。
也就是說,Timer
與CheckHandler
是沒有邏輯上的必然關係的。
例如:
setTimeout(()=>{
var start = Date.now();
setTimeout(()=>{
console.log('------------------');
console.log('timeout 1 = '+(Date.now()-start)+'ms');
})
setTimeout(()=>{
console.log('------------------');
console.log('timeout 2 = '+(Date.now()-start)+'ms');
})
setImmediate(()=>{
console.log('------------------');
console.log('check 1 = '+(Date.now()-start)+'ms');
})
setImmediate(()=>{
console.log('------------------');
console.log('check 2 = '+(Date.now()-start)+'ms');
})
})
輸出結果:
------------------
check 1 = 9ms
------------------
check 2 = 10ms
------------------
timeout 1 = 11ms
------------------
timeout 2 = 11ms
從結果上看,setImmediate
的回調永遠先於setTimeout
。
簡而言之, 在Timer
到期之前就已經觸發了CheckHandler
,因此會跳躍到Check
。執行完畢後Timer
必然到期,因此執行。
但是爲什麼在主模塊setTimeout
和setImmediate
這兩個函數是隨機呢?
var start = Date.now()
setImmediate(()=>{
console.log('------------------');
console.log('check = '+(Date.now()-start)+'ms');
})
setTimeout(()=>{
console.log('------------------');
console.log('timeout = '+(Date.now()-start)+'ms');
})
輸出:
timeout = 10ms
------------------
check = 12ms
------------------
check = 8ms
------------------
timeout = 9ms
說明:
- 開啓事件循環後,更新
now
時間,如果Timer
在now
時間到期,那麼就會直接運行。否則不會運行。簡而言之,如果能夠儘快進入事件循環,就不會執行Timer
反之就會執行。
這個解釋是在github
看到的,感覺很有道理的樣子。
nextTick VS Promise
在NodeJS
中,也有微任務microtasks
,不過它分爲兩種微任務:
nextTick
: 即nextTick
otherMicrotasks
:Promise
。
其中nextTick
總是會先於Promise
執行。無論哪種微任務,它們都能在跳躍到其他phase
前執行完畢。
例如:
console.log('start');
Promise.resolve('promise 1').then(console.log)
Promise.resolve('promise 2').then(console.log)
process.nextTick(()=>{
console.log('ha,ha,ha,ha');
console.log('im tickObject!!');
})
console.log('end');
驗證輸出:
start
end
ha,ha,ha,ha
im tickObject!!
promise 1
promise 2
然後四個一起,綜合來PK
一下(正常版本):
const fs = require('fs')
const start = Date.now()
fs.readFile(__filename,()=>{
console.log('start');
setImmediate(()=>{
console.log('checkhandler 1');
Promise.resolve('promise 1').then(console.log)
console.log('-----------------------------------');
})
setTimeout(()=>{
console.log('timeout 1');
})
setImmediate(()=>{
console.log('checkhandler 2');
Promise.resolve('promise 2').then(console.log)
process.nextTick(()=>{console.log('nextTick 2');
})
console.log('-----------------------------------');
})
process.nextTick(()=>{
console.log('nextTick 1');
})
console.log('end');
})
輸出:
start
end
nextTick 1
checkhandler 1
-----------------------------------
promise 1
checkhandler 2
-----------------------------------
nextTick 2
promise 2
timeout 1
上面的過程如下:
poll
階段:調用readFile
,開始文件讀寫。poll / pending_callback
階段: 文件讀寫完成。開始調用回調函數:
- 輸出
start
- 調用
setImmediate
;checkhandler1
活躍狀態- 調用
setTimeout
timeout 1
被初始化,等待到期。- 調用
setImmediate
checkhandler2
活躍狀態- 調用
nextTick
, 產生一個TickQueue
- 輸出
end
- 執行
TickQueue
中的所有任務,輸出nextTick 1
存在checkhandlers
, 進入check
階段check
階段:處理所有的checkhandlers
的回調:
- 執行
checkhander1
的回調:
- 輸出
checkhandler1
Promise.resolve().then
, 產生一個promiseList
- 輸出橫線
- 處理
promiseList
中所有的任務,輸出promise 1
- 執行
checkhander2
的回調:
- 輸出
checkhandler2
Promise.resolve().then
, 產生一個promiseList
nextTick
, 產生一個TickQueue
- 輸出橫線
- 處理
TickQueue
所有任務,輸出nextTick 2
- 處理
promiseList
所有任務,輸出promise 2
Timeout
到期,跳躍到Timer
Timer
階段: 處理Timeout
回調,輸出timeout 1
注意:
- 每處理完一個回調,就會調用一次
nextTick
或otherMicrotask
io callback
究竟是在哪個階段完成的,是未確定的。
實際上在其他模塊,也會出現隨機順序
問題,例如:
const fs = require('fs')
setTimeout(()=>{
fs.readFile(__filename,()=>{
console.log('file one');
setTimeout(()=>{
console.log('timeout one');
})
})
fs.readFile('./file.md', ()=>{
console.log('file two');
setTimeout(()=>{
console.log('timeout two');
})
})
})
然後出現以下三種結果(更離譜的代碼就沒寫):
這也側面證明了IO Callback的隨機性,它的完成與文件大小也是息息相關的。左右兩側的結果是比較容易理解的,中間的是爲什麼?
大概率是因爲file two
是在pending callback
階段完成的。
- 在
poll
階段,fileone
回調完成,添加一個定時器。 - 在
poll
階段,但是filetwo
回調未完成, 定時器到期,立即回到Timer
階段 - 執行
Timer
- 在
pending callback
,回調完成,添加一個定時器。 - …
下面是小生看了一遍源代碼得出的結論,大致上補充了點細節。不過因爲linux編程這一塊遺忘了不少,可能未必是正解,這方面還請多提寶貴意見。
同步轉發:
https://juejin.im/post/5ed36676e51d457b3a67ccef
附註:libuv事件循環所有細節
首先要知道幾個術語:
- handler來標識一個任務,事實上它就是句柄(文件描述符),總之通過它可以引用任務的一切信息:任務的回調函數、回調狀態等等。
- 一個handler普遍有三種狀態:初始化 、 掛起(即暫停) 、 活躍 。常用後面的兩種。
開始吧:
Bootstrap/Dispatcher:
- 初始化Node環境
- 執行同步JS代碼並進行事件分發(註冊)。
- 初始化線程池
然後正式進入到事件循環中:
UpdateLoop:
- 事件循環是否是活躍的(主要檢查是否有活躍的
handler
)- 如果是,更新當前時間(
now
),正式進入Phase I
- 否則終止事件循環,關閉。
Phase I: Timer
- 檢查 Timer 是否到期
- 如果到期,立即觸發事件然後繼續執行回調。
- 上面是最簡單的,就是
setTimeout/setInterval
這類調用。
Phase II:Pending (IO) Callbacks
- 檢查
Pending_Queue
是否有handler
- 如果有,立即從
Pending_Queue
取出handler
進行回調操作。- 如果
handler
中回調函數代碼中存在CheckHandlers
(即setImmediate(...)
),那麼處理完成後跳轉到Phase V
- 否則繼續。
附註:
Pending_Queue
用於放置未回調完成的handler
的隊列。- 一般IO Events 會在這個階段完成處理。例如:文件操作時拋出異常、讀取完成然後執行回調等等。
CheckHandlers
是極其特殊的handler
,簡單來說就是setImmediate(...)
的回調。不只會在Phase II
導致迭代前移(直接跳轉到Phase V),甚至在其他Phases也會如此。
Phase III.a: Idle/Idling
- 進入空轉監聽階段
- 它會不斷地監聽
handler
是否發生事件(此時handler
是活躍的),如果是進入Phase III.b
- …(記錄日誌等,與我們無關了……)
- 達到事先規定好的循環次數後,也未曾監聽到新事件 那麼
Idling
會被終止;事件循環也會被終止(因爲一直監聽不到事件發生)。
附註:
Idling
階段永遠不會停止;也就是說即使事件發生後轉入到Phase III.b時,Idling
也是一直在空轉監聽。- 也正因爲如此,我們才能任何時刻監聽到事件。
- 雖說如此,
Idling
會有一個極限循環次數,這個次數由硬件決定的。過了這個次數,它就結束了。
Phase III.b: Prepare
- 爲活躍的
handler
執行回調前做些準備- ……(準備上下文……等)
- 進入
Phase IV
,正式處理回調。
附註:
- 事實上,瞭解
Phase III
沒什麼大用,因爲不可能編寫一定處於Phase III階段的代碼。 - 它是libuv執行的。
- 只是補充了一點細節
Phase IV Poll:
- 計算超時時間(
Timeout
)- 在超時時間內(
Timeout
):
- 如果
checkhandler
存在,執行相應回調- 如果發現
IO Event
,立即執行回調。- 如果無事件發生,檢查是否超時
- 如果超時,立即退出
Phase IV
。否則回到(2.1)
- 超過超時時間後,會有
Timer
到期,直接前移到下一個事件循環。- 會存在未回調的
handler
,此時它們會在下一個事件循環的pending callbacks
完成處理。
未完待續。
附註:
Timeout
計算規則:
- 在以下情形,
Timeout=0
:- 事件循環將被終止(由
IdleHandler
設置) - 沒有活躍的
handler
。 - Idle不再監聽(即
IdleHandler
終止) Pending_Queue
不爲空.
- 事件循環將被終止(由
- 除卻以上情形, 如果有待處理的
Timer
,那麼Timeout
是最近的Timer到期時間。 - 如果沒有待處理的
Timer
, 那麼Timeout
爲-1
,可以理解爲poll
阻塞時間爲 無窮大(這塊我忘了,應該是阻塞時間不確定)。
Phase V : Check
- 檢查
CheckHandlers
是否存在(即setImmediate
的消息序列)- 若有則處理所有
CheckHandlers
- 完成, 進入
Phase VI
Phase VI : Close handlers
- 對處理完的
handler
進行Close
操作,例如send()/close()/destory()
等等。- 調用
GC
,進行垃圾回收- 轉至
UpdateLoop
。