一個(gè)由 Node.js vm 引發(fā)的 OOM 血案
大廠技術(shù) 高級(jí)前端 Node進(jìn)階
點(diǎn)擊上方 程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
大家在用 Node.js 的 vm 時(shí),可千萬(wàn)小心。冷不丁就哪里埋了坑。有時(shí)候補(bǔ)了這里可能又漏了那里。尤其是頻繁新建 vm 的時(shí)候,例如來(lái)一個(gè)請(qǐng)求,組合一段代碼,放進(jìn) vm 中執(zhí)行。
Talk is Cheap, Show Me the Code
先上一段最小復(fù)現(xiàn)代碼。

注意:在異步
while中每次循環(huán)的末尾都手動(dòng)調(diào)用一次gc()函數(shù)。
復(fù)現(xiàn) OOM
首先,我們看看正常的時(shí)候應(yīng)該是怎么樣的。大家可以跟著一起用 Node.js 12 執(zhí)行。
注意:一定是 Node.js 12。

乍一看沒(méi)什么問(wèn)題,用 top 查看,內(nèi)存會(huì)漲到約 100M 上下,然后迅速跌到非常小的值,如此循環(huán)往復(fù)。事實(shí)上,它就是沒(méi)什么問(wèn)題。
但問(wèn)題壞就壞在 Node.js 14 和 16 上。至少在 V8 修復(fù)這個(gè) Bug 之前(甚至很有可能 V8 不會(huì)認(rèn)為這是個(gè) Bug)的 Node.js 14 和 16 版本都會(huì)有這個(gè)問(wèn)題。
這里,我用 Node.js 14.16.0 以及 16.8.0 進(jìn)行實(shí)驗(yàn)。不排除后續(xù)版本會(huì)修復(fù)這個(gè)問(wèn)題。
依舊是上面那段腳本:

感覺(jué)像在玩恐怖游戲一樣,一人站一個(gè)角,跑著跑著,就跑沒(méi)了。并留下了一段話:
<--- 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 了。
真相還原
造成這個(gè)問(wèn)題的原因有好幾個(gè),缺一不可。就像東方列車(chē)案件一樣,一人來(lái)一刀。我們一一解析。
V8 Compilation Cache
緩存技術(shù)
首先,第一刀就是 V8 的 Compilation Cache。這個(gè) Compilation Cache 跟我們?nèi)粘J熘?nbsp;vm API 中的 cachedData 不一樣。它是更底層的一個(gè)緩存 Hash 表,整個(gè) V8 Isolate 共用一份,以傳進(jìn)去的源碼字符串本身作為 key 進(jìn)行查找和存儲(chǔ)。
在 Node.js 的 vm 中編譯(或者說(shuō)解釋?zhuān)┮欢文_本時(shí),最終依賴(lài)的對(duì)象叫 UnboundScript。這是一個(gè)尚未綁定至 Context 的腳本對(duì)象。在編譯過(guò)程中,會(huì)逐步調(diào)用至以下代碼:

用人話解釋就是:用源碼去檢索 Compilation Cache 中是否存在相同 key 的對(duì)象。若存在,直接返回已經(jīng)存在的緩存;否則,正常進(jìn)行反序列化,并將結(jié)果儲(chǔ)存在 Compilation Cache 中(V8 分配的堆內(nèi)存上),并由源碼字符串作為 key。
根據(jù)觀察得出的結(jié)論,這種緩存技術(shù)在真實(shí)世界的網(wǎng)頁(yè)中能夠達(dá)到 80% 的命中率。并且由于這種緩存直接存在于內(nèi)存中,所以它的速度會(huì)比較快。
——《V8 優(yōu)化之代碼緩存——Code Caching》
雖然 Node.js 并不是 Chrome,但它也用了 V8,所以這個(gè) Compilation Cache 也同樣存在。
我們可以在一開(kāi)始的源碼中加入點(diǎn)料來(lái)驗(yàn)證這一點(diǎn):在 times++ 一行之后加入:

這樣,當(dāng)執(zhí)行了 330 次循環(huán)后,會(huì)在當(dāng)前目錄下生成一個(gè) temp.heapsnapshot 的 Heap dump 文件。再執(zhí)行這個(gè)腳本,會(huì)發(fā)現(xiàn)它在 OOM 之前保留了一份現(xiàn)場(chǎng)。用 Chrome 的開(kāi)發(fā)者工具打開(kāi)這個(gè) Heap dump 文件,我們可以發(fā)現(xiàn):

