1. 背景
我們專案為ARPG手遊(也沒啥見不得人的,就叫暗黑血統手遊,後期不少坑錢活動的實現出自我手,輕拍。。。)。我們的伺服器底層設計源於某大廠,c/c++和luajit的實現,這次要說的是專案上線時(2014年11月左右)的一次luajit物件記憶體洩漏(廢棄的資料沒刪,我們都叫洩漏)和相應的解決方案。
2. 問題表現
記憶體增長,速率大概為200~300MB/天。
我們日誌會週期性列印Tcmalloc記憶體(Tcmalloc分享另見同事Wallen的部落格TCMalloc解密)和lua部分記憶體。獲取方法如下:
//tc malloc部分
size_t tc_memory = 0;
MallocExtension::instance()->GetNumericProperty( "generic.current_allocated_bytes", &tc_memory );
//luajit部分
int lua_memory = lua_gc( _L, LUA_GCCOUNT, 0 );
複製程式碼
通過日誌發現線上人數相似時,lua部分記憶體和總記憶體在同步增長,c/c++部分記憶體(即上兩個部分差值,主要是網路庫、物件體系物件和World部分的記憶體)基本穩定。日誌顯示lua的gc正常。
3. 分析
- c/c++部分沒有洩漏,player/monster等物件釋放沒有問題,c層物件析構基本由lua層觸發,lua層對應的物件記憶體釋放也是沒有問題的。
- 通過日誌顯示,場景/副本管理的釋放也是沒有問題的,線上匯出各個場景/副本內的物件個數/型別,經過分析,也沒發現什麼問題,日誌監控的資料也都正常。
- 最後懷疑是一些非主要的lua物件,存在表裡沒清除。因為lua物件裡,我們沒有太複雜的表結構,因此這些洩漏的記憶體會扁平地存在少量的幾個表裡。因此,只要能知道各個表的記錄數,結合線上人數推算其最大可能數,二者相比較,就能找出洩漏的表,在檢查表的增刪邏輯,就可以找到洩漏的邏輯。
4. lua表記錄數告警方案
如前述,只要知道各個表的記錄數,結合線上人數推算其最大可能數,二者相比較,就能找出洩漏的表。但是如果直接這麼做,勢必影響效能,即使熱更gm指令用lua全遍歷1次(因為table的值也可能是table,實際上要遍歷一棵樹),都是分鐘級別的。分幀做?這坑好大,如果不行也只能這麼幹了。按洩漏的速度,記憶體可以撐到下一次維護,所以,不慌。
然後看看c層luajit表的相關操作,看看有沒有更有效率的獲取方法(我們c層程式碼不熱更,改c層程式碼需要等維護重啟後才能生效,按洩漏速度,可以接受)。
程式碼裡主要關注的是lua table的增加和刪除記錄。然後看到lua table的resize(下面luajit相關程式碼都是luajit2.1分支程式碼,和1會長得有些不一樣,我們已升luajit2.1,將就一下)
/* Resize a table to fit the new array/hash part sizes. */
void lj_tab_resize(lua_State *L, GCtab *t, uint32_t asize, uint32_t hbits)
{
...
}
複製程式碼
邏輯其實就是陣列段或者雜湊段每次超過2的n次冪,會重新分配記憶體。
我們不需要精確的記錄數,其實只要在他每次resize的時候打條日誌就能知道這個table大概的記錄數,比如上一條日誌是1024->2048,那麼記錄數在1024~2048之間。日誌的優化見工程化部分。
怎麼確定是哪個table?這裡我們能取到的是table的地址,取不到table的名字,根據地址取名字也是一場噩夢。這裡我們曲線救國,既然能拿到lua_State,可以把lua的堆疊打出來,根據檔名和行號可以定位到程式碼行號,一行程式碼沒幾個table,這樣就能確定下來了。
//列印lua層堆疊,編譯lua加上除錯資訊
int32_t c_bt( lua_State* _L )
{
lua_Debug ldb;
LOG("[LUAWRAPPER](lua_stack) begin .......... ");
for(int32_t i = 0; lua_getstack( _L, i, &ldb)==1; i++)
{
lua_getinfo(_L, "Slnu", &ldb);
const char * name = ldb.name;
if (!name)
name = "";
const char * filename = ldb.source;
LOG("[LUAWRAPPER](bt) #%d: %s:'%s', '%s' line %d", i, ldb.what, name, filename, ldb.currentline );
}
LOG("[LUAWRAPPER](lua_stack) end .......... ");
return 0;
}
複製程式碼
到此,表的記錄數我們能拿到個粗略的值,也知道是哪張表了,每張表最大的數值也可以根據線上人數估計(大部分近似線上人數+暫時斷線的+跨服的,Buff之類的可以乘以一個最大倍數),剩下的就交給時間和人工分析日誌比較了。過濾掉正常的日誌,就能得到包含了洩漏物件的表了,在分析增刪邏輯就能找到廢棄又沒有清楚的資料了。
5. 工程化
前面講的只是方案,真正應用的時候,需要減少日誌的條數,以減輕分析的工作量。減少日誌通過下面兩種方式:
- 超過一定大小,才打日誌,我們一個服線上是3k左右,閥值取4096。
- 實踐發現,如果表在2的n次冪邊界發生頻繁切換時,resize日誌會重複打,所以修改了表結構,實現每個邊界只打一次。
所以,如果漏的不嚴重(<4096)又不會隨時間增長,是查不出來的,也就算了。
5.1. lua table表結構的修改
typedef struct GCtab {
...
//extended by ludong
int32_t max_sizearray;
int32_t max_sizeobj;
} GCtab;
複製程式碼
5.2. 初始化(luajit1的數值是不一樣的)
/* Create a new table. Note: the slots are not initialized (yet). */
static GCtab *newtab(lua_State *L, uint32_t asize, uint32_t hbits)
{
...
t->max_sizearray = 4096;
t->max_sizeobj = 4096;
return t;
}
複製程式碼
5.3. 寫日誌
為了解決庫編譯依賴的問題,將上層日誌函式定義為一個函式變數,程式啟動時註冊賦值。函式實現賦值就不貼了。
/* -- Table resizing ------------------------------------------------------ */
typedef void (*lua_large_table_warn_func)( lua_State *L, char* fmt, ... );
lua_large_table_warn_func g_lua_large_table_warn = NULL;
/* Resize a table to fit the new array/hash part sizes. */
void lj_tab_resize(lua_State *L, GCtab *t, uint32_t asize, uint32_t hbits)
{
...
//--------------------------------------------------
// by ludong
// check resize time
if (t->asize > t->max_sizearray || t->hmask > t->max_sizeobj) {
int32_t old_max_sizearray = t->max_sizearray;
int32_t old_max_sizeobj = t->max_sizeobj;
t->max_sizearray = (t->asize > t->max_sizearray) ? t->asize : t->max_sizearray;
t->max_sizeobj = (t->hmask > t->max_sizeobj) ? t->hmask : t->max_sizeobj;
if (g_lua_large_table_warn) {
g_lua_large_table_warn( L, "[ltable.c](resize) table:0x%x, array check size:%d, now:%d, node check size:%d, now:%d",
(size_t)t,
old_max_sizearray, t->asize,
old_max_sizeobj, t->hmask);
}
}
}
複製程式碼
5.4. 效能影響
每個表多8位元組(我們64位了,對齊後一樣的,32位自己摳一摳),對大部分表多兩個邏輯判斷,對大表每次日誌邊界打一條日誌和lua堆疊,記憶體和cpu基本都沒沒感覺。
6. 後記
這個方案做好之後基本是我們專案上線必檢查的日誌了,總會有一些不小心就沒刪的。