« Hive 增加了 socket 库 | 返回首页 | 增强了 skynet 的 socket 库 »

回调还是消息队列

前几天在做 Hive 的 socket 库的时候, 遇到一个问题很典型,我记得不是第一次遇到了。值得记录一下。

socket 底层有一个 poll 的 api ,通过 epoll 或 kqueue 或 select 取得一系列的事件。用 lua 怎么封装它呢?

一个比较直接的想法是注入一个 callback function ,对于每个事件回调一个 lua 函数。但这容易引起许多复杂的问题。因为回调函数很不可控,内部可能抛出异常,也可能引起函数重入,或是做了一些你不喜欢去做的事情。

如果面面俱到,就会让原本 C/Lua 边界的性能问题更加恶化。

所以,我采用了方案二:把所有事件以及相关数据全部返回,让后续的 Lua 代码去处理 C 层获取的所有事件。

这个方案也容易造成性能问题,那就是临时构件复杂数据结构,对 Lua VM 的 GC 造成的压力。

为了优化这点,我主张从外部传入一个接收结构的 table ,也就是一个空的消息队列。

然后在 C 层使用 rawseti 把消息加进去。rawseti 是一个相当高效的 lua api 。因为 lua table 如果只有顺序的整数做 key 的话,性能和 C 数组无异。

因为每条消息上有不只一个数据,所以最终的数据结构是一个二维数组。为了不每次临时创建大量的 table ,可以缓存用过的 table 到另一个 table 中。

比如,上次收到 4 条消息,这次收到 5 条的话,就新创建一个 table 保存第 5 条消息关联的参数。下次又收到三条消息的话,就把过剩的两个 table 转移到 cache 中备用。当系统稳定运行后,相当于创建好了整个二维数组接收 C 层产生的数据,而不会临时构建 lua 表了。


再说一个不那么相关的话题:Lua 下实现一个简单的消息队列,我觉得如下简单的几条代码就可以了。

local q1 = {}
local q2 = {}

-- 产生消息只需要
table.insert(q1, msg)

-- 分发消息需要两层循环, 可以处理 dispatch 过程中产生的新消息
while q1[1] do
  q1,q2 = q2,q1
  for i=1,#q2 do
    dispatch(q2[i])
    q2[i] = nil
  end
end

Comments

用LUA毫无悬念是为了热更。
如果让底层C或C++直接操作消息,那么LUA热更的意义比较小,LUA的功能仅限于修改BUG或者界面小更新。
于是失去了逻辑更新能力后作者不得不选择通过LUA处理底层消息,最后完成完整的逻辑处理。

@yao

还是调度问题.

我修复了. 但是现在第 1 次以后还是比第 1 次性能略低一点(大约 20%). 后面就稳定了. 暂时查不到原因.

另外, 在查找这次的 bug 过程中, 发现了 socket id 分配的一个 bug 一并改了.

cloud:
./redis-benchmark -t PING -n 100000
第一次20k tps
服务器不重启,第二次 4k tps

@yao

谢谢. cpu 100% bug 修复了.

cloud:
启动后cpu 100%

100ms的延迟可以接受?当年打quake3,50ms的延时都可以感觉非常明显,对于动作游戏,延时的体会是很敏感的,这就是为什么网游打击感一般都不好的一个原因,包括WOW。

@yao

我把 main.lua client.lua 贴在这里了.

https://gist.github.com/cloudwu/5974676

我的机器上测试是 10k+ tps

直接接管 dispatch 不分包的问题是, 如果用 redis-benchmark -P 做 pipeline request 会出错的.

cloud:
redisbench确实不是很合适,不过在同一个机器上,粘包问题应该不大吧,主要想对比一下和redis 的性能差异,更新了一下,单个连接都很慢了,好几秒,你的性能测试代码方便的话也提上去

@yao

我今天测试了一下, 发现在

hive_scheduler.c 里的 worker 线程里