字符串有將近 8000 個(gè),別的一些不重要——我們可以看到有不少源碼字符串根本沒(méi)有被回收,而一個(gè)動(dòng)輒 100K。而從下方 Object 一欄中可以看到,其都屬于 Compilation Cache。
這說(shuō)明了,哪怕我們手動(dòng)執(zhí)行了 gc(),這些 Compilation Cache 中的內(nèi)容(如源碼字符串)并沒(méi)有被回收。
緩存 GC 機(jī)制
根據(jù)上面的實(shí)驗(yàn)結(jié)果,我們不能武斷地認(rèn)為其不會(huì)被回收。事實(shí)上 Compilation Cache 也是在 GC 策略里面的。只不過(guò)它的策略與一般的 V8 JavaScript 對(duì)象不同。而且事實(shí)上,不管是 Node.js 12 所使用的 V8(v7.x)還是 Node.js 14 / 16 所使用的 V8(v8.x / v9.x),Compilation Cache 的回收策略是一樣的。
想想 Node.js 12 執(zhí)行這段代碼的結(jié)果:內(nèi)存會(huì)漲到約 100M 上下,然后迅速跌到非常小的值,如此循環(huán)往復(fù)。
也就是說(shuō),V8 堆內(nèi)存到達(dá)上限后,會(huì)對(duì) Compilation Cache 進(jìn)行回收。我們可以驗(yàn)證一下,在執(zhí)行的命令行上面加一個(gè)參數(shù):

然后繼續(xù)在 Node.js 12 下執(zhí)行,就能得到類(lèi)似這樣的輸出:
...[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
即內(nèi)存一路上漲,等到漲到頂?shù)臅r(shí)候,GC 報(bào)了個(gè)問(wèn)題:
allocation failure GC in old space requested
老生代空間不夠申請(qǐng)了。然后觸發(fā)了下一條 GC:
last resort GC in old space requested
這是一條 Last Resort GC,在該次 GC 之后,整體的內(nèi)存又降到了一個(gè)非常低的水位。
對(duì)的,這就是 V8 的策略。我們知道 V8 的 GC 策略中,有一步是將新生代的內(nèi)存給遷移到老生代去的。這個(gè)時(shí)候需要從老生代空間申請(qǐng)內(nèi)存。若申請(qǐng)不到,就執(zhí)行一次 Last Resort GC。我們可以看看 Node.js 14 / 16 的結(jié)果:
[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,沒(méi)等到進(jìn)行 Last Resort 就掛了。
知道差別之后,我們先來(lái)看看 Last Resort GC 到底做了些什么:

首先,Heap 中有三個(gè) GC 函數(shù),CollectGarbage、CollectAllGarbage(),還有一個(gè)就是上面的 CollectAllAvailableGarbage()。其中 CollectAllGarbage() 基本等同于調(diào)用指定參數(shù)下的 CollectGarbage()。通常情況下,Testing GC 就是調(diào)用的 CollectAllGarbage(),而 Last Resort 的 GC 只會(huì)調(diào)用 CollectAllAvailableGarbage()。我們看到這個(gè) CollectAllAvailableGarbage() 中就有清除 Compilation Cache 的邏輯。
這就與它的作用相符了。
"last resort gc" means that there was an allocation failure that a normal GC could "resolve".
——
--nouse-idle-notificationand "last resort gc"
當(dāng)有堆內(nèi)存分配失敗(到達(dá)上限)時(shí),V8 會(huì)以 Last Resort 為由做一次 CollectAllAvailableGarbage() 的 GC,看看能不能把雜七雜八的各種沒(méi)用的東西都回收掉。如果回收了之后,仍無(wú)法分配,那就只能干瞪眼并觸發(fā)進(jìn)程崩潰了。
而 Compilation Cache 的 GC 機(jī)制,就是 CollectAllGarbage() 不會(huì)回收它(就是我們看到從 Trace GC 中看到的 testing GC in old space requested),只有 CollectAllAvailableGarbage() 才會(huì)將其回收。而 CollectAllAvailableGarbage() 調(diào)起的理由之一就是 Last Resort,即嘗試分配堆內(nèi)存失敗時(shí)(也就是堆內(nèi)存到達(dá)上限了)。
這就是為什么在 Node.js 12 中,這段代碼會(huì)一直漲到 100M 左右,然后內(nèi)存分配失敗,接著執(zhí)行 Last Resort GC,最后內(nèi)存掉下來(lái)。
臨時(shí)解法
知道了這里有問(wèn)題之后,我們就可以臨時(shí)解決這個(gè)問(wèn)題了。其實(shí)只要把 Compilation Cache 禁掉就可以了。

老生代內(nèi)存分配失敗邏輯
我們?cè)谏弦还?jié)中粗略介紹了 Last Resort 這種 GC 的時(shí)機(jī)。那么它到底是如何運(yùn)作的呢。看看下面這段 V8 代碼:

