skynet框架去掉 cluster rpc 的消息长度限制方法
来源:网络 作者:佚名 点击:
次时间:2017-07-19 23:08
[摘要] Skynet 是一个基于 Actor 模式的开源并发框架。skynet 节点,通过 master ,认识网络中所有其它 skynet 节点。它们相互一一建立单向通讯通道。
为什么 skynet 提供的包协议只用 2 个字节表示包长度
skynet 提供了一个 lua 库 netpack ,用来把 tcp 流中的数据解析成 长度 + 内容的包。虽然使用 skynet 的同学可以不使用它,而自己另外实现一套解析协议来解析外部 TCP 数据流(比如 skynet 中的 redis driver 解析 redis server 的数据流就是用的换行符分割包),但依然有很多同学询问,能不能自定义包头长度。
这里的这个库定义的协议中,包长度是用 big-endian 的 2 个字节表示的,也就是说一个包的长度不得超过 64K 。这让很多人很为难。已经几次有同学建议,把长度放宽成 4 个字节,因为他们的应用环境中大部分包不会超过 64K ,但总有少量超过这个限制的。
历史上,skynet 的 gate 还是用 C 实现的时候(那个版本依然可以使用)的确可以自定义是使用 2 个字节还是 4 个字节表示包长。但经过一番考虑,我还是去掉了这个选择。
一个好的库,应该简洁,且引导使用者用正确的方法做正确的事情;而不应该提供让用户犯错的机会。在和游戏客户端通讯的时候,如果你只采用一个 TCP 连接,那么允许很长的数据包本身就是错误的。甚至 64K 都太大。
游戏通常需要比较快的响应速度,如果你允许在单个 TCP 连接中插入一个太大的数据块,比如 100K ,那么在比较弱的网络条件下(例如手机网络),处理这个包可能就需要超过 1 分钟的时间。而这么大的数据块,在业务逻辑上大多不期待立刻能发出或收到的。一个典型的应用场景就是用户在拍卖行中查询所有的上架物品,如果把所有返回数据都放在一个数据包中,很容易就变得很大。而查询大量这个操作,用户本身就对立刻回应没有期待。
而在单个 TCP 连接上,这样一个大数据块会阻塞住整个信道,后面本来需要快速送到的数据全部被延迟了。
如果你想在业务层做一个心跳包检测网络是否超时,很容易就把心跳包拦在后面。而通常网络处理层不会提供接口让业务层探知是否正在接受数据(skynet 的 gateserver 就没有提供这样的接口,虽然它很容易提供,只需要修改几行 lua 代码),只能在一个完整的数据包收到后才会交给业务层处理。
正确的做法是,在长度+内容这个协议上再加一个层次。加一条协议叫大数据块,允许把一个大数据块分几段发送。可以在这条协议中加上数据块 id ,在后面引用这个数据块的包中附上数据块 id 即可。
为什么要用这种比较绕的方式,而不直接把包头从 2 字节改成 4 字节?当你做这个设计时,就已经表明你重视了上面提到的问题。
当你把数据包都分割的比较小时,才能实现单个 TCP 连接上承载多个信道的能力。对于网络游戏,并不是所有的数据包都是上下文相关的,你可以看成隐含着有多条线索。比如聊天频道的信息和场景同步的信息就是相互独立的。skynet 为这种场景还提供了额外的 socket api 支持。socket.lwrite 可以把一个字符串(一个数据包)写到低优先级通道。只有等默认通道(高优先级)的包全部发送完后,低优先级通道上的包才至少被发送一个(单个包可以保证原子性)。
比如,你可以用它来发送聊天信息,就不会因为聊天信息泛滥把其它重要数据包都塞住。同样,你可以用来发送被分割后的大数据块。如果同时你还有很多其它重要的数据需要传输给客户端,那么这些数据块就会被打散穿插在其间。
当然,你也可以把所有给客户端的数据全部用 lwrite 发送,而仅仅把心跳包放在常规高优先级通道,可以保证心跳频率更稳定。
另外,采用 4 字节的包长度还有一个安全漏洞,可能被攻击利用。
一般的分割包的代码,在收到包头时,都会根据长度信息预先分配出相当长的空间,等着后面的数据达到后填入。如果攻击者不断在新建立的连接上发送一个恶意的长度信息,比如 2G ,服务器内存很容易被快速消耗光。
早期 skynet 的 gate 实现时,采用的是共享一个固定长度的 ringbuffer 的实现,可以避免这种攻击。但新的版本由于不再允许 4 字节长度,就没有做特别处理了。
如果你的应用环境非常特殊,坚持一个允许更大长度的数据包协议。那么我建议你慎重的实现一个分包模块,而不是简单的把 netpack 库中的 2 改成 4 。
去掉 skynet 中 cluster rpc 的消息长度限制
上面我们讲了 为什么 skynet 提供的包协议只用2个字节表示包长度 里面提到, 如果有体积很大的消息传递需求,那么应该在上一层去处理。
从另一方面来说,我们应该正视长消息的处理,而不应该将其和普通(较短)消息的处理混为一谈,在底层抹平之间的区别。
最近,需求就来了。
我们的一个新项目希望在 cluster 间通讯的时候,可以支持较大的消息。原本提出需求的同学想自己修改 skynet 的 cluster 模块,修改底层协议的包头长度的。我即使阻止了他,并自己动手做了修改。
简单说,就是在上一层的 cluster 协议上,增加了长消息的标识。由于之前就有一个字节做标识,其实并没有对协议做太大的修改。
当消息(无论是请求还是回应)太长时,就分开打包,并在标识字节上表明这只是一个完整消息的一部分。然后在接收方合并即可。
一开始我并不打算修改 cluster 依赖的 socket channel 模块 ,让分开的每个小包都有一个独立的 session 。做了一半以后,发现对 socket channel 做一些增强(不应该之前的接口)会更好一些。而且可以很容易利用上之前做的 socket 低优先级队列这个特性。
新的修改我暂时提交到一个叫 multipart 的 skynet 独立分支上,欢迎感兴趣的同学 review 。
同时,我把 skynet 之前的两个含义不明确的内部宏重新定义了,去掉了之前对内部消息长度的 16M 限制。
经过一些简单测试,目前可以达到设计目标:在 cluster 内部,rpc 的请求和回应消息都不再受之前的 64K 限制。而且,一旦消息过长(目前是以 32k 为界),长消息的传输会被分成多个小于 32K 的消息投递,并且其它短消息允许穿插在其间。也就是说,如果你用 cluster 向外投递一个几十 M 的巨大消息的话,不会因为消息过大而阻塞了通讯通道。
一个内存泄露 bug
起因是 skynet 的一个 Issue ,同时,这两天我们正在开发的一个项目也反应貌似有内存泄露。
我觉得两件事同时发生不太正常,就决定好好查一下。
其实在 skynet 里查内存泄露要比一般的项目容易的多。因为 skynet 天生就分成了很多小模块,叫作服务。模块申请的内存是独立的,内聚性很高。模块的生命期比整个进程短的多,模块的规模也不会太大,可以独立分析。一般说来,如果有内存申请没有归还,应该是 C 模块里的 bug 。而 skynet 会用到的 C 模块也很少,一旦有这样的问题,很快就能定位。
skynet 实现了一个内存管理的钩子,用于按服务分开统计内存的开销。代码在 skynetsrc 目录下的 mallochook.c 文件里。
我建议同事做这样的修改:
在每次内存分配释放时,按当前服务的 handle 做文件名记录 log 。这里有考虑一点点多线程并发的问题,但发生的概率很少,如果只是做临时的调试,可以暂时不理。因为同一个服务的分配行为不会并发,只有很少的情况释放在另一个线程中。如果发生了,会导致写 log 遇到一点错乱。
比较麻烦的是,在 hook 里第一次写 log 打开 log 文件有可能由 crt 触发 malloc 导致死循环。如果要避免这种情况,需要加一个 tls 标记,在进入 hook 后就设置上标记,避免被重入。
加上十多行代码后,内存管理的 log 就可以正常工作了。
一开始我认为 lua 本身的内存管理是没有问题的,所以可以将 malloc hook 里为 lua 定制的 skynet_lalloc 绕过 log (直接调用 je_malloc 即可)。这样就可以只记录 C 模块中的内存管理调用,数据量要少的多,比较容易排除问题。
但是加上 log 后,分析 log 似乎找不到泄露。我只好转而怀疑通过 lua 的分配器分配的内存有没有释放的。
怀疑点有两个,一是通过 lua_getallocf 取到分配器调用的。这个只在 lpeg 里发现过,应该没有问题;另外就是我自己对 lua 的修改了。
为了提高 skynet 下多 lua vm 的内存利用率,我曾经给 lua 打过一个 patch ,可以让不同 lua vm 共享相同的函数原型 。第一次加载的 lua 字节码是不会释放的,但第 2 次加载相同的代码,会复用之前的拷贝。
我仔细把 patch review 了一遍,没有发现问题。所以还是得靠新的 log 分析。
接下来把 log 重新写了一下,改成在 lua 定制分配器里记录。这个要容易的多,正好前几天刚做过 。还真发现了有一些长度为 88 字节的内存块没有释放。
lua 分配器在分配新内存时,会传入内存的用途(供什么类型的数据使用),这是一个重要的线索。这个泄露的 88 字节的类型为 0 (无记录),但它的上一条分配记录的类型是 9 也就是 LUA_TPROTO 。这说明很可能真的是函数原型中有数据没有释放。
而 88 字节恰好是我的 patch 中增加的 ShareProto 这个结构的长度。
最终 bug 的修复很简单:见这个 commit 。就是漏了一行 free 。
真是撞了鬼了。
因为我自己的硬盘上的本地代码明明一直是有这行的 :( 甚至我都不记得有过漏写这行代码。(这个 patch 是根据更早的 lua 5.2 版上的 patch 改过来升级到 lua 5.3 的。老版本是正确的。) git 的 diff 和 status 都没有察觉到我本地代码和仓库里有什么不同。这也是为什么这个内存泄露在我自己的机器上从来都没有发生过。
所以最后我重新 clone 了一份仓库,提交了这行遗漏的代码。但很难找到线索为何在操作 git 仓库的时候出现这个问题。或许是几个月前某次 push -f 强制推送导致的吧。
|
------分隔线----------------------------