Node.js 中記憶體洩漏分析

lellansin發表於2017-03-15

記憶體洩漏(Memory Leak)指由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體的情況。如果記憶體洩漏的位置比較關鍵,那麼隨著處理的進行可能持有越來越多的無用記憶體,這些無用的記憶體變多會引起伺服器響應速度變慢,嚴重的情況下導致記憶體達到某個極限(可能是程式的上限,如 v8 的上限;也可能是系統可提供的記憶體上限)會使得應用程式崩潰。

傳統的 C/C++ 中存在野指標,物件用完之後未釋放等情況導致的記憶體洩漏。而在使用虛擬機器執行的語言中如 Java、JavaScript 由於使用了 GC (Garbage Collection,垃圾回收)機制自動釋放記憶體,使得程式設計師的精力得到的極大的解放,不用再像傳統語言那樣時刻對於記憶體的釋放而戰戰兢兢。

但是,即便有了 GC 機制可以自動釋放,但這並不意味這記憶體洩漏的問題不存在了。記憶體洩漏依舊是開發者們不能繞過的一個問題,今天讓我們來了解如何分析 Node.js 中的記憶體洩漏。

GC in Node.js

Node.js 使用 V8 作為 JavaScript 的執行引擎,所以討論 Node.js 的 GC 情況就等於在討論 V8 的 GC。在 V8 中一個物件的記憶體是否被釋放,是看程式中是否還有地方持有改物件的引用。

在 V8 中,每次 GC 時,是根據 root 物件 (瀏覽器環境下的 window,Node.js 環境下的 global ) 依次梳理物件的引用,如果能從 root 的引用鏈到達訪問,V8 就會將其標記為可到達物件,反之為不可到達物件。被標記為不可到達物件(即無引用的物件)後就會被 V8 回收。更多細節,可以參見 alinode 的 解讀 V8 GC

瞭解上述的點之後,你就會知道,在 Node.js 中記憶體洩露的原因就是本該被清除的物件,被可到達物件引用以後,未被正確的清除而常駐記憶體。

記憶體洩漏的幾種情況

一、全域性變數

a = 10;
//未宣告物件。

global.b = 11;
//全域性變數引用

這種比較簡單的原因,全域性變數直接掛在 root 物件上,不會被清除掉。

二、閉包

function out() {
  const bigData = new Buffer(100);
  inner = function () {
    void bigData;
  }
}

閉包會引用到父級函式中的變數,如果閉包未釋放,就會導致記憶體洩漏。上面例子是 inner 直接掛在了 root 上,那麼每次執行 out 函式所產生的 bigData 都不會釋放,從而導致記憶體洩漏。

需要注意的是,這裡舉得例子只是簡單的將引用掛在全域性物件上,實際的業務情況可能是掛在某個可以從 root 追溯到的物件上導致的。

三、事件監聽

Node.js 的事件監聽也可能出現的記憶體洩漏。例如對同一個事件重複監聽,忘記移除(removeListener),將造成記憶體洩漏。這種情況很容易在複用物件上新增事件時出現,所以事件重複監聽可能收到如下警告:

(node:2752) Warning: Possible EventEmitter memory leak detected。11 haha listeners added。Use emitter。setMaxListeners() to increase limit

例如,Node.js 中 Agent 的 keepAlive 為 true 時,可能造成的記憶體洩漏。當 Agent keepAlive 為 true 的時候,將會複用之前使用過的 socket,如果在 socket 上新增事件監聽,忘記清除的話,因為 socket 的複用,將導致事件重複監聽從而產生記憶體洩漏。

原理上與前一個新增事件監聽的時候忘了清除是一樣的。在使用 Node.js 的 http 模組時,不通過 keepAlive 複用是沒有問題的,複用了以後就會可能產生記憶體洩漏。所以,你需要了解新增事件監聽的物件的生命週期,並注意自行移除。