這是 Node.js 14 對(duì)應(yīng)的 V8 代碼,我已將一些關(guān)鍵注釋標(biāo)上,大家應(yīng)該都能看懂。實(shí)際上 Node.js 12 基本一樣,就是函數(shù)名有點(diǎn)不一樣。總得來(lái)講非常簡(jiǎn)單,就是嘗試分配,若失敗就 Last Resort GC,再次嘗試分配,若還失敗則 OOM 崩潰。
其實(shí)在第一次分配失敗之前,它的依賴(lài)函數(shù) AllocateRawWithLightRetrySlowPath() 還有個(gè)小 Trick:

整體連起來(lái)就是,如果分配內(nèi)存失敗,則先嘗試兩次 CollectGarbage()。這種做法就已經(jīng)可以解決大多數(shù)的內(nèi)存分配失敗的問(wèn)題了。若兩次 CollectGarbage() 還無(wú)法清理出內(nèi)存,則再?lài)L試一次 CollectAllAvailableGarbage()。
實(shí)際上,Node.js 12、14 和 16 的 V8 在堆內(nèi)存分配失敗時(shí)的 GC 策略都一樣,都是上面的邏輯。分配失敗了,先嘗試進(jìn)行幾次不一樣的 GC,真不行了再最終 OOM。
既然一樣,為什么 Node.js 12 好好的,而 Node.js 14 和 16 就會(huì)掛呢?
--always-promote-young-mc
在 V8 的 v8.0.1 版本中,其引入了一個(gè)新的 Flag——--always-promote-young-mc。我愿稱(chēng)之為推陳出新。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 的時(shí)候,這個(gè) Flag 會(huì)保證在 GC 之后的新生代空間等為空,新生代的對(duì)象會(huì)全遷移至老生代。
我們看看它在代碼中的實(shí)際作用吧。

當(dāng) --always-promote-young-mc 打開(kāi)的時(shí)候,每次 Full GC 都會(huì)嘗試往老生代遷移。既然要遷移,肯定是要先老生代申請(qǐng)一塊內(nèi)存,才能遷移。若此時(shí)老生代內(nèi)存申請(qǐng)失敗(堆內(nèi)存達(dá)到上限),則直接拋出 OOM 錯(cuò)誤:MarkCompactCollector: young object promotion failed。這個(gè)錯(cuò)誤跟我們用 Node.js 14 執(zhí)行代碼最終的輸出對(duì)上了。而這個(gè) TryEvacuateObject() 最后兜兜轉(zhuǎn)轉(zhuǎn)會(huì)調(diào)用我們?cè)谥疤岬降?nbsp;AllocateRaw() 函數(shù)(AllocateRawWithLightRetrySlowPath() 中調(diào)用的也是這個(gè))了。
所以,整條崩潰鏈就是:
由于 Compilation Cache 的機(jī)制,一直不會(huì)被回收,直到堆內(nèi)存上限; GC 的時(shí)候,由于 --always-promote-young-mc開(kāi)關(guān)打開(kāi),所以執(zhí)行推陳出新操作;推陳出新的時(shí)候,由于堆內(nèi)存到達(dá)上限,無(wú)法申請(qǐng)更多的老生代內(nèi)存,導(dǎo)致 OOM 崩潰。
這簡(jiǎn)直就是一個(gè)死鎖。至于 V8 到底認(rèn)為這個(gè)是個(gè) Bug 還是個(gè) Feature,那我就不知道了。Bug 我是提了,大家可以跟我一起跟進(jìn)。
臨時(shí)解法
我們明白了 --always-promote-young-mc 會(huì)導(dǎo)致目前的 Bug。那就跟之前 Compilation Cache 臨時(shí)解法一樣,將其關(guān)掉即可。

