大家在用 Node.js 的 vm vm vm
時,可千萬小心。冷不丁就哪裡埋了坑。有時候補了這裡可能又漏了那裡。尤其是頻繁建立
的時候,例如來一個請求,組合一段代碼,放進
中執行。
Talk is Cheap, Show Me the Code
先上一段最小複現代碼。
// test.js
'use strict';
let times = 0;
function run() {
let str = 'var a = "';
for (let i = 0; i < 100 * 1024 / 18; i++) str += Math.random().toString();
str += '";';
const script = new (require('vm').Script)(str);
times++;
if (times % 1000 === 0) console.log(times);
}
(async () => {
while (true) {
run();
await new Promise(resolve => {
setTimeout(() => {
resolve();
}, 10);
});
gc();
}
})();
注意:在異步中每次循環的末尾都手動調用一次
while
函數。
gc()
複現 OOM
首先,我們看看正常的時候應該是怎麼樣的。大家可以跟着一起用 Node.js 12 執行。
注意:一定是 Node.js 12。
$ node --expose-gc --max-heap-size=100 test.js
乍一看沒什麼問題,用
top
檢視,記憶體會漲到約 100M 上下,然後迅速跌到非常小的值,如此循環往複。事實上,它就是沒什麼問題。
但問題壞就壞在 Node.js 14 和 16 上。至少在 V8 修複這個 Bug 之前(甚至很有可能 V8 不會認為這是個 Bug)的 Node.js 14 和 16 版本都會有這個問題。
這裡,我用 Node.js 14.16.0 以及 16.8.0 進行實驗。不排除後續版本會修複這個問題。
依舊是上面那段腳本:
$ node --expose-gc --max-heap-size=100 test.js
感覺像在玩恐怖遊戲一樣,一人站一個角,跑着跑着,就跑沒了。并留下了一段話:
<--- Last few GCs --->
[2425804:0x45d8100] 6324 ms: Mark-sweep 72.0 (91.8) -> 71.5 (92.1) MB, 6.5 / 0.0 ms (average mu = 0.691, current mu = 0.694) testing GC in old space requested
[2425804:0x45d8100] 6345 ms: Mark-sweep 72.2 (92.1) -> 71.7 (92.1) MB, 6.9 / 0.0 ms (average mu = 0.676, current mu = 0.660) testing GC in old space requested
[2425804:0x45d8100] 6367 ms: Mark-sweep 72.4 (92.3) -> 71.9 (92.3) MB, 7.4 / 0.0 ms (average mu = 0.670, current mu = 0.664) testing GC in old space requested
<--- JS stacktrace --->
FATAL ERROR: MarkCompactCollector: young object promotion failed Allocation failed - JavaScript heap out of memory
看吧,莫名其妙 OOM 了。
真相還原
造成這個問題的原因有好幾個,缺一不可。就像東方列車案件一樣,一人來一刀。我們一一解析。
V8 Compilation Cache
緩存技術
首先,第一刀就是 V8 的 Compilation Cache。這個 Compilation Cache 跟我們日常熟知的
vm
API 中的
cachedData
不一樣。它是更底層的一個緩存 Hash 表,整個 V8 Isolate 共用一份,以傳進去的源碼字元串本身作為
key
進行查找和存儲。
在 Node.js 的
vm
中編譯(或者說解釋)一段腳本時,最終依賴的對象叫
UnboundScript
。這是一個尚未綁定至
Context
的腳本對象。在編譯過程中,會逐漸調用至以下代碼:
...
CompilationCache* compilation_cache = isolate->compilation_cache();
if (...) {
...
// 從 Compilation Cache 中查找是否命中
maybe_result = compilation_cache->LookupScript(
source, script_details.name_obj, script_details.line_offset,
script_details.column_offset, origin_options, isolate->native_context(),
language_mode);
...
if (!maybe_result.is_null()) {
// 若命中,則标記命中
compile_timer.set_hit_isolate_cache();
} else if (can_consume_code_cache) {
// 反序列化
if (CodeSerializer::Deserialize(isolate, cached_data, source,
origin_options)
.ToHandle(&inner_result) &&
inner_result->is_compiled()) {
// 将反序列化後的内容加入 Compilation Cache
compilation_cache->PutScript(source, isolate->native_context(),
language_mode, inner_result);
}
}
}
用人話解釋就是:用源碼去檢索 Compilation Cache 中是否存在相同
key
的對象。若存在,直接傳回已經存在的緩存;否則,正常進行反序列化,并将結果儲存在 Compilation Cache 中(V8 配置設定的堆記憶體上),并由源碼字元串作為
key
。
1根據觀察得出的結論,這種緩存技術在真實世界的網頁中能夠達到 80% 的命中率。并且由于這種緩存直接存在于記憶體中,是以它的速度會比較快。
雖然 Node.js 并不是 Chrome,但它也用了 V8,是以這個 Compilation Cache 也同樣存在。
我們可以在一開始的源碼中加入點料來驗證這一點:在
times++
一行之後加入:
if (times === 330) require('v8').writeHeapSnapshot(require('path').join(__dirname, 'temp.heapsnapshot'));
這樣,當執行了 330 次循環後,會在目前目錄下生成一個
temp.heapsnapshot
的 Heap dump 檔案。再執行這個腳本,會發現它在 OOM 之前保留了一份現場。用 Chrome 的開發者工具打開這個 Heap dump 檔案,我們可以發現:

