在 GitHub 上玩耍至今,我發現很多時候可讀性帶來的助益,可以比效能的改進更為優先,尤其仍處於早期開發的項目,人們總是說不要過早進行最佳化的調適。

在 JS 的領域,許多最佳化一直仰賴著底層 JS 引擎的努力,例如 Chrome 系列的 V8 引擎,並且有著快速模式與最佳化模式的區別。

記憶體傾漏(Memory Leak)在 JS 專案中仍然不容小覷,拜 JS 的生態更新頻繁所賜,ES6 開始許多的語法糖或是新的介面、API,還有函式語言程式設計(Functional Programming)概念的興起,不外乎是希望能減少發生問題的機會,相比 ES5 之前的語法更為成熟、內斂。

曾經令 JS 開發者頭疼的回呼地獄(Callback Hell)[1]如今也有很多的解套,以往宣告變數所用的 var 所帶來的許多無法預料的影響也因 letconst 的出現,使得變數視野(Variable Scope)更易於掌握,var 無法預期的行為其實也和閉包(Closure)拖不了關係,而到了今天,閉包依舊與記憶體傾漏息息相關。

本文為了記一次抓漏的過程。

動機

最近研究了 RxJS 和函式語言程式設計的概念,於是對我的專案 node-cq-websocket 展開了反省,或許也有一點心思是想將學習到的新東西實現在這個專案,之後會在另一篇文中說明一下我計畫改動的幅度,牽涉許多公有 API 的變動,有些變動是為了鼓勵使用者對接函式語言程式設計相關的框架,如 RxJSHighland.js,有些則是修復問題,如原先自己實作的 EventBus 充斥著記憶體傾漏的問題[2]

什麼是記憶體傾漏?

記憶體使用量隨著時間過去程式持續運行而逐步成長,並且許多不再使用的記憶體區塊卻無法藉著垃圾回收(Garbage Collection)的機制而釋放,這便是記憶體傾漏問題。

V8 引擎的記憶體配置

Node.js memory organization

圖片引用自 https://www.valentinog.com/blog/memory-usage-node-js.

以下的名詞解釋,也可以自行參閱 NodeJS 的官方文件中,process.memoryUsage() 方法的描述。[3]

常駐集(Resident Set,RSS)[4]所表現的是一支程式所使用的記憶體配置,其中分為三個部分:

  • 棧(Stack):存放變數的地方。[5] [6]
  • 堆積(Heap):存放物件(Object)、字串(String)、閉包的地方。[6:1] [7]
  • 程式碼段(Code Segment):存放程式碼本身的地方。[8]

記憶體傾漏檢測

在 NodeJS 專案的記憶體傾漏方面,我們關注的焦點是 JS Heap 中是否有不再需要卻沒被 GC 的東西。

我選擇使用的是 leakage 這個模塊。

leakage 模塊推薦使用像是 mocha 或者 tape 這類簡單的測試框架以降噪,避免誤判。我的選擇是 tape,因為比起 mocha 它更為簡單,而且 API 設計上必須透過正常引用的方式(require())使用,而 mocha 的 API 則是直接提供給你全域變數,存在命名汙染的可能,因此沒必要我不會選擇 mocha。(雖然 mocha 的生態也很廣泛,它應該是社群最大的 NodeJS 測試框架)

安裝 leakage 的坑

由於 leakage 內部使用了 node-memwatch 由 Airbnb 所維護的分支(@airbnb/node-memwatch),在 postinstall 的腳本中,它會嘗試使用 node-gyp 建置一個 Node Addon。

然而這個步驟在 Windows 系統上會失敗,可以參考 andywer/leakage#33。原因是 Airbnb 在這個 C++ 撰寫的 Addon 中添加了 Windows 上所沒有的 sys/time.h 這個庫導致的引用失敗。[9]

我並不具備如何讓 Windows 也擁有 sys/time.h 的知識,因此我選擇換個環境,找個可以跑 Docker 的機器跑一個 node 的容器來執行記憶體傾漏的檢測作業。

測試程式

檢測用程式碼不難理解,可以在 node-cq-websocket v2 分支下的 performance/leakage/ 這個資料夾中找到。

下面兩個檔案,其中一個測試 once() 是不是真的會自己清除處理器(Handler),並且偵測傾漏狀況;另一個改為測試 on()off()(NodeJS 8 中的 EventEmitter 要用 removeAllListener()),一樣測試是否回收乾淨、判斷傾漏狀況。

每一個檔案中都包含兩個測試,第一個測試為對照組,採用原生的 EventEmitter 設計的,另一個是實驗組,是一個 CQWebSocket 的實例,這個實例包含了我自己實現的 EventBus 這個擬 EventEmitter 的類別。

結果

EventEmitter 不管在哪個測試中,均沒有傾漏的問題。

至於我的 EventBus,once() 的部分,每 180 次運行會有 342 KB 的記憶體成長;on()off() 的部分,每 180 次會有 305 KB 的記憶體成長。

記憶體傾漏確認。🐛

小結

在設計上面的測試實驗中,曾經一度設計錯誤,導致出來的結果是 EventEmitter 的部分也有傾漏問題,原因是我讓 once() 的處理器做了斷言(Assertion),更詳細一點,是 t.pass() 這樣的斷言。

然而斷言具有副作用,因為測試框架最後勢必給你一個交代,本次測試的結果怎麼樣怎麼樣的,所以斷言肯定會在測試框架上留下紀錄,產生副作用,導致記憶體使用增加,於是就被 leakage 判定傾漏了。

在修正這個行為後,說來慚愧,測試結果本牛設計的 EventBus 依然存在著傾漏問題。

以上若有任何問題或謬誤,包含但不限於測試碼、邏輯、觀念,歡迎指正,謝謝閱讀!


  1. Callback Hell ↩︎

  2. momocow/node-cq-websocket#63 ↩︎

  3. process.memoryUsage() ↩︎

  4. Resident Set 的翻譯來自維基百科「分頁」↩︎

  5. 維基百科「堆疊」↩︎

  6. Stack and heap in V8 (JavaScript) ↩︎ ↩︎

  7. 維基百科「堆積」↩︎

  8. 維基百科「程式碼段」↩︎

  9. Git 比較: Airbnb 在 node-memewatch 中添加 sys/time.h 引用↩︎