如何除錯 Node.js的記憶體洩露

至秦發表於2016-06-24

在產品應用程式中,記憶體洩露是很常見的。幸運的是通常不難發現它們。 接下來是一個練習的演練,這個練習是Igor Soarez 和我最近在 WDCNZ 上所教授 Node.js 效能專題課程的一部分。

問題

我們有一個服務執行在一個反向波蘭表示法計算器(RPN,Reverse Polish Notation)的產品上,這個產品是基於 WebSockets 實現的。在這個程式的生命週期裡,記憶體使用看上去不斷地增長,尤其明顯的是當我們使用這個服務時,記憶體使用會出現一個尖峰。

這個服務的原始碼可以在我們 Github 的 calc-server 下找到。你可能僅通過檢查這段很短的程式碼就可以找到這個記憶體洩露,但我們的想法是不閱讀程式碼就能確定這個洩露。

快速地看一下 client.js 以便知道客戶端是如何使用我們的服務。

在繼續確認問題和分析程式前,我們先在本地安裝和執行伺服器。 我們利用 heapdump 來分析 Node 程式裡的堆,以便找到一個解決方法。

確認診斷結果

檢查記憶體的使用

在產品中,你可以使用一個應用程式效能管理(APM,Application Performance Management)方案去監控 RAM 使用,如果出現問題它會提醒你。

在這個練習中,我們將結合使用古老的 ps 和 top,以及一些用來引發問題的負載檢測方法,以便我們證實問題的存在(檢查是程式碼中的哪些改動造成這樣的預期效果)。

使用 ps 來檢查程式的記憶體使用情況:

(使用 pgrep -lfa node,你可以找到 Node.js 程式的 PID)

examining memory usage of a Node process with ps

這告訴我們程式的駐留集大小和虛擬記憶體大小。(譯者注:RSS,即程式所使用的非交換區的實體記憶體)

RSS 用來表示這個程式當前正在使用的 RAM 大小。包含所有的棧和堆記憶體,也會包含共享庫的記憶體,只要那些庫的頁面實際上是在記憶體中。

VSZ 表示這個程式上有多少記憶體可以用。包含交換區的記憶體和所有的共享庫。VSZ 包括 RSS,而且通常比較大。

我們可以使用 top 來觀察記憶體使用的實時情況:

examining memory usage of a Node process with topOSX 說明:OSX 即使在有大量空閒 RAM 可用時,也會積極地壓縮它認為“不活動”程式的記憶體頁面。這可能會導致對於一個空閒的 Node.js 程式, ps 和 top 顯示記憶體佔用較小,一旦這個程式重新開始做事情,記憶體佔用就會激增。

(在Node 程式中呼叫 process.memmoryUsage 函式也可以測量 RAM 使用情況,但是我們不準備使用這種方法。)

測量記憶體使用的題外話

現代作業系統的記憶體管理是相當複雜的,對於“我的程式使用了多少記憶體”這個問題,並沒有一個簡單的答案。

這裡我們要尋找的是確認記憶體使用在負載時仍然在增長 —— 而不是使用記憶體的準確數量。

擴充套件閱讀:

確認增長

下一步就是要在伺服器上增加一些負載來確認記憶體的增加。我們使用 Mingigun,一個簡單卻強大的負載測試工具(實際上由你自己開發)來做這件事。

我們的負載測試指令碼(包含在 test.json 這個repo中)每秒將建立10個新的使用者會話,一共持續120秒的時間。每個使用者都呼叫我們的服務去進行兩個數字的加法操作:

執行下面指令碼,安裝 Minigun:

然後執行它:

當測試執行的時候,使用 top 監控你的 cal-server。我們應該看到在兩分鐘裡記憶體使用量穩定地增長。

在我電腦上,記憶體使用量在 Node 執行後,從 14 MB 增加到 38 MB。重新多次執行這個指令碼,記憶體使用量增加到 110 MB。好吧,休斯頓,我們的確有個問題。

題外話:讀者的練習

我們能確認存在一個記憶體洩露嗎?會不會僅僅因為系統還有很多空閒的記憶體,所以垃圾收集器就不再進行收集?

