對Node.js非同步的進一步理解

panda080發表於2018-03-26

上週寫的JS非同步程式設計的淺思,一步一步將反人類的非同步回撥演化到帶有async/await關鍵字的同步/順序執行,讓我的非同步程式設計處理能力有了質的突破,達到“非同步程式設計的最高境界,就是根本不用關心它是不是非同步”。

那麼,問題來了

Node.js的這種非同步是如何在單執行緒的JS中實現的呢?

Node.js的非同步設計,會有哪些好處,會有哪些限制和瓶頸呢?

Node.js架構

Node.js主要分為四大部分,Node Standard Library,Node Bindings,V8,Libuv。Node.js的結構圖如下:

Node.js架構圖

可以看出,Node.js的結構大致分為三個層次

  • Node Standard Library是我們每天都在用的標準庫,如 Http、Buffer、fs 模組。它們都是由 JavaScript 編寫的,可以通過require(..)直接能呼叫。
  • Node Bindings是溝通 JS 和 C++ 的橋樑,封裝 V8 和 Libuv 的細節,向上層提供基礎API服務。
  • 這一層是支撐 Node.js 執行的關鍵,由 C/C++ 實現。
  • V8是 Google 開發的 javascript 引擎,為 javascript 提供了在非瀏覽器端執行的環境,可以說它就是 Node.js 的發動機。它的高效是 Node.js 之所以高效的原因之一。
  • Libuv為Node.js提供了跨平臺,執行緒池,事件池,非同步 I/O 等能力,是Node.js如此強大的關鍵。
  • C-ares提供了非同步處理 DNS 相關的能力。
  • http_parser、OpenSSL、zlib等,提供包括 http 解析、SSL、資料壓縮等其他的能力。

libuv 架構

下圖是官網的關於libuv的架構圖

官網的libuv架構圖

從左往右分為兩部分,一部分是與網路I/O相關的請求,而另外一部分是由檔案I/O, DNS Ops以及User code組成的請求。

從圖中可以看出,對於Network I/O和以File I/O為代表的另一類請求,非同步處理的底層支撐機制是完全不一樣的。

對於Network I/O相關的請求,根據OS平臺不同,分別使用Linux上的epoll,OSX和BSD類OS上的kqueue,SunOS上的event ports以及Windows上的IOCP機制。

而對於File I/O為代表的請求,則使用thread pool。利用thread pool的方式實現非同步請求處理,在各類OS上都能獲得很好的支援。

舉個例子

var fs = require('fs');
fs.open('./test.txt', "w", function(err, fd) {
	//..do something
});
複製程式碼

這段程式碼的呼叫過程大致可描述為:lib/fs.jssrc/node_file.ccuv_fs

大致流程圖如下:

fs.open流程圖

具體來說,fs.open(..)的作用是根據指定路徑和引數去開啟一個檔案,從而得到一個檔案描述符,這是後續所有I/O操作的初始操作。

接著,Node.js通過process.binding呼叫 C/C++ 層面的 Open 函式,然後通過它呼叫 libuv 中的具體方法 uv_fs_open。

至此,javascript呼叫立即返回,由javascript層面發起的非同步呼叫的第一階段就此結束。javascript執行緒可以繼續執行當前任務的後續操作。當前的I/O操作線上程池中等待執行,不管它是否阻塞I/O,都不會影響到javascript執行緒的執行,如此就達到了非同步的目的。

第二階段,則是回撥通知。執行緒池中I/O操作呼叫完畢之後,會告訴事件迴圈,已經完成了。事件迴圈每一次迴圈中,都會檢查是否有執行完的I/O,如果有,則取出結果和對應的回撥函式執行。以此達到呼叫javascript中傳入的回撥函式的目的。

到此,整個非同步I/O的流程才算完全結束。

這裡需要特別說明的是,平臺判斷的流程,這一步是在編譯的時候已經決定好的,並不是在執行時才判斷。

事件迴圈

"事件迴圈是一個程式結構,用於等待和傳送訊息和事件。(a programming construct that waits for and dispatches events or messages in a program.)"

在程式啟動時,Node.js便會建立一個類似於while(true)的迴圈,每執行一次迴圈體的過程就是檢視是否事件待處理,如果有,就取出事件及其相關的回撥函式。如果存在關聯的回撥函式,就執行它們。然後進入下一個迴圈,如果不再有事件處理,就退出程式。

