歡迎回到 NodeJS 事件循環系列。在這篇文章中,我將談一下 NodeJS 處理 I/O 的細節。我希望能夠深挖事件循環的處理機制以及 I/O 如何和其它異步操作很好的協同工作。如果你錯過了任何這個系列的文章,我非常推薦你根據下面的閱讀導引去閱讀。我在前三篇文章中講了 NodeJS 事件循環中的許多概念。
原文的閱讀導引
- Reactor Pattern and the Big Picture
- Timers and Immediates
- Promises, Next-Ticks and Immediates
- Handling I/O (This Article)
- Event Loop Best Practices
異步 I/O 阻塞太主流了
我們經常討論關于NodeJS 的異步 I/O。比如我們之前寫過的第一篇文章(first article of this series),I/O 從未打算同步的。
在所有的操作系統處理中,他們為 I/O 操作提供事件通知接口(比如 linux 的 epoll, macOS 的 kqueue 等等)。NodeJS 利用這些平臺的事件通知系統提供非阻塞的異步 I/O。
正如我們所見,NodeJS 是一系列工具合集的高可用的 NodeJS 框架。這些工具包括:
- Chrome v8 引擎 -- 高可用 JS
- Libuv -- 帶有異步 I/O 的事件循環
- C-ares -- 用于 DNS 操作
-
其它的附加部分比如 http-parser, crypto 和 zlib
libuv 2.png
在這篇文章中,我們將會談到 Libuv 及它如何提供異步 I/O 給 Node,讓我們再次看一下這個示意圖。
讓我們回顧一下我們到目前為止學到的事件循環的內容:
- 事件循環是從執行多個 expired timers 開始
- 然后它將執行任何的 I/O 操作,然后選擇性等待任何 I/O 操作結束。
- 然后移動到下一步執行 setImmediate 的回調
- 最后他將執行所有的 I/O 結束處理程序
- 在每個階段之間,libuv 需要將每個階段的結果傳遞給 Node 架構(也就是 Javascript)的上層。每次這件事發生的時候,所有的 process.nextTick 回調和微任務回調將會被執行。
現在,讓我們嘗試來理解一下 NodeJS 如何在 event loop 中處理 I/O。
tips: 什么是 I/O?通常任何除了 CPU 之外的外部設備的調用都叫做 I/O,最常見的 I/O 操作就是文件處理和 TCP/UDP 網絡操作。
Libuv 和 NodeJS I/O
JS 自身并沒有任何地方可以去處理異步 I/O 操作。在開發 NodeJS 期間,libuv 被創建出來用來給 Node 提供異步 I/O 操作,雖然 libuv 是一個獨立的庫,也可以被單獨引用。Libuv 在 NodeJS 結構中的角色是抽象內部復雜的 I/O 操作并且提供共一個 Node 頂層的通用接口。所以 Node 可以執行獨立于平臺的異步 I/O,而不需要擔心它所運行的平臺。
警告!
如果你對事件循環沒有基本的了解,我推薦你去讀一下 event loop 系列之前的那幾篇文章,因為這邊我會一筆帶過一下,這篇文章我更多的會講解 I/O。
我可能會使用 libuv 自己的一些代碼片段,而且我只會使用特定的 Unix 代碼片段和示例來簡化操作。 特定的 Windows 的代碼可能略有不同,但應該沒有太大區別。
你需要懂一些 C 語言,不需要特別多,一些對基本的代碼流程的理解就可以。
在上面的 NodeJS 架構示例圖中可以看到,libuv 在結構的底層。現在讓我們看看 NodeJS 上層和 libuv 事件循環多個階段的關系。
在我們之前看到的示例圖2中,事件循環中有四個獨立的階段。但是在 libuv 中,有7個獨立階段,分別是:
1. 計時器 -- 執行通過 setTimeout 和 setInterval 添加的 expired timer 和 interval 回調
2. 懸停的 I/O 回調 -- 執行任何完成/錯誤的 I/O 操作回調函數
3. Idle 處理程序 -- 做一些 libuv 核心操作
4. 預備處理程序 -- 做一些輪詢 I/O 之前的預備工作
5. I/O 輪詢 — 選擇性的等待任何 I/O 去完成
6. 檢查處理程序 - 在輪詢I / O后執行一些事后檢查工作。 通常,setImmediate 調度的回調將在此處調用。
7. 關閉處理程序 -- 在任何結束的 I/O 操作后執行。
現在如果你還記得第一篇文章的內容,你也許會感到困惑:
1. 什么是檢查處理程序?它并沒有出現在事件循環示例圖中。
2. 什么是 I/O 輪詢?為什么我們執行在 I/O 回調的時候需要阻塞 I/O 操作。Node 不是非阻塞的嗎?
讓我來回答上面的問題。
檢查處理程序(Check Handlers)
現在你也許會困惑 I/O 輪詢到底是什么,雖然我將 I/O 回調隊列和 I/O 輪詢合并成事件循環示例圖的簡單的一個階段,I/O 輪詢會在完成/錯誤 的 I/O 回調后執行。
但是在大多數的 I/O 輪詢中,這是可選擇的。I/O 輪詢會在某些狀態下并不會執行。為了更徹底的理解,讓我們來看一下 libuv 是如何處理的。
r = uv__loop_alive(loop); if (!r) uv__update_time(loop); while (r != 0 && loop->stop_flag == 0) { uv__update_time(loop); uv__run_timers(loop); ran_pending = uv__run_pending(loop); uv__run_idle(loop); uv__run_prepare(loop); timeout = 0; if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) timeout = uv_backend_timeout(loop); uv__io_poll(loop, timeout); uv__run_check(loop); uv__run_closing_handles(loop); if (mode == UV_RUN_ONCE) { uv__update_time(loop); uv__run_timers(loop); } r = uv__loop_alive(loop); if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) break; }
這個會讓不熟悉 C 語言的人看起來比較費勁。別擔心,讓我們嘗試看一下。上述代碼 libuv 源碼 core.c 文件內部的 uv_run 函數的部分代碼,更重要的是,它是 NodeJS 事件循環的核心。
如果你再回過頭看一下圖3,上面的代碼會更好理解。讓我們一行一行去理解這個代碼。
1. uv__loop_alive — 檢查是否有關聯的處理程序需要被調用,或者是否有任何活動的操作掛起。
2. uv__update_time — 它會發起一個系統調用去獲取當前時間然后更新循環時間。(這個用來去辨認 expired timers).
3. uv__run_timers -- 執行所有的 expired timers
4. uv__run_pending -- 執行所有的完成/錯誤的 I/O 回調
5. uv__io_poll — 做 I/O 輪詢
6. uv__run_check -- 執行所有的檢查處理程序(setImmediate 回調在這執行)
7. uv__run_closing_handles -- 執行所有的關閉處理程序
首先,事件循環會檢查事件循環是否還是還是存在的,這個檢查是通過 uv__loop_alive 函數做的。這個函數很簡單:
static int uv__loop_alive(const uv_loop_t* loop) { return uv__has_active_handles(loop) || uv__has_active_reqs(loop) || loop->closing_handles != NULL; }
uv__loop_alive 函數就簡單的返回布爾值。如果符合以下條件則值是 true:
- 會有一些活動的處理程序可以被執行
- 會有一些活動的請求處于等待狀態
- 有一些關閉處理程序可以被調用
只要 uv__loop_alive 函數返回 true,事件循環就會保持高速循環。
在執行了所有的 expired timers 回調以后,uv__run_pending 函數會被調用。這個函數會將完成的 I/O 操作存儲在 libuv 事件的 pending_queue。如果 pending_queue 是空的,函數則返回 0,否則,所有的 pending_queue 的回調將會執行,并且函數返回 1。
static int uv__run_pending(uv_loop_t* loop) { QUEUE* q; QUEUE pq; uv__io_t* w; if (QUEUE_EMPTY(&loop->pending_queue)) return 0; QUEUE_MOVE(&loop->pending_queue, &pq); while (!QUEUE_EMPTY(&pq)) { q = QUEUE_HEAD(&pq); QUEUE_REMOVE(q); QUEUE_INIT(q); w = QUEUE_DATA(q, uv__io_t, pending_queue); w->cb(loop, w, POLLOUT); } return 1; }
現在讓我們看一下 libuv 中通過調用 uv__io_poll 的 I/O 輪詢操作。
你需要看到 uv__io_poll 函數獲取一個通過 uv _backend_timeout 函數計算的時間 timeout 參數,該參數是通過uv_backend_timeout 計算出來的 。uv__io_poll 使用這個倒計時去明確多久需要去阻塞這個 I/O。如果這個倒計時的時間是 0,I/O 輪詢將會被跳過并且事件循環將會到達檢查處理程序(setImmediate) 階段。如何檢查這個超時的值是一個有趣的部分。基于上面的 uv_run的代碼,我們可以推斷如下:
1. 如果事件循環執行 UV_RUN_DEFAULT 模式,timeout 是通過 uv_backend_timeout 方法計算的。
2. 如果事件循環執行 UV_RUN_ONCE 模式并且如果 uv_run_pending 返回0(例如 pending_queue 是空的),timeout 是通過 uv_backend_timeout 計算的。
3. 否則 timoout 是 0.
tips: 讓我們嘗試著不要在這里去關心事件循環的不同模式比如 UV_RUN_DEFAULT 和 UV_RUN_ONCE 的區別 。但是如果你真的對這些很感興趣,可以查看這里 here。
現在讓我們看一下 uv_backend_timeout方法去理解如何確定超時。
int uv_backend_timeout(const uv_loop_t* loop) { if (loop->stop_flag != 0) return 0; if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop)) return 0; if (!QUEUE_EMPTY(&loop->idle_handles)) return 0; if (!QUEUE_EMPTY(&loop->pending_queue)) return 0; if (loop->closing_handles) return 0; return uv__next_timeout(loop); }
- 如果設置了循環的 stop_flag ,確定循環即將退出,則 timeout 是 0。
- 如果這里沒有掛起的活躍事件處理程序或者活躍的操作,這里沒有需要等待的內容,因此 timeout 是0。
- 如果有掛起的 idle handles 準備執行,則不應該等待 I/O。因此 timeout 是 0。
- 如果 pending_queue 中有完成的 I/O 處理程序,則不應等待 I/O。 因此 timeout 為0。
- 如果有任何掛起的 close handlers 要執行,不應該等待 I/O。因此 timeout 是 0。
如果上面的情況都沒遇到過,uv__next_timeout 方法會被調用來明確 libuv 需要等待 I/O 多久。
int uv__next_timeout(const uv_loop_t* loop) { const struct heap_node* heap_node; const uv_timer_t* handle; uint64_t diff; heap_node = heap_min((const struct heap) &loop->timer_heap); if (heap_node == NULL) return -1; / block indefinitely */ handle = container_of(heap_node, uv_timer_t, heap_node); if (handle->timeout <= loop->time) return 0; diff = handle->timeout - loop->time; if (diff > INT_MAX) diff = INT_MAX; return diff; }
uv__next_timeout 的作用是,它會返回一個最接近計時器的時間值。如果沒有計時器,它將返回 -1 代表無限。
現在你已經知道這個問題的答案:為什么我們在執行 I/O 回調后要阻塞 I/O。Node 不是應該非阻塞的嗎?
如果有任何掛起的任務待執行,則事件循環不會被阻塞。如果沒有任何掛起的任務被執行,它將被阻塞直到所有的 next timer 都沒有了,此處會重新啟動循環。
tips:我希望你跟上了我(的節奏)。我知道這里對于你來說有太多細節了,但是清楚地去理解它,去清楚地知道到底發生了什么是很有必要的。
現在我們知道循環等待 I/O 完成要多久。超時值會傳遞到 uv__io_poll 函數。這個函數將會監聽任何傳入的 I/O 操作直到超時到期或者達到了系統指定的最大安全倒計時值。在超時后,事件循環將會再次變成活躍的并且移動到“check hanlders” 階段。
I/O 輪詢在不同的操作系統上會有不同的執行表現。在 Linux 中,它會被 epoll_wait 核心系統調用,在 macOS 使用 kqueue。在 windows 中,它通過 IOCP 的 GetQueueCompleteionsStatus 做到。我不會深究 I/O 輪詢是如何工作的因為它真的非常復雜,并且需要別的系列文章去講述了(并且我應該不會去寫。
一些線程池的內容
目前為止,在這些文章中我們還沒說過線程池。正如我們第一篇文章說到的,線程池主要用于在DNS操作期間執行所有文件I / O操作,getaddrinfo 和 getnameinfo 的調用僅僅是因為不同平臺中的文件 I/O 的復雜性(到底多復雜,看這篇文章)。自從線程池的尺寸被限制(默認是 4),多個文件系統操作的請求會被阻塞直到線程池是可獲取的。然而線程池的大小可以使用環境變量 UV_THREADPOOL_ZISE增長到 128(寫這篇文章的時候),以提高應用的性能。
盡管如此,這個固定大小的線程池已經確定是 NodeJS 應用的瓶頸。因為文件 I/O,getaddrinfo,getnameinfo 并不是唯一在線程池中被執行的操作。一些 CPU 密集型加密操作比如 randomBytes, randowFill 和 pbkqf2 一樣會在 libuv 線程池中執行來為了阻止一些不利于應用性能的事。但是同樣會導致給 I/O 操作的線程池有限的資源更緊張。
截止目前的 libuv 提高的提議,它被建議讓線程池基于負荷是可伸縮的,但是這個提案被停止了,它被替換為一個為了 threading 的可插拔 API,以便將來引入。
本文的某些部分的靈感來自SaúlIbarraCorretgé在NodeConfEU 2016上所做的演示。如果您想了解更多有關libuv的信息,我強烈建議您觀看它。
【地址不貼了】
更多
在這篇文章中,我講了一些 NodeJS 處理 I/O 的細節,深挖 libuv 的源碼。我相信非阻塞,事件驅動的 NodeJS 對于你來說更清楚了。如果你有任何問題,我將非常樂意回答。因此,請不要猶豫去回復這篇文章。 如果你真的喜歡這篇文章,如果你能鼓掌并鼓勵我,我很樂意寫更多。 謝謝。
原文地址:
https://jsblog.insiderattack.net/handling-io-nodejs-event-loop-part-4-418062f917d1