Nodejs是非阻塞的,源於它是基於事件迴圈的設計模式,該模式也稱為Reactor模式。
Nodejs同時也是單執行緒的,這裡的單執行緒指的是開發人員編寫的程式碼執行在單執行緒上,而Nodejs的內部一些實現程式碼卻是多執行緒的,如對於I/O 的處理(讀取檔案、網路請求等)。關於Event Loop
在另一篇文章中有粗略提到,本文將詳細闡述。
但對於I/O請求不也是開發人員編寫的程式碼嗎,不是說我們自己寫的程式碼都是執行在單執行緒上的,怎麼這裡又可能變成多執行緒了? 這裡就要講到reactor模式了。在此之前,先簡單瞭解下Blocking I/O
與Non-blocking I/O
。
Blocking I/O vs Non-blocking I/O
Blocking I/O
Blocking I/O
是程式會等待I/O請求直到結果返回,相當於控制權一直在等待I/O這邊,在等待的這段時間裡程式不會去幹其他事,就這麼一直乾等著。例子如:
data = socket.read();
// wait until the data fetch back
print(data)
複製程式碼
對於web server來說,是必須要處理多個請求的。對於Blocking I/O
情況,是無法處理多個請求,每個請求都會在上一個請求處理完才能處理。解決的方法是啟用多執行緒處理,該處理場景如下圖:
開啟多個執行緒處理的代價有點高(記憶體佔用,上下文切換),而且從圖中看到每個執行緒都有很多空餘時間在乾等著,無法充分利用時間。
Non-blocking I/O
對於Non-blocking I/O
, 一般是請求後直接返回,不用等待請求結果返回。如果沒有資料可以返回的話,是直接返回一個預設好的常量標識當前還沒資料可以返回。
這裡首先舉例一個最基本的實現方式,不斷迴圈這些資源直到能讀取到資料。
// 資源集合
resources = [socketA, socketB, pipeA];
// 只要還有資源沒獲取到資料,就一直迴圈操作
while(!resources.isEmpty()) {
for(i = 0; i < resources.length; i++) {
resource = resources[i];
// 直接返回non-blocking
// 若無資料則直接返回預設常量
let data = resource.read();
if(data === NO_DATA_AVAILABLE)
// 該資源還在等待中未準備好
continue;
if(data === RESOURCE_CLOSED)
// 該資源已經讀取完畢,從集合中刪除
resources.remove(i);
else
// 資料已經獲取,處理資料
consumeData(data);
}
}
複製程式碼
這樣就可以做到單個執行緒中處理併發處理多個請求資源了。這種做法被稱為busy-wait
,該做法雖然使得單個執行緒可以處理多個併發請求,但CPU會一直消耗在輪詢中,無法抽身去幹其他事情。因此non-blocking I/O
一般通過synchronous event demultiplexer來實現。
關於什麼是 synchronous event demultiplexer,這裡引用wikipedia中的一段話。
Uses an event loop to block on all resources. The demultiplexer sends the resource to the dispatcher when it is possible to start a synchronous operation on a resource without blocking
(Example: a synchronous call to read() will block if there is no data to read. The demultiplexer uses select() on the resource, which blocks until the resource is available for reading. In this case, a synchronous call to read() won't block, and the demultiplexer can send the resource to the dispatcher.)
簡單來說就是,對於事件迴圈中的資源會通過該多路分發器(demultiplexer)下發給對應的程式去處理,處理好了則把對應事件儲存到event queue
中等待事件迴圈輪詢執行。
如上述例子說的呼叫read()
之後馬上可以執行接下來的程式碼而不會產生阻塞,阻塞的事情交給了分發器去做了,具體怎麼做每個系統有不同的實現,這就是更底層的事了。
簡單例子如:
socketA, pipeB;
// 註冊事件
watchedList.add(socketA, FOR_READ);
watchedList.add(pipeB, FOR_READ);
// demultiplexer blocking 等待事件完成(成功取回資料)
// events儲存成功的事件
while(events = demultiplexer.watch(watchedList)) {
...
}
複製程式碼
Reactor Pattern
Nodejs中的事件迴圈正是基於event demultiplexer
和event queue
,而這兩塊正是Reactor Pattern的核心。對於Nodejs的事件迴圈,首選要明確的一點是:
只有一個主執行緒執行JS程式碼,我們寫的程式碼就是在該執行緒執行的,該執行緒也同是
event loop
執行的執行緒。(並不是主執行緒執行JS程式碼,然後又有一個執行緒在同時執行event loop
)。
該模式執行過程大致如下圖所示:
-
event demultiplexer
接收到I/O請求然後下發給對應的底層去處理。 -
一旦I/O獲取到了資料,
event demultiplexer
會把註冊的回撥函式新增到event queue
中等待event loop
去執行。 -
event queue
中的回撥函式依次被event loop
執行,直到event queue
為空。 -
當
event queue
中沒資料了或者event demultiplexer
沒有再接受到請求,程式即event loop
就會結束,意味著該應用就退出了,否則回到第一步。
Event Demultiplexer
之前已經初略講過了Event Demultiplexer
是什麼了,這裡詳細講下nodejs中的event demultiplexer
。
event demultiplexer
實際上是一個抽象的概念,不同的系統有不同的實現方式,如Linux的epoll,MacOS中的kqueue,Windows中的IOCP。nodejs則通過libuv遮蔽了對不同系統的實現支援跨平臺,提供了針對多種不同I/O請求的具體處理方式的API(如File I/O,Network I/O,DNS處理等)。
可以認為libuv把這一堆複雜的東西都結合在一起形成了nodejs中的event demultiplexer
。libuv結構如下圖所示:
libuv中,對於一些I/O操作是直接利用系統層級I/O中的non-blocking
和asynchronous
特性(如提到的epoll等),但對於一些型別的I/O,由於複雜性的問題libuv則通過thread pool來處理。
所以就如同一開始說的,使用者開發層面的程式碼是單執行緒的,但在I/O處理中是有可能出現多執行緒,但不會涉及到開發人員寫的JS程式碼,因為thread pool是在libuv庫裡面的。
Event Queue
上面說到了event queue
,是用來儲存回撥函式等待被event loop
處理的。但實際上,不止一個event queue
佇列,事件迴圈要處理的主要有4個型別的佇列。
- Timers and Intervals Queue: 儲存
setTimeout
和setInterval
中的回撥函式(實際上不是佇列,資料結構是最小堆實現,這裡就統一都叫佇列了) - IO Event Queue: 儲存已經完成的I/O回撥函式。
- Immediates Queue: 儲存
setImmediate
中的回撥函式。 - Close Handlers Queue: 其他所有
close
事件的回撥,如socket.on('close', ...)
。
除了上述四個主要佇列外,還有兩個比較特殊的佇列:
- Next Ticks Queue:儲存
process.nextTick
中的回撥函式。 - Other Microtasks Queue:儲存
Promise
等microtask中的回撥函式。
這裡又再插一句,macrotask和microtask的區別。
那麼這些佇列是怎麼被事件迴圈處理的呢?直接看圖。
事件迴圈會依次處理timers and intervals queue
,IO event queue
,immediates queue
,close handlers queue
這四個佇列,如果處理完close hanlers queue
後,timers and intervals
沒有資料再進來,就退出事件迴圈。
處理其中一個佇列的過程稱為一個phase。一次事件迴圈就是處理這四個phase的過程。那另外兩個特殊的佇列是在什麼時候執行的呢? 答案就是在每個 phase執行完後馬上就檢查這兩個佇列有無資料,有的話就馬上執行這兩個佇列中的資料直至佇列為空。當這兩個佇列都為空時,event loop 就會接著執行下一個phase。
這兩個佇列相比,Next Ticks Queue
的許可權要比Other Microtasks Queue
的許可權要高,因此Next Ticks Queue
會先執行。
此外要注意的是,如果process.nextTick
中出現遞迴呼叫沒有停止條件的話,Next Ticks Queue
將一直有資料進來一直都不會為空,則會阻塞event loop
的執行。為了防止該情況,process.maxTickDepth
定義了迭代的最大值,不過從NodeJS v0.12版本開始已經移除了。
參考
1.Event Loop and the Big Picture
2.What you should know to really understand the Node.js Event Loop