做Lua和C/C++混合编程的伙伴,大概都经历过这种绝望:程序跑着跑着,内存占用像坐了火箭一样往上飙,最后OOM(Out of Memory)崩掉。你盯着lua_gc看,发现GC明明在跑,Lua堆里的表也清了不少,但系统内存就是不掉。这时候,十有八九是C引用表(C Reference Table)里的东西,被Lua的GC当成“透明人”给无视了。
今天咱们不整那些虚头巴脑的定义,直接切入实战。我会把自己踩过的坑、查过的日志、改过的代码,像讲故事一样摊开给你看。咱们从一个真实的事故说起。
一、 惊魂时刻:那个“幽灵”内存
那是两年前的一个项目,我们在做一个高性能的游戏服务器后台,核心逻辑用Lua写,但涉及到网络I/O和大量数据序列化,必须通过C API插入。
上线一周后,监控报警:某节点内存占用从2GB飙升到8GB,然后进程挂掉。重启后又能撑一天,如此循环。
我接入排查时,第一时间运行了Lua的统计工具:
-- 简单的内存诊断脚本
print("Lua Heap Size:", collectgarbage("count"))
print("GC State:", collectgarbage("isrunning"))
print("GC Generations:", collectgarbage("generation"))
print("GC Steps:", collectgarbage("step", 0))
结果让人困惑:collectgarbage("count") 显示的Lua堆内存只有几百MB,非常健康。但系统层面的top命令显示RSS( resident set size )已经8GB了。
这就是典型的“内存泄露在Lua GC视线之外”。
为什么Lua GC看不到它?
Lua的垃圾回收器(GC)只管理Lua对象(table, function, string, thread, userdata等)。一旦你在C层创建一个对象,并通过lua_newuserdata分配了一块内存,只要这块内存没有关联到任何一个“Lua根对象”(Root),或者虽然有关联但引用链断裂,Lua GC就无法感知它的生死。
但在我们的案例中,问题更隐蔽:我们使用了一个C-side的引用表来缓存热数据,目的是避免重复创建和销毁对象的开销。这个引用表是一个std::unordered_map或者类似的C++数据结构,Key是某种ID,Value是指向Lua userdata的指针。
关键在于:我们忘记了把这个C-side引用表告诉Lua GC。
Lua GC不知道这个C++容器里还有活跃的Lua对象引用,所以当Lua代码里的逻辑不再使用这些对象时,Lua GC认为它们没用了,试图回收它们。但因为C侧的引用表还握着指针,强制回收会导致悬空指针,引发崩溃。为了安全,我们在C侧手动lua_newuserdata时,如果没有注册__gc元方法,或者没有通过lua_createtable创建一个Lua表来管理这些引用,那么:
- Lua GC:认为这些userdata是“孤儿”,但由于C侧有强引用,它无法回收(取决于具体API使用方式,有时甚至会直接报错或行为未定义)。
- C侧引用表:不断地往里面塞新的userdata指针,却从未清理旧的。
- 结果:C++的堆内存无限增长,Lua GC无能为力。
二、 深挖:C引用表是如何“逃逸”GC视线的
要解决这个问题,首先得理解Lua和C之间的内存边界在哪里。
2.1 Lua的两种Userdata
Lua提供了两种userdata,这是理解问题的基础:
- 全userdata(Full userdata):
lua_newuserdata分配的一块原始内存块。它是Lua对象,Lua GC可以管理它。如果你给它的元表注册了__gc方法,当Lua GC认为它不可达时,会调用这个方法。 - 轻userdata(Light userdata):
lua_pushlightuserdata,实际上就是一个C指针(void*)。它不是Lua对象,Lua GC完全忽略它。它没有元表,没有生命周期管理。
在我们的事故中,我们使用的是全userdata,但犯了一个致命错误:我们在C++层维护了一个引用计数或字典,试图自己管理生命周期,却忘记了Lua GC的机制。
更糟糕的是,有时候为了性能,我们会用轻userdata来传递一些简单的C对象指针。轻userdata永远不会被GC回收。如果你把一个指向C++对象的轻userdata推入Lua栈,然后在Lua侧把它存到一个全局表里,Lua GC不会回收这个轻userdata指针本身(因为它不是对象),但它也不会保护你指向的那个C++对象。如果C++对象被释放了,而Lua侧还存着这个指针,下次访问就是Segmentation Fault。
2.2 C引用表的“双刃剑”
我们当时的架构是这样的:
// 伪代码:出问题的C++模块
class LuaCache {
private:
// 这个map就是“幽灵”
std::unordered_map<int, lua_State*> userdatas;
public:
void cache(int id, void* lua_userdata) {
userdatas[id] = lua_userdata;
// 错误!这里没有通知Lua GC这个引用!
}
void* get(int id) {
auto it = userdatas.find(id);
if (it != userdatas.end()) {
return it->second;
}
return nullptr;
}
// 关键缺失:没有析构清理,也没有定期清理
};
每当Lua代码调用C接口请求一个对象时,C++层检查缓存。如果有,返回缓存的userdata;如果没有,创建一个新的,放入缓存,再返回。
问题在于:Lua侧的代码,比如这样:
-- Lua侧代码
local obj = get_or_create_from_cache(123)
-- 用完obj,局部变量obj在函数结束后就“不可达”了
-- Lua GC以为obj可以被回收了
Lua GC看到obj不可达,想要回收。但是,C++侧的userdatas[123]还持有它的强引用。Lua GC的检测机制是:如果有任何C侧的引用(通过lua_gc的C回调或lua_ref等机制)指向它,GC就不会回收。 然而,我们并没有使用lua_ref,而是自己存了个指针。Lua GC根本不知道C++侧有个指针指向它!
于是,Lua GC放心地“回收”了这块内存(实际上,对于全userdata,如果没有__gc,GC可能会直接释放底层内存,或者在某些实现中,如果它认为不可达就释放)。等等,这里有个细节:如果Lua GC认为userdata不可达,它会调用__gc(如果有的话)或者直接释放内存。但C++侧的指针仍然有效!这就变成了悬空指针。
我们的代码里,其实没有注册__gc,因为觉得“我自己管理”。结果,Lua GC在某个时刻,可能因为内存压力,强行回收了这些userdata(如果它们被认为是不可达的),而C++侧的map里还留着那些已经无效的指针。下次访问,直接崩溃。
或者,更常见的情况是:我们使用了lua_ref。lua_ref是Lua提供的一种机制,它会在Lua GC的内部引用表中注册一个引用。这样,Lua GC就知道“嘿,这个对象还被C代码引用着,不能回收”。
// 正确的做法:使用lua_ref
int ref = lua_ref(L, LUA_REGISTRYINDEX);
// 存ref而不是存userdata指针
userdatas[id] = ref;
// 清理时
lua_unref(L, ref);
如果我们用了lua_ref,Lua GC就会追踪这个引用,不会回收对应的userdata。但这又引入了新的问题:谁负责调用lua_unref?
如果C++侧的清理逻辑(比如服务关闭、对象销毁)没有正确调用lua_unref,那么这些引用就会永远留在Lua的注册表中,导致Lua GC认为这些userdata依然“可达”,从而永远无法回收。这就是“内存泄漏”的另一种形式——Lua GC层面的泄漏。
在我们的案例中,混合了两种错误:
- 部分地方用了裸指针(C++管理),导致Lua GC误判,引发悬空指针风险。
- 部分地方用了
lua_ref,但忘记在适当时候lua_unref,导致Lua GC无法回收,内存持续增长。
三、 排查实战:如何抓住“幽灵”
当发现内存增长但Lua GC计数正常时,怎么定位?
3.1 工具链组合拳
第一步:确认泄漏范围
先用Lua自带的debug库,看看对象到底分布在哪里。
-- debug.lua
local function dump_gc_info()
print("========== GC Info ==========")
print("Memory (KB):", collectgarbage("count"))
print("GC Steps:", collectgarbage("step", 0)) -- 触发一次GC
print("GC Pause:", collectgarbage("checkpause"))
print("GC Mode:", collectgarbage("isrunning"))
-- 获取所有userdata
print("\n--- All Userdatas ---")
local seen = {}
local function traverse(t)
if seen[t] then return end
seen[t] = true
local tag = lua_type(t, -1) -- 这里需要调整,debug.getregistry是更好的方式
end
-- 更直接的方式:遍历注册表
local registry = debug.getregistry()
for k, v in pairs(registry) do
if type(v) == "userdata" then
print("Registry Userdata found, key:", k, "type:", type(v))
end
end
end
注意,debug.getregistry()返回的是Lua的注册表,里面存储了通过lua_ref引用的对象。如果这里面的userdata数量随时间持续增长,而你的Lua堆内存却显示正常,那就基本锁定是lua_ref泄漏。
第二步:C++侧的内存分析
对于C++侧的引用表,使用Valgrind(Linux)或Visual Studio的内存诊断工具(Windows)。
# Linux下使用Valgrind
valgrind --leak-check=full --show-leak-kinds=all ./your_lua_game_server
Valgrind会告诉你,哪些C++对象分配了内存但没有被释放。结合堆栈跟踪,就能定位到是哪个cache()调用没有对应的uncache()。
第三步:Lua源码层面的追踪(高级)
如果以上方法还不够,可以编译一个带调试信息的Lua版本,或者在关键位置插入打印日志。
例如,在lua_newuserdata和lua_ref/lua_unref处加日志:
// 在C++封装层
void* create_userdata(lua_State* L, int size) {
void* u = lua_newuserdata(L, size);
LOG(INFO) << "Created userdata at " << u << ", total userdatas: " << ++g_userdata_count;
return u;
}
int ref_object(lua_State* L) {
int ref = lua_ref(L, LUA_REGISTRYINDEX);
LOG(INFO) << "Ref created: " << ref << ", total refs: " << ++g_ref_count;
return ref;
}
void unref_object(lua_State* L, int ref) {
lua_unref(L, ref);
LOG(INFO) << "Ref released: " << ref << ", total refs: " << --g_ref_count;
}
通过监控g_ref_count和g_userdata_count的趋势,可以清晰地看到是否有持续增长且没有回落的现象。
3.2 一个真实的排查案例
在我们的项目中,最终定位到的问题是:
有一个ConnectionManager类,负责管理所有玩家连接。每个连接对象是一个Lua userdata。当玩家断开连接时,C++侧调用了ConnectionManager::remove(id),从自己的std::map中删除了指针。但是,忘记调用lua_unref。
这意味着,虽然C++不再持有该连接的指针,但Lua的注册表里还留着对这个userdata的引用。Lua GC永远认为这个userdata是“可达”的(因为注册表是根),所以永远不会回收。
随着玩家频繁进出,注册表里的userdata数量线性增长,每个userdata可能占用几KB到几十KB不等,最终导致内存耗尽。
修复方案:
在ConnectionManager::remove(id)中,增加lua_unref的调用。
void ConnectionManager::remove(int conn_id, lua_State* L) {
auto it = connections.find(conn_id);
if (it != connections.end()) {
// 假设我们存的是ref
lua_unref(L, it->second.ref);
connections.erase(it);
}
}
同时,为了安全,我们还增加了一个析构函数,确保在ConnectionManager销毁时,所有残留的ref都被释放。
ConnectionManager::~ConnectionManager() {
for (auto& [id, data] : connections) {
lua_unref(L, data.ref); // 确保清理
}
connections.clear();
}
四、 性能优化:从“不用回收”到“优雅回收”
解决了泄漏,接下来是性能优化。C引用表的存在,初衷是为了性能。频繁创建和销毁Lua userdata开销较大,缓存起来可以避免这些开销。但如果不加以管理,缓存本身就会成为性能瓶颈和内存黑洞。
4.1 使用LRU缓存替代无限增长的Map
无限增长的Map是内存泄漏的温床。引入LRU(最近最少使用)策略,可以限制缓存的大小。
#include <list>
#include <unordered_map>
template<typename K, typename V>
class LRUCache {
private:
std::list<std::pair<K, V>> cache_list; // 用于维护访问顺序
std::unordered_map<K, std::list<std::pair<K, V>>::iterator> cache_map; // 用于快速查找
size_t max_size;
public:
LRUCache(size_t max) : max_size(max) {}
void put(K key, V value) {
auto it = cache_map.find(key);
if (it != cache_map.end()) {
// 已存在,更新值并移到头部
cache_list.erase(it->second);
} else if (cache_map.size() >= max_size) {
// 满了,移除最久未使用的(尾部)
auto last = cache_list.back();
cache_map.erase(last.first);
cache_list.pop_back();
}
cache_list.push_front({key, value});
cache_map[key] = cache_list.begin();
}
V get(K key) {
auto it = cache_map.find(key);
if (it == cache_map.end()) return V(); // 或者抛出异常
// 移到头部
cache_list.splice(cache_list.begin(), cache_list, it->second);
return it->second->second;
}
// 注意:这里V应该是lua_ref(int)或void*指针,需要根据实际情况调整
};
在使用时,将lua_ref的整数值存入LRU缓存,而不是直接存userdata指针。
class LuaCache {
LRUCache<int, int> ref_cache; // Key是业务ID, Value是lua_ref
lua_State* L;
public:
LuaCache(lua_State* state, size_t max_refs) : L(state), ref_cache(max_refs) {}
int get_or_create(int business_id) {
int ref = ref_cache.get(business_id);
if (ref == LUA_NOREF) {
// 创建新的userdata
void* u = lua_newuserdata(L, sizeof(MyStruct));
// 初始化...
// 设置元表...
ref = lua_ref(L, LUA_REGISTRYINDEX);
ref_cache.put(business_id, ref);
}
return ref;
}
void release(int business_id) {
int ref = ref_cache.get(business_id);
if (ref != LUA_NOREF) {
lua_unref(L, ref);
ref_cache.erase(business_id); // 假设LRU缓存支持erase
}
}
~LuaCache() {
// 清理所有剩余的ref
for (auto& [id, ref] : ref_cache.get_all()) {
lua_unref(L, ref);
}
}
};
这样,即使业务逻辑忘记调用release,当缓存满时,最旧的引用也会被自动lua_unref,从而允许Lua GC回收对应的userdata。
4.2 区分“强引用”和“弱引用”
有时候,我们并不希望缓存对象阻止GC回收。比如,一个临时计算结果,我们缓存它以便快速访问,但如果内存紧张,我们愿意让它被GC回收,下次再重新计算。
这时,可以使用弱引用表(Weak Table)。
在Lua中,创建一个表,并设置其元表的__mode为"k"(弱键)或"v"(弱值)。
-- Lua侧创建弱引用表
local weak_cache = setmetatable({}, {__mode = "v"}) -- 弱值,key是强引用
-- 或者
local weak_cache_key = setmetatable({}, {__mode = "k"}) -- 弱键,value是强引用
当Lua GC运行,发现表中的value(或key)没有其他强引用时,它会被从表中删除,从而被回收。
在C++侧,你可以提供一个接口,将lua_ref存入这个弱引用表。
void put_into_weak_cache(lua_State* L, int ref) {
// 假设L上已经有一个名为'weak_cache'的弱引用表
lua_getglobal(L, "weak_cache");
lua_pushinteger(L, ref);
lua_pushvalue(L, -2); // 复制ref作为value
lua_settable(L, -3); // weak_cache[ref] = ref
lua_pop(L, 1);
}
这样,如果Lua侧的代码不再使用某个对象,即使C++侧的weak_cache还存着它的ref,Lua GC也会清理掉这个ref,防止内存泄漏。
注意:弱引用表不能解决所有问题。如果C++侧的其他部分仍然强引用着这个
