« March 2015 | Main | May 2015 »

April 30, 2015

ltask :用于 lua 的多任务库

写这个东西的起源是,前段时间我们的平台组面试了一个同学,他最近一个作品叫做 luajit.io 。面试完了后,他专门找我聊了几个小时他的这个项目。他的核心想法是基于 luajit 做一个 web server ,和 ngx_lua 类似,但撇开 nginx 。当时他给我抱怨了许多 luajit 的问题,但是基于性能考虑又不想放弃 luajit 而转用 lua 。

我当时的建议是,不要把 lua/luajit 作为嵌入语言而自己写 host 程序,而是想办法做成供 lua 使用的库。这样发展的余地要大很多,也就不必局限于用户使用 lua 还是 luajit 了。没有这么做有很多原因是设计一个库比设计一个 host 程序要麻烦的多,不过麻烦归麻烦,其实还是可以做一下的,所以我就自己动手试了一下。

Lua 的多任务库有很多,有兴趣的同学 可以参考一下 lua user wiki

一般有几种做法:要么不使用操作系统的线程,只用 lua 本身的 coroutine 来模拟多任务。用 lua 写一个调度器即可。由于 lua 目前允许从 debug hook 中 yield (但暂时需要用 C 来实现 hook),所以甚至你可以实现一个抢占式的调度器;另一种流行的做法是在每个 os thread 里都开启一个独立的 lua vm ,然后用消息通讯的方式协作。

大多数库实现出来都是为了解决类似 web 服务这种需求,所以同时也都实现了一套配套的网络接口,让网络 IO 可以和多任务系统协调工作。

而我想了一段时间后觉得,如果有一个纯粹的 lua 多任务调度库更好。而且这种库不应该实现成 n:n 的调度器,也就是一个 os thread 配一个 lua vm 。这样就不适合做轻量的任务了。原本创建销毁 lua vm 都是很轻量的操作,而单个 lua vm 所占的内存资源也非常小,基本开销甚至比 os thread 本身的开销小的多。有了 vm 的隔离,实现成 m:n 的调度器应当是理所当然的事。btw, ngx_lua 这种让不同连接复用 lua vm 的方式,虽然可以提供响应速度,节省单连接上的内存开销,但从设计上我认为是不太干净的。

解决了任务调度器后,唯一必须的底层协作设施就只有 channel 通讯;而网络 IO ,timer 这些,都应该是更上层的设施,这样的设计可以让多任务库更简单纯粹。比如处理大量网络连接的 lua 库,我的同事就在 libev 的基础上实现过一个 levent 。完全可以在一个 task 中运行 levent ,然后把收发的网络包通过 channel 传给别的 task 去处理。

我大概花了两天时间做接口设计和实现。目前的代码基本可以运行,放在 github 上,https://github.com/cloudwu/ltask 。目前仅提供了一个 M:N 调度器和一组内建的 channel 设置。

你必须在主程序中启动初始化调度器,初始化的时候可以配置使用多少条工作线程。一旦调度器启动起来,将阻塞住永不退出(目前的概念阶段的简化处理),所以在启动之前,你需要启动你的 task 。每个 task 都是一个 lua 文件,可以传递给它任意参数。同一个 lua 文件被启动多次是多个独立的 task 。在 task 运行中也可以启动新的 task ,task 的总数只受内存限制。

调度器会平均把任务分配到固定的工作线程上。task 并没有固定绑定某个特定工作线程。任何一条工作线程空闲时,都会尝试从其它工作线程的队列中抢走待命的 task 。

每个 task 由于是一个独立的 lua vm ,所以不可以共享 lua 数据。但是它们可以通过 channel 协作。channel 从调度器里创建出来,用一个数字 id 来标识,所以 id 可以很方便的在 task 间传递。调度器尽量不复用 channel id (除非 32bit 回绕),所以即使一个 channel 被关闭,读写它也是安全的,并不会错误的操作到其它 channel 上。channel 中每组数据都可以被原子的读写,单条记录可以是任意多个 lua 数据的组合。channel 可以同时有多个读者和写入者。当 channel 存在多个读取者时,调度器会尽量平均的把数据依次分配出去。channel 的写入是非阻塞的,只要内存够用,你可以无限的向 channel 写入数据。

和很多类似框架不同,channel 的读操作并非阻塞的。如果 channel 为空,读操作也会立刻返回。所以在使用时,通常你需要在 lua 层再做一点封装。在读 channel 前应该先 select 它(可以同时 select 多个 channel )。由于 select 也并不阻塞,所以在 select 返回空时,应该立刻调用 coroutine.yield() 让出 cpu 。而最后一次 select 失败,会将当前的 task 标记为 blocked 状态,yield 后不再放回 worker 的处理调度队列中,直到相关 channel 有新的数据写入。

所以,一切的阻塞点都发生在 coroutine.yield 上。在 task 的 main thread 里调用 coroutine.yield 会让出当前 task 对 cpu 的占用。至于 worker 会不会立刻切回来,取决于你之前是否有 select 调用以及 channel 的状态。


ltask 看起来和 skynet 解决了差不多的问题:让 lua 可以充分利用多核系统处理多任务。但它们还是有一些不同的。

skynet 本质上是消息驱动的模型。每个服务(对应 ltask 中的 task )只有一个 channel ,就是它自己。每条消息会唤醒服务一次,赋予服务一小片 CPU 时间。skynet 和 erlang 也不同,它并没有将 channel 实现成一个 mailbox ,所以服务不能自己挑拣消息,而必须按次序消化掉每条消息。

ltask 更接近 erlang 的调度方式。每个 task 可以关心任意多 channel ,task 也可以随时让出 cpu ,而不一定以消息处理来分割时间片。如果你在 task 中设置 debug hook ,也可以很容易的模拟 erlang 那种调度方式,对指令情况做一个简单的记数,超过一定范围后就强制 yield 。而不必让业务层自己来主动调用 yield 。

目前我不太好判断这两种方式的优劣,只能说 skynet 在它设定的模式下,可以实现的效率更高一点点。但从通用性角度看,却不及 ltask 这样简单纯粹。skynet 也很难实现成一个外挂的 lua 库,而使用 ltask 你可以方便的和其它库一起工作来搭建一个你需要的业务处理框架。


能够在 1 天半时间把这个东西搭建好,也是因为之前做了许多铺垫工作。

最后随便提一下:

关于线程池,我没有使用现成的第三方跨平台库。而是自己先实现了一个简单的对 pthread 及 windows threading api 的封装。毕竟我需要用到的 api 很少,所以有个 100 多行的封装库就够了。有兴趣的同学可以看看这个独立的仓库:https://github.com/cloudwu/simplethread

在 ltask 中,task channel 都是暴露出数字 id 供业务层使用的。我觉得这是在多线程环境下最好的方式,比交给用户对象指针要健壮的多。这里我用到了前几天从 skynet 里提出来的另一个模块,之前写过一篇 blog 介绍

基于 skynet 的 MMO 服务器设计

最近,我们的合作方 陌陌 带了他们的一个 CP 到我们公司咨询一下 skynet 做 mmo 游戏项目中遇到的一些问题。因为他们即将上线一款 MMO ,在压力测试环节暴露了许多问题。虽然经过我们的分析,有很多问题出在他们的压力测试程序本身编写的 bug ,但同时也暴露出服务器的设计问题。

核心问题是,他们在实现 mmo 服务器时,虽然使用了 skynet 框架,但却把所有的业务逻辑都放在了同一个 lua 服务中,也就是一切都运行在一个 lua states 里。这样,几乎就没能利用上 skynet 原本想提供的东西。压力是一定存在的。

我花了一下午探讨了应该如何设计一个 MMO 的服务器。下面记录一下:


业务的拆分:

我认为业务拆分的原则是数据的交互频率。如果数据相互独立,那么就应该用独立的服务去处理;如果交互密切,就不要拆分它们。尽量不让一个服务承担太多热数据的处理,而冷数据或不需要高频响应的数据处理则可以堆积在一起。

例如:在 MMO 游戏中,玩家身上的背包管理,基本上只和玩家相关,那么处理背包的逻辑就可以放在玩家的 agent 中处理。即使背包中的物品的操作会影响到其他人或场景,也应该由 agent 和外部交互。

对于拍卖行、玩家摆摊等这些,提取出来放在一个或几个交易服务中去完成是没有问题的,但应该避免和场景服务合在一起。虽然拍卖行和摆摊的入口看起来放在场景里,但那仅仅是外在表现,玩家从客户端发出的交易指令,由 agent 转发即可。

聊天绝对应该被独立出来。虽然 MMO 中聊天很可能也是种玩法,发言同样需要消耗玩家的数值,表面上看起来并非独立系统,但切分出来不会太难。

而队伍系统可以被看成是一个特殊的聊天频道,这样划分可以把组队的行为和场景分离开,方便把场景独立成单独的服务。

任务和活动是绝对要独立的系统。按现在国产游戏的秉性,这些是游戏上线后维护的大头。我们必须把变化做多的部分独立开来做,不是吗?而且从数据上讲,这部分的数据日后会占到玩家的个人数据的很大一部分,只要我们把这块数据分离,也可以极大减轻单个服务对数据的维持负担。

关于游戏场景以及副本,通常大家都是把每单个场景分开的。(可惜,这次来咨询的同学没有这么做,他们游戏所有的场景都在一个服务中。)我的建议是,玩家可能和其他人交互的数据都应该放在场景服务中,比如他们的位置、HP 、buff 等等。在做战斗计算时,不必去做服务间的消息传递就可以计算出结果。但个人的物品、任务、活动信息则绝对不要加载到场景中。

最容易被忽视却非常重要的是登录认证。从实现角度看,比较简单的方法是做成一个认证模块,由每个 agent 加载去认证新连接。但实践表明,这样做有一定风险,很可能无法满足开服初期的大量玩家的同时拥入。目前的建议方案是:单独编写认证服务,把认证服务做成无状态的,这样可以启动多份以充分利用 cpu 。当新连接认证通过后,再给它分配 agent 服务对接。通常还需要创建一个 agent 池,预分配几百或几千 agent 备用,而不是临时创建。


关于状态同步:

对于回合制游戏,周围玩家的状态同步并不需要特别的实时。我们并不需要将周围所有的人都同步给他。尤其是在场景拥挤时,只需要看个热闹就够了,应该设置一个上限。而且周围能看到的人数也不必和每次需要同步的人数相同。简单说就是,你周围可能有 100 个人,但你在屏幕上只看到 50 个就够了,而这 50 人中,可能只需要将 20 人的状态变化会实时同步给你。

尤其是在移动网络中,针对客户端的接收和处理能力去调节同步信息的频率和数量是非常重要的。

最简单的处理方法是:由客户端主动来索取当前场景中周围玩家的状态变化。如果周围状态没有发生改变,服务器就不要回应,直到有新的状态改变信息需要推送。而客户端在没有收到回应前,禁止提出下一轮的请求。也就是网络上传输的状态传输信息永远只有一组(根据需要可以将限制调节成多组)。这样,当客户端网络缓慢,或同步信息量大时,自然就降低了同步频率。反之,则可以高频同步。


数据服务一般都会设计成独立的,负责和外部数据库打交道。

但最好不要按玩家为单位划分数据,当需要一个玩家的数据时,一古脑把玩家的装备物品,属性值,身上的活动任务全部扔给业务处理者。应该一开始就设计成几个部分,每个不同的业务处理服务去索取他们关心的部分即可。

这样,可以极大降低业务服务和数据服务的数据交换的负担。


就最后一个问题,这几天我做了一些思考。对于不同的 lua state 间交换数据,目前通用的做法是对 lua table 做序列化,然后传递序列化串,接收方再做反序列化。

在同一进程内,可以对序列化串的传输这点上做一点优化,减少进出 lua state 的复制。但有没有方法可以直接读写其它 lua state 中 table 呢?

如果能满足几个条件,那么看起来是可以的。如果有一张 table 希望被其它 lua state 读取时,在读取过程中保证不去修改它,那么读取过程就是安全的。而这一点,在 skynet 中很容易保证。

我花了一点时间写了一个用于 lua 5.3 的库。它用到了 lua 内部的 api 来实现 table 的遍历。稍加修改,应该可以用于 lua 5.1/5.2 。

https://github.com/cloudwu/tablepointer

你可以用 tablepointer.topointer 函数取到 table 的 C 对象指针(其实就是调用的 lua_topointer),然后可以把这个指针传递给读取方。

读取方可以用 tablepointer.pairs 来遍历这个指针。和 pairs 遍历表不同,tablepointer.pairs 返回三个值而不是两个。这是因为跨越了 lua vm 后,用 next key 的方式索取下个 key-value 对是非常低效的;所以 tablepointer.pairs 迭代出的第一个值是一个数字索引号,正值表示 table 中的 array part 部分,负值表示 hash part 部分。一般在使用的时候忽略第一个索引号即可。

tablepointer.pairs 不支持 __pairs 元方法,也不支持 key value 中有 function 或 userdata 类型(这些是很难跨 lua vm 使用的)。当源 key value 为 table 类型时,迭代出来会返回一个 lightuserdata 指针,可以继续用 tablepointer.pairs 迭代它。

tablepointer.createtable 可以帮助你(通过 lua_createtable ) 创建一个和源 table 指针相同大小的 table ,这可以避免在复制表的期间做无谓的 rehash 。

如果在 skynet 中使用,我能想到的最直接的用法是在场景服务中定期将玩家数据传输给数据服务。这样可以减少这个过程中序列化带来的延迟。

你需要做一点简单的封装。正如 sharemap 做的那样,你可以给玩家数据 table 加一个元表,把 index 和 newindex 都定向到另一个 table 。必须主动调用一个 commit 方法才将两张表合并。然后,在打算把数据发送走时,先用 skynet.fork 产生一个新的协程,用 tablepointer.topointer 转换成指针发送。等数据服务接收到,遍历复制完毕后再回应它。接下来,再用 commit 方法把这段时间对数据的修改合并到新的表中。

April 27, 2015

sproto rpc 的用法

sproto 是我自己设计, 用在我们新项目中取代过去用到的 google protocol buffers 的东西。

为什么不用 protobuf ? 这个问题我有足够的发言权。在 lua 语言为主的项目中,sproto 更合适。google 官方并没有给 protobuf 加入 lua 支持。现在在网上流传的 protobuf lua 方案,被人用的最多的两种,一个是 pbc 的 lua binding ,另一个是 protoc-gen-lua 。前者是我在开发维护,并使用了多年;后者是在我过去的项目中,项目中的同事因为需要而开发的。

另外,在我的项目的副产品中,还有开源的 protobuffer 的 as3 库以及 erlang 库,都有许多用户。所以,我相信我对 protobuf 有足够长时间的使用经验以及对它有足够的了解。这也是放弃 protobuf 而转向自己设计的 sproto 的底气所在。

在这一篇 blog 中,不想讨论 protobuf 的优劣,只谈谈 sproto 中如何使用 rpc 的 api 。这是 sproto 的 api 文档中没有写明,而很多想用它的同学问起的问题。


对于 request/response 的 RPC 方案,除了消息本身打包外,还有两个重要的信息需要传输。它们分别是请求的类型以及请求的 session 。

不要把请求的类型和消息的类型混为一谈。因为不同的请求可以用相同的消息类型,所以在 sproto 中,需要对 rpc 请求额外编码。你也不一定为每个请求额外设计一个消息类型,可以直接在定义 rpc 协议时内联写上请求(以及回应)的消息结构。

通常,我们用数字作为消息类型的标识,当然你也可以使用字符串。在用类 json 的无 schema 的协议中使用字符串多一些,但在 sproto 这种带 schema 的协议中,使用数字会更高效。同样,session 作为一条消息的唯一标识,你也可以用数字或字符串。而生成唯一数字 session 更容易,编码也更高效。

所以,每当我们发送一次远程请求,需要传输的数据就有三项:请求的类型、一个请求方自己保证唯一的 session id 以及请求的数据内容。

服务方收到请求后,应根据请求的类型对请求的数据内容解码,并根据类型分发给相应的处理器。同时应该把 session id 记录下来。等处理器处理完毕后,根据类型去打包回应的消息,并附加上 session id ,发送回客户端。

注意:回应是不需要传输消息类型的。这是因为 session id 就唯一标识了这是对哪一条请求的回应。而 session id 是客户端保证唯一的,它在产生 session id 时,就保存了这个 session 对应的请求的类型,所以也就有能力对回应消息解码。

btw ,如果只是单向推送消息(也就是 publish/subscribe 模式),直接省略 session 就可以了,也不需要回应。


sproto 提供的第一种 rpc 模式封装了上面的流程。

你需要定义一个叫做 package 的消息类型,里面包含 type 和 session 两项。

对于每个包,都以这个 package 开头,后面接上 (padding)消息体。最后连在一起,用 sproto 自带的 0-pack 方式压缩。

你可以用 sproto:host 这个 api 生成一个消息分发器 host ,用来处理上面这种 rpc 消息。

默认每个 rpc 处理端都有处理请求和处理回应的能力。也就是每个 rpc 端都同时可以做服务器和客户端。所以 host:dispatch 这个 api 可以处理消息包,返回它是请求还是回应,以及具体的内容。

如果 host 要对外发送请求,它可以用 host:attach 生成一个打包函数。这个生成的函数可以将 type session content 三者打包成一个串,这个串可以被对方的 host:dispatch 正确处理。

具体的说明,我放在了 skynet 的 wiki 页 上。


sproto 另外还提供了一组没有那么多封装的 rpc api 。它们分别是:

  • sproto:request_encode
  • sproto:request_decode
  • sproto:response_encode
  • sproto:response_decode

顾名思义,这组 api 不会帮你处理 type session 这些信息,而是留给你处理。它只是在你知道一条消息的内容在已知是请求还是回应包时,可以调用对应的 api 来编解码。

比如,当你的服务器只处理请求的话,就只需要调用 sproto:request_decode 。当然,你得自己先知道请求的 type 是什么(编码在额外的地方),也需要自己保存下 session ;需要回应请求时,调用 sproto:response_decode 即可。不过附带上前面保存的 session 也是你的责任。

设计这组 api 是源于 skynet 的 message server 模板。因为这个模板已经封装好了 session 。在模板上使用 sproto 的前一种 api 就不太合适了。

April 13, 2015

Xenonauts 中文化计划

作为一个 X-COM 老系列的爱好者(1994 年开始一直玩了 3 年),新的官方重制版是满足不了我的。

Xenonauts 作为原作的精神继承者,我很喜欢。

本来这个游戏上手了以后是不太依赖文字的,只是可惜了它写的不错的文案。我在网上搜了一下汉化包,几乎都是机器翻译的,读那个真不如读英文啊。

游戏的官方没有能力做中文化,但是在官方论坛提供了翻译的指引

我按指引用文泉驿制作了游戏用的字体文件,发现游戏引擎本身对中文支持是没有什么问题的,差的只是文字翻译了。

但是这个游戏文字量极大,而且不太容易翻译。我在 github 上创建了一个项目,希望可以集众人之力来完成这个大工程。项目的地址在 https://github.com/cloudwu/xenonauts

我在百度贴吧、新浪微博、和 steam 动态上分别发了信息的一天后,居然真有志愿者了。

我的想法是这样的,我们先从最难的,也是最有价值的游戏内百科开始翻译。如果顺利再继续界面、向导等部分。

原始文件是单个大的 xml 文件,很不利于协作。所以我昨天写了个 lua 脚本把它按条目拆分好了。也有脚本可以合并成最终的文件。

ps. 在 github 上协作翻译还是满有意思的。我认为这是一个非常好的协同创作平台,可以专心做校对工作。如果以后游戏策划可以用 md 或 adoc 格式直接写策划案就好了。也就可以搬到 github 上来供大家针对性的讨论了。

如果你也有兴趣贡献几个小时的业余时间的话,欢迎提交这个项目的 PR 。当然先读一下 readme ,看看我们目前的协作经验。

April 10, 2015

对象到数字 ID 的映射

skynet 中使用了一个 hash 表结构来保存服务和 32bit 数字地址的映射关系。

一个 skynet 的服务,其实是一个 C 对象。在有沙盒的系统中,尤其是并行构架,我们很少直接用 C 对象指针来标识一个 C 对象,而是采用数字 id 。用数字做 handle 可以让系统更健壮,更容易校验一个对象是否还有效,还可以减少悬空指针,多次释放对象等潜在问题。比如,操作系统为了把用户程序隔离在用户态,像文件对象就是用数字 id 的形式交给用户程序去用的。

和操作系统通常管理文件句柄的方式不同,skynet 尽量不复用曾经用过的 id 。这样,当你持有了一个已经不存在的 id ,不太会误当作某个新的对象。(操作系统的文件 handle 很容易造成这样的问题:你记住了一个文件,在别的流程中关闭了它,然后又打开了新文件,结果复用了过去的 handle ,导致你操作了错误的文件。很多年前,我就在自己的线上产品中碰到过这样的 bug 。)

但是,一旦尽量不复用 id 。用简单的数组来做映射就很难了。必须使用 hash 表来保证映射效率。在 skynet 中我是这样做的:

每次分配新的 id 时,首先递增上次分配出去的 id (允许回绕,但会跳过 0 。0 被当成无效 id )。然后检查 hash 表中对应的 slot 是否有冲突,若有冲突就继续递增。如果 hash 表的池不够用了,则成倍扩大池,并将内部的数据重新 hash 一次。

这样虽然会浪费一些 id ,但是可以保证 hash 表类的 key 永远不发生碰撞,这样可以让查询速度最快。hash 表的实现也相对简单一些。

我觉得这样的一个数据结构有一定的通用性,今天花了一点时间把 skynet 的这个部分单独抽出来,当成一个独立开源项目重新写了一遍。有兴趣的同学可以在 github 上查看

这里的 struct handlemap 就是这样的一张 hash 表。而 handleid 目前被定义为 unsigned int ,可以在实际使用时定义为其它字长的整数。

一共提供了 5 个 API :

struct handlemap * handlemap_init();
void handlemap_exit(struct handlemap *);

分别是创建和销毁 handlemap 结构,没什么好说的。和 skynet 的实现不同,这里做了一些简化。在销毁 handlemap 时,暂时没有手段去清理里面那些还没有删除的 id 。

我认为在实际使用时,可以通过外部手段去解决。

  • handleid handlemap_new(struct handlemap *, void *ud); 就是为 ud 分配并绑定一个 id 。这个操作一般会成功,但在很少情况下会因为内存不足而失败。失败则返回 0 。 0 在整个系统中永远表示无效 id 。

  • void * handlemap_grab(struct handlemap *, handleid id); 取出 id 对应的对象指针,并在内部增加对它的引用。如果 id 无效,那么会返回 NULL 指针。在实现时,采用了引用计数用来满足线程安全。

  • void * handlemap_release(struct handlemap *, handleid id); 当使用完对象指针后,应该调用这个 api 归还 id 的引用。另外,如果希望删除这个 id 也可以调用它。无论是归还引用还是删除,都必须检查这个 api 的返回值。当返回值不为 NULL 时,表示返回的这个 ud 已经脱离了 handlemap 的管理,通常你需要删除这个对象。


这个数据结构的实现难点在于需要线程安全。在不同的线程中可以并发去 grab 对象,同时也可以安全的删除它们。

我使用了一个读写锁来保证线程安全:

在创建新 id ,或当一个 id release 的时候引用减到零,都会加上写锁,完成成 handlemap 的修改。

而获取(grab)或释放 (release )id 时,则是加的读锁,这些操作是可以并发的。

btw, 和 skynet 不同,这份实现里我支持了 windows 的原子操作 api 。但没有仔细测试,如果有兴趣的同学可以试着用 VC 编译。

April 07, 2015

上次提到的阿瓦隆辅助工具

上次提到过 抵抗组织:阿瓦隆 的辅助工具的一个想法。主要是用手机帮助大家减少天黑请闭眼环节的繁琐工作。参加游戏的人只需要看一眼手机,就可以了解游戏局面。这样就不需要额外的主持人不停的叫大家闭眼,某某睁眼确认等等了。

另外,也可以简化投票任务这些环节(如果是杀人,那就是确认杀掉谁,指认谁等过程)。一旦去掉了额外的道具,我们就不再需要桌子来玩。可以方便的在饭桌上打发时间了。

我在 github 上创建了一个项目。初步的计划是做成一个 web 网站,当然以后改成手机 app 也可以(可以只需要一个 app 做 host,其他玩家还是继续用浏览器访问它,这样就不依赖互联网连接了)。

因为我几乎没有 web 开发的经验,所以之搭了个简单的框架,剩余的部分是同事参与来做的。目前之完成了进入房间分配身份的部分,进度比较慢。如果能有经验的同学加入会好一些 :)

btw, 我是基于 skynet 开发的。没有什么特别原因,就是因为我之前没做过 web 开发,什么 web 框架都没用过,skynet 比较熟而已。目前这个也谈不上什么特别的设计,目前是怎么简单怎么来。如果要把 skynet 发展成一个可以用的通用 web 框架,我也没经验去想应该怎么搞,所以千万不要把这个当成 skynet 应用的 example :)

仓库的地址是 https://github.com/ejoy/avalon 。如果你觉得这个小项目有点意思,想一起来开发,可以联系我交流一下想法。

skynet 近期更新及 sproto 若干 bug 的修复

skynet 的 1.0 版已经发布了 3 个 alpha 版,等稳定以后将发布 beta 版本。

最近的问题主要集中在一些我们在老项目中没有使用到的特性上面。尤其是 sproto 这个模块,我希望它将来作为 skynet 推荐的通讯协议,但我们老的项目开始的比 sproto 的项目早,所以早期项目全部使用的是 google protocol buffers (以及我自己做的实现)。 随着新项目的开展,我们公司内部开始大面积使用 sproto ,也就发现了一些 bug ,在最近集中修复。

由于 skynet 使用多 lua VM 结构,为了不在每个 VM 里重复加载 sproto 协议,最近增加了 sproto 协议对象的共享。这个作为未写入 sproto 文档的特性提供,当然也不影响 sproto 在其它领域的使用。不过加这个特性比较匆忙,第一次提交时在 gc 方面遗留了一个 bug ,有可能导致多个 VM 重复释放 C 对象,问题已经在仓库最新的提交中修复。

另一个为了 skynet 的应用而特别加上的特性是让 sproto 的 decode 可以接收指针(lightuserdata)。固然只接受 string 会让实现更稳固一些,不过在 skynet 里很多地方 string 和 lightuserdata + size 是通用的,所以就顺带支持了。这样可以减少一次内存拷贝。

根据使用的同学的需求,在 sproto 的 lua bingding 里增加了更为详细的出错提示,这可以帮助实际使用时的错误定位。另外,还增加更为严格的类型检查。缺少这些检查应该算是 bug ,因为使用 sproto 而不是 json 这种的无格式的协议,就是为了可以多做一些类型检查的。复杂类型(在 lua 里用 table 实现)不检查还会导致进程挂掉,这是绝对不可以接受的。

最后一个严重的 bug 是设计上的。

sproto 的 encode C API 采用的是 callback 的方式。由使用者(通常是其它语言的 binding )提供一个 callback 函数,C 核心根据 sproto 协议,每个字段调用一次这个函数。

如果它返回 -1 表示编码错误(一般是 buffer 不够大),会让 C 核心的编码过程错误返回。

如果它返回 0 表示这个字段不存在。这是因为 sproto 是允许字段不存在的,不存在的字段不会被编码进最终的串。另外,对数组的编码也依赖它。如果在编码一个数组时返回 0 ,表示数组结束。

其它情况应返回一个正数,表示当前需要编码的对象的长度。

对于简单类型,如 boolean ,integer ,一般返回的是固定值。boolean 返回 4 ,integer 返回 4 或 8 (提示 C 核心这个整数是 32bit 还是 64bit 的)。最终编码不一定按这个数字来,且 callback 函数得到的写入地址也并非最终 buffer 的地址。C 核心会提供一个地址对齐的地址,然后根据 sproto 的编码协议来转换到最终 buffer 中,同时还要处理大小端问题。

对于不定长类型,如 string 或自定义类型。这个长度会帮助 C 核心了解应该将 buffer 指针后移多少字节。callback 函数将直接把数据写入最终的 buffer 。而问题就出在这里。

当 string 是一个空串时,由于空串的长度为 0 ,会让 C 核心误会这个字段并不存在,这导致所有的空串无法编码。更严重的是,如果是字符串数组,碰到空串就会停止编码这个数组。最终的修补方案是,约定在编码 string 的时候,应该返回字符串长度 + 1 。这属于一个设计问题,所以除了 lua binding 之外,别的语言的 binding 也需要修改。好在目前已知的 python binding 也是我们公司的同学实现的,应该马上能改过来。

对于用户类型,没有 string 这个问题。即使是空的对象,也有一个数据头。所以不可能为 0 。对于空对象,不在数组中时,目前的 lua binding 会返回 0 ,让 C 核心跳过这个字段,而在数组中时,则会返回一个空的数据头。


利用 sproto 实现的 sharemap 也被查出一个 bug ,不过这个 bug 不属于 sproto 。它的 metatable 被不小心循环引用了,如果一个字段不存在会导致 lua 检测出 metatable 循环引用而出错。


还有一个问题是在使用 httpc 时发现的,虽然已经知道,但因为用的不多也没有特别在意。这次在正式版发布前,还是给出解决方案:

skynet 的 socket 层在处理域名的时候直接调用了系统 api getaddrinfo ,这会阻塞住线程。由于 skynet 的 socket 是单线程的,所以一旦做域名查询,会导致 skynet 所有的 socket 消息处理阻塞。一般我们不会使用域名,即使用,也是在数据库第一次连接的时候,通常发生在 skynet 进程启动的时候,所以影响不大。但一旦使用 httpc 模块,就很容易向外连接一个域名了。

由于系统并不提供异步的域名解析方法,很多其它网络库的做法是使用额外的线程去查询域名。我并不想针对这个需求而大幅度修改已经稳定了的 skynet socket 层,所以提供了独立的解决方案:那就是在上层自己使用 dns 协议发送 udp 包查询。为了让 httpc 模块可以使用它,对其也做了一点改变,允许用户连接一个 IP 地址,而自己在 http header 里填写 host 字段。

另外,在充当 http 客户端时,http 服务器往往会在返回的 header 中填写多个 Set-Cookie 字段,之前对同名的 header 中字段没有正确的处理,现在做了修正。(多个同名字段会生成一个 table )