探究 Node.js 中的 drain 事件

發表於2015-12-31

探究 Node.js 中的 drain 事件

起因

最近在用 Node.js 寫一些網路請求相關的程式碼時,頻繁在一些開原始碼中看到 drain 事件的使用,於是我也依葫蘆畫瓢寫到了自己的程式碼裡面:

實際放到線上測試的時候發現,在一些情況下,drain 事件真的會被觸發,那到底什麼時候會觸發 drain 事件呢?drain 事件能用來做什麼呢?本著打破砂鍋問到底的精神,我決定探究一番。

TLDR

請直接跳到小結部分。

探究

最簡單的辦法就是查文件。因為我寫的是網路請求相關的程式碼,那麼我就先翻越了 net 和 socket 相關的部分。在 Node.js 官方文件中,對於 socket.write 有這麼一部分描述:

Returns true if the entire data was flushed successfully to the kernel buffer. Returns false if all or part of the data was queued in user memory. ‘drain’ will be emitted when the buffer is again free.

也就是說,drain 事件是和 socket.write 的返回值強關聯的,那麼我們可以做一個簡單的實驗(只寫關鍵部分):

可是無論我怎麼執行這部分程式碼,返回值總是 true,drain 事件沒有被觸發。那為啥線上就能觸發呢?按照文件所說,只有全部或者部分資料被緩衝在了記憶體裡面才會返回 false。那問題又來了,什麼時候資料才會被緩衝呢?

既然是被緩衝了,那最先猜測到就是資料流量太大。就像每天上下班高峰期的文一西路那樣,一旦車流量太大,前面的路口塞滿了,交警就會讓後面的車停下來。

好,那我們加大“車”流量(為節省篇幅,部分程式碼省略):

服務端程式碼:

客戶端程式碼:

但執行多次之後發現仍舊沒有看到任何 drain 事件的跡象。難道次數不夠?隨著我繼續增大 i 的最大值,直到遇到(libuv) kqueue(): Too many open files in system的錯誤時候,我仍舊沒看到 drain 事件。

逼我用絕招。看 Node.js 原始碼!

因為 socket.write 實際上是呼叫的 Stream.write(參考此處原始碼),最後我們在 Stream.write 的程式碼中找到了一絲端倪:

可以看到當要寫的資料的長度大於 highWaterMark (字面理解:高水位線)的時候,那麼 Stream.write 就會返回 false,也就會觸發 drain 事件了。

那這個高水位線具體是多少呢?可以繼續看程式碼

預設值是 16KB,看來還是挺大的啊。所以回想一下剛才我們的實驗程式,一個是寫的資料比較小,另外一個是實驗程式碼中的伺服器端沒有複雜邏輯,資料處理的也比較快,我們仍舊拿剛才的車流量的例子,雖然車很多很多,但是如果每輛車都開得飛快,那路也不會堵。只有當一些車比較慢,影響到了後面車的速度的時候,整體速度就會下來,就變堵了。

根據這個思路,我們換成下面這個實驗:

這裡我們啟動了一個簡單的 HTTP server,任何 requset 過來,都會返回一個 5M 大小的 聖誕歌曲的內容。然後我們對這個 HTTP server 發起 1000 次 GET 請求。果然不出所料,還沒等所有請求發完,一堆的 drain event fired 日誌出現了:

如果對 res.write 的返回值做下日誌,也會發現返回了很多 false。

原因也很容易想到,硬碟讀取這個 MP3 檔案的速度(測試環境為 RMBP 的 SSD 硬碟)一般都會快於將資料通過 HTTP Response 返回給使用者(即便是 localhost 的訪問,更不用說外網複雜錯綜的網路環境了),所以,當 MP3 很快就被讀取過來,但又沒有很快的將資料寫回,那麼這個 Stream 中的 data 就被快取了。於是,我很自然而然的設想,在我這個小應用中,也許這麼做並沒有什麼,但當應用的訪問量逐漸增大的時候,這個問題就可能會爆發,比如造成個記憶體洩露啊之類的。

怎麼用

