對Node.js異步的進一步理解

上週寫的JS異步編程的淺思,一步一步將反人類的異步回調演化到帶有async/await關鍵字的同步/順序執行,讓個人異步編程處理能力有了質的突破,達到「異步編程的最高境界,就是根本不用關心它是否是異步」。javascript

那麼,問題來了html

Node.js的這種異步是如何在單線程的JS中實現的呢?java

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

Node.js架構

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

Node.js架構圖

能夠看出,Node.js的結構大體分爲三個層次github

  • 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爲表明的另外一類請求,異步處理的底層支撐機制是徹底不同的。bash

對於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進程分配給事件循環。也就是說,timeout一、timeout3與timeout二、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資源的優點。

參考:

一、《深刻淺出Node.js》——雖然基於V0.10版本寫做的,但仍然有不少內容讓我豁然開朗。

二、不要混淆nodejs和瀏覽器中的event loop——經過解讀源碼的方式,幫助我理解了事件循環。

相關文章
相關標籤/搜索