记一次lua内存泄漏查找

3,302 阅读3分钟

现象

我们服务器上有个进程每次运行超过一天就会退出,由于我们的日志只是在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个字节的内存泄漏.