1. 程式人生 > >雲風的 BLOG: Lua GC 的工作原理

雲風的 BLOG: Lua GC 的工作原理

幾乎所有現代程式語言都有自動化記憶體管理設施,在記憶體不再使用的時候,能夠自動釋放它們。有兩種方法可以做到這點,一是引用計數,二是垃圾收集。引用計數有迴圈引用無法將不再使用的物件引用減到零的問題,但這並不是 Lua 選擇垃圾收集方法的主要原因。主要原因是對於動態語言來說,引用計數帶來的額外開銷太大,尤其是即使一段程式完全不分配記憶體,你也需要承擔這額外開銷。可以類比的動態語言是 Python ,它就是主要基於引用計數來實現自動記憶體管理的,Python 直譯器的執行效率較低,我認為很大程度源於此。

以 Lua 為例,執行時的物件,要麼存在於登錄檔間接引用的 table 中,要麼存在於執行棧上(嚴格說來,登錄檔引用了主執行緒,執行棧線上程結構內)。當一個物件被一個 table 引用時,對於步進式垃圾收集,它需要一個 Barrier 來維持物件的可見性狀態,這和遞增引用計數的成本一致;不過物件從 table 中移除則不需要額外做遞減引用計數的操作;我們可以認為在這個問題上,引用計數帶來的成本僅僅是垃圾收集的兩倍。但效能問題出在物件在執行棧上的操作。不光是函式呼叫和返回會在棧幀間傳遞物件的引用,任何一段程式碼都會在棧上反覆移動物件。對於大部分靜態語言,可以通過程式碼的靜態分析,把加減引用的操作新增在必要的位置,然後再通過編譯器優化,去掉不必要的操作。例如 C++ 的 RAII 機制,Objective-C 的 ARC 都是這麼幹的。但對於 Lua 來說,這就增加了太多的直譯器的複雜度;即便生成了類似的程式碼,開銷也無法忽略。對比 C++ ,它是通過大量 inline 函式才得以消除大部分 RAII 的冗餘操作的,這在 Lua 這類動態語言中行不通。

Lua 因為引用計數的額外開銷問題選擇了垃圾收集器。垃圾收集器並不負責記憶體分配釋放,記憶體的底層管理是通過在建立 Lua 虛擬機器時從外部注入的分配器完成的。虛擬機器工作時所有產生的物件都被串在一個連結串列上組成一個集合,而被虛擬機器根集間接引用的物件都會被保留,剩下的物件引用無法被根集引用,則會在恰當的時機回收。虛擬機器的根集包括了登錄檔,以及原生型別的 metatable 。全域性表、主執行緒、標準庫的程式碼等等,都被登錄檔所引用。

在 Lua 5.0 以前,Lua 使用的是一個非常簡單的標記掃描演算法。它從根集開始遍歷物件,把能遍歷到的物件標記為活物件;然後再遍歷通過分配器分配出來的物件全集連結串列,把沒有標記為活物件的其它物件都刪除。

但是,Lua 5.0 支援 userdata ,它可以有 __gc 方法,當 userdata 被回收時,會呼叫這個方法。所以,一遍標記是不夠的,不能簡單的把死掉的 userdata 簡單剔除,那樣就無法正確的呼叫 __gc 了。所以標記流程需要分兩個階段做,第一階段把包括 userdata 在內的死物件剔除出去,然後在死物件中找回有 __gc 方法的,對它們再做一次標記復活相關的物件,這樣才能保證 userdata 的 __gc 可以正確執行。執行完 __gc 的 userdata 最終會在下一輪 gc 中釋放(如果沒有在 __gc 中復活)。 userdata 有一個單向標記,標記 __gc

方法是否有執行過,這可以保證 userdata 的 __gc 只會執行一次,即使在 __gc 中復活(重新被根集引用),也不會再次分離出來反覆執行 finalizer 。也就是說,執行過 finalizer 的 userdata 就永久變成了一個沒有 finalizer 的 userdata 了。

