Lua GC機制


說明

分析lua使用的gc算法,如何做到分步gc,以及測試結論

gc算法分析

lua gc采用的是標記-清除算法,即一次gc分兩步:

  1. 從根節點開始遍歷gc對象,如果可達,則標記
  2. 遍歷所有的gc對象,清除沒有被標記的對象

二色標記法

image
lua 5.1之前采用的算法,二色回收法是最簡單的標記-清除算法,缺點是gc的時候不能被打斷,所以會嚴重卡住主線程

三色標記法

image

  1. lua5.1開始采用了一種三色回收的算法
    • 白色:在gc開始階段,所有對象顏色都為白色,如果遍歷了一遍之后,對象還是白色的將被清除
    • 灰色:灰色用在分步遍歷階段,如果一直有對象為灰色,則遍歷將不會停止
    • 黑色:確實被引用的對象,將不會被清除,gc完成之后會重置為白色
  2. luajit使用狀態機來執行gc算法,共有6中狀態:
    • GCSpause:gc開始階段,初始化一些屬性,將一些跟節點(主線程對象,主線程環境對象,全局對象等)push到灰色鏈表中
    • GCSpropagate:分步進行掃描,每次從灰色鏈表pop一個對象,遍歷該對象的子對象,例如如果該對象為table,並且value沒有設置為week,則會遍歷table所有table可達的value,如果value為gc對象且為白色,則會被push到灰色鏈表中,這一步將一直持續到灰色鏈表為空的時候。
    • GCSatomic:原子操作,因為GCSpropagate是分步的,所以分步過程中可能會有新的對象創建,這時候將再進行一次補充遍歷,這遍歷是不能被打斷的,但因為絕大部分工作被GCSpropagate做了,所以過程會很快。新創建的沒有被引用的userdata,如果該userdata自定義了gc元方法,則會加入到全局的userdata鏈表中,該鏈表會在最后一步GCSfinalize處理。
    • GCSsweepstring:遍歷全局字符串hash表,每次遍歷一個hash節點,如果hash沖突嚴重,會在這里影響gc。如果字符串為白色並且沒有被設置為固定不釋放,則進行釋放
    • GCSsweep:遍歷所有全局gc對象,每次遍歷40個,如果gc對象為白色,將被釋放
    • GCSfinalize:遍歷GCSatomic生成的userdata鏈表,如果該userdata還存在gc元方法,調用該元方法,每次處理一個

什么時候會導致gc?

  1. luajit中有兩個判斷是否需要gc的宏,如果需要gc,則會直接進行一次gc的step操作

    1
    2
    3
    4
    5
    6
    7
    /* GC check: drive collector forward if the GC threshold has been reached. */
    #define lj_gc_check(L) \
    { if (LJ_UNLIKELY(G(L)->gc.total >= G(L)->gc.threshold)) \
    lj_gc_step(L); }
    #define lj_gc_check_fixtop(L) \
    { if (LJ_UNLIKELY(G(L)->gc.total >= G(L)->gc.threshold)) \
    lj_gc_step_fixtop(L); }
    • gc.total: 代表當前已經申請的內存
    • gc.threshold:代表當前設置gc的閾值
  2. 這兩個宏會在各個申請內存的地方進行調用,所以當前申請的內存如果已經達到設置的閾值,則會申請的所有對象都會有gc消耗。

lua gc api

lua可以通過

1
collectgarbage([opt [, arg]])

 

來進行一些gc操作,其中opt參數可以為:

  • “collect”:執行一個完整的垃圾回收周期,這是一個默認的選項
  • “stop”:停止垃圾收集器(如果它在運行),實現方式其實就是將gc.threshold設置為一個巨大的值,不再觸發gc step操作
  • “restart”:將重新啟動垃圾收集器(如果它已經停止)。
  • “count”:返回當前使用的的程序內存量(單位是Kbytes),返回gc->total/1024
  • “step”:執行垃圾回收的步驟,這個步驟的大小由參數arg(較大的數值意味着較多的步驟),如果這一步完成了一個回收周期則函數返回true。
  • “setpause”:設置回收器的暫停參數,並返回原來的暫停數值。該值是一個百分比,影響gc.threshold的大小,即影響觸發下一次gc的時間,設置代碼如下:

    1
    g->gc.threshold = (g->gc.estimate/100) * g->gc.pause;

    g->gc.estimate為當前實際使用的內存的大小,如果gc.pause為200,則該段代碼表示,設置gc的閾值為當前實際使用內存的2倍

  • “setstepmul”:設置回收器的步進乘數,並返回原值。該值代表每次自動step的步長倍率,影響每次gc step的速率,具體這么影響可以查看后面小節

luajit gc速率控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int LJ_FASTCALL lj_gc_step(lua_State *L)
{
global_State *g = G(L);
GCSize lim;
int32_t ostate = g->vmstate;
setvmstate(g, GC);
// 設置此次遍歷的限制值,每次調用gc_onestep都會返回此次step的消耗,限制值消耗完畢之后此次step結束;
lim = (GCSTEPSIZE/100) * g->gc.stepmul;
if (lim == 0)
lim = LJ_MAX_MEM;
if (g->gc.total > g->gc.threshold)
g->gc.debt += g->gc.total - g->gc.threshold;
do {
lim -= (GCSize)gc_onestep(L);
if (g->gc.state == GCSpause) {
g->gc.threshold = (g->gc.estimate/100) * g->gc.pause;
g->vmstate = ostate;
return 1; /* Finished a GC cycle. */
}
} while (sizeof(lim) == 8 ? ((int64_t)lim > 0) : ((int32_t)lim > 0));
if (g->gc.debt < GCSTEPSIZE) {
g->gc.threshold = g->gc.total + GCSTEPSIZE;
g->vmstate = ostate;
return -1;
} else {
// 加快內存上漲速度;
g->gc.debt -= GCSTEPSIZE;
g->gc.threshold = g->gc.total;
g->vmstate = ostate;
return 0;
}
}
  • 可以看到最重要的變量為lim,該變量控制着一個lj_gc_step里的循環次數。每次調用gc_onestep都會返回此次的step消耗,例如如果處於GCSpropagate階段,則返回值為該step遍歷的內存大小,所以如果遍歷了一個較大的table就會消耗更多的lim值
  • lim大小主要由gc.stepmul控制,所以設置該值的大小會影響每次step的調用時間

測試大table對gc的影響

從luajit gc原理上看,以為每次gc的遍歷都會遍歷所有的gc對象,所以大的table是會影響gc性能

測試環境

操作系統:Debian GNU/Linux 8
CPU:Intel(R) Xeon(R) CPU E5-2640 v2 @ 2.00GHz
內存:64G
lua環境:LuaJIT-2.1.0-beta3 (測試的時候關閉jit)

測試代碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 關閉jit
if jit then
jit.off()
end

local data = {} -- 一個大的table,用來模擬常駐內存的table,測試的時候使用的是drop_data.lua里面的數據,該data有8655個table元素(在gc的時候產生消耗),60810個元素(包括table元素,會在遍歷的時候產生消耗)

function deepCopyTable(t)
local ret = {}
for k, v in pairs(t) do
if type(v) == "table" then
ret[k] = deepCopyTable(v)
else
ret[k] = v
end
end
return ret
end

datas = {}

-- 循環產生更多的常駐內存的table,可以看到總共會有865W+的table元素和總共6000W+的元素
for i = 1, 1000 do
datas[#datas+1] = deepCopyTable(data)
end

print("begin")
local time = os.clock()
for i = 1, 2000000 do
-- 模擬產生臨時變量
local temp = deepCopyTable(data)

-- 每10次計算一次時間和內存
if i % 10 == 0 then
local time_temp = os.clock()
print(collectgarbage("count"), time_temp-time)
time = time_temp
end
end

測試結果(第一個列為當前內存,第二列為當前內存閾值,第三列為當前gc狀態,第四列為循環10次的時間)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- gc沒有介入階段,平均時間大概在0.059s,這時候代表着內存的分配速度
3345733.2617188 4136590.0390625 0 0.058304
3366347.6367188 4136590.0390625 0 0.058013000000003
3386962.0117188 4136590.0390625 0 0.058147999999996
3407576.3867188 4136590.0390625 0 0.059978000000001
3428190.7617188 4136590.0390625 0 0.059843999999998
3448805.1367188 4136590.0390625 0 0.058331000000003
3469419.5117188 4136590.0390625 0 0.058205000000001
3490033.8867188 4136590.0390625 0 0.058352999999997
3510648.2617188 4136590.0390625 0 0.058503000000002
3531262.6367188 4136590.0390625 0 0.058151000000002
3551877.0117188 4136590.0390625 0 0.058059999999998

-- gc進入sweep階段,刪除內存,峰值時間在0.78s左右,后面時間變少應該是因為那一塊都是常駐內存的gc對象,很少會去調用free函數
5056726.3867188 5056726.3242188 1 0.076171000000002
5077340.7617188 5077340.9492188 1 0.076453999999998
4955367.8554688 4955368.0429688 4 0.140509
3994134.0820313 3994134.0195313 4 0.679567
3032849.7617188 3032850.1992188 4 0.786561
2133608.0117188 2133608.7617188 4 0.788004
2154222.3867188 2154222.3242188 4 0.255904
2174836.7617188 2174837.1992188 4 0.254212
full sweep time: 2.850359
2195451.1367188 4137406.4453125 0 0.066203999999999
  • 火焰圖分析(gc處於sweep狀態):
    image
    主要時間消耗在gc_sweep(51.34%):該步驟會遍歷所有的gc對象,如果可回收,就進行free操作,所以gc_sweep里面最耗時的就是free函數(34%左右)

gc優化

從火焰圖上看到,gc_sweep函數耗時嚴重,其主要工作是遍歷所有gc對象,如果為白色,則free它,所以優化方案有兩點:

  1. 內存分配算法優化
  2. 減少gc遍歷的對象,即減少那些明確常駐內存的gc對象遍歷

    內存分配算法優化

    luajit默認使用的是自己的內存分配算法,現在嘗試分別使用glibc自帶的內存分配和第三方高性能jemalloc(選擇的版本是jemalloc-stable-4),tcmalloc(選擇的是gperftools-2.7)的分配算法進行分析

    測試結果

    image
    image
    image

結果分析

  • 申請內存的速率跟常駐內存的table大小關系不大,luajit自帶的分配算法最快,但是總體相差不大
  • 隨着常駐內存的table大小變大,會影響gc釋放速度,這將會卡主主線程
  • 釋放內存速率jemalloc最好,並且隨着常駐內存的table大小變大,效率體現的越明顯

table緩存優化

思路

自己寫一個table緩沖池,緩沖一定數量、一定大小的table在c++內存,避免每次反復申請內存及rehash,reszie table操作
TODO: 需要具體修改luajit源碼進行測試

減少gc遍歷的對象

思路

對於那些常駐內存的table,可以主動加一個標記,在gc時候遍歷到這個table,將對其以及所有子gc對象從全局gc鏈表刪除,並加入到一個全局const gc對象鏈表中。
源代碼可以查看github

測試結果

image

對比結果

image

火焰圖(jemalloc-4G內存)

image

    • gc_sweep在總的采樣占比上已經變得很少,這點從打log上面就能看出
    • free占比gc_sweep的時間比重增加,說明減少了遍歷的時間消耗

      注意點

    • 從給table設置constant之后完整的一次gc之前,不能主動調用full gc否則會導致table子元素沒有被標記,這樣就會被誤刪除,導致訪問的時候出現內存問題
    • table不能設置weak
    • table元素只能是table、string、number,不能有function,線程

      結論

      可以看到優化在常駐內存table大的時候很明顯,主要提升了兩個方面的速度:
    • 在GCSpropagate階段減少不必要的遍歷,加快遍歷速度,同時減少了新臨時變量的生成
    • 在GCSweep階段,減少不必要的遍歷,同時因為加快遍歷速度,需要free的臨時變量變少,所以減少了GCSweep的時間


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM