雲風的 BLOG: Lua GC 的原始碼剖析 (2)
lua 的 GC 分為五個大的階段。GC 處於哪個階段(程式碼中被稱為狀態),依據的是 global_State
中的 gcstate 域。狀態以巨集形式定義在 lgc.h 的 14 行。
狀態的值的大小也暗示著它們的執行次序。需要注意的是,GC 的執行過程並非每步驟都擁塞在一個狀態上。
GCSpause 階段是每個 GC 流程的啟始步驟。只是標記系統的根節點。見 lgc.c 的 561 行。
markroot 這個函式所做之事,就是標記主執行緒物件,標記主執行緒的全域性表、登錄檔,以及為全域性型別註冊的元表。標記的具體過程我們後面再講。
GCSpause 階段執行完,立刻就將狀態切換到了 GCSpropagate 。這是一個標記流程。這個流程會分步完成。當檢測到尚有物件待標記時,迭代標記(反覆呼叫 propagatemark);最終,會有一個標記過程不可被打斷,這些操作放在一個叫作 atomic 的函式中執行。見 lgc.c 的 565 行:
這裡可能需要順帶提一下的是 gray 域。顧名思義,它指 GCObject 中的灰色節點鏈。何為灰色,即處於白色和黑色之間的狀態。關於節點的顏色,馬上就會展開分析。
接下來就是清除流程了。
前面我們提到過,string 在 Lua 中是單獨管理的,所以也需要單獨清除。GCSsweepstring 階段乾的就是這個事情。string table 以 hash 表形式管理所有的 string 。GCSsweepstring 中,每個步驟(step) 清理 hash 表的一列。程式碼見 lgc.c 的 573 行
這裡可以看到 estimate 和 totalbytes 兩個域,從名字上可以知道,它們分別表示了 lua vm 佔用的記憶體位元組數以及實際分配的位元組數。
ps. 如果你自己實現過記憶體管理器,當知道記憶體管理本身是有額外的記憶體開銷的。如果有必要精確控制記憶體數量,我個人傾向於結合記憶體管理器統計準確的記憶體使用情況。比如你向記憶體管理器索要 8 位元組記憶體,實際的記憶體開銷很可能是 12 位元組,甚至更多。如果想做這方面的修改,讓 lua 的 gc 能更真實的反應記憶體實際使用情況,推薦修改 lmem.c 的 76 行,luaM_realloc_
函式。所有的 lua 中的記憶體使用變化都會通過這個函式。
從上面這段程式碼中,我們還見到了 GCSWEEPCOST 這個神祕數字。這個數字用於控制 GC 的進度。這超出了今天的話題。留待以後分析。
接下來就是對所有未標記的其它 GCObject 做清理工作了。即 GCSsweep 階段。它和上面的 GCSsweepstring 類似。
最後是 GCSfinalize 階段。如果在前面的階段發現了需要呼叫 gc 元方法的 userdata 物件,將在這個階段逐個呼叫。做這件事情的函式是 GCTM 。
前面已經談到過,所有擁有 gc 元方法的 userdata 物件以及其關聯的資料,實際上都不會在之前的清除階段被清除。(由於單獨做過標記)所有的元方法呼叫都是安全的。而它們的實際清除,則需等到下一次 GC 流程了。或是在 lua_close
被呼叫時清除。
ps. lua_close
並不做完整的 gc 工作,只是簡單的處理所有 userdata 的 gc 元方法,以及釋放所有用到的記憶體。它是相對廉價的。
接下來我們看看 GC 標記流程涉及的一些概念。
簡單的說,lua 認為每個 GCObject (需要被 GC 收集器管理的物件)都有一個顏色。一開始,所有節點都是白色的。新創建出來的節點也被預設設定為白色。
在標記階段,可見的節點,逐個被設定為黑色。有些節點比較複雜,它會關聯別的節點。在沒有處理完所有關聯節點前,lua 認為它是灰色的。
節點的顏色被儲存在 GCObject 的 CommonHeader 裡,放在 marked 域中。為了節約記憶體,是以位形式存放。marked 是一個單位元組量。總共可以儲存 8 個標記。而 lua 5.1.4 用到了 7 個標記位。在 lgc.h 的 41 行,有其解釋:
lua 定義了一組巨集來操作這些標記位,程式碼就不再列出。只需要開啟 lgc.h 就能很輕鬆的理解這些巨集的函式。
白色和黑色是分別標記的。當一個物件非白非黑時,就認為它是灰色的。
為什麼有兩個白色標記位?這是 lua 採用的一個小技巧。在 GC 的標記流程結束,但清理流程尚未作完前。一旦物件間的關係發生變化,比如新增加了一個物件。這些物件的生命期是不可預料的。最安全的方法是把它們標記為不可清除。但我們又不能直接把物件設定為黑色。因為清理過程結束,需要把所有物件設定回白色,方便下次清理。lua 實際上是單遍掃描,處理完一個節點就重置一個節點的顏色的。簡單的把新創建出來的物件設定為黑,有可能導致它在 GC 流程結束後,再也沒機會變回白色了。
簡單的方法就是設定從第三種狀態。也就是第 2 種白色。
在 Lua 中,兩個白色狀態是一個乒乓開關,當前需要刪除 0 型白色節點時, 1 型白色節點就是被保護起來的;反之也一樣。
當前的白色是 0 型還是 1 型,見 global_State
的 currentwhite 域。otherwhite() 用於乒乓切換。獲得當前白色狀態,使用定義在 lgcc.h 77 行的巨集:
FINALIZEDBIT 用於標記 userdata 。當 userdata 確認不被引用,則設定上這個標記。它不同於顏色標記。因為 userdata 由於 gc 元方法的存在,釋放所佔記憶體是需要延遲到 gc 元方法呼叫之後的。這個標記可以保證元方法不會被反覆呼叫。
KEYWEAKBIT 和 VALUEWEAKBIT 用於標記 table 的 weak 屬性,無需多言。
FIXEDBIT 可以保證一個 GCObject 不會在 GC 流程中被清除。為什麼要有這種狀態?關鍵在於 lua 本身會用到一個字串,它們有可能不被任何地方引用,但在每次接觸到這個字串時,又不希望反覆生成。那麼,這些字串就會被保護起來,設定上 FIXEDBIT 。
在 lstring.h 的 24 行定義有:
可以把一個字串設定為被保護的。
典型的應用場合見 llex.c 的 64 行:
以及 ltm.c 的 30 行:
以元方法為例,如果我們利用 lua 標準 api 來模擬 metatable 的行為,就不可能寫的和原生的 meta 機制高效。因為,當我們取到一個 table 的 key ,想知道它是不是 __index
時,要麼我們需要呼叫 strcmp 做比較;要麼使用 lua_pushlstring
先將需要比較的 string 壓入 lua_State
,然後再比較。
我們知道 lua 中值一致的 string 共享了一個 string 物件,即 TString 地址是一致的。比較兩個 lua string 的代價非常小(只需要比較一個指標),比 C 函式 strcmp 高效。但 lua_pushlstring
卻有額外開銷。它需要去計算 hash 值,查詢 hash 表 (string table) 。
lua 的 GC 演算法並不做記憶體整理,它不會在記憶體中遷移資料。實際上,如果你能肯定一個 string 不會被清除,那麼它的記憶體地址也是不變的,這樣就帶來的優化空間。ltm.c 中就是這樣做的。
見 lstate.c 的 93 行:
global_State
中 tmname 域就直接以 TString 指標的方式記錄了所有元方法的名字。換作標準的 lua api 來做的話,通常我們需要把這些 string 放到登錄檔,或環境表中,才能保證其不被 gc 清除,且可以在比較時拿到。lua 自己的實現則利用 FIXEDBIT 做了一步優化。
最後,我們來看看 SFIXEDBIT 。其實它的用途只有一個,就是標記主 mainthread 。也就是一切的起點。我們呼叫 lua_newstate
返回的那個結構。
為什麼需要把這個結構特殊對待?因為即使到 lua_close
的那一刻,這個結構也是不能隨意清除的。我們來看看世界末日時,程式都執行了什麼?見 lstate.c 的 105 行。
這是 lua_close
的最後一個步驟。
luaC_freeall
將釋放所有的 GCObject ,但不包括有 SFIXEDBIT 的 mainthread 物件 。見 lgc.c 484 行
這裡 FIXEDBIT 是被無視的,而在此之前,FIXEDBIT 被保護著。見 lstate.c 的 153 行(lua_newstate
函式):
這麼做很容易理解,lua 世界的起源,一切根資料都放在這個物件中,如果被提前清理,後面的程式碼就會出問題。真正釋放這個物件不是在 GC 中,而是最後那句:
順帶還 assert 了一下,最終,世界上是不是隻剩下這個結構。
寫累了,待續。