1. 程式人生 > 其它 >Node.js 的事件迴圈

Node.js 的事件迴圈

  當啟動node程式時,比如 node index.js, index.js 就會從上到下依次執行 ,執行完畢後,就會進入到事件迴圈階段。事件迴圈從事件佇列中取出事件(回撥函式),傳送給JS引擎去執行。很簡單,是吧! 但是Node.js的事件迴圈並不是迴圈一個佇列 ,而是有多個佇列,不同型別的事件放到不同的佇列中,而且,這些佇列,還來自不同的地方,libuv中提供了佇列,Node.js本身也提供的佇列。這就很容易產生一個問題,Node.js是怎麼輪詢這些佇列的?首先Node.js為libuv中的佇列分了階段,每一個階段都包含一個佇列(先進先出的佇列),事件迴圈到了某一個階段就會取出佇列中的事件(回撥函式)傳送給JS引擎去執行。其次,每執行完一個階段,它會檢視Node.js本身提供的佇列有沒有事件,有就執行,沒有就進行到下一個階段,先看libuv中的階段

   timer階段:包含過期的setTimeout, setInterval的回撥函式組成的佇列;當用setTimeout或setInterval 新增一個定時器時,Node.js會把定時器和相應的回撥函式放到定時器堆(一種資料結構)中。每一次事件迴圈執行到timer階段,都會呼叫系統時間,到堆中看看有沒有定時器過期,有多少個定時器過期,如果有,就會把對應的回撥函式,放到佇列中。那Node.js是怎麼檢視過期的?

const myTimer = setTimeout(function a(){console.log('Timer executed')},15);
console.log(myTimer);

  setTimeout 返回一個timeOut物件,它有兩個屬性,一個是_idleTimeout, 就是設計的15ms, 一個是_idleStart:它是Node程式啟動後,執行setTimeout語句時建立的時間。只要執行到timer階段,減去這兩個時間,就會計算出有沒有過期。

  pending callback 階段:包含系統相關的回撥函式組成的佇列。比如寫了node服務,監聽8080埠,但8080埠被佔用了,Node丟擲了錯誤,如果你監聽錯誤,註冊了回撥函式,回撥函式就會執行。但一些Linux作業系統,想讓這個回撥函式等一下再執行,因為它要處理一些其它的事情,像這樣的回撥函式就會放到pending callback佇列中。

  idle和prepare階段,是內部使用的佇列。

  poll階段:包含I/O相關的回撥函式組成的佇列;

  check階段:包含setImmediate註冊的回撥函式。

  close callbacks 階段,包含close事件的回撥函式組成的佇列,比如socket.on('close', callback); callback就會放到這個佇列中。

  事件迴圈就是按照 timer -> pending callback -> idle和prepare -> poll -> check -> close callbacks 的順序迴圈,每到一個階段就會把佇列中的回撥函式傳送JS引擎去執行。當然我們真正關心的是timer階段,poll階段,check階段和close callbacks 階段。

  事件迴圈每執行完一個階段,不是都會檢查Node.js 提供的佇列嗎? 是的, Node.js本身提供了兩個佇列, process.nexttick 回撥函式佇列,和microtasks佇列,比如promise 回撥函式組成的佇列。不過要注意,process.nextick佇列的優先順序比microtaks佇列的高,也就是先檢查process.nextick佇列,再檢查microtask佇列,只有next tick佇列中的所有事件都執行比畢,才會執行microtask 佇列中的事件。Node.js 整個事件迴圈如下圖所示

  圖中有幾個點,前面沒有提到,Node.js 的事件迴圈是不是可以不開啟?是的,如果沒有事件佇列中規定的事件發生,它就不會開啟,比如程式是 const sum = 1; 它也就沒有必要開啟事件迴圈了。Node.js中的事件迴圈是不是可以退出?是的,如果事件佇列中的事件都執行完了,沒有事情可以做了,它也就退出了。timer階段和Check階段的事件執行過程也有所改變,每執行完佇列中的一個事件,都會檢查nextTick佇列和microtask佇列,而不是把該階段佇列中的所有事件都執行完,再檢查nextTick佇列和microtask佇列。

setTimeout(() => {
    console.log('setTimeout 1');
}, 0)

setTimeout(() => {
    console.log('setTimeout 2');
    Promise.resolve().then(() => {
        console.log('setTimeout2 Promise');
    })
}, 0)

setTimeout(() => {
    console.log('setTimeout 3');
}, 0)

  上面程式碼的執行結果是

setTimeout 1
setTimeout 2       
setTimeout2 Promise
setTimeout 3

  setTimeout2執行完以後,它就會檢查micorTask佇列,有一個事件,所以就執行了,再回到timer佇列繼續執行,setTimeout3執行了。poll階段有一個等待動作,如果事件迴圈執行到poll階段,佇列中有I/O事件,它會把佇列中的所有I/O事件都執行完畢,然後再計算要不要在這個地方等待其它I/O的完成,如果沒有事件,它就會直接計算要不要等待。I/O 都處理完了,也沒pending的I/O請求,它就不用等了。再者,如果close 階段有事件,它就會執行close 事件,也不用這裡等。 如果兩者都不是,比如有pending 的I/O 或close沒有事件,它要不要在這等,還要取決於時間,如果setTime, setImmediat, 設定了handle,如果過期了,它也不會停止在polling, 它會執行timer事件,如果它們沒有過期,node會計算,到過期時間的間隔,然後等待這個間隔,如果沒有setimeout,setimmediate 等, 它會一直在這裡等。setimmediate 是一個特殊的時間。polling也就解釋了伺服器程式一直不停止的原因。

   nextTick和microtask佇列除了在事件迴圈的每個階段執行外,主程式執行完,也會先檢查這兩個佇列,