GC 的效能表現對整個系統的效能表現影響重大。Go 語言早期就是因為 GC 問題而飽受詬病。如果我們把 GC 關閉,那麼 CPU 就完全沒有額外開銷,但是會有極大的記憶體開銷;如果我們每次分配新物件都執行一遍 GC ,那麼就不會有任何額外的記憶體開銷,但是 CPU 開銷會完全不可接受(現在 Lua 保留著一個巨集開關,可以不停的執行完整的 GC ,用來測試 GC 實現的正確性)。Lua 5.0 採用的是一個折中的方案:每當記憶體分配總量超過上次 GC 後的兩倍,就跑一遍新的 GC 流程。但 Lua 5.0 這種會把整個虛擬機器都停下來的 (Stop the World )的簡單粗暴的 GC 實現,在實踐中的問題非常明顯,這導致 Lua 5.0 成為一個分水嶺。5.0 之前的 Lua 多用於內嵌指令碼,只充當系統中的底層模組間的粘合劑,而之後解決了大部分的 GC 停頓問題後,人們才逐漸讓 Lua 承擔更多工作。

從 Lua 5.1 開始,Lua 實現了一個步進式垃圾收集器。這個新的垃圾收集器會在虛擬機器的正常指令邏輯間交錯分佈執行,儘量把每步的執行時間減到合理的範圍。

一旦 GC 不能一次完成,它就無法把整個虛擬機器看成一塊靜態資料加以分析。那麼怎麼辦呢?我們就要藉助 Mutator 模式 。只要我們把所有物件的修改都監控起來,從垃圾收集器的角度來看,程式就只是一段段在修改它需要去回收的資料的東西,它不用管程式到底執行了什麼,只要知道什麼時候修改了什麼。

Lua 5.1 採用了一種三色標記的演算法。每個物件都有三個狀態:無法被訪問到的物件是白色,可訪問到,但沒有遞迴掃描完全的物件是灰色,完全掃描過的物件是黑色。

我們可以假定在任何時間點,下列條件始終成立( Invariants):

所有被根集引用的物件要麼是黑色,要麼是灰色的。

黑色的物件不可能指向白色的。

那麼,白色物件集就是日後會被回收的部分;黑色物件集就是需要保留的部分;灰色物件集是黑色集和白色集的邊界。

隨著收集器的運作,通過充分遍歷灰色物件,就可以把它們轉變為黑色物件,從而擴大黑色集。一旦所有灰色物件消失,收集過程也就完成了。

但 mutator 本身會打破上面的規則,比如原有一個黑色物件 t ,和一個白色物件 {} ,當我們執行 t.x = {} (觸發了一個 mutator ) 時,就會讓這個黑色物件指向一個白色物件。這時,我們需要在所有這種賦值的地方插入一個 write barrier 檢查這種情況,恢復不變條件(invariant) 。

我們有兩種方式維持不變條件:一種是把白色物件變為灰色的(forward),另一種是把黑色物件變回 (backward) 灰色。如果參考 Lua 5.1 以後的程式碼,在 lgc.h 中能找到兩個 api 物件這兩種操作,luaC_barrierluaC_barrierback

什麼時候採用什麼方法更好是實現的時候憑經驗(感覺?)決定的。比方說,給 table 賦值的時候,就直接把被賦值的黑色 table 變回灰色。我猜是因為大部分時候被修改的 table 都不會是黑色,同時不需要檢查賦值的量的型別和顏色。如果一個黑色的 table 變回了灰色,就證明在掃描中途被打斷(處於某種不常見的臨界狀態),就把它單獨放在一個獨立的連結串列 (grayagain)裡,留待後面原子處理,避免它在黑和灰之間反覆折騰。對於堆疊,則乾脆不讓它變黑,這樣對棧的操作就不需要 barrier ,提高棧寫入的效能。

如果是給物件設定一個 metatable ,例如 setmetatable(obj, mt) 這樣的,我們可以採用 forward 策略,當 obj 為黑,而 mt 為白色的,將 mt 置灰。

掃描過程一步步的將灰色物件標記為黑色,最後會留下一些反覆的灰色物件(曾被標記為黑色,又因為 mutator 的 barrier 檢查變回了灰色),這些一次性原子的遞迴遍歷,最後再遍歷所有的堆疊。原子步驟中還包括清理需要清理的弱表(弱表中有至少一個白色物件的引用)、分離出需要呼叫 __gc 的物件。這些原子步驟是 GC 最可能長時間停頓的時機,但原子步驟是 GC 演算法正確性的前提。所以我們應該注意,儘量減少不必要的 __gc 物件,減少不必要的弱表使用,才能儘可能的減少 GC 停頓。

