前言
從Node.js進入人們的視野時,我們所知道的它就由這些關鍵字組成 事件驅動、非阻塞I/O、高效、輕量,它在官網(wǎng)中也是這么描述自己的。
Node.js? is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient.
于是,會有下面的場景出現(xiàn):
當我們剛開始接觸它時,可能會好奇:
為什么在瀏覽器中運行的 Javascript 能與操作系統(tǒng)進行如此底層的交互?
當我們在用它進行文件 I/O 和網(wǎng)絡 I/O 的時候,發(fā)現(xiàn)方法都需要傳入回調(diào),是異步的:
那么這種異步,非阻塞的 I/O 是如何實現(xiàn)的?
當我們習慣了用回調(diào)來處理 I/O,發(fā)現(xiàn)當需要順序處理時,Callback Hell 出現(xiàn)了,于是有想到了同步的方法:
那么在異步為主的 Node.js,有同步的方法嘛?
身為一個前端,你在使用時,發(fā)現(xiàn)它的異步處理是基于事件的,跟前端很相似:
那么它如何實現(xiàn)的這種事件驅動的處理方式呢?
當我們慢慢寫的多了,處理了大量 I/O 請求的時候,你會想:
Node.js 異步非阻塞的 I/O 就不會有瓶頸出現(xiàn)嗎?
之后你還會想:
Node.js 這么厲害,難道沒有它不適合的事情嗎?
等等。。。
看到這些問題,是否有點頭大,別急,帶著這些問題我們來慢慢看這篇文章。
Node.js 結構
上面的問題,都挺底層的,所以我們從 Node.js 本身入手,先來看看 Node.js 的結構。
[圖片上傳中。。。(1)]
我們可以看到,Node.js 的結構大致分為三個層次:
Node.js 標準庫,這部分是由 Javascript 編寫的,即我們使用過程中直接能調(diào)用的 API。在源碼中的 lib 目錄下可以看到。
Node bindings,這一層是 Javascript 與底層 C/C++ 能夠溝通的關鍵,前者通過 bindings 調(diào)用后者,相互交換數(shù)據(jù)。實現(xiàn)在 node.cc
這一層是支撐 Node.js 運行的關鍵,由 C/C++ 實現(xiàn)。V8:Google 推出的 Javascript VM,也是 Node.js 為什么使用的是 Javascript 的關鍵,它為 Javascript 提供了在非瀏覽器端運行的環(huán)境,它的高效是 Node.js 之所以高效的原因之一。
Libuv:它為 Node.js 提供了跨平臺,線程池,事件池,異步 I/O 等能力,是 Node.js 如此強大的關鍵。
C-ares:提供了異步處理 DNS 相關的能力。
http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、數(shù)據(jù)壓縮等其他的能力。
Libuv
Libuv 是 Node.js 關鍵的一個組成部分,它為上層的 Node.js 提供了統(tǒng)一的 API 調(diào)用,使其不用考慮平臺差距,隱藏了底層實現(xiàn)。
具體它能做什么,官網(wǎng)的這張圖體現(xiàn)的很好:
可以看出,它是一個對開發(fā)者友好的工具集,包含定時器,非阻塞的網(wǎng)絡 I/O,異步文件系統(tǒng)訪問,子進程等功能。它封裝了 Libev、Libeio 以及 IOCP,保證了跨平臺的通用性。
我們只要先知道它本身是異步和事件驅動的,記住這點,下面的問題就有了答案,我們一一來看。
與操作系統(tǒng)交互
舉個簡單的例子,我們想要打開一個文件,并進行一些操作,可以寫下面這樣一段代碼:
var fs = require('fs');fs.open('./test.txt', "w", function(err, fd) { //..do something});
這段代碼的調(diào)用過程大致可描述為:lib/fs.js → src/node_file.cc → uv_fs
Node.js 深入淺出上的一幅圖:
具體來說,當我們調(diào)用 fs.open
時,Node.js 通過 process.binding
調(diào)用 C/C++ 層面的 Open
函數(shù),然后通過它調(diào)用 Libuv 中的具體方法 uv_fs_open
,最后執(zhí)行的結果通過回調(diào)的方式傳回,完成流程。在圖中,可以看到平臺判斷的流程,需要說明的是,這一步是在編譯的時候已經(jīng)決定好的,并不是在運行時中。
總體來說,我們在 Javascript 中調(diào)用的方法,最終都會通過 process.binding
傳遞到 C/C++ 層面,最終由他們來執(zhí)行真正的操作。Node.js 即這樣與操作系統(tǒng)進行互動。
通過這個過程,我們可以發(fā)現(xiàn),實際上,Node.js 雖然說是用的 Javascript,但只是在開發(fā)時使用 Javascript 的語法來編寫程序。真正的執(zhí)行過程還是由 V8 將 Javascript 解釋,然后由 C/C++ 來執(zhí)行真正的系統(tǒng)調(diào)用,所以并不需要過分擔心 Javascript 執(zhí)行效率的問題。可以看出,Node.js 并不是一門語言,而是一個 平臺,這點一定要分清楚。
異步、非阻塞 I/O
通過上文,我們了解到,真正執(zhí)行系統(tǒng)調(diào)用的其實是 Libuv。之前我們提到,Libuv 本身就是異步和事件驅動的,所以,當我們將 I/O 操作的請求傳達給 Libuv 之后,Libuv 開啟線程來執(zhí)行這次 I/O 調(diào)用,并在執(zhí)行完成后,傳回給 Javascript 進行后續(xù)處理。
這里面的 I/O 包括文件 I/O 和 網(wǎng)絡 I/O。兩者的底層執(zhí)行略有不同。從上面的 Libuv 官網(wǎng)的圖中,我們可以看到,文件 I/O,DNS 等操作,都是依托線程池(Thread Pool)來實現(xiàn)的。而網(wǎng)絡 I/O 這一大類,包括:TCP、UDP、TTY 等,是由 epoll、IOCP、kqueue 來具體實現(xiàn)的。
總結來說,一個異步 I/O 的大致流程如下:
發(fā)起 I/O 調(diào)用
用戶通過 Javascript 代碼調(diào)用 Node 核心模塊,將參數(shù)和回調(diào)函數(shù)傳入到核心模塊;
Node 核心模塊會將傳入的參數(shù)和回調(diào)函數(shù)封裝成一個請求對象;
將這個請求對象推入到 I/O 線程池等待執(zhí)行;
Javascript 發(fā)起的異步調(diào)用結束,Javascript 線程繼續(xù)執(zhí)行后續(xù)操作。
執(zhí)行回調(diào)
I/O 操作完成后,會將結果儲存到請求對象的 result 屬性上,并發(fā)出操作完成的通知;
每次事件循環(huán)時會檢查是否有完成的 I/O 操作,如果有就將請求對象加入到 I/O 觀察者隊列中,之后當做事件處理;
處理 I/O 觀察者事件時,會取出之前封裝在請求對象中的回調(diào)函數(shù),執(zhí)行這個回調(diào)函數(shù),并將 result 當參數(shù),以完成 Javascript 回調(diào)的目的。
這里面涉及到了 Libuv 本身的一個設計理念,事件循環(huán)(Event Loop),它是一個類似于 while true
的無限循環(huán),其核心函數(shù)是 uv_run
,下文會用到。
從這里,我們可以看到,我們其實對 Node.js 的單線程一直有個誤會。事實上,它的單線程指的是自身 Javascript 運行環(huán)境的單線程,Node.js 并沒有給 Javascript 執(zhí)行時創(chuàng)建新線程的能力,最終的實際操作,還是通過 Libuv 以及它的事件循環(huán)來執(zhí)行的。這也就是為什么 Javascript 一個單線程的語言,能在 Node.js 里面實現(xiàn)異步操作的原因,兩者并不沖突。
事件驅動
說到,事件驅動,對于前端來說,并不陌生。事件,是一個在 GUI 開發(fā)時很常用的一個概念,常見的有鼠標事件,鍵盤事件等等。在異步的多種實現(xiàn)中,事件是一種比較容易理解和實現(xiàn)的方式。
說到事件,一定會想到回調(diào),當我們寫了一大堆事件處理函數(shù)后,Libuv 如何來執(zhí)行這些回調(diào)呢?這就提到了我們之前說到的 uv_run
,先看一張它的執(zhí)行流程圖:
在 uv_run
函數(shù)中,會維護一系列的監(jiān)視器:
typedef struct uv_loop_s uv_loop_t;typedef struct uv_err_s uv_err_t;typedef struct uv_handle_s uv_handle_t;typedef struct uv_stream_s uv_stream_t;typedef struct uv_tcp_s uv_tcp_t;typedef struct uv_udp_s uv_udp_t;typedef struct uv_pipe_s uv_pipe_t;typedef struct uv_tty_s uv_tty_t;typedef struct uv_poll_s uv_poll_t;typedef struct uv_timer_s uv_timer_t;typedef struct uv_prepare_s uv_prepare_t;typedef struct uv_check_s uv_check_t;typedef struct uv_idle_s uv_idle_t;typedef struct uv_async_s uv_async_t;typedef struct uv_process_s uv_process_t;typedef struct uv_fs_event_s uv_fs_event_t;typedef struct uv_fs_poll_s uv_fs_poll_t;typedef struct uv_signal_s uv_signal_t;
這些監(jiān)視器都有對應著一種異步操作,它們通過 uv_TYPE_start
,來注冊事件監(jiān)聽以及相應的回調(diào)。
在 uv_run
執(zhí)行過程中,它會不斷的檢查這些隊列中是或有 pending
狀態(tài)的事件,有則觸發(fā),而且它在這里只會執(zhí)行一個回調(diào),避免在多個回調(diào)調(diào)用時發(fā)生競爭關系,因為 Javascript 是單線程的,無法處理這種情況。
上面的圖中,對 I/O 操作的事件驅動,表達的比較清楚。除了我們常提到的 I/O 操作,圖中還表述了一種情況,timer(定時器)。它與其他兩者不同之處在于,它沒有單獨開立新的線程,而是在事件循環(huán)中直接完成的。
事件循環(huán)除了維護那些觀察者隊列,還維護了一個 time
字段,在初始化時會被賦值為0,每次循環(huán)都會更新這個值。所有與時間相關的操作,都會和這個值進行比較,來決定是否執(zhí)行。
在圖中,與 timer 相關的過程如下:
更新當前循環(huán)的 time 字段,即當前循環(huán)下的“現(xiàn)在”;
檢查循環(huán)中是否還有需要處理的任務(handlers/requests),如果沒有就不必循環(huán)了,即是否 alive。
檢查注冊過的 timer,如果某一個 timer 中指定的時間落后于當前時間了,說明該 timer 已到期,于是執(zhí)行其對應的回調(diào)函數(shù);
執(zhí)行一次 I/O polling(即阻塞住線程,等待 I/O 事件發(fā)生),如果在下一個 timer 到期時還沒有任何 I/O 完成,則停止等待,執(zhí)行下一個 timer 的回調(diào)。如果發(fā)生了 I/O 事件,則執(zhí)行對應的回調(diào);由于執(zhí)行回調(diào)的時間里可能又有 timer 到期了,這里要再次檢查 timer 并執(zhí)行回調(diào)。
Node.js 會一直調(diào)用 uv_run
直到到循環(huán)不在 alive。
同步方法
雖然 Node.js 是以異步為主要模式的,但我們在實際開發(fā)中,難免會有一些情況是有時序性的,如果由異步來寫,就會寫出很丑的 Callback Hell,如下:
db.query('select nickname from users where id="12"', function() { db.query('select * from xxx where id="12"', function() { db.query('select * from xxx where id="12"', function() { db.query('select * from xxx where id="12"', function() { //... }); }); });});
這個時候如果有同步方法,就會方便很多。這一點,Node.js 的開發(fā)者也想到了,目前大部分的異步操作函數(shù),都存在其對應的同步版本,只需要在其名稱后面加上 Sync
即可,不用傳入回調(diào)。
var file = fs.readFileSync('/test.txt', {"encoding": "utf-8});
這寫方法還是比較好用的,執(zhí)行 shell 命令,讀取文件等都比較方便。不過,體驗不太好的一點就是這種調(diào)用的錯誤收集,它不會像回調(diào)函數(shù)那樣,在第一參數(shù)中傳入錯誤信息,它會將錯誤直接拋出,你需要使用 try...catch
來獲取,如下:
var data;try { data = fs.readFileSync('/test.txt');} catch (e) { if (e.code == 'ENOENT') { //... } //...}
至于這些方法如何實現(xiàn)的,我們下回再論。
一些可能的瓶頸
這里只見到討論下自己的理解,歡迎指正。
首先,文件的 I/O 方面,用戶代碼的運行,事件循環(huán)的通知等,是通過 Libuv 維護的線程池來進行操作的,它會運行全部的文件系統(tǒng)操作。既然這樣,我們拋開硬盤的影響,對于嚴謹?shù)?C/C++ 來說,這個線程池一定是有大小限制的。官方默認給出的大小是 4。當然是可以改變的。在啟動時,通過設置 UV_THREADPOOL_SIZE
來改變這個值即可。不過,最大也只能是 128,因為這個是涉及到內(nèi)存占用的。
這個線程池對于所有的事件循環(huán)是共享的。當一個函數(shù)要使用線程池的時候(比如調(diào)用 uv_queue_work
),Libuv 會預先分配并初始化 UV_THREADPOOL_SIZE
所允許的線程出來。而128 占用的內(nèi)存大約是 1MB,如果設置的太高,當使用線程池頻繁時,會因為內(nèi)存占用過多而降低線程的性能。具體說明;
對于網(wǎng)絡 I/O 方面,以 Linux 系統(tǒng)下來說,網(wǎng)絡 I/O 采用的是 epoll 這個異步模型。它的優(yōu)點是采用了事件回調(diào)的方式,大大降低了文件描述符的創(chuàng)建(Linux下什么都是文件)。
在每次調(diào)用 epoll_wait
時,實際返回的是就緒描述符的數(shù)量,根據(jù)這個值,去 epoll 指定的數(shù)組里面取對應數(shù)量的描述符,是一種 內(nèi)存映射 的方式,減少了文件描述符的復制開銷。
上面提到的 epoll 指定的數(shù)組,它的大小即可監(jiān)聽的數(shù)量大小,它在不同的系統(tǒng)下,有不同的默認值,可見這里 epoll create。
有了大小的限制,還遠不夠,為了保證運行的穩(wěn)定,防止你在調(diào)用 epoll 函數(shù)時,指針越界,導致內(nèi)存泄漏。還會用到另外一個值 maxevents
,它是 epoll_wait
所能處理的最大數(shù)量,在調(diào)用 epoll_wait
時可以指定。一般情況下小于創(chuàng)建時(epoll_create)的數(shù)組大小,當然,也可以設置的比 size 大,不過應該沒什么用??梢韵氲饺绻途w的事件很多,超過了 maxevents
,那么超出的事件就要等待前面的事件處理完成,才可以繼續(xù),可能會導致效率的下降。
在這種情況下,你可能會擔心事件會丟失。其實,是不會丟失的,它會通過 ep_collect_ready_items
將這些事件保存在一個隊列中,在下一個 epoll_wait
再進行通知。
Node.js 不適合做什么
雖然看起來,Node.js 可以做很多事情,并且擁有很高的性能。比如做聊天室,搭建 Blog 等等,這些 I/O 密集型的應用,是比較適合 Node.js 的。
但是,有一種類型的應用,可能 Node.js 處理起來會比較吃力,那就是 CPU 密集型的應用。前文提到,Libuv 通過事件循環(huán)來處理異步的事件,這是存在于 Node.js 主線程的機制。通過這個機制,所有的 I/O 操作,底層API的調(diào)用都變成了異步的。但用戶的 Javascript 代碼是運行在主線程中的,如果這部分代碼運行耗時很長,就會導致事件循環(huán)被阻塞。因為,它對于事件的處理,都是按照隊列順序的,所以如果其中的任何一個事務/事件本身沒有完成,那么其他的回調(diào)、監(jiān)聽器、超時、nextTick() 都得不到運行的機會,被阻塞的事件循環(huán)沒有機會去處理它們。這樣下去,輕則效率降低,重則運行停滯。
比如我們常見的模板渲染,壓縮,解壓縮,加/解密等操作,都是 Node.js 的軟肋,所以使用的時候要考慮到這方面。
總結
Node.js 通過 libuv
來處理與操作系統(tǒng)的交互,并且因此具備了異步、非阻塞、事件驅動的能力。
Node.js 實際上是 Javascript 執(zhí)行線程的單線程,真正的的 I/O 操作,底層 API 調(diào)用都是通過多線程執(zhí)行的。
CPU 密集型的任務是 Node.js 的軟肋。