Promise.resolve().then(() => console.log('promise1 resolved'));
Promise.resolve().then(() => {
    console.log('promise2 resolved');
    process.nextTick(() => console.log('next tick inside promise resolve handler'));
});

process.nextTick(() => console.log('next tick1'));

setImmediate(() => console.log('set immedaite1'));

setTimeout(() => console.log('set timeout'), 0);

  程式輸出結果

next tick1
promise1 resolved
promise2 resolved
next tick inside promise resolve handler
set timeout
set immedaite1

  主程式執行完,得知此時有待處理的 next tick 回撥,Node 將會執行它們直到佇列為空。然後檢查 promises 微任務佇列,佇列中有回撥需要被執行,開始執行這些回撥,在處理 promises 微任務佇列的過程中,有一個 next tick 回撥被新增到 nexttick 佇列中。在 promises 微任務佇列完成之後,得知 next tick 佇列中有一個回撥通過 promises 微任務新增到了佇列中,然後 node 會再一次執行 next tick 佇列中的那一個回撥任務。執行完 promises 和 next tick 的所有任務之後,事件迴圈會移動到第一個階段即定時器階段,此時它將會發現在定時器佇列中有一個到期的定時器回撥需要被執行,然後執行該回調。執行完定時器佇列中所有回撥之後,事件迴圈到了poll階段,佇列中沒有事件,並且setImmediate設定了回撥,事件迴圈則會移動到 check階段。它將會檢測到有待執行處理的回撥,事件迴圈會將它們逐一執行。最後,事件迴圈完成了所有事件... 然後程式退出。

   完整的Node.js的事件迴圈或Node.js的執行過程

  在Poll階段,EventLoop在等待I/O的完成?Node.js是怎麼做非同步I/O的?I/O event是誰放到佇列中的?Node.js有一個Event demultiplexer(事件多路分發機制)的概念 Event Demultiplexer接受到I/O請求後,就會交給相應的硬體去處理。一旦這個I/O請求被處理完成,就會把這個I/O 對應的事件處理函式,放到佇列中。

  但是現實並不是這麼簡單,Event Demultiplexer並不是一個真實存在的元件,它只是一個抽象的概念,在不同的作業系統中有不同的實現名稱,比如在Linux,它叫epoll,在Mac上,它叫kqueue, 在windows上,它叫IOCP( IOCP (Input Output Completion Port))。Node就是消費這些實現提供的非同步,非block的硬體I/O功能。但並不是所有型別的I/O都能用硬體的非同步I/O功能。網路I/O可以使用epoll, Kqueue,IOCP來實現,但檔案I/O不行,比較複雜,比如Linux並不支援完全非同步的檔案讀取。在Mac上,檔案系統的非同步通知也有一定的限制,為了提供完整的非同步來解決所有這些檔案系統的複雜性,幾乎不可能,因此Node.js的提供了執行緒池,來支援這些I/O。只要I/O不能通過硬體的非同步I/O功能(epoll/kqueue/IOCP)來解決,就使用執行緒池.  因此並不是所有的I/O功能都發生線上程池中,Node.js會盡最大可能地使用非阻塞的,非同步硬體I/O, 對於那些阻塞的或非常複雜,才能解決的I/O型別,它使用執行緒池。高CPU消耗的功能也是使用執行緒池,比如壓縮,加密,避免阻塞事件迴圈。

   總之,在現實的世界中,在不同型別的作業系統,支援所有的不同型別的I/O是非常困難的,一些I/O是使用原生支援的非同步,一些則使用執行緒池來保證非同步。為了把這些複雜細節封裝起來,libuv出現,它暴露了一層API給Node上層。Event Demultiplexer就可以看做是Libuv抽象出來的,供Node.js上層呼叫的處理I/O的API 的集合。

 

   事件迴圈的幾個細節

setTimeout(function() {
    console.log('setTimeout')
}, 0);
setImmediate(function() {
    console.log('setImmediate')
});

  以上程式的輸出結果並不能被保證。node.js內部,最小的timeout是1ms,也就是即使寫了0,node.js也會把它變成1ms。當每一次開始eventloop,node.js都會呼叫系統時間,來看看timer是不是過期。根據當時的cpu情況,獲到系統時間,可能需要小於1ms的時間,也可能需要大於1ms的時間。如果時間少於1ms,那麼Node.js就會覺得timer 並不過期,回撥函式就不會被執行,EventLoop 就會到下一個階段,I/O,再到immediate,如果獲取時間大於1ms,過期了,它就會執行setTimeout的回撥函式。可以通過下面的程式來看一下,Node在看有沒有過期的時候,每次都會呼叫系統時間

const start = process.hrtime();

setTimeout(() => {
    const end = process.hrtime(start);
    console.log(`timeout callback executed after ${end[0]}s and ${end[1]/Math.pow(10,9)}ms`);
}, 1000);

  這個回撥函式都是1s多一點才執行,執行回撥函式之前,它都要呼叫系統時間來檢查有沒有過期