《單線程的 Node.js》推薦給所有剛學習nodejs的朋友~

前言

從Node.js進入人們的視野時,我們所知道的它就由這些關鍵字組成 事件驅動、非阻塞I/O、高效、輕量,它在官網中也是這麼描述自己的。

Node.js® is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-drivennon-blocking I/O model that makes it lightweight andefficient.

於是,會有下面的場景出現:

當我們剛開始接觸它時,可能會好奇:

  • 爲什麼在瀏覽器中運行的 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 的軟肋。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章