說明
分析lua使用的gc算法,如何做到分步gc,以及測試結論
gc算法分析
lua gc采用的是標記-清除算法,即一次gc分兩步:
- 從根節點開始遍歷gc對象,如果可達,則標記
- 遍歷所有的gc對象,清除沒有被標記的對象
二色標記法
lua 5.1之前采用的算法,二色回收法是最簡單的標記-清除算法,缺點是gc的時候不能被打斷,所以會嚴重卡住主線程
三色標記法
- lua5.1開始采用了一種三色回收的算法
- 白色:在gc開始階段,所有對象顏色都為白色,如果遍歷了一遍之后,對象還是白色的將被清除
- 灰色:灰色用在分步遍歷階段,如果一直有對象為灰色,則遍歷將不會停止
- 黑色:確實被引用的對象,將不會被清除,gc完成之后會重置為白色
- 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?
-
luajit中有兩個判斷是否需要gc的宏,如果需要gc,則會直接進行一次gc的step操作
1
2
3
4
5
6
7/* GC check: drive collector forward if the GC threshold has been reached. */
- gc.total: 代表當前已經申請的內存
- gc.threshold:代表當前設置gc的閾值
-
這兩個宏會在各個申請內存的地方進行調用,所以當前申請的內存如果已經達到設置的閾值,則會申請的所有對象都會有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 |
int LJ_FASTCALL lj_gc_step(lua_State *L) |
- 可以看到最重要的變量為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 |
-- 關閉jit |
測試結果(第一個列為當前內存,第二列為當前內存閾值,第三列為當前gc狀態,第四列為循環10次的時間)
1 |
-- gc沒有介入階段,平均時間大概在0.059s,這時候代表着內存的分配速度 |
- 火焰圖分析(gc處於sweep狀態):
主要時間消耗在gc_sweep(51.34%):該步驟會遍歷所有的gc對象,如果可回收,就進行free操作,所以gc_sweep里面最耗時的就是free函數(34%左右)
gc優化
從火焰圖上看到,gc_sweep函數耗時嚴重,其主要工作是遍歷所有gc對象,如果為白色,則free它,所以優化方案有兩點:
- 內存分配算法優化
- 減少gc遍歷的對象,即減少那些明確常駐內存的gc對象遍歷
內存分配算法優化
luajit默認使用的是自己的內存分配算法,現在嘗試分別使用glibc自帶的內存分配和第三方高性能jemalloc(選擇的版本是jemalloc-stable-4),tcmalloc(選擇的是gperftools-2.7)的分配算法進行分析測試結果
結果分析
- 申請內存的速率跟常駐內存的table大小關系不大,luajit自帶的分配算法最快,但是總體相差不大
- 隨着常駐內存的table大小變大,會影響gc釋放速度,這將會卡主主線程
- 釋放內存速率jemalloc最好,並且隨着常駐內存的table大小變大,效率體現的越明顯
table緩存優化
思路
自己寫一個table緩沖池,緩沖一定數量、一定大小的table在c++內存,避免每次反復申請內存及rehash,reszie table操作
TODO: 需要具體修改luajit源碼進行測試
減少gc遍歷的對象
思路
對於那些常駐內存的table,可以主動加一個標記,在gc時候遍歷到這個table,將對其以及所有子gc對象從全局gc鏈表刪除,並加入到一個全局const gc對象鏈表中。
源代碼可以查看github
測試結果
對比結果
火焰圖(jemalloc-4G內存)
- gc_sweep在總的采樣占比上已經變得很少,這點從打log上面就能看出
- free占比gc_sweep的時間比重增加,說明減少了遍歷的時間消耗
注意點
- 從給table設置constant之后完整的一次gc之前,不能主動調用full gc否則會導致table子元素沒有被標記,這樣就會被誤刪除,導致訪問的時候出現內存問題
- table不能設置weak
- table元素只能是table、string、number,不能有function,線程
結論
可以看到優化在常駐內存table大的時候很明顯,主要提升了兩個方面的速度: - 在GCSpropagate階段減少不必要的遍歷,加快遍歷速度,同時減少了新臨時變量的生成
- 在GCSweep階段,減少不必要的遍歷,同時因為加快遍歷速度,需要free的臨時變量變少,所以減少了GCSweep的時間