和 Lua 5.0 的單次全量 GC 一樣,__gc 物件對 gc 過程的影響很大。因為我們需要單獨把帶 __gc 方法的物件重新掃描一次復活所有相關物件,保證在 __gc 呼叫時相關物件都還在,和普通的掃描流程不同,這一步必須原子的單步完成,不可拆解。

步進式 GC 如何步進工作的呢?

和很多不瞭解演算法實現的人的直覺不同,GC 的步進並不和真實的時間相關(通常多執行緒 GC 和時間相關,因為 GC 執行過程獨立於主邏輯執行緒之外),它只和虛擬機器分配新的記憶體有關。也就是說,只要虛擬機器不分配更多記憶體,GC 是不會自動執行的。GC 依靠新增記憶體量來決定該做多少工作,它也依靠標記或清理的記憶體量來決定工作做了多少。每次做多了,會導致程式更多額外的停頓,做少了,會導致記憶體回收速度趕不上新增速度。

步進式 GC 比全量 GC 複雜,不能再只用一個量來控制 GC 的工作時間。對於全量 GC ,我們能調節的是 GC 的發生時機,對於 lua 5.0 ,就是 2 倍上次 GC 後的記憶體使用量;在 5.1 以後的版本中,這個 2 倍可以由 LUA_GCSETPAUSE 調節。另外增加了 LUA_GCSETSTEPMUL 來控制 GC 推進的速度,預設是 2 ,也就是新增記憶體速度的兩倍。lua 用掃描記憶體的位元組數和清理記憶體的位元組數作為衡量工作進度的標準,有在使用的記憶體需要標記,沒在使用的記憶體需要清理,GC 一個迴圈大約就需要處理所有已分配的記憶體數量的總量的工作。這裡 2 是個經驗數字,通常可以保證記憶體清理流程可以跑的比新增要快。

我見過不少人曾在郵件列表中抱怨,自己實現的 userdata 可能能被 Lua 感知到的記憶體並沒有多少(只有一個指標),但實際佔了很大的記憶體(背後的 C/C++ 物件),lua 虛擬機器無法正確的驅動 GC 工作。如果大量分配這種 userdata ,程式使用的記憶體暴漲,無法及時回收。其實,正確的方法很簡單,在分配新的 userdata 時,利用 lua_gc 傳入 LUA_GCSTEP 讓它步進相應真實佔據的記憶體數量就可以了,而沒有必要讓虛擬機器記住這個 userdata 真的使用了多少記憶體。

從上面的演算法分析可見,步進式 GC 能夠減少每次 GC 工作時的停頓時間,但是無法減少 GC 帶來的額外開銷,相反,GC 的時間成本(額外的 Barrier )和空間成本(未能及時回收不再使用的記憶體)反而較之全量 GC 增加了。

我們經常可以看到峰值時 Lua 會使用大量超過我們預期的記憶體總量,是我們估算的程式需要的記憶體的兩倍左右。這是因為長期執行的程式理論上應該穩定在一定的記憶體總開銷左右,但 Lua 的 GC 週期觸發條件卻是新的不斷分配的物件的記憶體總量達到過去的兩倍。為了改善這點,Lua 引入了分代 GC 。在 Lua 5.2 中,以一個試驗特性提供,後來因為沒有收到太多正面反饋,又在 Lua 5.3 中移除。事實上 Lua 5.2 提供的分代 GC 過於簡單,的確有設計問題,未能很好的解決問題,在還沒有釋出的 Lua 5.4 中,分代 GC 被重新設計實現。

分代 GC 之所以可以更及時的回收記憶體其實是基於這樣的假設:

大部分物件被分配出來後很快就回收掉了(C/C++/Go 等靜態語言中,特別把只存在於棧上的臨時物件單列出來做記憶體管理正是如此)。所以,垃圾收集器可以集中精力對付剛剛構造出來的年輕物件。

我們將物件分為青年的和年老的兩代,新創建出來的物件一律歸為青年代,一旦年輕的物件經歷了兩輪收集週期而依舊健在,他們就成長為老年物件。在每個次級收集週期,收集器只對青年代的物件進行遍歷清除工作。這樣就避免了每次都遍歷大量並不活躍卻長期存活的老年物件,又可以及時清理掉大量生命短暫的青年物件。

次級收集週期越密集,單個週期內的青年物件數量就越少,需要做的工作也越少,停頓時間也就縮小了。可見增加收集週期並不會太多的增加整體工作量,卻可以更及時的回收記憶體;而相對於之前的演算法,增加收集週期則意味了更多的工作(因為每個週期都需要遍歷所有的物件)。

對於分代 GC ,我們也有一個始終成立的條件(Invariant):老物件不會指向新物件。但是,分步卻變得更困難了。當變化發生時,無論是 forward 還是 backward 都有問題:對於 forward ,也就是把新物件變成老的,無疑會製造大量老物件,還需要遞迴變數,否則就會打破規則。如果是採用 backward 策略,更很難保持條件成立(物件很難知道誰引用了自己,就無法準確的把老物件變回新的)。

所以,需要引入第三態:觸碰過的物件(The Touched Objects)。

當 back barrier 需要解決老物件指向新物件的問題時,老物件被標記為觸碰過,並放入一個特別的集合。被觸碰的物件在次級收集週期中也會參與遍歷,但是不會被清理。被觸碰的物件如果不再被觸碰,那麼在兩個週期後,它會回到老年集。

這裡為什麼強調是兩個次級收集週期而不是單個?這就要提到 Lua 5.2 所犯的錯誤。Lua 5.2 的分代 GC 演算法中,就只針對單個次級收集週期處理。任何物件活過當前的收集週期,就會變老。這樣處理固然簡單,但有極大的問題。如果一個物件剛剛被創建出來,次級收集過程就開啟了,它很容易就活過這個週期(例如函式尚未返回,剛在堆疊上創建出來的物件就不能回收),這個物件就迅速變老了。這種本該隨即回收的物件未被回收的越多,並不老的老物件增多會進一步增加中間狀態的物件數量,次級收集過程能收集的臨時物件更少。

我們判定新東西變老(長期引用)的準則是活過足夠長的時間,至少也要一個次要收集週期。所以活過當下的週期肯定不夠(無法避免剛建立的物件變老),至少應該活過當下和前一個週期才行。

但是兩個週期的實現演算法勢必要複雜的多。只判斷在當前週期存活的規則很簡單,每次次級收集後,所有沒被收集的物件都歸為老年代即可,然後就可以把觸碰集直接清空。而兩個週期的規則,則需要好幾個連結串列之間倒騰:每個次級收集週期結束,只有部分新物件變老,另一些還需要維持,觸碰物件集的一部分需要繼承到下一個週期。這實現起來真的很複雜,並難以測試。

不過一個正確實現的分代 GC 可以極大的減少傳統 GC 額外的記憶體開銷。有同學在他的基於 skynet 的專案中嘗試過切換到 lua 5.4 ,程序在長期執行時,記憶體使用峰值要少且穩定的多。

分代模式的一個問題是當進入主收集週期,必須做一次完整的標記清除,這和 Lua 5.0 的全量 GC 是一樣的,這會帶來停頓問題。只不過分代 GC 模式下,全量 GC 頻率可以降的非常低,因為大量臨時記憶體都通過次級收集週期清理掉了,記憶體並不會增長太快。當遇到必須消除停頓的環境,我們可以手工精確調整:發現記憶體持續增長,不要主動觸發完整的主收集週期,而是主動切換到步進模式,然後週期性的呼叫 gc step (不等記憶體分配器來觸發)在合理的時間內分佈報完一個完整的 GC 週期,再切換回分代模式。

我認為 Lua 5.2/5.4 沒有預設做這種自動模式切換,是因為預設 GC 無法通過時間驅動來分片工作,而必須依賴記憶體分配器新增記憶體驅動導致的。如果我們對 GC 工作原理有清晰的理解,便很容易在程式框架的週期迴圈內自己來驅動 GC 按需工作。