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

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

/* ** Garbage-collection function */ LUA_API int lua_gc (lua_State *L, int what, int data) { int res = 0; global_State *g; lua_lock(L); g = G(L); switch (what) { case LUA_GCSTOP: { g->GCthreshold = MAX_LUMEM; break; } case LUA_GCRESTART: { g->GCthreshold = g->totalbytes; break; } case LUA_GCCOLLECT: { luaC_fullgc(L); break; } case LUA_GCCOUNT: { /* GC values are expressed in Kbytes: #bytes/2^10 */ res = cast_int(g->totalbytes >> 10); break; } case LUA_GCCOUNTB: { res = cast_int(g->totalbytes & 0x3ff); break; } case LUA_GCSTEP: { lu_mem a = (cast(lu_mem, data) << 10); if (a <= g->totalbytes) g->GCthreshold = g->totalbytes - a; else g->GCthreshold = 0; while (g->GCthreshold <= g->totalbytes) { luaC_step(L); if (g->gcstate == GCSpause) { /* end of cycle? */ res = 1; /* signal it */ break; } } break; } case LUA_GCSETPAUSE: { res = g->gcpause; g->gcpause = data; break; } case LUA_GCSETSTEPMUL: { res = g->gcstepmul; g->gcstepmul = data; break; } default: res = -1; /* invalid option */ } lua_unlock(L); return res; }

從程式碼可見,對內部狀態的訪問,都是直接訪問 global state 表的。GC 控制則是呼叫內部 api 。lua 中對外的 api 和內部模組互動的 api 都是分開的。這樣層次分明。內部子模組一般名為 luaX_xxx X 為子模組代號。對於收集器相關的 api 一律以 luaC_xxx 命名。這些 api 定義在 lgc.h 中。

此間提到的 api 有兩個:

LUAI_FUNC void luaC_step (lua_State *L); LUAI_FUNC void luaC_fullgc (lua_State *L);

用於分步 GC 已經完整 GC 。

另一個重要的 api 是:

#define luaC_checkGC(L) { \ condhardstacktests(luaD_reallocstack(L, L->stacksize - EXTRA_STACK - 1)); \ if (G(L)->totalbytes >= G(L)->GCthreshold) \ luaC_step(L); }

它以巨集形式定義出來,用於自動的 GC 。如果我們審查 lapi.c ldo.c lvm.c ,會發現大部分會導致記憶體增長的 api 中,都呼叫了它。保證 gc 可以隨記憶體使用增加而自動進行。

這裡插幾句。

使用自動 gc 會有一個問題。它很可能使系統的峰值記憶體佔用遠超過實際需求量。原因就在於,收集行為往往發生在呼叫棧很深的地方。當你的應用程式呈現出某種週期性(大多數包驅動的服務都是這樣)。在一個服務週期內,往往會引用眾多臨時物件,這個時候做 mark 工作,會導致許多臨時物件也被 mark 住。

一個經驗方法是,呼叫 LUA_GCSTOP 停止自動 GC。在週期間定期呼叫 gcstep 且使用較大的 data 值,在有限個週期做完一整趟 gc 。

另,condhardstacktests 是一個巨集,通常是不開啟的。

先來看 luaC_fullgc 。它用來執行完整的一次 gc 動作。fullgc 並不是僅僅把當前的流程走完。因為之前的 gc 行為可能執行了一半,可能有一些半路加進來的需要回收的物件。所以在走完一趟流程後,fullgc 將阻塞著再完整跑一遍 gc 。整個流程有一些優化的餘地。即,前半程的 gc 流程其實不必嚴格執行,它並不需要真的去清除什麼。只需要把狀態恢復。這個工作是如何做到的呢?見 lgc.c 的 637 行:

