Node.js精進(10)——效能監控(下)

2022-07-18 09:01:11

  本節會重點分析記憶體和程序奔潰,並且會給出相應的監控方法。

  本系列所有的範例原始碼都已上傳至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 檔案分析,並且做了深度客製化,能更清晰地看到錯誤原始碼。

 

參考資料:

Node.js 環境效能監控探究

Nodejs: MemoryUsage()返回的rss,heapTotal,heapUsed,external的含義和區別

What do the return values of node.js process.memoryUsage() stand for? 

如何分析 Node.js 中的記憶體漏失  Node.js 應用故障排查手冊

Record heap snapshots

前端記憶體洩露淺析  Node常用dump分析

Chrome Memory Tab: Learn to Find JavaScript Memory Leaks

Node 案發現場揭祕 —— Coredump 還原線上異常

Node.js偵錯之llnode篇  Node偵錯指南-uncaughtException

coredump lldb常用命令與偵錯技巧

node常用dump分析 Node偵錯指南-記憶體篇

Explore Node.js core dumps using the llnode plugin for lldb

v8 source list always fails w/ error: USAGE: v8 source list