Node.js精進(10)——性能監控(下)
本節會重點分析記憶體和進程奔潰,並且會給出相應的監控方法。
本系列所有的示例源碼都已上傳至Github,點擊此處獲取。
一、記憶體
雖然在 Node.js 中並不需要手動的對記憶體進行分配和銷毀,但是在開發中因為程式編寫問題也會發生記憶體泄漏的情況。
所以還是有必要了解一些 Node.js 開放的記憶體操作和常見的記憶體泄漏場景。
1)記憶體指標
Node.js 項目在啟動後(例如 node index.js),會創建一個服務進程。進程是具有獨立功能的程式在一個數據集合上運行的過程,它是系統進行資源分配和調度的一個獨立單位。
程式在運行時會被分配一些記憶體空間,這個空間稱為常駐記憶體(Resident Set),V8 會將記憶體分為幾個段(也叫存儲空間):
- 程式碼(Code):存儲可執行的程式碼。
- 棧(Stack):存儲原始類型的值(例如整數、布爾值等),以及對象的引用地址(指針)。
- 堆(Heap):存儲引用類型的值,例如對象、字元串和閉包。
在下圖中描繪了各個段,以及之間的關係。
Node.js 提供了 process.memoryUsage() 方法,用於讀取一個描述 Node.js 進程的記憶體使用量對象,所有屬性值都以位元組為單位。
- rss:resident set size (常駐記憶體大小)的縮寫,表示進程使用了多少記憶體(RAM中的物理記憶體),包括所有 C++ 和 JavaScript 對象和程式碼。
- heapTotal:堆的總大小,包括不能分配的記憶體,例如在垃圾回收之前對象之間的記憶體碎片。
- heapUsed:堆的使用量,已分配的記憶體,即堆中所有對象的總大小。
- external:使用到的系統鏈接庫所佔用的記憶體,包含 C++ 模組的記憶體使用量。
- arrayBuffers:為所有 Buffer 分配的記憶體,它被包含在 external 中。當 Node.js 被用作嵌入式庫時,此值可能為 0,在這種情況下可能不會追溯 ArrayBuffer 的分配。
下面的例子演示了本機的進程記憶體使用情況,默認都是位元組,為了便於閱讀,已將輸出結果換算成 MB。
// 換算成 MB function format (bytes) { return (bytes / 1024 / 1024).toFixed(2) + 'MB'; }; // 進程的記憶體使用 const mem = process.memoryUsage(); // 單位 位元組 // { // rss: '20.05MB', // heapTotal: '3.86MB', // heapUsed: '3.02MB', // external: '0.24MB', // arrayBuffers: '0.01MB' // } console.log({ rss: format(mem.rss), heapTotal: format(mem.heapTotal), heapUsed: format(mem.heapUsed), external: format(mem.external), arrayBuffers: format(mem.arrayBuffers) });
在 os 模組中,有兩個方法:freemem() 和 totalmem(),分別表示系統的空閑記憶體和總記憶體。
以本機為例,電腦的記憶體是 16G,因此總記憶體也是這個數,而系統的空閑記憶體會動態變化。
const os = require('os'); // 系統的空閑記憶體 const freemem = os.freemem(); format(freemem); // 178.58MB // 系統所有的記憶體 const totalmem = os.totalmem(); format(totalmem); // 16384.00MB = 16G
2)記憶體泄漏
記憶體泄漏(memory leak)是電腦科學中的一種資源泄漏,主因是程式的記憶體管理失當,因而失去對一段已分配記憶體的控制。
程式繼續佔用已不再使用的記憶體空間,或是存儲器所存儲對象無法透過執行程式碼而訪問,令記憶體資源空耗。下面會羅列幾種記憶體泄漏的場景:
第一種是全局變數,它不會被自動回收,而是會常駐在記憶體中,因為它總能被垃圾回收器訪問到。
第二種是閉包(closure),當一個函數能夠訪問和操作另一個函數作用域中的變數時,就會構成一個閉包,即使另一個函數已經執行結束,但其變數仍然會被存儲在記憶體中。
如果引用閉包的函數是一個全局變數或某個可以從根元素追溯到的對象,那麼就不會被回收,以後不再使用的話,就會造成記憶體泄漏。
第三種是事件監聽,如果對某個目標重複註冊同一個事件,並且沒有移除,那麼就會造成記憶體泄漏,之前記錄過一次這類記憶體泄漏的排查。
第四種是快取,當快取中的對象屬性越來越多時,長期存活的概率就越大,垃圾回收器也不會清理,部分不需要的對象就會造成記憶體泄漏。
3)heapdump
想要定位記憶體泄漏,可以使用快照工具(例如 heapdump、v8-profiler 等)導出記憶體快照,使用 DevTools 查看記憶體快照。
在下面的示例中,會在全局快取之前和之後導出一份記憶體快照。
const heapdump = require('heapdump'); // 記憶體泄漏前的快照 heapdump.writeSnapshot('prev.heapsnapshot'); // 全局快取 const cached = []; for(let i = 0; i < 10; i++) cached.push(new Array(1000000)); // 記憶體泄漏後的快照 heapdump.writeSnapshot('next.heapsnapshot');
得到文件後,打開 Chrome DevTools,選擇 Memory =》Profiles =》Load 載入記憶體快照。
默認是 Summary 視圖,顯示按構造函數名稱分組的對象,如下圖所示。
視圖中的欄位包括:
- Contructor:使用構造函數創建的對象,其中 (closure) 表示閉包。後面增加 * number 表示構造函數創建的實例個數。
- Distance:到 GC 根元素的距離,距離越大,引用越深。
- Shallow Size:對象自身的大小,即在 V8 堆上分配的大小,不包括它引用的對象。
- Retained Size:對象自身的大小和它引用的對象的大小,即可以釋放的記憶體大小。
切換到 Comparison 視圖,選擇比較的記憶體快照(next.heapsnapshot),檢查兩者的數據差異和記憶體變化,如下圖所示。
如果 Delta 一直增長,那麼需要特別注意,有可能發生了記憶體泄漏,視圖中的所有欄位說明如下所列:
- # New:新建的對象個數。
- # Deleted:刪除的對象個數。
- # Delta:發生變化的對象個數,凈增對象個數。
- Alloc.Size:已經分配的使用中的記憶體。
- Freed Size:為新對象釋放的記憶體。
- Size Delta:可用記憶體總量的變化,上圖中的數字是負數,說明可用記憶體變少了。
- Containment 視圖提供了一種從根元素作為入口的對象結構鳥瞰圖,如下圖所示。
打開 GC roots =》 Isolate =》 Array 可以看到在程式碼中插入給 cached 數組的 10 個元素。
要想能快速定位線上的記憶體泄漏,需要很多次的實踐,知道欄位含義僅僅是第一步。
還需要在這麼多資訊中,定位到問題程式碼所在的位置,這才是監控地最終目的。
二、Core Dump
Core Dump(核心轉儲)是作業系統在進程收到某些訊號而終止運行時,將此時進程地址空間的內容以及有關進程狀態的其他資訊寫入一個磁碟文件中。
在這個文件中包含記憶體分配資訊 、堆棧指針等關鍵資訊,對於診斷和分析程式異常非常重要,因為可以還原真實的案發現場。
1)lldb
本機是 Mac OS,默認自帶了 lldb 命令,先用此命令來載入和分析 Core Dump 文件。
首先要在終端放開 Core Dump 文件的大小限制,這樣才能成功生成,命令如下。
ulimit -c unlimited
但是一開始怎麼樣都生成不了,查了 Mac 官方文檔、stackoverflow 等各種網路資料都無濟於事。
後面自己才不經意的發現,這個命名只有在當前終端才有效,換個終端或 Tab 頁都將無效,白白浪費了 3 個小時。
然後創建 error.js 文件,裡面就寫一段會報錯的程式碼,例如讀取 undefined 的屬性。
const test = { }; setTimeout(() => { console.log(test.obj.name); }, 1000);
接著在終端輸入啟動的命令,但是需要帶上參數 –abort-on-uncaught-exception。
node --abort-on-uncaught-exception error.js
程式碼運行完成後,Mac OS 就會在 /cores 目錄中生成一個 core.[pid] 的文件,pid 就是當前進程的編號,通過 process.pid 也能讀取到。
在本地生成了一個 core.5889 文件,足足有 1.8G,怪不得不能隨便生成,硬碟吃不消。最後輸入 lldb 命令載入和分析文件。
lldb -c core.5889
在載入成功成功後,會有一段提示。在最後一行需要手動輸入 bt(backtrace)查看堆棧資訊。
上述是 C++ 的堆棧,可以看到 uv_run 開啟事件循環,然後運行 uv__run_timers 階段,接著就發生了錯誤,底層的錯誤內容看不大懂。
2)llnode
這個 llnode 其實是 lldb 的一個插件,能還原 JavaScript 堆棧幀、對象、源程式碼等可讀資訊,類似於 Source Map 的功能。
直接運行安裝命令 npm install llnode 會報錯,如下所示。
Reading lldb version... xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance Error: Command failed: xcodebuild -version xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance
查看官方文檔,在 Mac OS 中,需要安裝 LLDB 及其庫或者直接安裝 Xcode 並使用它附帶的 LLDB,前者的命令如下。
brew install --with-lldb --with-toolchain llvm
但是這條命令會報下面的錯誤,於是將 –with-lldb 參數去除。
Error: invalid option: --with-lldb
再運行一次,持續了一個小時,才下載 32%,最後又是報錯。
Error: invalid option: --with-toolchain
無奈就想到去安裝 Xcode,但是集成軟體太大,要 10G多,於是選擇 Command Line Tools (macOS 10.14) for Xcode 10.1,下載了 20 多分鐘。
安裝完成後,還是無法下載 llnode 包,只得去下載 Xcode 10.1,又是 20 多分鐘,Xcode_10.1.xip 是一個壓縮包,需要解壓。
解壓安裝完成後,將當前目錄的 Xcode 移動到應用程式目錄,運行下面命令。
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
重新下載 llnode 包,這次終於不報錯了,開始出現下面的提示。
Looking for llvm-config... ⠹ [0/1] Installing llnode@*No llvm-config found Reading lldb version... ⠼ [0/1] Installing llnode@*Deduced lldb version from Xcode version: Xcode 10.1 -> lldb 3.9 Installing llnode for lldb, lldb version 3.9 Looking for headers for lldb 3.9... Could not find the headers, will download them later Looking for shared libraries for lldb 3.9... Could not find the shared libraries llnode will be linked to the LLDB shared framework from the Xcode installation
因為沒有全局安裝 llnode,所以載入命令要加 npx,core.5889 加了絕對路徑。
npx llnode -c /cores/core.5889
下圖是成功載入後的圖,運行 v8 bt 命令後,並沒有得到預期的堆棧資訊。
過程非常曲折,最後還是很遺憾沒有成功解析,不知道是 lldb 的問題還是生成的文件問題,亦或是 Node 版本的問題。
如果不想這麼麻煩的解析,還可以直接使用成熟的 Node.js 性能平台,也有 Coredump 文件分析,並且做了深度訂製,能更清晰地看到錯誤源碼。
參考資料:
Nodejs: MemoryUsage()返回的rss,heapTotal,heapUsed,external的含義和區別
What do the return values of node.js process.memoryUsage() stand for?
如何分析 Node.js 中的記憶體泄漏 Node.js 應用故障排查手冊
Chrome Memory Tab: Learn to Find JavaScript Memory Leaks
Node 案發現場揭秘 —— Coredump 還原線上異常
Node.js調試之llnode篇 Node調試指南-uncaughtException
Explore Node.js core dumps using the llnode plugin for lldb
v8 source list always fails w/ error: USAGE: v8 source list