1. 程式人生 > >雲風的 BLOG: Lua GC 的原始碼剖析 (2)

雲風的 BLOG: Lua GC 的原始碼剖析 (2)

lua 的 GC 分為五個大的階段。GC 處於哪個階段(程式碼中被稱為狀態),依據的是 global_State 中的 gcstate 域。狀態以巨集形式定義在 lgc.h 的 14 行。

/* ** Possible states of the Garbage Collector */ #define GCSpause 0 #define GCSpropagate 1 #define GCSsweepstring 2 #define GCSsweep 3 #define GCSfinalize 4

狀態的值的大小也暗示著它們的執行次序。需要注意的是,GC 的執行過程並非每步驟都擁塞在一個狀態上。

GCSpause 階段是每個 GC 流程的啟始步驟。只是標記系統的根節點。見 lgc.c 的 561 行。

switch (g->gcstate) { case GCSpause: { markroot(L); /* start a new collection */ return 0; }

markroot 這個函式所做之事,就是標記主執行緒物件,標記主執行緒的全域性表、登錄檔,以及為全域性型別註冊的元表。標記的具體過程我們後面再講。

GCSpause 階段執行完,立刻就將狀態切換到了 GCSpropagate 。這是一個標記流程。這個流程會分步完成。當檢測到尚有物件待標記時,迭代標記(反覆呼叫 propagatemark);最終,會有一個標記過程不可被打斷,這些操作放在一個叫作 atomic 的函式中執行。見 lgc.c 的 565 行:

case GCSpropagate: { if (g->gray) return propagatemark(g); else { /* no more `gray' objects */ atomic(L); /* finish mark phase */ return 0; } }

這裡可能需要順帶提一下的是 gray 域。顧名思義,它指 GCObject 中的灰色節點鏈。何為灰色,即處於白色和黑色之間的狀態。關於節點的顏色,馬上就會展開分析。

接下來就是清除流程了。

前面我們提到過,string 在 Lua 中是單獨管理的,所以也需要單獨清除。GCSsweepstring 階段乾的就是這個事情。string table 以 hash 表形式管理所有的 string 。GCSsweepstring 中,每個步驟(step) 清理 hash 表的一列。程式碼見 lgc.c 的 573 行

case GCSsweepstring: { lu_mem old = g->totalbytes; sweepwholelist(L, &g->strt.hash[g->sweepstrgc++]); if (g->sweepstrgc >= g->strt.size) /* nothing more to sweep? */ g->gcstate = GCSsweep; /* end sweep-string phase */ lua_assert(old >= g->totalbytes); g->estimate -= old - g->totalbytes; return GCSWEEPCOST; }

這裡可以看到 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 行,有其解釋:

/* ** Layout for bit use in `marked' field: ** bit 0 - object is white (type 0) ** bit 1 - object is white (type 1) ** bit 2 - object is black ** bit 3 - for userdata: has been finalized ** bit 3 - for tables: has weak keys ** bit 4 - for tables: has weak values ** bit 5 - object is fixed (should not be collected) ** bit 6 - object is "super" fixed (only the main thread) */ #define WHITE0BIT 0 #define WHITE1BIT 1 #define BLACKBIT 2 #define FINALIZEDBIT 3 #define KEYWEAKBIT 3 #define VALUEWEAKBIT 4 #define FIXEDBIT 5 #define SFIXEDBIT 6 #define WHITEBITS bit2mask(WHITE0BIT, WHITE1BIT)

lua 定義了一組巨集來操作這些標記位,程式碼就不再列出。只需要開啟 lgc.h 就能很輕鬆的理解這些巨集的函式。

白色和黑色是分別標記的。當一個物件非白非黑時,就認為它是灰色的。

為什麼有兩個白色標記位?這是 lua 採用的一個小技巧。在 GC 的標記流程結束,但清理流程尚未作完前。一旦物件間的關係發生變化,比如新增加了一個物件。這些物件的生命期是不可預料的。最安全的方法是把它們標記為不可清除。但我們又不能直接把物件設定為黑色。因為清理過程結束,需要把所有物件設定回白色,方便下次清理。lua 實際上是單遍掃描,處理完一個節點就重置一個節點的顏色的。簡單的把新創建出來的物件設定為黑,有可能導致它在 GC 流程結束後,再也沒機會變回白色了。

簡單的方法就是設定從第三種狀態。也就是第 2 種白色。

在 Lua 中,兩個白色狀態是一個乒乓開關,當前需要刪除 0 型白色節點時, 1 型白色節點就是被保護起來的;反之也一樣。

當前的白色是 0 型還是 1 型,見 global_State 的 currentwhite 域。otherwhite() 用於乒乓切換。獲得當前白色狀態,使用定義在 lgcc.h 77 行的巨集:

#define luaC_white(g) cast(lu_byte, (g)->currentwhite & WHITEBITS)

FINALIZEDBIT 用於標記 userdata 。當 userdata 確認不被引用,則設定上這個標記。它不同於顏色標記。因為 userdata 由於 gc 元方法的存在,釋放所佔記憶體是需要延遲到 gc 元方法呼叫之後的。這個標記可以保證元方法不會被反覆呼叫。

KEYWEAKBIT 和 VALUEWEAKBIT 用於標記 table 的 weak 屬性,無需多言。

FIXEDBIT 可以保證一個 GCObject 不會在 GC 流程中被清除。為什麼要有這種狀態?關鍵在於 lua 本身會用到一個字串,它們有可能不被任何地方引用,但在每次接觸到這個字串時,又不希望反覆生成。那麼,這些字串就會被保護起來,設定上 FIXEDBIT 。

在 lstring.h 的 24 行定義有:

#define luaS_fix(s) l_setbit((s)->tsv.marked, FIXEDBIT)

可以把一個字串設定為被保護的。

典型的應用場合見 llex.c 的 64 行:

void luaX_init (lua_State *L) { int i; for (i=0; itsv.reserved = cast_byte(i+1); /* reserved word */ } }

以及 ltm.c 的 30 行:

void luaT_init (lua_State *L) { static const char *const luaT_eventname[] = { /* ORDER TM */ "__index", "__newindex", "__gc", "__mode", "__eq", "__add", "__sub", "__mul", "__div", "__mod", "__pow", "__unm", "__len", "__lt", "__le", "__concat", "__call" }; int i; for (i=0; itmname[i] = luaS_new(L, luaT_eventname[i]); luaS_fix(G(L)->tmname[i]); /* never collect these names */ } }

以元方法為例,如果我們利用 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 行:

TString *tmname[TM_N]; /* array with tag-method names */

global_State 中 tmname 域就直接以 TString 指標的方式記錄了所有元方法的名字。換作標準的 lua api 來做的話,通常我們需要把這些 string 放到登錄檔,或環境表中,才能保證其不被 gc 清除,且可以在比較時拿到。lua 自己的實現則利用 FIXEDBIT 做了一步優化。

最後,我們來看看 SFIXEDBIT 。其實它的用途只有一個,就是標記主 mainthread 。也就是一切的起點。我們呼叫 lua_newstate 返回的那個結構。

為什麼需要把這個結構特殊對待?因為即使到 lua_close 的那一刻,這個結構也是不能隨意清除的。我們來看看世界末日時,程式都執行了什麼?見 lstate.c 的 105 行。

static void close_state (lua_State *L) { global_State *g = G(L); luaF_close(L, L->stack); /* close all upvalues for this thread */ luaC_freeall(L); /* collect all objects */ lua_assert(g->rootgc == obj2gco(L)); lua_assert(g->strt.nuse == 0); luaM_freearray(L, G(L)->strt.hash, G(L)->strt.size, TString *); luaZ_freebuffer(L, &g->buff); freestack(L, L); lua_assert(g->totalbytes == sizeof(LG)); (*g->frealloc)(g->ud, fromstate(L), state_size(LG), 0); }

這是 lua_close 的最後一個步驟。 luaC_freeall 將釋放所有的 GCObject ,但不包括有 SFIXEDBIT 的 mainthread 物件 。見 lgc.c 484 行

void luaC_freeall (lua_State *L) { global_State *g = G(L); int i; g->currentwhite = WHITEBITS | bitmask(SFIXEDBIT); /* mask to collect all elements */ sweepwholelist(L, &g->rootgc); for (i = 0; i < g->strt.size; i++) /* free all string lists */ sweepwholelist(L, &g->strt.hash[i]); }

這裡 FIXEDBIT 是被無視的,而在此之前,FIXEDBIT 被保護著。見 lstate.c 的 153 行(lua_newstate 函式):

g->currentwhite = bit2mask(WHITE0BIT, FIXEDBIT);

這麼做很容易理解,lua 世界的起源,一切根資料都放在這個物件中,如果被提前清理,後面的程式碼就會出問題。真正釋放這個物件不是在 GC 中,而是最後那句:

lua_assert(g->totalbytes == sizeof(LG)); (*g->frealloc)(g->ud, fromstate(L), state_size(LG), 0);

順帶還 assert 了一下,最終,世界上是不是隻剩下這個結構。

寫累了,待續。