ret &= _message_dispatch(mq)

写错了,导致调度器在有消息的时候 usleep 使得消息处理很慢.

修改后, 处理能力就上去了 (我的测试增加了两个数量级)

不过, 我认为用 redis-benchmark 测试不是特别合适.

至少简单的响应 socket 消息回应是不行的. 必须去解析 redis 指令才可以. 因为 socket 库不保证把一个 redis 请求用一个消息传递过来.

另外在找这个问题时, 发现了多处 socket 库实现的 bug , 一并修改了.

cloud:
将thread数量调整为100后,tps能达到20k tps,估计还是调度的问题

cloud:
redis_bench默认是50个客户端连接

cloud:
测试代码为client.lua改的
local cell = require "cell"
local obj
local sfd
cell.dispatch {
id = 6, -- socket
--dispatch = csocket.send, -- fd, sz, msg
dispatch=function(...)
local fd,sz,msg=...
local line="+PONG\r\n"
obj:write(line)
end,
replace = true,
}

function cell.main(fd, addr)
print(addr, "connected")
obj = cell.bind(fd)
sfd=fd

end
测试工具使用的是redis_bench:
./redis-benchmark -t ping -n 100000
csocket.poll(result,1)能提高10%左右tps

@yao

或者同时开 10 个 echo 服务测试,可能成绩要比单个服务要好。

@yao

目前 hive 的 socket 实现,读写同一个 socket 有最多 100ms 的延迟,但不影响单向推送数据。也就是吞吐量不受限制。

Echo 服务可能受其影响延迟看起来很大。但我认为 CPU 占用低可以说明没有浪费。

对于大部分的应用,一个固定的延迟是可以接受的。

低延迟的实现可能需要另外做,或者可以考虑把 hive/socket.lua 中的

csocket.poll(result)

改为

csocket.poll(result,10) 甚至更小。

@yao

请贴一下如何测试的。

cloud:
socket性能不是很好,简单echo,tps 在1500左右,cpu很低

callback类似中断调用,是否可以支持callback,然后callback中不停读直到没数据包呢,好像linux的网卡驱动都是这么做的。

虽然我不懂lua,但是我还是很喜欢云风的做事风格,我也是个c和lua的爱好者,虽然我现在还在用着QT C++,但是设计风格向c靠近了,KISS了才真的好。
我觉得那帮拿C++,PYTHON来说事的是真没自己做个性能要求高的游戏程序,特别是一个10年周期,很多人流转维护的程序。当然我也没做个了,但是我能想象到你的方式是最终接近理想的方式了。

云风你们已经做了两年了吧,还在折腾这么底层的东西?你们游戏想什么时候上呢?
用C++ boost asio boost python
半天就搞完这么底层的东西了,然后就用python写逻辑开发游戏了。

在游戏行业推java, 只能说你没有再游戏行业带过。

用什么都会有坑,无非是坑多坑少的问题,坑浅坑深的问题。

这样也太折腾了,真心觉得还不如当时Erlang一条路走到黑呢。

所以这篇 blog 的重点不在怎么设计 socket API

在于怎么处理 c 层产生的大量消息,是逐个 callback 还是一次传出来。

我的观点是,用 callback 是不好的。实测性能问题是一方面,错误处理是另一方面。还要避免在 callback 中再调 poll 引起的重入问题。

C 层 resume coroutine 也有一些坑就不展开讲了。

Hive 的库其实是两部分独立的,poll 库和数据处理。 cell 里面是后一部分,其实跟 os API 没什么关系的。

write 是否完成需要应用层协议保证,所以 API 无需也保证不了。因为即使 os API 的write 全部写成功,也不保证对方全部收到。

lua 的 low level API , 我认为对少量 magic number 加上注释即可。有助于代码简洁,保证性能。

好像我们一直都没讨论在同一个点上,所以我只好先去看一眼你的hive代码。话说你那代码还真是非常难读啊,socket的API定义竟然不是在socket.lua里而是在cell.lua里,太坑爹了。lua和c之间的参数定义只能去代码里找,消息分发相关的代码脉络很不清晰,竟然还有用magic number做id来分发的,看的好累。

看下来read部分的接口其实没有太大分歧,怪我之前没去读你的代码,造成了误解。至于是回调还是poll,两者在性能上也不会有太大差别,区别也就是在c里面还是在lua里面唤醒等待中的coroutine。

另外write部分的接口是有问题的,write之后数据发没发出去无从得知,如果用户要发送一组很大的数据流(比如大文件传送),是没法用这个接口的。

@qiaojie

那么你说的 context 根本上还是:把收到的数据如何处理的回调。

作为一个 lua 库,怎么处理这件事还是要从 lua 注入。最终还是演变成 epoll 那里每个连接上收到一组数据就回调一次 lua 。

这就是文中讨论的 callback 模式。而且被 c 库再次封装成 context 后显得更加厚重了。

我说的context是异步请求的上下文,发起read/write请求后要有这个请求的上下文才能把结果回调给请求方。
至于要不要分包跟这个问题没关系。如果你要封装成通用的socket接口,那就没必要分包,直接操作数据块就行了。如果是针对具体协议的,那么在c代码里进行分包和解析则有助于提高性能并且隐藏更多细节。

@qiaojie

如果你说的 c 语言注册 context 指的是分包处理, 以及如何分发它们的逻辑的话,那正是我文中所指的 callback 模式,且我认为是不好的。

首先不要谈脚本。lua 和 python ruby 等一样,是一门独立语言,它有封装 epoll 模型的需求。

读写事件如何相应跟 read write 怎样调用是两回事。由于 socket 库的实现者无法知道如何分包,所以必然要留出接口来。这个接口是通知有 socket 可以读写,还是读写好把结果传出去,和本文讨论的东西没有关系。

本文的核心是如何把 c 层次获得的消息交给 lua 去用。socket 可读写是消息,读写的数据无非是消息的参数而已。socket 消息只是用来说明问题的实例。

至于 Hive 的 socket 库暴露了多少细节,读一下便知。它没有在 socket 可读写状态改变时仅发送一个状态。

@cloud
我当然知道你说的是socket的读写事件。

通常的做法都是,脚本层在socket上发起读写请求,c代码注册相应的请求context,在调用epoll_wait/select之后得到一组socket读写事件,然后c代码调用send/recv API去完成对应的读写请求,之后再通过context返回给脚本,通知read/write请求完成。脚本是不需要知道和处理底层的socket读写事件的,脚本只需要关心读写请求的发起和完成就够了。

如果你让脚本去处理socket的读写事件,让脚本去决定调用send/recv API,这就会暴露太多的底层细节,给上层带来性能问题和额外的复杂度。


在 Skynet/hive 的模型中,处理 epoll 事件,读写数据,处理数据都不一定在同一个 lua state 中。

同一 lua state 中的 Coroutine 的生成和调度更不适合在 c 中完成。

@qiaojie

我说的事件是

Epoll 的读写事件。

我不理解的是为什么lua层要处理socket事件这么底层的东西。lua层不是应该只有connect,read,write,close之类的接口嘛?有coroutine的话可以直接变成同步调用,没有的话像node.js一样传一个callback来处理完成事件,两者从底层上看是一样的东西。底层的socket事件由C来处理,处理之后塞到消息队列中,再通过coroutine或者callback队列调度回脚本中。

@ Cloud
请教一下,按照下面说的回调方式

cmd_map = {}
cmd_map["msg_key"] = callback

从socket库访问cmd_map,根据消息令牌回调相应的回调函数,如果不想在c库里处理异常或者令牌不存在的情况,可以重载表的_index方法来进行处理,lua/c的边界也是比较干净的,没有多余的临时变量的产生.这样会引起什么复杂的问题?

另外,例子虽然是 socket 库,但问题是典型的。当一个 lua 的 c 库需要把大量消息传递给 lua 层时,回调方式常常不是好的方式。这种问题在游戏领域,还有许多地方会碰到。例如,用 c 编写的 Aoi 模块, ai 模块 等等。

我写这篇 blog 主要是谈 lua 语言以及围绕它的 c 库的设计方案。

楼下的各位都完全没看明白我在写什么。

我这篇 blog 前后两段谈的是两个独立问题。

前半是谈一个类似 lua socket 的库的 API 以及实现应该怎么搞。

后一半谈 lua 里实现一个消息队列的惯用法。

整篇跟游戏服务器开发一点关系都没有。

同回复1,楼主虽然以前批判过BW,相信你仔细看过他那网络部分代码,但是有很多好的东西为什么不借鉴下了,同1楼。
为啥不共同设置双方通讯结构文件-包括记录回调脚本的函数名,参数类型。初始化时载入,然后直接还原接收到的消息到rpc调用了--你否决回调方式是不是认为必须要脚本像他注册?-其实通讯配置文件就能确定了(bw进一步封到实体层,不过一般的通讯倒是不需要),对于脚本调用显然特别灵活。还需要脚本去处理分发函数,只需要脚本中写好函数等待被c层回调就好了,分发这种事非让脚本做干什么,然后增加效率?
另外你说的异常和重入的影响,我也不可理解?网络层肯定要和逻辑层分开,怎么可能收一个就处理一个来影响网络接收数据?肯定是一次接受完整后,到达逻辑模块,再去分析包的数据得到各种整包以后,再进行脚本层的调用。这样哪有这些问题,有这些问题说明脚本逻辑层写的有问题。

@dwing
我们对轮子的理解不一样,你觉得轮子能转就可以了。我们会在意它用了什么轮胎,轮毂的设计等等。总之此轮子非彼轮子
BTW,作为一个C#程序员,我认为这事跟Java一毛钱关系也没有。

我并不是去推崇java, 而是在现在以及未来的形势, 总是拘泥于底层代码和无谓的繁杂, 重复造轮子, 会把正事拖累太多, 不适合IT业高效开发的大潮.
如果是自顾自地清闲地玩弄程序, 作为真正喜欢编程的人何尝不想呢.

@dwing
让我写java不如让我去死,个人观点
想起了程序语言简史(伪)(http://www.soimort.org/posts/160/),摘抄部分供没看过的同学同乐:
1996 - James Gosling发明了Java。Java是一个相对繁冗的、带垃圾收集的、基于类的、静态类型的、单分派的面向对象语言,拥有单实现继承和多接口继承。Sun不遗余力地宣传着Java的独一无二不同凡响之处。

2001 - Anders Hejlsberg发明了C#。C#是一个相对繁冗的、带垃圾收集的、基于类的、静态类型的、单分派的面向对象语言,拥有单实现继承和多接口继承。微软不遗余力地宣传着C#的独一无二不同凡响之处。

真想摆脱C/C++直接用java不好么, 不比C+lua的性能差, 还不用考虑跨语言的麻烦, 而且java的轮子已经很多了, 即使自己写也很快上手, 只是会让人感觉不亲自写点底层代码就没有技术含量似的

@qiaojie 在出现性能问题之前性能不成问题,我们这边的服务器架构和skynet很像,但是我们牺牲了更多的性能来摆脱对c/c++的依赖,性能依然不成问题,lua是非常高效的,除非你错误地去使用它.
我甚至于不觉得这是一个技术问题,就犹如扇扇子的说用什么电扇,还要用电;吹电扇的说用什么空调,那么费电,这真是蛋疼啊.

像socket消息这种底层的东西为什么要用lua去处理呢?搞出各种性能问题,然后再用各种扭曲的方式去优化,感觉真是蛋疼。

Post a comment

非这个主题相关的留言请到:留言本