Debugging Memory Leaks in Node.js Applicationsjavascript
Node.js 是一個基於 Chrome 的 V8 JavaScript 引擎構建的平臺,用於輕鬆構建快速且可擴展的網絡應用程序。java
Google 的 V8 ——Node.js 背後的 JavaScript 引擎, 它的性能使人難以置信,而且 Node.js 在許多用例中運行良好的緣由有不少,但您老是受到堆大小的限制。 當您須要在 Node.js 應用程序中處理更多請求時,您有兩種選擇:垂直擴展或者水平擴展。 水平擴展意味着您必須運行更多併發應用程序實例。 若是作得好,您最終可以知足更多請求。 垂直擴展意味着您必須提升應用程序的內存使用和性能或增長應用程序實例可用的資源。node
Node.js Memory Leak Debugging Arsenal若是您搜索「如何在 node.js 中查找泄漏」,您可能會找到的第一個工具是 memwatch。 原來的包早就廢棄了,再也不維護。 可是,您能夠在 GitHub 的存儲庫分叉列表中輕鬆找到它的更新版本。 這個模塊頗有用,由於它能夠在看到堆增加超過 5 次連續垃圾收集時發出泄漏事件。算法
很棒的工具,它容許 Node.js 開發人員拍攝堆快照並在之後使用 Chrome 開發人員工具檢查它們。數組
甚至是 heapdump 的更有用的替代方案,由於它容許您鏈接到正在運行的應用程序,進行堆轉儲,甚至能夠即時調試和從新編譯它。瀏覽器
Taking 「node-inspector」 for a Spin不幸的是,您將沒法鏈接到在 Heroku 上運行的生產應用程序,由於它不容許將信號發送到正在運行的進程。 然而,Heroku 並非惟一的託管平臺。緩存
爲了體驗 node-inspector 的實際操做,咱們將使用 restify 編寫一個簡單的 Node.js 應用程序,並在其中放置一些內存泄漏源。 這裏全部的實驗都是用 Node.js v0.12.7 進行的,它是針對 V8 v3.28.71.19 編譯的。網絡
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });
這裏的應用很簡單,有很明顯的泄露。 陣列任務會隨着應用程序生命週期的增加而增加,致使它變慢並最終崩潰。 問題是咱們不只泄漏了閉包,還泄漏了整個請求對象。session
V8 中的 GC 使用 stop-the-world 策略,所以這意味着內存中的對象越多,收集垃圾所需的時間就越長。 在下面的日誌中,您能夠清楚地看到,在應用程序生命週期開始時,收集垃圾平均須要 20 毫秒,但幾十萬個請求以後須要大約 230 毫秒。 因爲 GC,試圖訪問咱們應用程序的人如今必須等待 230 毫秒。 您還能夠看到每隔幾秒就會調用一次 GC,這意味着每隔幾秒用戶就會在訪問咱們的應用程序時遇到問題。 延遲會愈來愈大,直到應用程序崩潰。閉包
當使用 –trace_gc 標誌啓動 Node.js 應用程序時,會打印這些日誌行:
node --trace_gc app.js
讓咱們假設咱們已經使用這個標誌啓動了咱們的 Node.js 應用程序。 在將應用程序與節點檢查器鏈接以前,咱們須要將 SIGUSR1 信號發送給正在運行的進程。 若是您在集羣中運行 Node.js,請確保您鏈接到從屬進程之一。
kill -SIGUSR1 $pid # Replace $pid with the actual process ID
經過這樣作,咱們使 Node.js 應用程序(準確地說是 V8)進入調試模式。 在此模式下,應用程序會使用 V8 調試協議自動打開端口 5858。
咱們的下一步是運行 node-inspector,它將鏈接到正在運行的應用程序的調試界面,並在端口 8080 上打開另外一個 Web 界面。
$ node-inspector
Node Inspector v0.12.2
Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.
若是應用程序在生產環境中運行而且您有防火牆,咱們能夠經過隧道將遠程端口 8080 鏈接到本地主機:
ssh -L 8080:localhost:8080 admin@example.com
如今,您能夠打開 Chrome 網絡瀏覽器並徹底訪問附加到遠程生產應用程序的 Chrome 開發工具。
Let’s Find a Leak!V8 中的內存泄漏並非咱們從 C/C++ 應用程序中知道的真正的內存泄漏。 在 JavaScript 中,變量不會成爲 void,它們只會被「遺忘」。 咱們的目標是找到這些被開發人員遺忘的變量。
在 Chrome 開發者工具中,咱們能夠訪問多個分析器。 咱們對記錄堆分配特別感興趣,它會隨着時間的推移運行並拍攝多個堆快照。 這讓咱們能夠清楚地看到哪些對象正在泄漏。
開始記錄堆分配,讓咱們使用 Apache Benchmark 在咱們的主頁上模擬 50 個併發用戶。
ab -c 50 -n 1000000 -k http://example.com/
在拍攝新快照以前,V8 會執行標記-清除垃圾收集,因此咱們確定知道快照中沒有舊垃圾。
Fixing the Leak on the Fly在 3 分鐘內收集堆分配快照後,咱們最終獲得以下結果:
咱們能夠清楚地看到,堆中有一些巨大的數組,還有不少 IncomingMessage、ReadableState、ServerResponse 和 Domain 對象。讓咱們嘗試分析泄漏的來源。
在圖表上從 20 秒到 40 秒選擇堆差別後,咱們只會看到從您啓動分析器時起 20 秒後添加的對象。這樣您就能夠排除全部正常數據。
記下系統中每種類型的對象有多少,咱們將過濾器從 20 秒擴展到 1 分鐘。咱們能夠看到,已經至關龐大的陣列還在不斷增加。在「(array)」下咱們能夠看到有不少等距的對象「(object properties)」。這些對象是咱們內存泄漏的源頭。
咱們也能夠看到「(閉包)」對象也在快速增加。
查看字符串也可能很方便。在字符串列表下有不少「Hi Leaky Master」短語。這些也可能給咱們一些線索。
在咱們的例子中,咱們知道字符串「Hi Leaky Master」只能在「GET /」路由下組裝。
若是您打開保留器路徑,您將看到此字符串以某種方式經過 req 引用,而後建立了上下文並將全部這些添加到一些巨大的閉包數組中。
因此在這一點上咱們知道咱們有某種巨大的閉包數組。 讓咱們在「源」選項卡下實時爲全部閉包命名。
完成代碼編輯後,咱們能夠按 CTRL+S 來保存和從新編譯代碼!
如今讓咱們記錄另外一個堆分配快照,看看哪些閉包正在佔用內存。
很明顯 SomeKindOfClojure() 是咱們的 target。 如今咱們能夠看到 SomeKindOfClojure() 閉包被添加到全局空間中一些名爲任務的數組中。
很容易看出這個數組是沒有用的。 咱們能夠註釋掉。 可是咱們如何釋放已經佔用的內存呢? 很簡單,咱們只需爲任務分配一個空數組,下一次請求時它將被覆蓋並在下一次 GC 事件後釋放內存。
V8堆分爲幾個不一樣的空間:
每一個空間由頁面組成。頁面是從操做系統使用 mmap 分配的內存區域。除了大對象空間中的頁面外,每一個頁面的大小始終爲 1MB。
V8 有兩個內置的垃圾收集機制:Scavenge、Mark-Sweep 和 Mark-Compact。
Scavenge 是一種很是快速的垃圾收集技術,能夠處理 New Space 中的對象。 Scavenge 是切尼算法的實現。這個想法很簡單,New Space 被分紅兩個相等的半空間:To-Space 和 From-Space。當 To-Space 已滿時,會發生 Scavenge GC。它只是交換 To 和 From 空間並將全部活動對象複製到 To-Space 或將它們提高到舊空間之一,若是它們在兩次清除中倖存下來,而後從空間中徹底刪除。清理速度很是快,可是它們具備保持雙倍大小的堆和不斷在內存中複製對象的開銷。使用清除的緣由是由於大多數對象都很年輕。
Mark-Sweep 和 Mark-Compact 是 V8 中使用的另外一種類型的垃圾收集器。另外一個名稱是 full garbage collector. 它標記全部活動節點,而後清除全部死節點並整理內存碎片。
GC Performance and Debugging Tips雖然對於 Web 應用程序來講,高性能可能不是什麼大問題,但您仍然但願不惜一切代價避免泄漏。 在 full GC 的標記階段,應用程序實際上會暫停,直到垃圾收集完成。 這意味着堆中的對象越多,執行 GC 所需的時間就越長,用戶等待的時間也就越長。
當全部閉包和函數都有名稱時,檢查堆棧跟蹤和堆會容易得多。
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })
理想狀況下,您但願避免在 hot function 內部使用大對象,以便全部數據都適合新空間。 全部 CPU 和內存綁定操做都應在後臺執行。 還要避免 hot function 的去優化觸發器,優化的 hot function 比未優化的 hot function 使用更少的內存。
內聯緩存 ( Inline Caches ) 用於經過緩存對象屬性訪問 obj.key 或某些簡單函數來加速某些代碼塊的執行。
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, 「string」); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3
當 x(a,b) 第一次運行時,V8 建立了一個單態 IC。 當您第二次調用 x 時,V8 會擦除舊 IC 並建立一個新的多態 IC,該 IC 支持整數和字符串兩種類型的操做數。 當您第三次調用 IC 時,V8 重複相同的過程並建立另外一個級別爲 3 的多態 IC。
可是,有一個限制。 在 IC 級別達到 5(可使用 –max_inlining_levels 標誌更改)後,該函數變得超態,再也不被認爲是可優化的。
直觀上能夠理解,單態函數運行速度最快,內存佔用也更小。
這是顯而易見的,也是衆所周知的。 若是您有大文件要處理,例如一個大 CSV 文件,請逐行讀取並以小塊處理,而不是將整個文件加載到內存中。 在極少數狀況下,單行 csv 會大於 1mb,所以您能夠將其放入新空間。
若是您有一些須要一些時間來處理的熱門 API,例如調整圖像大小的 API,請將其移至單獨的線程或將其轉換爲後臺做業。 CPU 密集型操做會阻塞主線程,迫使全部其餘客戶等待並繼續發送請求。 未處理的請求數據會堆積在內存中,從而迫使 full GC 須要更長的時間才能完成。
我曾經對restify有過奇怪的經歷。 若是您向無效 URL 發送數十萬個請求,那麼應用程序內存將迅速增加到數百兆字節,直到幾秒鐘後徹底 GC 啓動,此時一切都會恢復正常。 事實證實,對於每一個無效的 URL,restify 會生成一個新的錯誤對象,其中包含長堆棧跟蹤。 這迫使新建立的對象在大對象空間而不是新空間中分配。
在開發過程當中訪問這些數據可能很是有幫助,但在生產中顯然不須要。 所以規則很簡單——除非您確實須要,不然不要生成數據。
總結瞭解 V8 的垃圾收集和代碼優化器的工做原理是提升應用程序性能的關鍵。 V8 將 JavaScript 編譯爲原生程序集,在某些狀況下,編寫良好的代碼能夠得到與 GCC 編譯的應用程序至關的性能。
更多Jerry的原創文章,盡在:「汪子熙」: