深入探究Lua的GC演算法
對於記憶體的管理,是程式在應用的時候的必需知識點,《Lua設計與實現》中對Lua語言的GC原理做了一個詳細的講解,雲風的blog也對其進行了詳盡的講解Lua GC 的原始碼剖析 系列
給出作者
在github上的lua原始碼連結:https://github.com/lichuang/Lua-5.1.4-codedump這兒就繼續做《Lua設計與實現》的閱讀筆記,對Lua GC原理及其過程做一個詳盡的講解,由於篇幅較大,就一分為二,寫上下篇來講解整個過程。
一、GC的原理及其演算法設計
不同的語言,對GC演算法的設計不同,常見的GC演算法是引用計數和Mark-Sweep演算法, c#採用的是Mark-sweep && compact演算法, Lua採用的是Mark-sweep演算法,分開說一下:
引用計數演算法:在一個物件被引用的情況下,將其引用計數加1,反之則減1,如果計數值為0,則在GC的時候回收,這個演算法有個問題就是迴圈引用。
Mark-sweep演算法:每次GC的時候,對所有物件進行一次掃描,如果該物件不存在引用,則被回收,反之則儲存。
在Lua5.0及其更早的版本中,Lua的GC是一次性不可被打斷的過程,使用的Mark演算法是雙色標記演算法(Two color mark),這樣系統中物件的非黑即白,要麼被引用,要麼不被引用,這會帶來一個問題:在GC的過程中如果新加入物件,這時候新加入的物件無論怎麼設定都會帶來問題,如果設定為白色,則如果處於回收階段,則該物件會在沒有遍歷其關聯物件的情況下被回收;如果標記為黑色,那麼沒有被掃描就被標記為不可回收,是不正確的。
為了降低一次性回收帶來的效能問題以及雙色演算法的問題,在Lua5.1後,Lua都採用分佈回收以及三色增量標記清除演算法(Tri-color incremental mark and sweep)
其基本的原理虛擬碼,參考書中原文為:
每個新建立的物件顏色設定為白色
//初始化階段
遍歷root節點中引用的物件,從白色置為灰色,並且放入到灰色節點列表中
//標記階段
while(灰色連結串列中還有未掃描的元素):
從中取出一個物件,將其置為黑色
遍歷這個物件關聯的其他所有物件:
if 為白色
標記為灰色,加入到灰色連結串列中(insert to the head)
//回收階段
遍歷所有物件:
if 為白色,
沒有被引用的物件,執行回收
else
重新塞入到物件連結串列中,等待下一輪GC
二、GC的資料結構
分析Lua中對於需要GC的型別資料
#define iscollectable(o) (ttype(o) >= LUA_TSTRING)
都會有一個基本的定義CommonHeader,其定義為:
next: GCObject連結串列指標,該指標用來將所有的GC物件都連結在一個表中;
tt: 資料型別:nil, boolean, number, string...
marked: 標記欄位,byte表示的欄位顏色定義為
這兒特定解釋一下為什麼會有兩種白色,前面提到,5.1後的Lua採用的是三色標記演算法,其實質是四色標記演算法,分為0型白色和1型白色,在GC回收的時候,會設定當前的白色為其中一種,詳見globalstate中的currentwhite,這樣在程式碼回收的時候,如果當前物件的白色不為currentwhite,則認為其不可回收,這樣的物件需要等到下一次的GC才能決定是否回收,具體參看後面的,會有對應的應用。對於global_state的設計為:
具體的引數的作用,詳見註釋,就不在一一解釋了
三、GC的流程
1、資料的建立
想要了解GC的過程,首先看看資料是怎麼在建立的時候被連結到GC連結串列中的,主要分為三種資料的建立
1) 一般資料的建立 luaC_link
簡單直接,直接insert to the head
2) upval的建立 luaC_linkupval
3) userdata(udata)的建立 luaS_newdata
2、開始GC
整個GC過程分為五個階段,其定義為
其執行GC的函式為singlestep,來看第一步的操作:
進一步看看markroot的操作:
其實就是reset一遍相關的變數,然後標記mainthread, G表,registry表,然後切換到下一個標記階段。
參看定義:
最後都要執行reallymarkobject函式(此處需要展示一下我的豎屏截圖便利了:D):
基本的註釋都解釋了各個物件是如何的處理的,udata是不會引用其他型別的資料,所以一步到黑色,upvalue則根據是否為close來決定是否標記到黑色,open狀態的upvalue變化較為頻繁,需要在後面的remarkupvals中解決。
3、GC的掃描階段 GCSpropagate
只要處於這個階段,就會分2種情況執行,一個是propagatemark,一個是atomic,讓我們分別看其實現過程。
首先看處於灰色連結串列中一直都有物件的情況,在這步操作當中,是可以分步操作的,整個GC的分步操作,就是在這一步操作中,在每次掃描後,都會返回本次掃描標記的物件的大小之和,再下一個分步執行的時候再繼續執行,而一旦進入atomic函式中,就需要一次性的執行,不能再分步執行了。
來看propagatemark函式是如何實現的:
對於table,如果該表是weak表,則退回到灰色狀態,否則遍歷表的陣列和散列表部分進行標記,詳見traversetable函式;
對於func,traverseclosure主要對func中的upval進行標記;
對於thread, 則將其移植到grayagain中,放在atomic中進行處理;
對於proto,對其中的字串、upvalue、區域性變數等進行遍歷標記;
注意,這兒沒有處理string\udata型別資料,這是放在其他部分進行的,不需要進行相關的標記;
4、GC 掃描階段的barrier操作
由於採用分步式增量掃描標記演算法,所以會出現在分步操作過程中,新增加的物件與被掃描過的物件之間有引用關係的變化,未來確保黑色物件引用的物件中有白色物件,lua提供了兩種操作設計:
1)標記過程向前走一步 luaC_barrierf
如果新建物件是白色,而它被一個黑色物件引用了,那麼將這個新建物件顏色從白色變為灰色;
2)標記過程向後走一步 luaC_barrierback
類似於上,此時將引用的它的黑色物件的顏色從黑色變為灰色,使得其重新被掃描一次
(或許你看出截圖顏色變了,是的,回家了,又是新的編輯器了~)
從define可以看出,只有table需要進行luaC_barrierback,這是由於table本身設計,就是一個table可能會對應N個key或者value,這樣如果新增一個key/value,如果將其置為灰色,然後將其加入gray連結串列中,這樣多個新增會帶來較大的效能。
採用向後,就是將該table物件退回到gray狀態,這樣新增多個,其實質都是隻改變該table一次,注意這個gray不是改為gray鏈中,而是將該table加入到grayagain鏈中,在掃描完gray鏈後再掃描grayagain鏈即可。參考原始碼即可:
對比向前比較簡單了:直接呼叫reallymarkoject
5、GC的atomic操作
當gray連結串列中物件都標記完成後,會執行一次atomic操作,注意這個操作是不能被打斷的,所以叫原子操作,參考原始碼:
首先處理上一篇文章中提到的對open狀態的upvalues,然後處理一次gray連結串列;
然後處理整個弱表,將lua_State指標指向meta表,然後處理一次gray連結串列
然後處理grayagain連結串列,類似於上
然後處理udata,其處理函式為luaC_separateudata:
註釋很詳細,注意放到tmudata連結串列中後,是在後續操作再集中處理一次;
處理完基本的幾個資料後,atomic會把白色型別切換到下一個GC操作的白色型別,然後修改狀態到回收階段CGSsweepstring, 這兒對sweepstrgc進行了賦初值,是為了下面的字串定位。
6、GC的回收階段 GCSsweepstring/GCSsweep
首先進入的回收階段是對字串的處理
雖然是case,但是其實質是一個迴圈,每次取出散列表中的一個字串連結串列,進行一次遍歷回收,sweepwholelist最終會呼叫到sweeplist,等一下給出原始碼。
當處理完所有的字串後,切換到GCSsweep狀態:
關鍵操作是sweeplist,參看其原始碼:
程式碼中也對前面說的多色標記中的兩種白色的作用做了講解,otherwhite就是本次不可回收的白色,如果處理的物件的白色就是otherwhite,是不會被回收的
7、結束階段 GCSfinalize
這是整個GC的最後階段了,來看看其操作的原始碼:
首先處理,是否有前面提到的tmudata連結串列, 其操作函式為GCTM:
注意,udata本身有GC方法,未來確保其GC方法的呼叫,實在這次GC中呼叫G方法,但是這個udata本身,是在下一次的GC中才會被回收的。udata的GC呼叫則是在fasttm中呼叫TM_GC來實現。
初看也會迷糊怎麼迴圈的,其實結合上面的case中的 if(g->tmudata)可以理解,為什麼每次GCTM都會執行 g->tmudata的移動賦值操作。
最終萬事大吉,本次GC流程走完,設定到GCSpause狀態,等待下一次GC呼叫。
8、GC的進度控制
其實GC的呼叫,可以分為兩種,一種是自動呼叫,一個是手動呼叫
自動呼叫函式: luaC_checkGC
一般不希望自動GC,可以採用setthreshold,將GCthreshold的值設定為非常大,這樣不回自動觸發GC
手動呼叫,則設定GC的相關引數 setthreshold:
estimate是對當前記憶體使用量的一個預估值,gcpause是一個百分比,通過lua_gc可以設定,另一個gc進度的引數是gcstepmul,其主要影響singlestep函式的呼叫次數,具體原因參看原始碼:
整個流程都在註釋中講解了,其中關鍵是lim的設定,然後不斷的呼叫singlestep, 然後處理GC狀態即可,注意setthreshold是設定的兩次GC之間的時間間隔。由於修改了threshold,對於關閉自動GC的情況,需要再次重新設定關閉自動GC一次。
9、總結
對於lua的GC的原理的探究就到這兒,熟悉一門語言的GC流程後,同理去推導理解其他語言的GC會有很大幫助,同時也可以在平時使用lua的時候,對於GC的一些操作更加知其所以然。大家共勉!