Skynet/Lua 凭借其轻量易用、灵活热更、Actor并发模型的特性被广泛应用于游戏开发等场景,但 Lua 动态内存管理机制也带来了内存占用过高的挑战。
本文结合我最近对一个 skynet 项目做的内存优化分析经验,做一下分享。
1、lua 内存使用优化
1.1. 减少 lua 函数调用层数
服务端收到协议后,要经过很多次函数调用才到真正的执行函数。以手头的项目举例,服务 A 函数负责接收协议数据,收到数据后,会将协议和参数传给 B 函数处理,B 函数根据定义找到对应的模块和方法,再由 C 函数排队(一个玩家一个队列)调用 D 目录下对应的模块和函数, D 目录的模块再 require 对应的目标模块,才调用到真正的执行函数 E。最后,E 函数还会调用其他模块来完成业务逻辑。
调用层数过深的问题是,lua 要为每个过程保留过程数据,如函数地址、参数等,也就是调用栈信息(CallInfo),也叫栈帧
建议:减少调用层级,可以使用注册回调机制
1.2. 匿名函数使用注意点
玩家登录后,很多模块会使用 skynet.timeout 、skynet.fork 等来驱动模块数据更新和协议推送。这里传入的参数通常都是匿名函数,方便修改上下文数据。
虽然匿名函数使用非常方便,但函数内存在对外部变量的引用,将导致这个变量数据无法被 GC 回收。
特别是一个服务承载多个玩家的情况,有些时候玩家退出了,但定时器等事件还在,玩家的数据就没有清理干净。
建议:回调函数使用玩家id取数据,只存在一个id的索引,不会影响玩家数据回收。
作者 没有开花的树
1.3. 玩家数据表默认值
设计上,玩家数据字段都会设置默认值,方便业务对数据进行初始化判定和处理。
默认值的存在,会导致玩家登录后,虽然数据库数据为空,但这些默认字段数据都会占用内存,以及在对玩家数据做持久化时,会将默认值写入数据库,增加数据库的大小。很多玩家登录一两次就流失了,大部分玩家也不会体验所有的功能,显然这是不必要的开销。
local base = { level = 0, exp = 0, gold = 0 } local player = setmetatable({}, { __index = base })
作者 没有开花的树
1.4. lua 字符串拼接
字符串拼接,最方便的是使用 .. 拼接两个字符串,但字符串在 Lua 中是不可变的,每次拼接都会创建新的字符串,占用内存。如果是拼接比较长的字符串,建议使用 table.concat
-- 不推荐长字符串拼接使用 local str = "" for i = 1, 1000 do str = str .. tostring(i) end -- 推荐长字符串拼接使用 local tmp = {} for i = 1, 1000 do table.insert(tmp, tostring(i)) end local str = table.concat(tmp)
1.5. lua GC 选择与优化
lua 5.4 提供了两种 GC 方式: 增量式 GC 和 分代 GC,该怎么选择?
分代 GC 对比增量式的优势是,临时数据不用后可以很快释放,当临时数据占比越大时,这个优势越大
场景 | 说明 |
---|---|
临时数据较多,总内存使用适中 | 优先使用分代 GC,设置加快 minor GC 频率 |
临时数据较多,总内存使用较高 | 考虑使用分代 GC + 定时 full GC 的方式,加快 minor GC 频率 |
临时数据较少,总内存使用较高 | 考虑使用增量式 GC,设置加快 GC 频率,定时 full GC |
总内存使用较低 | GC 调节的必要性不大,考虑增量式 GC,定时 step GC |
作者 没有开花的树
lua 自动 GC 主要靠新数据驱动,在 lua 内存使用很高时,新数据短时间很难超过总内存的一定比例,这时就需要手动 GC 释放内存。或者是根据总内存大小调整 GC 参数,总内存大时收紧 GC 阈值,加快全量 GC。
当单个 lua 超过 10G 时,就要考虑拆分服务了,无论哪种 GC 方式,都避免不了全量 GC 中断时间比较久。我本地测试 40G 的数据量,单次全量 GC 在 4 秒左右,还不包括对弱表、metatable、字符串等的额外处理时间。
关于 lua GC 更多实现细节,我之前整理了两篇文章,可以移步看看:
lua5.3 垃圾回收分析
lua5.4 分代垃圾回收
2、skynet 内存使用优化
2.1. 协议消息堆积
网关服务收到客户端协议消息就转发给 player服务,虽然能提高 socket io 吞吐量,但不能利用 TCP /IP 协议抑制对端数据发送,容易引起业务系统不稳定。
其次,在 player 服务处理不过来的情况下,消息会堆积在 player 服务消息队列,也会占用很多内存。
建议:
1. 当服务器处于繁忙时,可以利用 skynet.socketdriver 的 pause 方法暂停某个 socket 消息,需要时,再通过 start 方法恢复。
2. 当玩家登录时,在成功登录之前,不要接收登录协议之后的 socket 消息,有可能玩家处于登录排队状态,就不用额外使用队列维护协议数据
2.2. 不要依赖 skynet.queue
使用队列 skynet.queue 可以将当前协程挂起,排队一个一个处理,但是,当协程挂起,lua 也要保留该协程的数据。
一些项目会使用 skynet.queue 来保证玩家协议顺序处理,但服务端处理速度是有限的,会导致很多协议收到后,处理协程会被放到队列中挂起。
建议:减少同步处理过程,将协议数据记录到 table,所有协程不用挂起等待结果,再由一个协程 FIFO 方式顺序处理。
作者 没有开花的树
2.3. 定时器慎用 skynet.timeout
玩家业务涉及的定时器非常多,使用 skynet.timeout 会比较重度。当在线玩家人数非常多时,会明显拖垮 skynet 的性能。
而且,如果一个服务承载多个玩家,在玩家离线时,无法立刻回收 skynet.timeout 事件,就算回调函数会判断玩家不在线就空操作,但是这种空消息一多,一方面是占用内存,另一方面也会影响 skynet 消息调度。
建议: 可以使用 zset 库实现业务定时器,score 取事件触达时间,github 有 lua zset 库可以参考
2.4. 关注消息队列的长度
skynet 虽然是一个并发异步框架,但对于一个服务,同时只会由一个线程调度,而且线程每次根据权重消费其中 1 到 N 个消息就让出机会调度其他服务。
当这个服务处于繁忙的情况下,其他服务还不断向其投递消息,将可能导致这个服务消息队列堆积消息,还可能因为消息过多而“独占”一个线程从而增加整个系统并发延迟。
这里以 socket 缓冲区队列为例说明,尽管不是在说 skynet 消息队列,但本质上的问题是类似的。
如果客户端出现卡死,socket消息处理不过来的情况,服务端还一直给这个玩家发消息,将会导致服务端 socket 消息数据堆积,占用大量内存。
这个问题不难处理,skynet 框架有对上层暴露问题。这里只要检查 size 超过服务端设定,将玩家踢下线即可。
function SOCKET.warning(fd, size) -- size K bytes havn't send out in fd logger.warn("watchdog socket warning, fd=%s, size=%s", fd, size) end
如果是 skynet 服务消息堆积,可以在容易出现堆积的服务,通过 skynet.mqlen() 关注消息队列长度,做好预防措施。
作者 没有开花的树
2.5. 慎重使用 skynet.call
我在之前的文章盘点了 skynet.call 潜在问题,主要是三个问题:没有超时机制、 内存方面的隐患、增加并发延迟。这里不展开赘述了,有兴趣的小伙伴可以移步看看。
另外,这里再提一点, skynet.call 看起来像是当前服务做同步等待操作,但实际上,当前协程挂起时,服务其后的消息会被先执行。(之前文章有提到同步问题,也提供了解决思路)
建议: 尽量用 skynet.send 代替 skynet.call,但不是 skynet.call 就不可以使用。一些低频同步的场景还是使用 skynet.call 比较方便,减少逻辑复杂度。
2.6. 使用 sharedata 共享配置
虽然将配置生成为 lua 文件,可以利用 skynet 共享代码机制,但其生成的 table 在不同的服务会存在多份,造成内存浪费。
使用 sharedata 共享配置是不错的选择,还能支持热更。但 sharedata 查询的数据会比原生的 lua table 更耗时一点,这是因为 sharedata 是利用 metatable 查询数据,访问时增加了延迟。所以,对于个别高频读取的配置,可以在计算过程中 sharedata 读到配置后 clone 一份(注意别 clone 全部配置)。
作者 没有开花的树
另外,使用 sharedata 还要注意一个问题,等配置热更后,需要手动 flush,确保删除旧数据引用。
写在最后
时间有限,文章后续还会持续更新,欢迎关注作者博客。有不对的地方,欢迎交流指正。