字元串有将近 8000 個,别的一些不重要——我們可以看到有不少源碼字元串根本沒有被回收,而一個動辄 100K。而從下方
Object
一欄中可以看到,其都屬于 Compilation Cache。
這說明了,哪怕我們手動執行了
gc()
,這些 Compilation Cache 中的内容(如源碼字元串)并沒有被回收。
緩存 GC 機制
根據上面的實驗結果,我們不能武斷地認為其不會被回收。事實上 Compilation Cache 也是在 GC 政策裡面的。隻不過它的政策與一般的 V8 JavaScript 對象不同。而且事實上,不管是 Node.js 12 所使用的 V8(v7.x)還是 Node.js 14 / 16 所使用的 V8(v8.x / v9.x),Compilation Cache 的回收政策是一樣的。
想想 Node.js 12 執行這段代碼的結果:記憶體會漲到約 100M 上下,然後迅速跌到非常小的值,如此循環往複。
也就是說,V8 堆記憶體到達上限後,會對 Compilation Cache 進行回收。我們可以驗證一下,在執行的指令行上面加一個參數:
$ node --trace-gc --expose-gc --max-heap-size=100 test.js
然後繼續在 Node.js 12 下執行,就能得到類似這樣的輸出:
...
[2432962:0x321a0b0] 6902 ms: Mark-sweep 77.7 (98.5) -> 77.2 (98.7) MB, 5.4 / 0.0 ms (average mu = 0.710, current mu = 0.704) testing GC in old space requested
[2432962:0x321a0b0] 6920 ms: Mark-sweep 77.9 (99.0) -> 77.4 (98.7) MB, 5.1 / 0.0 ms (average mu = 0.712, current mu = 0.714) testing GC in old space requested
[2432962:0x321a0b0] 6938 ms: Mark-sweep 78.1 (99.0) -> 77.6 (99.2) MB, 4.9 / 0.0 ms (average mu = 0.720, current mu = 0.728) testing GC in old space requested
[2432962:0x321a0b0] 6955 ms: Mark-sweep 78.3 (99.2) -> 77.8 (99.2) MB, 4.8 / 0.0 ms (average mu = 0.721, current mu = 0.722) testing GC in old space requested
[2432962:0x321a0b0] 6961 ms: Mark-sweep 78.4 (99.2) -> 78.0 (99.2) MB, 4.1 / 0.0 ms (average mu = 0.626, current mu = 0.368) allocation failure GC in old space requested
[2432962:0x321a0b0] 6966 ms: Mark-sweep 78.0 (99.2) -> 77.9 (99.5) MB, 4.0 / 0.0 ms (average mu = 0.475, current mu = 0.033) allocation failure GC in old space requested
[2432962:0x321a0b0] 6967 ms: Mark-sweep 77.9 (99.5) -> 1.9 (13.2) MB, 1.6 / 0.0 ms (average mu = 0.404, current mu = 0.077) last resort GC in old space requested
[2432962:0x321a0b0] 6977 ms: Mark-sweep 1.9 (13.2) -> 1.8 (4.0) MB, 9.9 / 0.0 ms (average mu = 0.136, current mu = 0.002) last resort GC in old space requested
即記憶體一路上漲,等到漲到頂的時候,GC 報了個問題:
allocation failure GC in old space requested
老生代空間不夠申請了。然後觸發了下一條 GC:
last resort GC in old space requested
這是一條 Last Resort GC,在該次 GC 之後,整體的記憶體又降到了一個非常低的水位。
對的,這就是 V8 的政策。我們知道 V8 的 GC 政策中,有一步是将新生代的記憶體給遷移到老生代去的。這個時候需要從老生代空間申請記憶體。若申請不到,就執行一次 Last Resort GC。我們可以看看 Node.js 14 / 16 的結果:
[2433812:0x4743cd0] 5820 ms: Mark-sweep 72.3 (92.1) -> 71.8 (92.1) MB, 5.7 / 0.0 ms (average mu = 0.723, current mu = 0.701) testing GC in old space requested
[2433812:0x4743cd0] 5839 ms: Mark-sweep 72.5 (92.3) -> 72.0 (92.6) MB, 5.9 / 0.0 ms (average mu = 0.707, current mu = 0.693) testing GC in old space requested
<--- Last few GCs --->
[2433812:0x4743cd0] 5801 ms: Mark-sweep 72.1 (91.8) -> 71.6 (91.8) MB, 4.4 / 0.0 ms (average mu = 0.746, current mu = 0.748) testing GC in old space requested
[2433812:0x4743cd0] 5820 ms: Mark-sweep 72.3 (92.1) -> 71.8 (92.1) MB, 5.7 / 0.0 ms (average mu = 0.723, current mu = 0.701) testing GC in old space requested
[2433812:0x4743cd0] 5839 ms: Mark-sweep 72.5 (92.3) -> 72.0 (92.6) MB, 5.9 / 0.0 ms (average mu = 0.707, current mu = 0.693) testing GC in old space requested
<--- JS stacktrace --->
FATAL ERROR: MarkCompactCollector: young object promotion failed Allocation failed - JavaScript heap out of memory
一直是
testing GC in old space requested
,沒等到進行 Last Resort 就挂了。
知道差别之後,我們先來看看 Last Resort GC 到底做了些什麼:
void Heap::CollectAllAvailableGarbage(GarbageCollectionReason gc_reason) {
...
isolate_->compilation_cache()->Clear();
...
}
首先,
Heap
中有三個 GC 函數,
CollectGarbage
、
CollectAllGarbage()
,還有一個就是上面的
CollectAllAvailableGarbage()
。其中
CollectAllGarbage()
基本等同于調用指定參數下的
CollectGarbage()
。通常情況下,Testing GC 就是調用的
CollectAllGarbage()
,而 Last Resort 的 GC 隻會調用
CollectAllAvailableGarbage()
。我們看到這個
CollectAllAvailableGarbage()
中就有清除 Compilation Cache 的邏輯。
這就與它的作用相符了。
"last resort gc" means that there was an allocation failure that a normal GC could "resolve".
當有堆記憶體配置設定失敗(到達上限)時,V8 會以 Last Resort 為由做一次
CollectAllAvailableGarbage()
的 GC,看看能不能把雜七雜八的各種沒用的東西都回收掉。如果回收了之後,仍無法配置設定,那就隻能幹瞪眼并觸發程序崩潰了。
而 Compilation Cache 的 GC 機制,就是
CollectAllGarbage()
不會回收它(就是我們看到從 Trace GC 中看到的
testing GC in old space requested
),隻有
CollectAllAvailableGarbage()
才會将其回收。而
CollectAllAvailableGarbage()
調起的理由之一就是 Last Resort,即嘗試配置設定堆記憶體失敗時(也就是堆記憶體到達上限了)。
這就是為什麼在 Node.js 12 中,這段代碼會一直漲到 100M 左右,然後記憶體配置設定失敗,接着執行 Last Resort GC,最後記憶體掉下來。
臨時解法
知道了這裡有問題之後,我們就可以臨時解決這個問題了。其實隻要把 Compilation Cache 禁掉就可以了。
$ node --trace-gc --expose-gc --max-heap-size=100 --no-compilation-cache test.js
老生代記憶體配置設定失敗邏輯
我們在上一節中粗略介紹了 Last Resort 這種 GC 的時機。那麼它到底是如何運作的呢。看看下面這段 V8 代碼:
HeapObject Heap::AllocateRawWithRetryOrFailSlowPath(
int size, AllocationType allocation, AllocationOrigin origin,
AllocationAlignment alignment) {
AllocationResult alloc;
// 嘗試配置設定記憶體
HeapObject result =
AllocateRawWithLightRetrySlowPath(size, allocation, origin, alignment);
// 若配置設定成功,則直接傳回
if (!result.is_null()) return result;
isolate()->counters()->gc_last_resort_from_handles()->Increment();
// 進行 `CollectAllAvailableGarbage()` 回收,并标記理由為 Last Resort
CollectAllAvailableGarbage(GarbageCollectionReason::kLastResort);
{
AlwaysAllocateScope scope(this);
// 再次嘗試配置設定記憶體
alloc = AllocateRaw(size, allocation, origin, alignment);
}
// 若配置設定成功,則傳回
if (alloc.To(&result)) {
DCHECK(result != ReadOnlyRoots(this).exception());
return result;
}
// 若還失敗,則 OOM
FatalProcessOutOfMemory("CALL_AND_RETRY_LAST");
return HeapObject();
}
這是 Node.js 14 對應的 V8 代碼,我已将一些關鍵注釋标上,大家應該都能看懂。實際上 Node.js 12 基本一樣,就是函數名有點不一樣。總得來講非常簡單,就是嘗試配置設定,若失敗就 Last Resort GC,再次嘗試配置設定,若還失敗則 OOM 崩潰。
其實在第一次配置設定失敗之前,它的依賴函數
AllocateRawWithLightRetrySlowPath()
還有個小 Trick:
HeapObject Heap::AllocateRawWithLightRetrySlowPath(
int size, AllocationType allocation, AllocationOrigin origin,
AllocationAlignment alignment) {
HeapObject result;
AllocationResult alloc = AllocateRaw(size, allocation, origin, alignment);
// 若配置設定成功,則傳回
if (alloc.To(&result)) {
DCHECK(result != ReadOnlyRoots(this).exception());
return result;
}
// Two GCs before panicking. In newspace will almost always succeed.
// 在急眼之前,先嘗試兩次 `CollectGarbage()`。
for (int i = 0; i < 2; i++) {
CollectGarbage(alloc.RetrySpace(),
GarbageCollectionReason::kAllocationFailure);
alloc = AllocateRaw(size, allocation, origin, alignment);
if (alloc.To(&result)) {
DCHECK(result != ReadOnlyRoots(this).exception());
return result;
}
}
return HeapObject();
}
整體連起來就是,如果配置設定記憶體失敗,則先嘗試兩次
CollectGarbage()
。這種做法就已經可以解決大多數的記憶體配置設定失敗的問題了。若兩次
CollectGarbage()
還無法清理出記憶體,則再嘗試一次
CollectAllAvailableGarbage()
實際上,Node.js 12、14 和 16 的 V8 在堆記憶體配置設定失敗時的 GC 政策都一樣,都是上面的邏輯。配置設定失敗了,先嘗試進行幾次不一樣的 GC,真不行了再最終 OOM。
既然一樣,為什麼 Node.js 12 好好的,而 Node.js 14 和 16 就會挂呢?
--always-promote-young-mc
--always-promote-young-mc
在 V8 的 v8.0.1 版本中,其引入了一個新的 Flag——
--always-promote-young-mc
。我願稱之為推陳出新。Node.js 14 用的就是 V8 的 v8.* 版本。
Add FLAG_always_promote_young_mc that always promotes young objects during a Full GC when enabled. This flag guarantees that the young gen and the sweeping remembered set are empty after a full GC.
This CL also makes use of the fact that the sweeping remembered set is empty and only invalidates an object when there were old-to-new slots recorded on its page.
每次 Full GC 的時候,這個 Flag 會保證在 GC 之後的新生代空間等為空,新生代的對象會全遷移至老生代。
我們看看它在代碼中的實際作用吧。
...
if (always_promote_young_) {
heap_->UpdateAllocationSite(object.map(), object,
local_pretenuring_feedback_);
// 嘗試往老生代遷移
if (!TryEvacuateObject(OLD_SPACE, object, size, &target_object)) {
heap_->FatalProcessOutOfMemory(
"MarkCompactCollector: young object promotion failed");
}
promoted_size_ += size;
return true;
}
...
當
--always-promote-young-mc
打開的時候,每次 Full GC 都會嘗試往老生代遷移。既然要遷移,肯定是要先老生代申請一塊記憶體,才能遷移。若此時老生代記憶體申請失敗(堆記憶體達到上限),則直接抛出 OOM 錯誤:MarkCompactCollector: young object promotion failed。這個錯誤跟我們用 Node.js 14 執行代碼最終的輸出對上了。而這個
TryEvacuateObject()
最後兜兜轉轉會調用我們在之前提到的
AllocateRaw()
函數(
AllocateRawWithLightRetrySlowPath()
中調用的也是這個)了。
是以,整條崩潰鍊就是:
- 由于 Compilation Cache 的機制,一直不會被回收,直到堆記憶體上限;
- GC 的時候,由于
開關打開,是以執行推陳出新操作;--always-promote-young-mc
- 推陳出新的時候,由于堆記憶體到達上限,無法申請更多的老生代記憶體,導緻 OOM 崩潰。
這簡直就是一個死鎖。至于 V8 到底認為這個是個 Bug 還是個 Feature,那我就不知道了。Bug 我是提了,大家可以跟我一起跟進。
我們明白了
--always-promote-young-mc
會導緻目前的 Bug。那就跟之前 Compilation Cache 臨時解法一樣,将其關掉即可。
$ node --expose-gc --max-heap-size=100 --no-always-promote-young-mc test.js
看!一切……别高興太早。
設定了似乎并沒什麼用。這又是為什麼呢?
--array-buffer-extension
--array-buffer-extension
這又是一個 V8 的 Flag。與别的 Flag 不同的是,它這一個隻讀的 Flag,且是在編譯時就指定了的。
雖然這個 Flag 在之前就有,但是在 V8 的 v8.3 版本中,為這個 Flag 做了一次性能提升。
Backing stores of ArrayBuffers are allocated outside V8’s heap using ArrayBuffer::Allocator provided by the embedder. These backing stores need to be released when their ArrayBuffer object is reclaimed by the garbage collector. V8 v8.3 has a new mechanism for tracking ArrayBuffers and their backing stores that allows the garbage collector to iterate and free the backing store concurrently to the application. More details are available in this design document ( https://docs.google.com/document/d/1-ZrLdlFX1nXT3z-FAgLbKal1gI8Auiaya_My-a0UJ28/edit#heading=h.gfz6mi5p212e). This reduced total GC pause time in ArrayBuffer heavy workloads by 50%.
有興趣的小可愛們可以自行去看看上面提到的設計文檔。總之來說,在 V8 的 v8.3 版本之後,打開這個開關可以提高
ArrayBuffer
約 50% 的性能。
正是因為這樣,Node.js 在 v14.5.0 中就将這個開關在編譯時由關閉狀态變成了打開狀态。(
https://github.com/nodejs/node/commit/2c59f9bbe29df1ee3e714671de1433369992eba7#diff-d53f68b29a1c48c958c2e6779cc25c916a986357c6010dd01421c17adcf2f09bR150)
别以為我扯遠了。這個 Flag 與
--always-promote-young-mc
息息相關。在 V8 中,Flag 們有互相依賴的關系。
DEFINE_IMPLICATION(array_buffer_extension, always_promote_young_mc)
上面的宏的展開的意思就是說:
- 若
開關處于關閉狀态,則--array-buffer-extension
可為任意值;--always-promote-young-mc
-
開關處于開啟狀态,則--array-buffer-extension
會被強制開啟。--always-promote-young-mc
也就是說,哪怕你自己
--no-always-promote-young-mc
,由于 Node.js 在編譯時就将
--array-buffer-extension
開關打開,
--always-promote-young-mc
也會被強制開啟。
大家可以試試看早于 Node.js 14.5.0 的版本,那個時候 Node.js 的
--array-buffer-extension
開關還處于關閉狀态。也就是說,在該版本中,我們是可以通過執行:
$ node --expose-gc --max-heap-size=100 --no-always-promote-young-mc test.js
來規避這個問題的。Node.js 14.5.0 之後,開關打開,你就關不掉了。
小結
導緻該 OOM 有幾個問題:
- vm 依賴
,其依賴 V8 Compilation Cache,該 Cache 隻有在UnboundScript
時才會被回收,導緻記憶體一直上漲;CollectAllAvailableGarbage()
- Node.js 14 / 16 對應的 V8 在堆記憶體抵達上限後 GC 會觸發 OOM 的“Bug”;
- Compilation Cache 可被
關閉,但如此一來則無法享受 Compilation Cache;GC 的 OOM 無法通過--no-compilation-cache
關閉,因為其前置開關被 Node.js 在編譯時強制開啟。--no-always-promote-young-mc
解決辦法
說了那麼多,臨時解決辦法其實已經貼在各小節中了。要問我最終解決辦法是什麼,就倆:
- 跟我一起跟進這個 V8 的“Bug”; https://bugs.chromium.org/p/v8/issues/detail?id=12198
- 避免這種頻繁 vm 的場景。
彩蛋
即使在 Node.js 14 / 16 下,若我們使用 Inspector 進入程序調試,那麼一切表現又正常了。因為 Inspector 的一些政策會不一樣,GC 自然也不一樣。有興趣的小可愛們可自行去探查一番。