上週寫的JS異步編程的淺思,一步一步將反人類的異步回調演化到帶有async/await
關鍵字的同步/順序執行,讓個人異步編程處理能力有了質的突破,達到「異步編程的最高境界,就是根本不用關心它是否是異步」。javascript
那麼,問題來了html
Node.js的這種異步是如何在單線程的JS中實現的呢?java
Node.js的異步設計,會有哪些好處,會有哪些限制和瓶頸呢?node
Node.js主要分爲四大部分,Node Standard Library,Node Bindings,V8,Libuv。Node.js的結構圖以下:git
能夠看出,Node.js的結構大體分爲三個層次github
Node Standard Library
是咱們天天都在用的標準庫,如 Http、Buffer、fs 模塊。它們都是由 JavaScript 編寫的,能夠經過require(..)
直接能調用。Node Bindings
是溝通 JS 和 C++ 的橋樑,封裝 V8 和 Libuv 的細節,向上層提供基礎API服務。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的架構圖編程
從左往右分爲兩部分,一部分是與網絡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.js→src/node_file.cc→uv_fs
大體流程圖以下:
具體來講,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 │
└───────────────────────┘
複製代碼
事件循環的每一次循環都須要依次通過上述的階段。每一個階段都有本身的回調隊列,每當進入某個階段,都會從所屬的隊列中取出回調來執行,當隊列爲空或者被執行回調的數量達到系統的最大數量時,進入下一階段。這六個階段都執行完畢稱爲一輪循環。
舉個例子:
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進程與事件循環之間是如何協調和事件循環本身是如何工做的。
這裏有個地方須要說明一下,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——經過解讀源碼的方式,幫助我理解了事件循環。