Node.js探祕(一)-初識單執行緒的Node.js
前言
從Node.js進入人們的視野時,我們所知道的它就由這些關鍵字組成 事件驅動、非阻塞I/O、高效、輕量,它在官網中也是這麼描述自己的。
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.
於是,會有下面的場景出現:
當我們剛開始接觸它時,可能會好奇:
- 為什麼在瀏覽器中執行的 Javascript 能與作業系統進行如此底層的互動?
當我們在用它進行檔案 I/O 和網路 I/O 的時候,發現方法都需要傳入回撥,是非同步的:
- 那麼這種非同步,非阻塞的 I/O 是如何實現的?
當我們習慣了用回撥來處理 I/O,發現當需要順序處理時,Callback Hell 出現了,於是有想到了同步的方法:
- 那麼在非同步為主的 Node.js,有同步的方法嘛?
身為一個前端,你在使用時,發現它的非同步處理是基於事件的,跟前端很相似:
- 那麼它如何實現的這種事件驅動的處理方式呢?
當我們慢慢寫的多了,處理了大量 I/O 請求的時候,你會想:
- Node.js 非同步非阻塞的 I/O 就不會有瓶頸出現嗎?
之後你還會想:
- Node.js 這麼厲害,難道沒有它不適合的事情嗎?
等等。。。
看到這些問題,是否有點頭大,別急,帶著這些問題我們來慢慢看這篇文章。
Node.js 結構
上面的問題,都挺底層的,所以我們從 Node.js 本身入手,先來看看 Node.js 的結構。
我們可以看到,Node.js 的結構大致分為三個層次:
- Node.js 標準庫,這部分是由 Javascript 編寫的,即我們使用過程中直接能呼叫的 API。在原始碼中的 lib 目錄下可以看到。
- Node bindings,這一層是 Javascript 與底層 C/C++ 能夠溝通的關鍵,前者通過 bindings 呼叫後者,相互交換資料。實現在 node.cc
- 這一層是支撐 Node.js 執行的關鍵,由 C/C++ 實現。
- V8:Google 推出的 Javascript VM,也是 Node.js 為什麼使用的是 Javascript 的關鍵,它為 Javascript 提供了在非瀏覽器端執行的環境,它的高效是 Node.js 之所以高效的原因之一。
- Libuv:它為 Node.js 提供了跨平臺,執行緒池,事件池,非同步 I/O 等能力,是 Node.js 如此強大的關鍵。
- C-ares:提供了非同步處理 DNS 相關的能力。
- http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、資料壓縮等其他的能力。
Libuv
Libuv 是 Node.js 關鍵的一個組成部分,它為上層的 Node.js 提供了統一的 API 呼叫,使其不用考慮平臺差距,隱藏了底層實現。
具體它能做什麼,官網的這張圖體現的很好:
可以看出,它是一個對開發者友好的工具集,包含定時器,非阻塞的網路 I/O,非同步檔案系統訪問,子程式等功能。它封裝了 Libev、Libeio 以及 IOCP,保證了跨平臺的通用性。
我們只要先知道它本身是非同步和事件驅動的,記住這點,下面的問題就有了答案,我們一一來看。
與作業系統互動
舉個簡單的例子,我們想要開啟一個檔案,並進行一些操作,可以寫下面這樣一段程式碼:
var fs = require(`fs`);
fs.open(`./test.txt`, "w", function(err, fd) {
//..do something
});
這段程式碼的呼叫過程大致可描述為:lib/fs.js → src/node_file.cc → uv_fs
Node.js 深入淺出上的一幅圖:
具體來說,當我們呼叫 fs.open
時,Node.js 通過 process.binding
呼叫 C/C++ 層面的 Open
函式,然後通過它呼叫 Libuv 中的具體方法 uv_fs_open
,最後執行的結果通過回撥的方式傳回,完成流程。在圖中,可以看到平臺判斷的流程,需要說明的是,這一步是在編譯的時候已經決定好的,並不是在執行時中。
總體來說,我們在 Javascript 中呼叫的方法,最終都會通過 process.binding
傳遞到 C/C++ 層面,最終由他們來執行真正的操作。Node.js 即這樣與作業系統進行互動。
通過這個過程,我們可以發現,實際上,Node.js 雖然說是用的 Javascript,但只是在開發時使用 Javascript 的語法來編寫程式。真正的執行過程還是由 V8 將 Javascript 解釋,然後由 C/C++ 來執行真正的系統呼叫,所以並不需要過分擔心 Javascript 執行效率的問題。可以看出,Node.js 並不是一門語言,而是一個 平臺,這點一定要分清楚。
非同步、非阻塞 I/O
通過上文,我們瞭解到,真正執行系統呼叫的其實是 Libuv。之前我們提到,Libuv 本身就是非同步和事件驅動的,所以,當我們將 I/O 操作的請求傳達給 Libuv 之後,Libuv 開啟執行緒來執行這次 I/O 呼叫,並在執行完成後,傳回給 Javascript 進行後續處理。
這裡面的 I/O 包括檔案 I/O 和 網路 I/O。兩者的底層執行略有不同。從上面的 Libuv 官網的圖中,我們可以看到,檔案 I/O,DNS 等操作,都是依託執行緒池(Thread Pool)來實現的。而網路 I/O 這一大類,包括:TCP、UDP、TTY 等,是由 epoll、IOCP、kqueue 來具體實現的。
總結來說,一個非同步 I/O 的大致流程如下:
-
發起 I/O 呼叫
- 使用者通過 Javascript 程式碼呼叫 Node 核心模組,將引數和回撥函式傳入到核心模組;
- Node 核心模組會將傳入的引數和回撥函式封裝成一個請求物件;
- 將這個請求物件推入到 I/O 執行緒池等待執行;
- Javascript 發起的非同步呼叫結束,Javascript 執行緒繼續執行後續操作。
-
執行回撥
- I/O 操作完成後,會將結果儲存到請求物件的 result 屬性上,併發出操作完成的通知;
- 每次事件迴圈時會檢查是否有完成的 I/O 操作,如果有就將請求物件加入到 I/O 觀察者佇列中,之後當做事件處理;
- 處理 I/O 觀察者事件時,會取出之前封裝在請求物件中的回撥函式,執行這個回撥函式,並將 result 當引數,以完成 Javascript 回撥的目的。
這裡面涉及到了 Libuv 本身的一個設計理念,事件迴圈(Event Loop),它是一個類似於 while true
的無限迴圈,其核心函式是 uv_run
,下文會用到。
從這裡,我們可以看到,我們其實對 Node.js 的單執行緒一直有個誤會。事實上,它的單執行緒指的是自身 Javascript 執行環境的單執行緒,Node.js 並沒有給 Javascript 執行時建立新執行緒的能力,最終的實際操作,還是通過 Libuv 以及它的事件迴圈來執行的。這也就是為什麼 Javascript 一個單執行緒的語言,能在 Node.js 裡面實現非同步操作的原因,兩者並不衝突。
事件驅動
說到,事件驅動,對於前端來說,並不陌生。事件,是一個在 GUI 開發時很常用的一個概念,常見的有滑鼠事件,鍵盤事件等等。在非同步的多種實現中,事件是一種比較容易理解和實現的方式。
說到事件,一定會想到回撥,當我們寫了一大堆事件處理函式後,Libuv 如何來執行這些回撥呢?這就提到了我們之前說到的 uv_run
,先看一張它的執行流程圖:
在 uv_run
函式中,會維護一系列的監視器:
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;
這些監視器都有對應著一種非同步操作,它們通過 uv_TYPE_start
,來註冊事件監聽以及相應的回撥。
在 uv_run
執行過程中,它會不斷的檢查這些佇列中是或有 pending
狀態的事件,有則觸發,而且它在這裡只會執行一個回撥,避免在多個回撥呼叫時發生競爭關係,因為 Javascript 是單執行緒的,無法處理這種情況。
上面的圖中,對 I/O 操作的事件驅動,表達的比較清楚。除了我們常提到的 I/O 操作,圖中還表述了一種情況,timer(定時器)。它與其他兩者不同之處在於,它沒有單獨開立新的執行緒,而是在事件迴圈中直接完成的。
事件迴圈除了維護那些觀察者佇列,還維護了一個 time
欄位,在初始化時會被賦值為0,每次迴圈都會更新這個值。所有與時間相關的操作,都會和這個值進行比較,來決定是否執行。
在圖中,與 timer 相關的過程如下:
- 更新當前迴圈的 time 欄位,即當前迴圈下的“現在”;
- 檢查迴圈中是否還有需要處理的任務(handlers/requests),如果沒有就不必迴圈了,即是否 alive。
- 檢查註冊過的 timer,如果某一個 timer 中指定的時間落後於當前時間了,說明該 timer 已到期,於是執行其對應的回撥函式;
- 執行一次 I/O polling(即阻塞住執行緒,等待 I/O 事件發生),如果在下一個 timer 到期時還沒有任何 I/O 完成,則停止等待,執行下一個 timer 的回撥。如果發生了 I/O 事件,則執行對應的回撥;由於執行回撥的時間裡可能又有 timer 到期了,這裡要再次檢查 timer 並執行回撥。
Node.js 會一直呼叫 uv_run
直到到迴圈不在 alive。
同步方法
雖然 Node.js 是以非同步為主要模式的,但我們在實際開發中,難免會有一些情況是有時序性的,如果由非同步來寫,就會寫出很醜的 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 的開發者也想到了,目前大部分的非同步操作函式,都存在其對應的同步版本,只需要在其名稱後面加上 Sync
即可,不用傳入回撥。
var file = fs.readFileSync(`/test.txt`, {"encoding": "utf-8});
這寫方法還是比較好用的,執行 shell 命令,讀取檔案等都比較方便。不過,體驗不太好的一點就是這種呼叫的錯誤收集,它不會像回撥函式那樣,在第一引數中傳入錯誤資訊,它會將錯誤直接丟擲,你需要使用 try...catch
來獲取,如下:
var data;
try {
data = fs.readFileSync(`/test.txt`);
} catch (e) {
if (e.code == `ENOENT`) {
//...
}
//...
}
至於這些方法如何實現的,我們下回再論。
一些可能的瓶頸
這裡只見到討論下自己的理解,歡迎指正。
首先,檔案的 I/O 方面,使用者程式碼的執行,事件迴圈的通知等,是通過 Libuv 維護的執行緒池來進行操作的,它會執行全部的檔案系統操作。既然這樣,我們拋開硬碟的影響,對於嚴謹的 C/C++ 來說,這個執行緒池一定是有大小限制的。官方預設給出的大小是 4。當然是可以改變的。在啟動時,通過設定 UV_THREADPOOL_SIZE
來改變這個值即可。不過,最大也只能是 128,因為這個是涉及到記憶體佔用的。
這個執行緒池對於所有的事件迴圈是共享的。當一個函式要使用執行緒池的時候(比如呼叫 uv_queue_work
),Libuv 會預先分配並初始化 UV_THREADPOOL_SIZE
所允許的執行緒出來。而 128 佔用的記憶體大約是 1MB,如果設定的太高,當使用執行緒池頻繁時,會因為記憶體佔用過多而降低執行緒的效能。具體說明;
對於網路 I/O 方面,以 Linux 系統下來說,網路 I/O 採用的是 epoll 這個非同步模型。它的優點是採用了事件回撥的方式,大大降低了檔案描述符的建立(Linux下什麼都是檔案)。
在每次呼叫 epoll_wait
時,實際返回的是就緒描述符的數量,根據這個值,去 epoll 指定的陣列裡面取對應數量的描述符,是一種 記憶體對映 的方式,減少了檔案描述符的複製開銷。
上面提到的 epoll 指定的陣列,它的大小即可監聽的數量大小,它在不同的系統下,有不同的預設值,可見這裡 epoll create。
有了大小的限制,還遠不夠,為了保證執行的穩定,防止你在呼叫 epoll 函式時,指標越界,導致記憶體洩漏。還會用到另外一個值 maxevents
,它是 epoll_wait
所能處理的最大數量,在呼叫 epoll_wait
時可以指定。一般情況下小於建立時(epoll_create)的陣列大小,當然,也可以設定的比 size 大,不過應該沒什麼用。可以想到如果就緒的事件很多,超過了 maxevents
,那麼超出的事件就要等待前面的事件處理完成,才可以繼續,可能會導致效率的下降。
在這種情況下,你可能會擔心事件會丟失。其實,是不會丟失的,它會通過 ep_collect_ready_items
將這些事件儲存在一個佇列中,在下一個 epoll_wait
再進行通知。
Node.js 不適合做什麼
雖然看起來,Node.js 可以做很多事情,並且擁有很高的效能。比如做聊天室,搭建 Blog 等等,這些 I/O 密集型的應用,是比較適合 Node.js 的。
但是,有一種型別的應用,可能 Node.js 處理起來會比較吃力,那就是 CPU 密集型的應用。前文提到,Libuv 通過事件迴圈來處理非同步的事件,這是存在於 Node.js 主執行緒的機制。通過這個機制,所有的 I/O 操作,底層API的呼叫都變成了非同步的。但使用者的 Javascript 程式碼是執行在主執行緒中的,如果這部分程式碼執行耗時很長,就會導致事件迴圈被阻塞。因為,它對於事件的處理,都是按照佇列順序的,所以如果其中的任何一個事務/事件本身沒有完成,那麼其他的回撥、監聽器、超時、nextTick() 都得不到執行的機會,被阻塞的事件迴圈沒有機會去處理它們。這樣下去,輕則效率降低,重則執行停滯。
比如我們常見的模板渲染,壓縮,解壓縮,加/解密等操作,都是 Node.js 的軟肋,所以使用的時候要考慮到這方面。
總結
- Node.js 通過
libuv
來處理與作業系統的互動,並且因此具備了非同步、非阻塞、事件驅動的能力。 - Node.js 實際上是 Javascript 執行執行緒的單執行緒,真正的的 I/O 操作,底層 API 呼叫都是通過多執行緒執行的。
- CPU 密集型的任務是 Node.js 的軟肋。
相關文章
- 寶付初識單執行緒的 Node.js執行緒Node.js
- 淺談 Node.js 單執行緒模型Node.js執行緒模型
- Node.js的執行緒和程式Node.js執行緒
- Node.js 的單執行緒事件驅動模型和內建的執行緒池模型Node.js執行緒事件模型
- Node.js 中的程式和執行緒Node.js執行緒
- 初識 Node.js streamNode.js
- Node.js 多執行緒完全指南Node.js執行緒
- node.js的非同步I/O、事件驅動、單執行緒Node.js非同步事件執行緒
- 初識Node.js【01】Node.js是什麼?Node.js
- 初識Node.js中的stream(流):Node.js
- Node.js中執行緒的完整指南 – LogRocketNode.js執行緒
- Node.js 執行緒你理解的可能是錯的Node.js執行緒
- Node.js 真·多執行緒 Worker Threads 初探Node.js執行緒thread
- RocketMQ(八):高效能探祕之執行緒池MQ執行緒
- 【初識】-JUC·ThreadPoolExecutor 執行緒池thread執行緒
- node.js初級基礎一Node.js
- 多執行緒系列(一):認識執行緒執行緒
- 多執行緒 -- 初學簡單例子執行緒單例
- 求不更學不動之Node.js多執行緒Node.js執行緒
- 窺探Node.js裡的StreamNode.js
- Node.js stream 流的簡單認識Node.js
- 多執行緒的祕密執行緒
- Node.js 知識點一Node.js
- Node.js執行系統命令Node.js
- Java執行緒的深入探討Java執行緒
- Node.js初體驗Node.js
- 多執行緒之初識執行緒執行緒
- 使用Node.js執行Cesium專案Node.js
- 一個Node.js初學者的“班門弄斧Node.js
- Java執行緒的深入探討 (轉)Java執行緒
- 執行緒1-單執行緒執行緒
- 探祕System.Threading序列 - 第一篇:從Thread的執行緒單元狀態ApartmentState說起thread執行緒
- 揭祕Node.js深受歡迎的原因Node.js
- 我與Node.js重新認識的第一週 - Node.js 風格特點Node.js
- 深入理解Node.js 程式與執行緒(8000長文徹底搞懂)Node.js執行緒
- 認識執行緒、建立執行緒寫法執行緒
- 多執行緒Demo學習(執行緒的同步,簡單的執行緒通訊)執行緒
- Node.js和PHP執行機制對比Node.jsPHP