void luaC_fullgc (lua_State *L) { global_State *g = G(L); if (g->gcstate <= GCSpropagate) { /* reset sweep marks to sweep all elements (returning them to white) */ g->sweepstrgc = 0; g->sweepgc = &g->rootgc; /* reset other collector lists */ g->gray = NULL; g->grayagain = NULL; g->weak = NULL; g->gcstate = GCSsweepstring; } lua_assert(g->gcstate != GCSpause && g->gcstate != GCSpropagate); /* finish any pending sweep phase */ while (g->gcstate != GCSfinalize) { lua_assert(g->gcstate == GCSsweepstring || g->gcstate == GCSsweep); singlestep(L); }

比較耗時的 mark 步驟被簡單跳過了(如果它還沒進行完的話)。和正常的 mark 流程不同,正常的 mark 流程最後,會將白色標記反轉。見 lgc.c 548 行,atomic 函式:

/* flip current white */ g->currentwhite = cast_byte(otherwhite(g));

在 fullgc 的前半程中,直接跳過了 GCSpropagate ,重置了內部狀態,但沒有翻轉白色標記。這會導致後面的 sweep 流程不會真的釋放那些白色物件。sweep 工作實際做的只是把所有物件又重新設定回白色而已。

接下來就是一個完整不被打斷的 gc 過程了。

markroot(L); while (g->gcstate != GCSpause) { singlestep(L); } setthreshold(g);

從根開始 mark ,直到整個 gc 流程執行完畢。最後,重新設定了 GCthreshold 。注:呼叫 fullgc 會重置 GCthreshold ,所以如果你曾經呼叫 LUA_GCSTOP 暫停自動 GC 的話(也是通過修改 GCthreshold 實現) ,記得再呼叫一次。

stepgc 要相對複雜一些。在 lua 手冊的 2.10 解釋了 garbage-collector pause 和 step multiplier 的意義,卻沒有給出精確定義。lua_gc 的說明裡,也只說“LUA_GCSTEP: 發起一步增量垃圾收集。 步數由 data 控制(越大的值意味著越多步), 而其具體含義(具體數字表示了多少)並未標準化。 如果你想控制這個步數,必須實驗性的測試 data 的值。 如果這一步結束了一個垃圾收集週期,返回返回 1 。 並沒有給出準確的含義。實踐中,我們也都是以經驗取值。

回到原始碼,我們就能搞清楚它們到底是什麼了。

case LUA_GCSETPAUSE: { res = g->gcpause; g->gcpause = data; break; } case LUA_GCSETSTEPMUL: { res = g->gcstepmul; g->gcstepmul = data; break; }

這裡只是設定 gcpause gcstepmul 。gcpause 實際只在 lgc.c 59 行的 setthreshold 巨集中用到

#define setthreshold(g) (g->GCthreshold = (g->estimate/100) * g->gcpause)

看見,GCSETPAUSE 其實是通過調整 GCthreshold 來實現的。當 GCthreshold 足夠大時,luaC_step 不會被 luaC_checkGC 自動觸發。事實上,GCSTOP 正是通過設定一個很大的 GCthreshold 值來實現的。

case LUA_GCSTOP: { g->GCthreshold = MAX_LUMEM; break; }

gcpause 值的含義很文件一致,用來表示和實際記憶體使用量 estimate 的比值(放大 100 倍)。一旦記憶體使用量超過這個閥值,就會出發 GC 的工作。

要理解 gcstepmul ,就要從 lua_gcLUA_GCSTEP 的實現看起。

case LUA_GCSTEP: { lu_mem a = (cast(lu_mem, data) << 10); if (a <= g->totalbytes) g->GCthreshold = g->totalbytes - a; else g->GCthreshold = 0; while (g->GCthreshold <= g->totalbytes) { luaC_step(L); if (g->gcstate == GCSpause) { /* end of cycle? */ res = 1; /* signal it */ break; } } break; }

step 的長度 data 被放大了 1024 倍。在 lgc.c 的 26 行,也可以看到

#define GCSTEPSIZE 1024u

我們姑且可以認為 data 的單位是 KBytes ,和 lua 總共佔用的記憶體 totalbytes 有些關係。

ps. 這裡 totalbytes 是嚴格通過 Alloc 管理的記憶體量。而前面提到的 estimate 則不同,它是一個估算量,比 totalbytes 要小。這是因為,前面也提到過,userdata 的回收比較特殊。被檢測出已經訪問不到的 userdata 佔用的記憶體並不會馬上釋放(保證 gc 元方法的安全呼叫),但 estimate 會拋去這部分,不算在實際記憶體使用量內。

見 lgc.c 544 行

udsize = luaC_separateudata(L, 0); /* separate userdata to be finalized */

以及 lgc.c 553 行

g->estimate = g->totalbytes - udsize; /* first estimate */

從程式碼邏輯,我們暫時可以把 data 理解為,需要處理的位元組數量(以 K bytes 為單位)。如果需要處理的資料量超過了 totalbytes ,自然就可以把 GCthreshold 設定為 0 了。

實際上不能完全這麼理解。因為 GC 過程並不是一點點回收記憶體,同時可用記憶體越來越多。GC 分標記(mark) 清除(sweep) 呼叫 userdata 元方法等幾個階段。只有中間的清除階段是真正釋放記憶體的。所以可用記憶體的增加( totalbytes 減少)過程,時間上並不是線性的。通常標記的開銷更大。為了讓 gcstep 的每個步驟消耗的時間更平滑,就得有手段動態調整 GCthreshold 值。它和 totalbytes 最終影響了每個 step 的時間。

下面的關注焦點轉向 luaC_step ,見 lgc.c 的 611 行:

void luaC_step (lua_State *L) { global_State *g = G(L); l_mem lim = (GCSTEPSIZE/100) * g->gcstepmul; if (lim == 0) lim = (MAX_LUMEM-1)/2; /* no limit */ g->gcdept += g->totalbytes - g->GCthreshold; do { lim -= singlestep(L); if (g->gcstate == GCSpause) break; } while (lim > 0); if (g->gcstate != GCSpause) { if (g->gcdept < GCSTEPSIZE) g->GCthreshold = g->totalbytes + GCSTEPSIZE; /* - lim/g->gcstepmul;*/ else { g->gcdept -= GCSTEPSIZE; g->GCthreshold = g->totalbytes; } } else { lua_assert(g->totalbytes >= g->estimate); setthreshold(g); } }

從程式碼我們可以看到,GC 的核心其實在於 singlestep 函式。luaC_step 每次呼叫多少次 singlestep 跟 gcstepmul 的值有關。

如果是自動進行的 GC ,當 totalbytes 大於等於 GCthreshold 時,就會觸發 luaC_step 。每次 luaC_step ,GCthreshold 都會被調高 1K (GCSTEPSIZE) 直到 GCthreshold 追上 totalbytes 。這個追趕過程通常發生在 mark 流程。因為這個流程中,totalbytes 是隻增不減的。

如果是手控 GC ,每次 gcstep 呼叫執行多少次 luaC_step 則跟 data 值有關。大體上是 1 就表示一次(在 mark 過程中就是這樣)到了 sweep 流程就不一定了。這和 singlestep 呼叫次數,即 gcstepmul 的值有關。它影響了 totalbytes 的減小速度。

所以,一兩句話很難嚴格定義出這些控制 GC 步進量的引數的含義,只能慢慢閱讀程式碼,看看實現了。

在 lua 手冊的 2.10 這樣描述“step multiplier 控制了收集器相對記憶體分配的速度。 更大的數字將導致收集器工作的更主動的同時,也使每步收集的尺寸增加。 小於 1 的值會使收集器工作的非常慢,可能導致收集器永遠都結束不了當前週期。 預設值為 2 ,這意味著收集器將以記憶體分配器的兩倍速執行。”

從程式碼看,這絕非嚴格定義。至少從今天已經分析的程式碼中還看不出這一點。

gcstepmul 的值和記憶體增漲速度如何產生聯絡?明天再寫 :)