那上面的程式碼該怎麼改進呢?

也就是說,當 write 返回 false 的時候,我們暫停讀取流,當快取的資料清空之後,我們再繼續讀取流,相當於我們根據輸出流來對讀取流做限流。反過來,如果寫入流快於讀取流,我們也可以對寫入流限流。

真的是這樣嗎?

剛才提到了,我想如果我們沒做這部分處理的話,應該很容易造成記憶體洩露,那我們不妨做個實驗驗證下。使用上面兩段程式碼分別進行不帶限流和帶限流的實驗,每隔 1s 使用 process.memoryUsage() 列印出來記憶體使用(我們在啟動 server 之後就開始列印記憶體資料):

最終我得出瞭如下資料:

限流

沒有限流

可以看到,記憶體使用其實差不多……

難道我猜想錯了麼?最簡單的辦法,繼續看原始碼。

看下 fs.createReadStream 的程式碼,就可以知道它的工作方式是先在記憶體中準備一段 Buffer,然後在 fs.read() 讀取檔案到這個 Buffer 中,完成一次讀取時,則從 Buffer 中通過 slice 方法取出那個資料作為一個小 Buffer 物件,再通過 data 事件傳給呼叫方[1]

再看下 pause 具體是怎麼實現的:

pause 方法設定了一個狀態位。在 flow 方法中,如果是 paused 的狀態,那麼就不再從 檔案流中讀取資料了:

所以在 pause 的時候,檔案的資料流依舊在 Buffer 中。

回頭看下我們這個例子,其實是寫比較慢,如果我們沒有對寫入的返回值做判斷的話,Writable Stream 本身也會把多餘的資料快取起來。具體可以看 WriteOrBuffer 這個方法的實現

可以看出這裡如果之前的寫入沒有完成,那麼會把需要寫入的資料快取起來。

這樣就不難理解了,如果我們採用限制讀取流的方案,那麼資料快取在讀取流的 Buffer 裡,如果我們採取不限制讀取流的方案,那麼資料快取在寫入流的 Buffer 裡,總之這部分資料都是要被快取,只是快取到不同的流的 Buffer 而已,所以這也能解釋了為啥我們的測試結果,記憶體佔用基本沒有差別了。

更好的解決方案

其實,Node.js 裡面提供了更好的解決方案,也就是 pipe。

我們將上面的程式碼繼續改造下:

是不是更簡單了呢?

我們看下官方程式碼是怎麼實現的(縮減版):

是不是和我們自己的實現差不多呢?當然官方程式碼處理的更加細緻,對很多情況做了判斷,感興趣的同學不妨閱讀看看:)

該怎麼用?

看到這裡,大家可能會問,那按照你這麼說,drain 就完全沒用了是麼?非也非也。雖然我一開始也是這麼想的,但經過我在 github 上一陣狂搜,終於有了一些端倪。

再回想我們剛才的案例,我們的寫入流是以 HTTP Response 的形式返回給使用者,但如果寫入流是我們的一個服務呢?比如我們需要往一臺 Redis Server 裡面插入大量資料,而這臺 Redis 又承擔對外提供服務的艱鉅任務,那麼,為了保證我們寫入的同時這臺 Server 依舊能較好的對外服務,很自然的就可以想到我們可以對寫入流做限流。比如 node_redis 這個模組裡面的一個 example

在這個例子中,當寫入流開始快取的時候,我們就停止寫入了,等 buffer flush 完,我們再繼續寫入。通過這種形式,我們可以控制傳送命令的速率。

如果你有更好的案例,歡迎與我一起探討。

小結

其實在我們平時寫程式碼的過程中,並不需要刻意用到 drain 事件。Node.js 本身已經幫我們處理了很多細節。對於流的處理,推薦大家直接用簡單方便的 pipe 方法。而drain 事件比較適合用在一些需要自己手工處理限流的場景。另外,在看 Node.js 原始碼的時候很多細節還是很讚的,推薦大家可以閱讀下。

註解

  1. 樸大師的《深入淺出 Node.js》6.4節

相關文章