事件迴圈流程圖

上面只是簡單的描述了事件迴圈的流程。我們知道,Node.js不止有一些非同步I/O,還有其他的非同步API:setTimeout、setInterval、setImmediate等等。他們之間的又是按照什麼樣的流程工作的呢?

nodejs的事件迴圈會分為6個階段,每個階段的作用如下

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製程式碼
  • timers:執行setTimeout() 和 setInterval()中到期的callback。
  • I/O callbacks:上一輪迴圈中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
  • idle, prepare:僅內部使用
  • poll:最為重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
  • check:執行setImmediate的callback
  • close callbacks:執行close事件的callback,例如socket.on("close",func)

事件迴圈的每一次迴圈都需要依次經過上述的階段。每個階段都有自己的回撥佇列,每當進入某個階段,都會從所屬的佇列中取出回撥來執行,當佇列為空或者被執行回撥的數量達到系統的最大數量時,進入下一階段。這六個階段都執行完畢稱為一輪迴圈。

舉個例子:

console.log(1);
console.log(2);
const timeout1 = setTimeout(function(){
    console.log(3)
    const timeout2 = setTimeout(function(){
        console.log(6);
    })
},0)
const timeout3 = setTimeout(function(){
    console.log(4);
    const timeout4 = setTimeout(function(){
        console.log(7);
    })
},0)
console.log(5)
複製程式碼

如果能說出上面的例子的列印結果,說明大致理解了js程式與事件迴圈之間是如何協調和事件迴圈自己是如何工作的。

  • 順序執行列印出1
  • 順序執行列印出2
  • js程式將timeout1(為了說明方便,就用它來指代第一個定時器,下同)分配給事件迴圈裡的timers,並返回
  • js程式將timeout3分配給事件迴圈的timers,並返回
  • 順序執行列印出5
  • libuv在timers階段會迴圈檢查定時器的時間是否過期了。當它檢查timeout1的時間到了,就通知js程式執行timeout1的回撥,列印出3,並將timeout2分配給事件迴圈的timers。
  • 接著檢查到timeout3的時間過期了,則通知js程式執行timeout3的回撥,列印出4,並將timeout4分配給事件迴圈的timers。
  • 這裡事件迴圈將進入下一階段,直到迴圈到了timers階段,取出超出時間最小的定時器,執行回撥。列印出6,接著列印出7。

這裡有個地方需要說明一下,timeout1裡的timeout2和timeout3裡的timeout4,需要分別等待timeout1和timeout3的回撥被執行了,再由js程式分配給事件迴圈。也就是說,timeout1、timeout3與timeout2、timeout4不是在同一輪事件迴圈中執行的。

優勢和難點

Node.js帶來的最大特性莫過於基於事件驅動的非阻塞I/O模型,這是它的靈魂所在。非阻塞I/O可以使CPU與I/O並不相互依賴等待,讓資源得到更好的利用。

Node.js利用事件迴圈的方式,使javascript執行緒像一個分配任務和處理結果的大管家,I/O執行緒池裡的各個I/O執行緒都是小二,負責兢兢業業地完成分配來的任務,小二與管家之間互不依賴,所以可以保持整體的高效率。

這個模型的缺點:管家無法承擔過多細節性的任務,如果承擔太多,則會影響到任務的排程,管家忙個不停,小二卻得不到活幹。比如說,js迴圈百萬次,就會阻塞javascript執行緒,導致管家忙於處理迴圈了,不能去排程任務了。

事件迴圈模型面對海量請求時,而海量請求同時都作用在單執行緒上,就需要防止任何一個計算耗費過多的邏輯片段。只要計算不影響到非同步I/O的排程,也能應用於CPU密集型的場景。

建議對CPU的耗用不要超過10ms,或者將大量的計算分解為諸多的小量計算,通過setImmediate(..)進行排程。只要合理利用Node.js的非同步模型與V8的高效能,就可以充分發揮CPU和I/O資源的優勢。

參考:

1、《深入淺出Node.js》——雖然基於V0.10版本寫作的,但仍然有很多內容讓我豁然開朗。

2、不要混淆nodejs和瀏覽器中的event loop——通過解讀原始碼的方式,幫助我理解了事件迴圈。

相關文章