關於這個問題的例項,可以看 Github 上的 issues(node Agent keepAlive 記憶體洩漏

四、其他原因

還有一些其他的情況可能會導致記憶體洩漏,比如快取。在使用快取的時候,得清楚快取的物件的多少,如果快取物件非常多,得做限制最大快取數量處理。還有就是非常佔用 CPU 的程式碼也會導致記憶體洩漏,伺服器在執行的時候,如果有高 CPU 的同步程式碼,因為Node.js 是單執行緒的,所以不能處理處理請求,請求堆積導致記憶體佔用過高。

定位記憶體洩漏

一、重現記憶體洩漏情況

想要定位記憶體洩漏,通常會有兩種情況:

  1. 對於只要正常使用就可以重現的記憶體洩漏,這是很簡單的情況只要在測試環境模擬就可以排查了。
  2. 對於偶然的記憶體洩漏,一般會與特殊的輸入有關係。想穩定重現這種輸入是很耗時的過程。如果不能通過程式碼的日誌定位到這個特殊的輸入,那麼推薦去生產環境列印記憶體快照了。需要注意的是,列印記憶體快照是很耗 CPU 的操作,可能會對線上業務造成影響。

快照工具推薦使用 heapdump 用來儲存記憶體快照,使用 devtool 來檢視記憶體快照。使用 heapdump 儲存記憶體快照時,只會有 Node.js 環境中的物件,不會受到干擾(如果使用 node-inspector 的話,快照中會有前端的變數干擾)。

PS:安裝 heapdump 在某些 Node.js 版本上可能出錯,建議使用 npm install heapdump -target=Node.js 版本來安裝。

二、列印記憶體快照

將 heapdump 引入程式碼中,使用 heapdump.writeSnapshot 就可以列印記憶體快照了了。為了減少正常變數的干擾,可以在列印記憶體快照之前會呼叫主動釋放記憶體的 gc() 函式(啟動時加上 –expose-gc 引數即可開啟)。

const heapdump = require('heapdump');

const save = function () {
  gc();
  heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');
}

在列印線上的程式碼的時候,建議按照記憶體增長情況來列印快照。heapdump 可以使用 kill 向程式傳送訊號來列印記憶體快照(只在 *nix 系統上提供)。

kill -USR2 <pid>

推薦列印 3 個記憶體快照,一個是記憶體洩漏之前的記憶體快照,一個是少量測試以後的記憶體快照,還有一個是多次測試以後的記憶體快照。

第一個記憶體快照作為對比,來檢視在測試後有哪些物件增長。在記憶體洩漏不明顯的情況下,可以與大量測試以後的記憶體快照對比,這樣能更容易定位。

三、對比記憶體快照找出洩漏位置

通過記憶體快照找到數量不斷增加的物件,找到增加物件是被誰給引用,找到問題程式碼,改正之後就行,具體問題具體分析,這裡通過我們在工作中遇到的情況來講解。

const {EventEmitter} = require('events');
const heapdump = require('heapdump');

global.test = new EventEmitter();
heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');

function run3() {
  const innerData = new Buffer(100);
  const outClosure3 = function () {
    void innerData;
  };
  test.on('error', () => {
    console.log('error');
  });
  outClosure3();
}

for(let i = 0; i < 10; i++) {
  run3();
}
gc();

heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');

這裡是對錯誤程式碼的最小重現程式碼。

首先使用 node –expose-gc index.js 執行程式碼,將會得到兩個記憶體快照,之後開啟 devtool,點選 profile,載入記憶體快照。開啟對比,Delta 會顯示物件的變化情況,如果物件 Delta 一直增長,就很有可能是記憶體洩漏了。

可以看到有三處物件明顯增長的地方,閉包、上下文以及 Buffer 物件增長。點選檢視一下物件的引用情況:

其實這三處物件增長都是一個問題導致的。test 物件中的 error 監聽事件中閉包引用了 innerData 物件,導致 buffer 沒有被清除,從而導致記憶體洩漏。

其實這裡的 error 監聽事件中沒有引用 innerData 為什麼會閉包引用了 innerData 物件,這個問題很是疑惑,後來弄清是 V8 的優化問題,在文末會額外講解一下。對於對比快照找到問題,得看你對程式碼的熟悉程度,還有眼力了。

如何避免記憶體洩漏

文中的例子基本都可以很清楚的看出記憶體洩漏,但是在工作中,程式碼混合上業務以後就不一定能很清楚的看出記憶體洩漏了,還是得依靠工具來定位記憶體洩漏。另外下面是一些避免記憶體洩漏的方法。

  1. ESLint 檢測程式碼檢查非期望的全域性變數。
  2. 使用閉包的時候,得知道閉包了什麼物件,還有引用閉包的物件何時清除閉包。最好可以避免寫出複雜的閉包,因為複雜的閉包引起的記憶體洩漏,如果沒有列印記憶體快照的話,是很難看出來的。
  3. 繫結事件的時候,一定得在恰當的時候清除事件。在編寫一個類的時候,推薦使用 init 函式對類的事件監聽進行繫結和資源申請,然後 destroy 函式對事件和佔用資源進行釋放。

額外說明

在做了很多測試以後得到下面關於閉包的總結。

class Test{};
global.test = new Test()
function run5(bigData) {
  const innerData = new Buffer(100);

  // 被閉包引用,建立一個 context: context1。
  // context1 引用 bigData,innerData。
  // closure 為 function run5()
  // run5函式沒有 context,所以 context1 沒有previous。
  // 在 run5中新建的函式將繫結上 context1。

  test.outClosure5 = function () {

    // 此函式閉包 context 指向 context1。

    void bigData;
    const closureData = new Buffer(100);

    // 被閉包使用,建立 context: context2。
    // outClosure5 函式有 context1,previous 指向 context1。
    // 在 outClosure5 中新建的函式將繫結上context2。

    test.innerClosure5 = function () {

      // 此函式閉包 context 指向 context2。

      void innerData;
    }
    test.innerClosure5_1 = function () {

      // 此函式閉包 context 指向 context2。

      void closureData;
    }
  };
  test.outClosure5_1 = function () {

  }
  test.outClosure5();
}

run5(new Buffer(1000));

V8 會生成一個 context 內部物件來實現閉包。下面是 V8 生成 context 的規則。

V8 會在被閉包引用變數宣告處建立一個 context2,如果被閉包的變數所在函式擁有 context1 ,則建立的 context2 的 previous指向函式 context1。在被閉包引用變數的函式內新建的函式將會繫結上 context2。

由於這個和 V8版本相關,這裡只測試了 v6.2.2 和 v6.10.1 還有 v7.7.1,都是相同的情況。如果想實踐測試可以在這個 repo 上了解更多。

相關文章