现象
我们服务器上有个进程每次运行超过一天就会退出,由于我们的日志只是在lua层的,所以异常退出的时候并没有任何dump.在我的经验中,导致这种情况一般有两种原因,一个是进入死循环,cpu高占用率,被系统杀掉了.还有就是内存使用量太高,被系统杀掉.我们用的是skynet框架,该框架可以检测到死循环,看日志里面没有死循环相关的记录.所以大致定位在内存泄漏,为了验证猜测,我将进程启动,使用top查看内存使用量,刚启动的时候占用内存为0.7%.3个小时后再查看,内存使用量达到了15%.这肯定是内存泄漏没跑了.
内存泄漏原理
lua作为一个带gc的语言.内存泄漏出现一般是因为引用没有删除,但是查起来非常困难.一个对象可能在程序的任何地方被索引.lua的gc算法采用的是mark and sweep.在内部维护一个以root为根结点的树.gc运行的时候,先标记出从root节点不可达的对象,然后在sweep阶段回收该对象.例如:
local tmp = {}
local tmp_ref = tmp
如上代码片段将产生日下对象树:
当 tmp_ref = nil 后树变形如下
这个时候root 还能够通过tmp到达{},所以gc并不会将{}回收.我们继续将tmp设置为nil tmp = nil.
{}对象此时为root不可达对象,将会被gc回收.
lua中所谓的内存泄漏就是root可达对象越来越多,换句话说,就是以root为根节点的树的节点越来越多.所以检测的方法就是,在程序运行的不同的时刻对比树结构.找出那些持续增加的对象,再根据增加的对象定位到相关代码就能准确定位问题了.例如:
local tmp = {}
---- snapshot 1
local tmp1 = {}
----- snapshot 2
上述代码三个位置的树结构快照如下:
snapshot1: snapshot2:
可以看出snapshot2比snapshot1 多出 tmp1 节点.
检测过程
以我们出问题的代码为例,我们出问题的代码三个回合制的游戏,我在每个回合游戏开始的时候打印对象树,因为每局游戏开始会将所有状态重置,此时将上一回合开始和本回合开始两个时刻的代码树进行对比,看看本回合有哪些新增对象,以此得出导致内存泄漏的对象.用的工具为 https://github.com/cloudwu/lua-snapshot.git.(关于该工具使用方法自行看其github)相关检测代码如下:
local old_snap
local function instance_diff()
-- 第二及后续回合
if old_snap then
-- 本回合和上一回合的对象树对比,找出本回合新增的树
local new_snap = snapshot()
local diff = {}
for k,v in pairs(new_snap) do
if not old_snap[k] then
diff[k] = v
end
end
-- 打印所有本回合新增对象
luadump(diff, "snapshot diff")
else
-- 第一回合,先得到快照
old_snap = snapshot()
end
end
运行完后,看到很多tf函数对象,以此为线索找到如下代码:
function timer.timeout(ti, f)
local function tf()
local f = timer.handles[tf]
if f then
f()
end
end
skynet.timeout(ti, tf)
timer.handles[tf] = f
return tf
end
function timer.cancel(tf)
timer.handles[tf] = nil
end因为skynet的定时器没有取消功能,所以设计了上述代码.如果希望提前取消定时器可以通过调用timer.cancel.实际上,timer.cancel是必须要调用的,要不然每次调用timer.timeout,tf函数是临时生成的,又在timer.handles[tf]=f使用tf地址作为key来索引f,因为每次生成的tf地址不一样,所以每次调用timer.handles都将增加一个索引,也就是4个字节的内存泄漏.