看!一切……別高興太早。

設(shè)置了似乎并沒(méi)什么用。這又是為什么呢?
--array-buffer-extension
這又是一個(gè) V8 的 Flag。與別的 Flag 不同的是,它這一個(gè)只讀的 Flag,且是在編譯時(shí)就指定了的。
雖然這個(gè) Flag 在之前就有,但是在 V8 的 v8.3 版本中,為這個(gè) 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%.
有興趣的小可愛(ài)們可以自行去看看上面提到的設(shè)計(jì)文檔。總之來(lái)說(shuō),在 V8 的 v8.3 版本之后,打開(kāi)這個(gè)開(kāi)關(guān)可以提高 ArrayBuffer 約 50% 的性能。
正是因?yàn)檫@樣,Node.js 在 v14.5.0 中就將這個(gè)開(kāi)關(guān)在編譯時(shí)由關(guān)閉狀態(tài)變成了打開(kāi)狀態(tài)。(詳見(jiàn) https://github.com/nodejs/node/commit/2c59f9bbe29df1ee3e714671de1433369992eba7#diff-d53f68b29a1c48c958c2e6779cc25c916a986357c6010dd01421c17adcf2f09bR150)
別以為我扯遠(yuǎn)了。這個(gè) Flag 與 --always-promote-young-mc 息息相關(guān)。在 V8 中,F(xiàn)lag 們有相互依賴(lài)的關(guān)系。

上面的宏的展開(kāi)的意思就是說(shuō):
若 --array-buffer-extension開(kāi)關(guān)處于關(guān)閉狀態(tài),則--always-promote-young-mc可為任意值;若 --array-buffer-extension開(kāi)關(guān)處于開(kāi)啟狀態(tài),則--always-promote-young-mc會(huì)被強(qiáng)制開(kāi)啟。
也就是說(shuō),哪怕你自己 --no-always-promote-young-mc,由于 Node.js 在編譯時(shí)就將 --array-buffer-extension 開(kāi)關(guān)打開(kāi),--always-promote-young-mc 也會(huì)被強(qiáng)制開(kāi)啟。
大家可以試試看早于 Node.js 14.5.0 的版本,那個(gè)時(shí)候 Node.js 的 --array-buffer-extension 開(kāi)關(guān)還處于關(guān)閉狀態(tài)。也就是說(shuō),在該版本中,我們是可以通過(guò)執(zhí)行:

來(lái)規(guī)避這個(gè)問(wèn)題的。Node.js 14.5.0 之后,開(kāi)關(guān)打開(kāi),你就關(guān)不掉了。
小結(jié)
導(dǎo)致該 OOM 有幾個(gè)問(wèn)題:
vm 依賴(lài) UnboundScript,其依賴(lài) V8 Compilation Cache,該 Cache 只有在CollectAllAvailableGarbage()時(shí)才會(huì)被回收,導(dǎo)致內(nèi)存一直上漲;Node.js 14 / 16 對(duì)應(yīng)的 V8 在堆內(nèi)存抵達(dá)上限后 GC 會(huì)觸發(fā) OOM 的“Bug”; Compilation Cache 可被 --no-compilation-cache關(guān)閉,但如此一來(lái)則無(wú)法享受 Compilation Cache;GC 的 OOM 無(wú)法通過(guò)--no-always-promote-young-mc關(guān)閉,因?yàn)槠淝爸瞄_(kāi)關(guān)被 Node.js 在編譯時(shí)強(qiáng)制開(kāi)啟。
解決辦法
說(shuō)了那么多,臨時(shí)解決辦法其實(shí)已經(jīng)貼在各小節(jié)中了。要問(wèn)我最終解決辦法是什么,就倆:
掃碼跟我一起跟進(jìn)這個(gè) V8 的“Bug”; 避免這種頻繁 vm 的場(chǎng)景。
彩蛋
即使在 Node.js 14 / 16 下,若我們使用 Inspector 進(jìn)入進(jìn)程調(diào)試,那么一切表現(xiàn)又正常了。因?yàn)?Inspector 的一些策略會(huì)不一樣,GC 自然也不一樣。有興趣的小可愛(ài)們可自行去探查一番。
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。

“分享、點(diǎn)贊、在看” 支持一波??
