Skynet 广播优化小记
最近和 QC 同学共建一套自动化压测的框架,希望能够确定游戏的性能基线,通过自动化定期回顾,看看有没有引入性能劣化的代码。压测过程中,发现广播性能不够强,搞的清明假期也在家想问题,写篇博客记录一下
为了让房间服务能腾出手专心处理战斗相关内容,我们针对性的改进了打包和广播,分离到一个与房间 1 比 1 配置的 sidecar 服务上去,期待能多利用一个核的算力来实现。在维持客户端链接的 login 节点,我们也做了类似 Skynet 的 cluster 模块的优化,拆分开一个 gate 和多个 sender,sender 负责实际的写 socket,配合 Skynet 的 direct_write,可以实现多线程写 socket。整个链路如下:
room -> sidecar -> cluster -> gate -> sender
多亏了子熏老师搞的火焰图,可以快速定位到热点函数。配合 top 工具,先定位到 CPU 占用最高的热点服务,再抓到耗时最多的函数,发现是在 sender 上运行的广播函数。功能是将输入的一堆玩家标记数据及包数据,发送到对应的 fd 去。我有点想当然认为是 Lua 与 C 频繁交互的锅,于是写了个多播的 C 接口,外面先用 Netpack 打好包头,只调用一次 C 接口,由 C 全部完成数组的遍历与 socket API 的调用。
实际效果的确不错,但是单独测试过 for 循环遍历 table 后,我发现原因主要并不是减少交互。在单独测试中,C 遍历 lua table 只有 10% 左右的性能提升。真正起作用的,应该是省略了多次 Netpack。之前的实现是面向单个玩家设计的,每次发送前都要对相同的数据包调用一次 Netpack,无形中多拷贝了很多数据。优化后,sender 的 CPU 占用虽然也在 80% 左右,但是每秒处理的包量从 5000+ 到 9000+ 了
还能更快吗
sender 优化完后,轮到 gate 的性能吃紧了。我第一反应也是搞个 C 接口,cm.yang 觉得我们可以先在 Lua 层优化一波。通过将最终用户的 fd 上推到 sidecar 里,简化 gate 和 sender 的分发逻辑,实现精细化管理,真的能有效吗?在之前的实现里,gate 是遍历所有收包玩家的标记,找出需要发送的 sender,再给 sender 一个一个的发消息的。因为 sender 只有 6 个,我觉得还不如直接给 6 个 sender 都发了,因为 sender 自己还有一层类似判断,通过遍历标记找属于自己管理的 fd,不怕发重了的。结果 gate 的负载没有优化掉,反而 sender 的包更多了
于是,我们上了精细化管理的改造。gate 拿到了每个 sender 对应的 fd 列表,对每个 sender 精准推送需要发的 fd 和协议包。gate 本身省掉了遍历玩家标记找 sender 的过程,发送给 sender 的参数也可以变短,从所有需要发送的玩家,到该 sender 所属的玩家。提升效果非常巨大,gate 的 QPS 已经到了 4w,CPU 占用在 90%,而省掉了分拣逻辑的 sender,也上升到 1.2w 的 QPS 了
还有办法更快吗
压测后发现 CPU 被用完了,虽然性能很高,但是对总体 CPU 消耗还是太大了,撑了几分钟就挂了。当前的压测机器用了 8 核,gate 和 sender 已经占了 7 个,高负载的情况下的确有点离谱。我又去 review 了一遍压测机器人的代码,发现与客户端的实际行为还是有偏差的。真正的客户端并不会关注那么多玩家,本身终端性能就不支持画那么多角色。。。接下来应该会降一下关注数量的硬上限,减少广播包的数量
恰好遇上一个清明假期,忍不住花点时间研究下,还能不能继续提升广播性能。节前波波同学提到用 Skynet 的 multicast,优化下 gate 和 sender 的通信。写起来是挺快的,但是要额外绕一层 multicastd,而且发给 sender 的消息需要退化成老版本,每个 sender 收包需要一致,再自己分拣需要发出的 fd 列表。我琢磨了一下,还不如直接用 multicast 底层的 C 库。猜想是压力在于 Skynet 的服务间消息通信,需要反复拷贝字符串,那我将需要反复拷贝的部分改为发指针不就好了。但是折腾过 multicast 的代码后,还是不明所以,觉得很绕
multicast 的示例代码里,首先是用 Skynet.pack 将 lua 数据变成一个指针和长度,然后用 mc.pack 将数据放进一个带引用计数的 package 里。这个 mc.pack 返回了一个指向 package 指针的指针和 package 指针的大小,接着用 Skynet.tostring 模拟成一个 lua string。再调用 mc.bind 来设置 package 中的引用计数,顺便就将这个指向指针的指针 free 掉了!我不理解,为啥要用 Skynet.tostring 来模拟 lua string,难道这样才能跨服务发指针?另外一头是怎么解压 tostring 的结果的呢?我也没看懂,难道 Skynet.redirect 配搭 mc.unpack 就行了?mc.unpack 的输入是指向 package 指针的指针和 size,tostring 可以模拟第一个参数,那第二个 size 是怎么来的呢?
放假期间就在各种折腾,基本将 gate 和 sender 两边的打包解包排列组合都试了一遍 …… 最后自己折腾了一个方向,加了一个 pack_reference 的 C 接口用于打包并设置引用计数,以及另一个直接从 package 指针解压数据的 new_unpack,配合使用暂时没啥问题,回头压测看看性能。通过这组接口,gate 只需要打包一次数据,然后各个 sender 负责解包,解包过程只是指针操作,不需要拷贝字符串,应该能释放更多性能。最后通过 mc.close 来减引用计数并释放内存块
折腾期间经常有种冲动,如果我将 fd 列表用自己写的 C 打包格式来打包解包,是不是用起来更快了?但是这样会导致侵入量过多,和 Skynet 设计初衷不太相符。现在这套协作方式应该还是以 Lua 为主的,Lua 驱动主逻辑运行,性能关键部位用 C 写,所以所有的 C 模块交互,都是通过和 Lua 交互完成的,而不是直接调用另一个 C 模块的接口。对于目前的广播场景这种热点,希望部分参数不经过框架层打包解包的,毕竟还是少数
update: 请教了大佬们, 用Skynet.tostring是为了方便管理,发过去解包的时候,会用个void*指针指向这个字符串,正好就是mc_package**了
引用计数管理协议字节块没体现什么优势,可能包太小了,接下来试试将conns打成字符串再发送,C层直接解析这个字符串成fd列表,看看性能