Node.js design pattern : Reactor (Event Loop)

鈞嘢嘢發表於2018-01-17

Nodejs是非阻塞的,源於它是基於事件迴圈的設計模式,該模式也稱為Reactor模式。

Nodejs同時也是單執行緒的,這裡的單執行緒指的是開發人員編寫的程式碼執行在單執行緒上,而Nodejs的內部一些實現程式碼卻是多執行緒的,如對於I/O 的處理(讀取檔案、網路請求等)。關於Event Loop另一篇文章中有粗略提到,本文將詳細闡述。

但對於I/O請求不也是開發人員編寫的程式碼嗎,不是說我們自己寫的程式碼都是執行在單執行緒上的,怎麼這裡又可能變成多執行緒了? 這裡就要講到reactor模式了。在此之前,先簡單瞭解下Blocking I/ONon-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情況,是無法處理多個請求,每個請求都會在上一個請求處理完才能處理。解決的方法是啟用多執行緒處理,該處理場景如下圖:

blocking IO

開啟多個執行緒處理的代價有點高(記憶體佔用,上下文切換),而且從圖中看到每個執行緒都有很多空餘時間在乾等著,無法充分利用時間。

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 demultiplexerevent queue,而這兩塊正是Reactor Pattern的核心。對於Nodejs的事件迴圈,首選要明確的一點是:

只有一個主執行緒執行JS程式碼,我們寫的程式碼就是在該執行緒執行的,該執行緒也同是event loop執行的執行緒。(並不是主執行緒執行JS程式碼,然後又有一個執行緒在同時執行event loop)。

該模式執行過程大致如下圖所示:

event loop

  1. event demultiplexer接收到I/O請求然後下發給對應的底層去處理。

  2. 一旦I/O獲取到了資料,event demultiplexer會把註冊的回撥函式新增到event queue中等待event loop去執行。

  3. event queue中的回撥函式依次被event loop執行,直到event queue為空。

  4. 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

libuv中,對於一些I/O操作是直接利用系統層級I/O中的non-blockingasynchronous特性(如提到的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: 儲存setTimeoutsetInterval中的回撥函式(實際上不是佇列,資料結構是最小堆實現,這裡就統一都叫佇列了)
  • 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的區別

那麼這些佇列是怎麼被事件迴圈處理的呢?直接看圖。

event queue

事件迴圈會依次處理timers and intervals queueIO event queueimmediates queueclose 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

3.Node.js Design Patterns

4.what is the eventloop

相關文章