我們可以通過如下步驟強制執行垃圾收集來加以確認:

  1. 使用 –expose-gc 標記執行伺服器:node –expose-gc server.js 這讓 JS 程式碼中 gc() 函式可用,可以強制進行收集。
  2. 用 process.on 建立一個 SIGUSR2 的處理程式,process.on會呼叫gc()。
  3. 重新執行負載測試,通過下面命令讓程式執行垃圾收集 kill -SIGUSR2 $(pgrep -lfa node | grep server.js | awk ‘{print $1}’),看會有什麼不一樣。

堆分析

heapdump 模組讓我們對記憶體中的物件進行快照。接著我們可以使用 Chrome 開發工具來仔細檢視它們以便找到記憶體洩露的物件型別,這樣將幫助我們查明應用程式中有問題的程式碼。

我們採取如下步驟:

  1. 在程式啟動後使用 heapdump —— 這作為我們的基準快照
  2. 執行一個負載測試程式來引起記憶體的增加
  3. 再一次進行堆快照。這個快照和基準快照的差別就是那些被掛起的物件不能被 GC 回收再利用。

用 heapdump 進行快照

首先,我們用 npm install heapdump 安裝 heapdump,server.js 裡會使用到它(或者在應用程式的 index.js 裡)。

接著我們可以傳送 SIGUSR2 給 Node 程式,這樣將會把一個 heap 快照寫到程式的工作路徑下(一個名字類似 heapdump-706203888.138768.heapsnapshot的檔案)

在這個練習中,我有三個快照:(1)伺服器剛啟動的;(2)負載測試過程中的;(3)負載測試結束後的。

說明

某些版本的 Node 或 Io.js,和 Chrome 有一些已知的相容性問題,開發工具不能正確計算保留的大小和完整顯示保留樹。我使用的是 Node 0.12.7 和 Chrome 42,如果你偶爾遇到類似問題你可以升級 Node 或者使用 nvm。

另一個可能需要注意的問題是,當進行快照時,你係統中的可用 RAM 需要有 2 個 heap 大小,否則你將看到空的 heap 快照檔案或者 記憶體耗盡的訊息(OOM,Out of Memory)。

使用 Chrome 開發工具

一旦我們有了快照,就可以使用開發工具進行分析。

Chrome DevTools memory profile

一旦載入上,我們就可以觀察堆。

像保留數量這些不同的術語,可以參考如下內容:

heap snapshot in Chrome DevTools

我們想使用比較檢視來進一步定位記憶體使用量增加的原因。

heap snapshot difference

這個檢視告訴我們和剛開始的快照相比,我們有 813 個新的 smalloc 型別物件,它們一共佔用了 12.4 MB。

我們對於“保留數量”和“# 新的” 這兩列很感興趣 —— 這個例子中我選擇關注 smalloc,因為這些物件和記憶體增長有關。

object's retaining tree

深入研究這些物件的其中一個,我們可以推出 cleanup() 函式是用來監聽 SIGINT 事件的,涉及到一個叫做 clients 的陣列,它是儲存WebSocket 連線的。SIGINT (和 SIGTERM)是讓程式退出的訊號,是由程式的管理者發出的,類似 Upstart 或者 init(或者在終端按下 Ctrl+C)。這個例子裡,cleanup() 函式在程式退出前斷開所有連線的 WebSocket 客戶端。我們猜測在進行堆快照時,伺服器有 813 個活動的 WebSocket 連線。

負載測試結束後,看看堆是什麼樣子的:

heap snapshot difference after load-test

糟了,看上去不妙。WebSocket 連線的引用數目已經增加到1649個,這隻有在仍有客戶端連線到伺服器的時候才算合理,但是因為(a)在我們快照前,負載測試已經結束;(b)在快照被寫入前,GC 已經執行了,我們知道在程式碼中(我們可以開始檢視了)有些斷開連線的引用沒有被去除。

修正這個問題就作為一個練習留給讀者。:)

備註:如果Node.js 效能是一個深入你內心的主題,你可能會感興趣知道 Igor Soarez 和 我碰巧正在寫一本這方面的書。請前往 nodeperformance.com 登記預覽(暫定在這個秋天)。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

如何除錯 Node.js的記憶體洩露 如何除錯 Node.js的記憶體洩露

相關文章