記一次 Node.js 應用內存暴漲分析
之前 TMS 在運行時 CPU 中占用率和內存占用一直很高,導致應用運行狀態不是很良好,需要頻繁重啟。經過排查,找出了部分原因:
-
使用的 html-minifier 模塊有問題,如果輸入的內容是一個有錯誤的 HTML 結構,會使解析進入死循環,導致 CPU 占用率 100%。
-
在使用 vm 模塊時,使用姿勢錯誤,導致內存占用無法釋放,使內存占用暴漲。
第一個問題我們今天不予討論,主要來說一下第二個問題。
VM(Virtual Machine) 模塊
我們就先了解下 VM 這個模塊。
從它的名字和暴露的 API 可以看出,它能創建一個擁有指定上下文的運行環境,可以在里面直接運行 JavaScript 代碼,類似 eval 。這樣運行代碼時,不會污染當前作用域,一旦出問題,也不會對當前環境造成很大影響。
雖然這個模塊我們平時用的比較少,但它算是 Node.js 的核心模塊,在 require 的實現中,你會發現它的身影。我們在使用 Node.js 時,會使用 require 引入很多外部模塊,對于 Node.js 來說,我們引入的代碼如果直接和運行環境交互,是十分危險的。所以在 Node.js 模塊加載的過程中,會先將 .js 文件的內容進行包裹,變成類似 function(...) {}(...) 的形式,然后使用 vm.runInThisContext 去運行,同時將 module、require 等方法傳入返回的函數中。具體的模塊加載機制,可以在 lib/module.js 中看到實現,不是本文重點,就不細說了。
當然,我們也可以用它來執行我們的代碼:
const vm = require('vm'); const code = 'result = 2 * n;'; const script = new vm.Script(code); // 預編譯后供之后使用 const sandbox = { n: 5 }; const _sandbox = { n: 10 }; const ctx = vm.createContext(_sandbox); // contextify // 供 runInThisContext 使用 global.result = 0; global.n = 16; // 在當前上下文運行,32 vm.runInThisContext(code); script.runInThisContext(); // 在新的上下文中運行,10 vm.runInNewContext(code, sandbox); script.runInNewContext(sandbox); // 在執行上下文中運行,20 vm.runInContext(code, _sandbox); script.runInContext(_sandbox);
問題出現
在 TMS 中,需要壓縮用戶上傳的代碼,出于安全和穩定的考慮,需要和當前運行環境進行隔離,這里就可以使用 VM 模塊。為了便于理解,簡化了一個類似的 Demo,如下:
// fibonacci,計算斐波納挈數列 http.createServer(function(req, res) { let sandbox = { fibonacci: fibonacci, number: 10 }; vm.runInNewContext('a = fibonacci(number)', sandbox); res.end(); }).listen(8999, '127.0.0.1');
運行 Demo。為了模擬實際環境中的并發,這里我們使用 ab 來發起請求。
ab -n 1000 -c 100 http://127.0.0.1:8999/
Apache HTTP server benchmarking tool,簡稱 ab,是一個常用的開源網站壓力測試工具, 官網 。
在運行期間,我們使用 top 來觀察內存的占用情況。
可以發現一些問題,
- 內存占用暴漲,大約 800M
- 占用的內存在運行結束(沒有請求)后,釋放很慢
- QPS 很低
Demo 應用比較簡單,引發的問題不大。但如果在實際的應用場景中,一旦發生內存占用過高,無法分配內存空間的情況,會對應用穩定性照成很大影響,甚至導致應用崩潰。
接下來,我們再看一個例子,將上面的代碼稍作修改,如下:
let sandbox = { fibonacci: fibonacci, number: 10 }; http.createServer(function server (req, res) { vm.runInNewContext('a = fibonacci(number)', sandbox); res.end(); }).listen(8999, '127.0.0.1');
用上面同樣的方法觀察,結果如下圖:
這次,我們看到內存僅占用了 19M,而且增長很平緩,QPS 提高了不少。
僅僅是聲明 sandbox 位置的不同,差別卻如此之大,為什么呢?
探究原因
我們都知道,一般一個在函數中聲明的變量,在函數運行完,就會被釋放掉,所占用的空間也會被回收。但在之前的例子,很有可能 sandbox 變量沒有被回收,導致的內存暴漲。它和其它變量有什么區別,導致它不能被正確釋放呢?
翻了下 vm 的代碼,發現在使用 vm.runInNewContext 時,會將你傳入的 sandbox 進行 contextify,問題可能就出在這里。
contextify 大體流程如下( src/node_contextify.cc#L281 MakeContext ):
- 檢查傳入的對象(sandbox)是否有 _contextifyHidden 這個隱藏的屬性。
- 如果沒有,則 new 一個 ContextifyContext 實例,并且掛載到 sandbox 的 _contextifyHidden 屬性上。
- 如果存在,則返回,不做處理,防止在一個對象上多次進行 contextify。
如果我們用一個在函數外部聲明的 sandbox,如同第二種寫法,那么無論我們調用多少次 runInNewContext,都只會進行一次 contextify 操作,效果類似于 vm.runInContext 。但是,如果像第一種寫法那樣,每次都使用一個新的對象,那么每次都要進行 contextify,而 contextify 過程中比較關鍵的一步是創建一個 ContextifyContext 實例,這個類有些特殊的地方,我們看下它的具體定義(在 src/node_contextify.cc#L49 ):
class ContextifyContext { ... Persistent<Object> sandbox_; Persistent<Context> context_; Persistent<Object> proxy_global_; ... public: explicit ContextifyContext(Environment* env, Local<Object> sandbox) { ... sandbox_.MarkIndependent(); ... context_.MarkIndependent(); ... proxy_global_.MarkIndependent(); ... } ... }
它里面有三個被聲明成 Persistent 類型的變量,重點就在這里。
在 V8 中,有三種概念, Handle 、 Local 、 Persistent 。所有 JavaScript 數據都是有 GC 管理的。JavaScript 中的變量在 C++ 層面都是和 Handle 對應的,可以把它理解成一個普通的指針,用來指向數據的內存位置。而 Local 可以看做一個實際存儲數據的空間,擁有 new 方法,當它 new 出來后,無論是否有變量接收,都會存在于 HandleScope 中。 HandleScope 可以理解成一個管理和回收 Handle 的東西。所以,一個 Local 可以有多個 Handle 指向。而 HandleScope 類似于函數的作用域,它管理著 Handle 和 Local,一旦 HandleScope 退出,其上的 Handle 和 Local 就會被釋放掉,可以聯系 JavaScript 中的函數作用域來理解。
如同 JavaScript 中的閉包一樣,我們有時會需要一種在函數退出后依然存在的變量,這就是 Persistent 類型,它不由 HandleScope 管理,只要沒有手動釋放,它就一直可以被使用。可以簡單用堆和棧的概念來理解,Persistent 是堆變量,HandleScope 是棧,Local 是棧變量,而 Handle 是一種引用。
對于 Persistent 類型變量,除了手動調用 Dispose() 釋放外,V8 還提供了一種自動的,依賴 GC 的釋放方式,就是 Weak Callback + MarkIndependent 的組合,顯然,Node.js 就是使用的這種。這種方式的優勢在于自動化,不用開發者去管理這部分內存,但是過分的依賴 GC,難免會產生各種各樣的問題,比如:內存釋放不及時,占用過多系統資源等。
要知道,GC 并不是實時的,它是需要程序停下來一段時間來讓它來進行回收操作的,如果程序一直在運行,那么 GC 操作就會被延后,直到它覺得必需要運行的時候。這樣,會造成要釋放資源的積壓。如果頻繁執行 GC,則會影響程序的運行效率。
而且, Weak Callback 的執行是由 GC 決定的,一般是在 Full GC 前后。比較過分的是,GC 不保證一定會調用這個回調。。。
另外,在上述的場景中,通過試驗,可以做這樣的猜想:因為 old space 默認大小為 1G,而我們看到在 1000 次執行完后,old space 才 800M 左右,沒有達到閾值,所以 V8 并不會處理這部分的內存占用。當我們把 old space 設為 200M 時,其值穩定在 180M 左右,可以大體印證這個猜想。
綜上,問題的根源找到了。每次請求回調里都會創建一個新的 sandbox,并且它不能在使用完后立即釋放,于是就形成很多無用的 Persistent Handle,堆積在內存中,導致內存占用暴漲。而且,它們的釋放主要依賴于 MarkSweep,執行頻率不高,所以占用釋放很慢。可以想象,在一個高 QPS 的應用下,內存基本上是只增不降的,一點點被蠶食干凈。
解決問題
問題既然找到了,那么就來看下如何解決。
方案一
把 sandbox 在回調外面聲明,減少重復 contextify。因為腳本運行所需要的 context 對象實際上就是 sandbox 對象,只是在底層標識了一下(_contextifyHidden),這一點在 MakeContext 函數中以及獲取 vm 里的返回值時可以看出來,所以修改 sandbox 的值即可以實現傳遞不同參數的效果。
let sandbox = { fibonacci: fibonacci, number: 10 }; http.createServer(function(req, res) { // 傳遞不同的值 sandbox.number = Math.floor(Math.random() * 20); vm.runInNewContext('a = fibonacci(number)', sandbox); res.end(); }).listen(8999, '127.0.0.1');
方案二
vm 模塊本身提供了復用的能力, Script 和 createContext ,所以可以利用它們來處理。
const code = 'a = fibonacci(number)'; const script = new vm.Script(code); let sandbox = { fibonacci: fibonacci, number: 10 }; let ctx = vm.createContext(sandbox); http.createServer(function(req, res) { sandbox.number = Math.floor(Math.random() * 20); script.runInContext(ctx); res.end(); }).listen(8999, '127.0.0.1');
從上面 contextify 的過程中,我們除了可以發現 context 和 sandbox 是關聯的之外,還有一點就是 runInNewContext 會對 sandbox 做校驗,所以這里使用 runInNewContext 也不會有上述的問題。
方案三
這種方案更有普適性,不一定針對于這個問題本身。
Node.js 本身提供了很多關于 GC 方面的參數。
MarkSweep,Full GC 的標記階段
- --trace_gc ,打印 GC 日志
- --expose-gc ,暴露 GC 方法,可以手動調用 global.gc() 來強制執行 GC 過程,并不推薦使用。
- --max-new-space-size ,最大 new space 大小,執行 scavenge 回收,默認 16M,單位 KB
- --max-old-space-size ,最大 old space 大小,執行 MarkSweep 回收,默認 1G,單位 MB
- --gc-global ,強制每次執行 MarkSweep。
可以通過調節這些參數的配置,觀察 GC 日志中 sweeping from (內存積壓狀況)、 Mark-sweep (MarkSweep 用時)等,來優化 GC 過程,需要一定的耐心。當然,有些值不能太極端,比如把 --max-old-space-size 設置的很小,頻繁觸發 GC,會導致應用的執行效率下降。
以后如何發現問題
以后如果遇到一些性能問題,我們該如何去排查呢?這里介紹一些常用的方法。
v8 prof
使用 V8 自帶的 profiler 功能,分析 JavaScript 各個函數的消耗和 GC 部分。
npm install profiler node --prof xxx.js
會生成 xxxx-v8.log,之后使用工具轉換成可讀的。
npm install tick node-tick-processor xxxx-v8.log
就可以查看相關的數據了。
node-inspector
這個工具就不多介紹了,大家應該很熟了,它可以使用 Chrome 開發者工具來調試 Node.js 應用。
node-heapdump
它可以對 Node.js 應用進行 heapdump。然后,可以使用 Chrome 開發者工具打開生成的 xxx.heapsnapshoot 文件,查看 heap 中的內容。
npm install heapdump
在應用中引入
var heapdump = require('heapdump');
執行一段時間后退出,或者在命令行中:
kill -USR2 <pid>
v8-profiler
這個被 node-inspector 集成了,可以提供 HeapDump 和 CPU Profile 功能。
詳見 v8-profiler 。
node-memwatch
可以幫助發現代碼存在的內存泄露問題,也可以做在不同時間點堆的比較。
當然,工具只是輔助作用,在平時寫代碼時多思考一下,善用 API,在處理問題時多積累些經驗,才能寫出更好的代碼。
總結
V8 提供的內存釋放方案有它的優勢所在,但 GC 是個很復雜的過程,過分依賴自動化,也不一定是好事。特別在寫 Node.js 底層的 C++ 部分時,我們還是要考慮下是否該手動釋放的問題,不要把問題都拋給 V8。當然,對于 API 應用也要注意,本身 VM 模塊提供了更好的方案,但我們卻忽略了。
V8 比較復雜,理解有誤的地方,歡迎指正,討論。
參考資料:
來自: http://taobaofed.org/blog/2016/01/14/nodejs-memory-leak-analyze/