什麼是Event Loop
Event Loop是一個程式結構,用於等待和傳送訊息和事件,是計算機系統的一種執行機制。JavaScript語言就採用這種機制,來解決單執行緒執行帶來的一些問題。
那麼首先要了解一下JavaScript的單執行緒的原因
JavaScript語言的一大特點就是單執行緒,也就是說,同一個時間只能做一件事。
JavaScript的單執行緒,與它的用途有關。作為瀏覽器指令碼語言,JavaScript的主要用途是與使用者互動,以及操作DOM。這決定了它只能是單執行緒,否則會帶來很複雜的同步問題。比如,假定JavaScript同時有兩個執行緒,一個執行緒在某個DOM節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器應該以哪個執行緒為準?
所以,為了避免複雜性,從一誕生,JavaScript就是單執行緒,這已經成了這門語言的核心特徵,將來也不會改變。
為了利用多核CPU的計算能力,HTML5提出Web Worker標準,允許JavaScript指令碼建立多個執行緒,但是子執行緒完全受主執行緒控制,且不得操作DOM。所以,這個新標準並沒有改變JavaScript單執行緒的本質。
任務佇列
單執行緒就意味著,所有任務需要排隊,前一個任務結束,才會執行後一個任務。如果前一個任務耗時很長,後一個任務就要一直等著。很多時候CPU是閒著的,因為IO裝置很慢(比如Ajax操作從網路讀取資料),不得不等著結果出來,再往下執行。然而這時主執行緒完全可以不管IO裝置,掛起處於等待中的任務,先執行排在後面的任務。等到IO裝置返回了結果,再回過頭,把掛起的任務繼續執行下去。
於是,所有任務可以分成兩種,一種是同步任務(synchronous),另一種是非同步任務(asynchronous)。
- 同步任務,指的是在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;
- 非同步任務,指的是不進入主執行緒、而進入"任務佇列"(task queue)的任務,只有"任務佇列"通知主執行緒,某個非同步任務可以執行了,該任務才會進入主執行緒執行。
具體來說,非同步執行的執行機制如下。(同步執行也是如此,因為它可以被視為沒有非同步任務的非同步執行。)
- 所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack)。
- 主執行緒之外,還存在一個"任務佇列"(task queue)。只要非同步任務有了執行結果,就在"任務佇列"之中放置一個事件。
- 一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務佇列",看看裡面有哪些事件。那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。
- 主執行緒不斷重複上面的第三步。
由於I/O操作很慢,這個執行緒的大部分執行時間都在空等I/O操作的返回結果。這種執行方式稱為"同步模式"(synchronous I/O)或"堵塞模式"(blocking I/O)。Event Loop就是為了解決這個問題而提出的。
Event Loop
簡單說,就是在程式中設定兩個執行緒:一個負責程式本身的執行,稱為"主執行緒";另一個負責主執行緒與其他程式(主要是各種I/O操作)的通訊,被稱為"Event Loop執行緒"(可以譯為"訊息執行緒")。
每個階段的簡單概要:
timers
: 執行setTimeout() 和 setInterval() 預先設定的回撥函式。
I/O callbacks
:大部分執行都是timers 階段或是setImmediate() 預先設定的並且出現異常的回撥函式事件。
idle
,prepare
:nodejs 內部函式呼叫。
poll
: 搜尋I/O事件,nodejs程式在這個階段會選擇在該階段適當的阻塞一段時間。
check
: setImmediate() 函式會在這個階段執行。
close callbacks
: 執行一些諸如關閉事件的回撥函式,如socket.on('close', ...) 。
每個階段都有一個先進先出的回撥函式佇列。只有一個階段的回撥函式佇列清空了,該執行的回撥函式都執行了,事件迴圈才會進入下一個階段。
(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', ...)
。
上圖中,主執行緒執行的時候,產生堆(heap)和棧(stack),棧中的程式碼呼叫各種外部API,它們在"任務佇列"中加入各種事件(click,load,done)。只要棧中的程式碼執行完畢,主執行緒就會去讀取"任務佇列",依次執行那些事件所對應的回撥函式。
執行棧中的程式碼(同步任務),總是在讀取"任務佇列"(非同步任務)之前執行。
計時器
下面是來自官方文件的一個示例。
const fs = require('fs');
const timeoutScheduled = Date.now();
// 非同步任務一:100ms 後執行的定時器
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms`);
}, 100);
// 非同步任務二:檔案讀取後,有一個 200ms 的回撥函式
fs.readFile('test.js', () => {
const startCallback = Date.now();
while (Date.now() - startCallback < 200) {
// 什麼也不做
}
});
複製程式碼
上面程式碼有兩個非同步任務,一個是 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
完成。
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
複製程式碼
上面程式碼應該先輸出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。
const fs = require('fs');
fs.readFile('test.js', () => {
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
});
複製程式碼
上面程式碼會先進入 I/O callbacks 階段,然後是 check 階段,最後才是 timers 階段。因此,setImmediate
才會早於setTimeout
執行。
根據上圖,Node.js的執行機制如下。
(1)V8引擎解析JavaScript指令碼。
(2)解析後的程式碼,呼叫Node API。
(3)libuv庫負責Node API的執行。它將不同的任務分配給不同的執行緒,形成一個Event Loop(事件迴圈),以非同步的方式將任務的執行結果返回給V8引擎。
(4)V8引擎再將結果返回給使用者。
除了setTimeout
和setInterval
這兩個方法,Node.js還提供了另外兩個與"任務佇列"有關的方法:process.nextTick
和setImmediate
。它們可以幫助我們加深對"任務佇列"的理解。
process.nextTick
方法可以在當前"執行棧"的尾部----下一次Event Loop(主執行緒讀取"任務佇列")之前----觸發回撥函式。也就是說,它指定的任務總是發生在所有非同步任務之前。setImmediate
方法則是在當前"任務佇列"的尾部新增事件,也就是說,它指定的任務總是在下一次Event Loop時執行,這與setTimeout(fn, 0)
很像。