Node.js探祕(一)-初識單執行緒的Node.js

凌恆發表於2016-01-25

前言

從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 Architecture

我們可以看到,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 呼叫,使其不用考慮平臺差距,隱藏了底層實現。

具體它能做什麼,官網的這張圖體現的很好:

libuv_architecture

可以看出,它是一個對開發者友好的工具集,包含定時器,非阻塞的網路 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 深入淺出上的一幅圖:

Node.js File操作

具體來說,當我們呼叫 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 呼叫

    1. 使用者通過 Javascript 程式碼呼叫 Node 核心模組,將引數和回撥函式傳入到核心模組;
    2. Node 核心模組會將傳入的引數和回撥函式封裝成一個請求物件;
    3. 將這個請求物件推入到 I/O 執行緒池等待執行;
    4. Javascript 發起的非同步呼叫結束,Javascript 執行緒繼續執行後續操作。
  • 執行回撥

    1. I/O 操作完成後,會將結果儲存到請求物件的 result 屬性上,併發出操作完成的通知;
    2. 每次事件迴圈時會檢查是否有完成的 I/O 操作,如果有就將請求物件加入到 I/O 觀察者佇列中,之後當做事件處理;
    3. 處理 I/O 觀察者事件時,會取出之前封裝在請求物件中的回撥函式,執行這個回撥函式,並將 result 當引數,以完成 Javascript 回撥的目的。

Node.js 非同步

這裡面涉及到了 Libuv 本身的一個設計理念,事件迴圈(Event Loop),它是一個類似於 while true 的無限迴圈,其核心函式是 uv_run,下文會用到。

從這裡,我們可以看到,我們其實對 Node.js 的單執行緒一直有個誤會。事實上,它的單執行緒指的是自身 Javascript 執行環境的單執行緒,Node.js 並沒有給 Javascript 執行時建立新執行緒的能力,最終的實際操作,還是通過 Libuv 以及它的事件迴圈來執行的。這也就是為什麼 Javascript 一個單執行緒的語言,能在 Node.js 裡面實現非同步操作的原因,兩者並不衝突。

事件驅動

說到,事件驅動,對於前端來說,並不陌生。事件,是一個在 GUI 開發時很常用的一個概念,常見的有滑鼠事件,鍵盤事件等等。在非同步的多種實現中,事件是一種比較容易理解和實現的方式。

說到事件,一定會想到回撥,當我們寫了一大堆事件處理函式後,Libuv 如何來執行這些回撥呢?這就提到了我們之前說到的 uv_run,先看一張它的執行流程圖:

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 相關的過程如下:

  1. 更新當前迴圈的 time 欄位,即當前迴圈下的“現在”;
  2. 檢查迴圈中是否還有需要處理的任務(handlers/requests),如果沒有就不必迴圈了,即是否 alive。
  3. 檢查註冊過的 timer,如果某一個 timer 中指定的時間落後於當前時間了,說明該 timer 已到期,於是執行其對應的回撥函式;
  4. 執行一次 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 的軟肋。
該文章來自於阿里巴巴技術協會(ATA


相關文章