« July 2012 | Main | September 2012 »

August 29, 2012

开发笔记(25) : 改进的 RPC

自从动了重新实现 skynet 的念头,最近忙的跟狗一样。每天 10 点醒来就忙着写代码,一句废话都不想说,一直到晚上 11 点回家睡觉。连续干了快一个月了。

到昨天,终于把全部代码基本移植到了新框架下,正常启动了起来。这项工作算是搞一段落。庆幸的是,我这个月的工作,并没有影响到其他人对游戏逻辑的开发。只是我单方面的同步不断新增的逻辑逻辑代码。

Skynet 的重写,实际上在半个月前就已经完成。那时,已经可以用新的服务器承载原有的独立的用户认证系统了。那么后半个月的这些琐碎工作,其实都是在移植那些游戏逻辑代码。

在 Skynet 原始设计的时候,api 是比较简洁的,原则上讲,是可以透明替换。但实际上,在使用中,增加了许多阴暗角落。一些接口层的小变动,增加的隐式特性,使得并不能百分百兼容。另外,原来的一些通讯协议和约定不算太合理,在重新制作时,我换掉了部分的方案,但需要编写一个兼容的链路层。

比如:以前,我们把通过 tcp 接入的 client 和 server 内部同进程内的服务等同处理。认为它们都是通过相同的二进制数据包协议通讯。但是,同进程内的服务间通讯明显是可以被优化的,他们可以通过 C 结构而不是被编码过的数据包交换信息,并可以做到由发起请求方分配内存,接受方释放内存,减少无谓的数据复制。在老的版本中,强行把两者统一了起来,失去了许多优化空间。在新版本里,我增加了较少的约定,修改了一点接口,就大幅度提升了进程内服务间信息交换的效率。

另一方面,一旦固定采用单进程多线程方案,之前的多进程共享数据的模块就显得过于厚重了。新的方案更为轻量,也更适合 lua 使用。这项工作在上一篇 blog 中提到过。这和 skynet 的重写原本是两件事情,但我强行放在一起做迁移,增加了许多难度。但考虑到,原本我就需要梳理一次我们的全部服务器端代码(包括大量我没有 review 过的),就把这两件事情同时做了。

在这个过程中,可以剔除许多冗余代码,去掉一些我们曾经以为会用到,到实际废弃的模块。彻底解决一些历史变更引起的问题。过程很痛苦,但很值得。新写的代码各种类型检查更严格,就此发现了老的逻辑层代码中许多隐藏的 bug 。一些原有用 erlang 实现的模块,重新用 lua 实现了一遍,混合太多语言做开发,一些很疼的地方,经历过的人自然清楚。以后如非必要,尽量不用 lua 之外的语言往这个系统里增加组件了。

btw, 新系统还没有经过压力测试。一些优化工作也没有展开。但初步看起来,还是卓有成效的。至少,改进了数据共享模块,以及提出许多冗余后,整个系统的内存占用量下降到原来的 1/5 不到。CPU 占用率也有大幅度的下降。当然,这几乎不关 C 还是 Erlang 做开发的事,重点得益于经过半年的需求总结,以及我梳理了大部分模块后做的整体改进。


今天想重点谈谈下面一段时间我希望做的改进。是关于服务间 RPC 的。

目前能找到年初的一篇记录 ,经过大半年的演化,已经不完全是记录的那个样子了。但大体上的思路一直沿用着。

这个方案的优点在于,使用通用的 google proto buffer 协议做严格的 RPC 协议定义。但缺点也是很明显的,最麻烦的地方在于,在一个需要大量服务间交互的应用环境内,新实现一组 RPC 需要做大量的工作,这对程序员是一个负担。

程序员需要:一,在一个协议描述文件中,定义出协议名以及对应的协议号;二,在对应名字的 proto buffer 文件中,定义出协议对应的输入参数和输出参数列表;三,在特定的位置,创建特定名字的 lua 源文件,在里面实现特定名字的协议过程。

有时,这个流程是好事,它能够在 lua 这种弱类型系统中,辅助检查出潜在的错误;但对开发效率的影响也是显著的。同时,这个长长的流程,以及对应的各种编解码工作,也引起了部分性能损失。

我希望在以后的系统中,引入更为方便简洁的 RPC 机制。

简单的说,我的最终需求是:程序员不需要为 RPC 调用和本地调用写不同的代码。程序员需要心里了解一次调用是远程调用,但他不必为实现这些方法做额外的事情。在他不需要把一些方法定义为远程方法时,只需要把源文件换个位置,或是重新组织一下代码加载的过程,就可以轻松的完成。远程对象和本地对象对调用者来说,也应该尽可能的透明。

我今天把这个想法基本实现了。

经过重写 skynet ,我对这类事务的处理稍微建立了一点模式。首先,应该把同进程内的服务间 RPC 调用同跨进程的调用区分开。

跨进程(跨机)调用,可以通过增加一个特定的服务,在链路层把它们接起来即可。应该专心实现同进程内,不同服务间的高性能 RPC 才是重点。等这一步完成,只需要为异地对象建立一个本地副本做消息中转就够了。

我们应该专心考虑 Lua State 实现的服务,而不必过于考虑不同语言间的 RPC 调用。一起以 Lua 为主语言来考虑功能。

首先我引入了之前实现好的 Lua 数据序列化模块 。并对它做了一些改动 ,合并到 skynet 项目中。

这个改动是,让序列化模块底层理解远程对象。增加了一个远程对象类型。因为所有的 Lua State 其实是在一个系统进程内的,数据交换工作通过 Lua 的 C 扩展库就可以完成。在同个进程内,每个远程对象都有唯一的数字 id 。传递远程对象,只需要传递这个 id 即可。

每个 Lua State 中,维护一张表,记录所有在这个 State 中创建出来的远程对象,以及对应的 id 。在序列化模块中,一旦发现提到的远程对象是自己进程内的,就自动翻译成本地对象。

序列化模块本身不处理任何远程对象的特殊行为。它把这个工作交给外部注入。skynet 模块把远程调用的方法注入到远程对象中。

所谓 RPC 调用,就是一个远程对象加一个方法名,加上若干参数。通过序列化方法,打包成一个数据包,查询到远程对象所在的服务地址,发送过去即可。

而所谓远程对象,其实只是在 lua table 里设置一个叫 __remote 的字段,设入数字 handle 就可以让序列化模块识别了。

这个模块并不复杂,我实现在了 skynet.lua 中,不到 100 行代码。

作为一个范例,我实现了一个叫 root 的远程对象,让它在一个独立服务 中启动。它可以提供一个基本的名字服务。在一个简单的 test 程序 中,我们可以看到一个远程对象把自己注册到 root 里,别的服务从 root 拿到这个对象,就可以向本地对象一样调用上面的方法了。

August 17, 2012

记录一个并发引起的 bug

今天发现 Skynet 消息处理的一个 bug ,是由多线程并发引起的。又一次觉得完全把多线程程序写对是件很不容易的事。我这方面经验还是不太够,特记录一下,备日后回顾。


Skynet 的消息分发是这样做的:

所有的服务对象叫做 ctx ,是一个 C 结构。每个 ctx 拥有一个唯一的 handle 是一个整数。

每个 ctx 有一个私有的消息队列 mq ,当一个本地消息产生时,消息内记录的是接收者的 handle ,skynet 利用 handle 查到 ctx ,并把消息压入 ctx 的 mq 。

ctx 可以被 skynet 清除。为了可以安全的清除,这里对 ctx 做了线程安全的引用计数。每次从 handle 获取对应的 ctx 时,都会对其计数加一,保证不会在操作 ctx 时,没有人释放 ctx 对象。

skynet 维护了一个全局队列,globalmq ,里面保存了若干 ctx 的 mq 。

这里为了效率起见(因为有大量的 ctx 大多数时间是没有消息要处理的),mq 为空时,尽量不放在 globalmq 里,防止 cpu 空转。

Skynet 开启了若干工作线程,不断的从 globalmq 里取出二级 mq 。我们需要保证,一个 ctx 的消息处理不被并发。所以,当一个工作线程从 globalmq 取出一个 mq ,在处理完成前,不会将它压回 globalmq 。

处理过程就是从 mq 中弹出一个消息,调用 ctx 的回调函数,然后再将 mq 压回 globalmq 。这里不把 mq 中所有消息处理完,是为了公平,不让一个 ctx 占用所有的 cpu 时间。当发现 mq 为空时,则放弃压回操作,节约 cpu 时间。

所以,产生消息的时刻,就需要执行一个逻辑:如果对应的 mq 不在 globalmq 中,把它置入 globalmq 。

需要考虑的另一个问题是 ctx 的初始化过程:

ctx 的初始化流程是可以发送消息出去的(同时也可以接收到消息),但在初始化流程完成前,接收到的消息都必须缓存在 mq 中,不能处理。我用了个小技巧解决这个问题。就是在初始化流程开始前,假装 mq 在 globalmq 中(这是由 mq 中一个标记位决定的)。这样,向它发送消息,并不会把它的 mq 压入 globalmq ,自然也不会被工作线程取到。等初始化流程结束,在强制把 mq 压入 globalmq (无论是否为空)。即使初始化失败也要进行这个操作。


问题的焦点在于:删除 ctx 不能立刻删除 mq ,这是因为 mq 可能还被 globalmq 引用。而 mq 中并没有记录 ctx 指针(保存 ctx 指针在多线程环境是很容易出问题的,因为你无非保证指针有效),而保存的是 ctx 的 handle 。

我之前的错误在于,我以为只要把 mq 的删除指责扔给 globalmq 就可以了。当 ctx 销毁的那一刻,检查 mq 是否在 globalmq 中,如果不在,就重压入 globalmq 。等工作线程从 globalmq 中取出 mq ,从其中的 handle 找不到配对的 ctx 后,再将 mq 销毁掉。

问题就在这里。handle 和 ctx 的绑定关系是在 ctx 模块外部操作的(不然也做不到 ctx 的正确销毁),无法确保从 handle 确认对应的 ctx 无效的同时,ctx 真的已经被销毁了。所以,当工作线程判定 mq 可以销毁时(对应的 handle 无效),ctx 可能还活着(另一个工作线程还持有其引用),持有这个 ctx 的工作线程可能正在它生命的最后一刻,向其发送消息。结果 mq 已经销毁了。

Skynet 这次在编写过程中,经历过一次大的改变:最早我是采用的一级消息队列,而不是现在的两级。但是一级队列很难同时保证消息的时序性和 ctx 消息处理模块不可被并行运行。在设计修改的过程中,我可能做出了许多不优雅的实现。上面的问题或许可以经过一次梳理,更简单的解决。

而我现在是这样做的:

当 ctx 销毁前,由它向其 mq 设入一个清理标记。然后在 globalmq 取出 mq ,发现已经找不到 handle 对应的 ctx 时,先判断是否有清理标记。如果没有,再将 mq 重放进 globalmq ,直到清理标记有效,在销毁 mq 。

Skynet 的一些改进和进展

最近我的工作都围绕 skynet 的开发展开。

因为这个项目是继承的 Erlang 老版本的设计来重新用 C 编写的。 再一些接口定义上也存在一些历史遗留问题. 我需要尽量兼容老版本, 这样才能把上层代码较容易的迁移过来。

最近的开发已经涉及具体业务流程了, 搬迁了不少老代码过来。 我不想污染放在外面的开源版本。 所以在开发机上同时维护了两个分支, 同时对应到 github 的公开仓库, 以及我们项目的开发仓库。

btw, 我想把自己的开发机上一个分支版本对应到办公室仓库的 master 分支, 遇到了许多麻烦。 应该是我对 git 的工作流不熟悉导致的。

我的开发机的 master 对应着 github 上的 master , 但我大多数时间在一个叫 ejoy 的分支上开发。

这个分支对应到办公室服务器的 master 分支上。这里需要 git config push upstream 设置一下,不然每次 push 分支都需要指名。

偶尔,我会修改一些 bug 以及加一些基础特性,这个时候,我从 ejoy 分支切换到 master 上修改。修改完后,我会切回 ejoy 上,做 rebase master 。 似乎,这个流程用 merge 要好一些。因为 rebase 后,就不能 push 到办公室的仓库上了。(需要加 -f )

目前没有精力去搞清楚 git 的正确工作流,暂时把这个问题放一边吧。


最近几个比较大的改动是,增加了一个对外建立连接的服务 (connection) 。 建立起连接上,就让这个服务把所有外部连接上的数据推送到指定节点。

我一开始的设计是有问题的。我企图采用拉的方式来工作。定义了拉数据的接口: 拉一行(设定行分割符), 以及拉指定数据块。问题在于,在异步操作环境下,有可能同时在一个连接上发起几次异步操作,在需要多步分析数据流的情况中,很可能无法得到正确的结果。


无论是 gate 的接收外部连接, 还是 connection 发起外部连接,处理 skynet 系统外的数据,都和内部数据包的流通有所不同。因为,外部包是可以组合成任何内部布局并加以处理的(可以有指针),而外部数据则必须是连续数据块,并有一定的包分割规则。

认清这个不同点,我就增加了内部和外部数据的识别方式(之前的版本是认为可以隐藏掉这个细节的)。我的方法是用了一个特殊的 session 号 (0x7fffffff) 。因为 session id 是由 skynet 自己递增产生的,所以不会有冲突。

为了兼容之前的通讯协议,以及 rpc 协议。我增加了过滤器模块,可以把老的协议过滤成新的格式。这个等全部移植工作做完再去掉。

同时,为了提高性能,增加了重定向包的接口,在做一些中间设施时,能减少包的复制。


在我把一些基础设施补全后,尤其是在我实现了和老的服务器集群对接模块后,我发现之前用 zeromq 实现的内部集群通讯模块显的很冗余。绑上 zeromq 这么一个重量级的库,却没用上多少功能,很是不爽。

所以我最终重写了一千多行代码,替换掉了原来使用 zeromq 的 harbor 模块。使用 zeromq 其实并没有减少总代码量(原来也有将近一千行代码)。


目前,我已经把原有的独立的认证服务的集群替换到新 skynet 上了。并可以和老的游戏服务器集群对接。这是第一步,完全替换掉游戏服务器底层还需要一点时间。

这可以让我做一些压力测试了。

我们用的类似 Kerberos 的认证授权协议。中间涉及多个服务间的通讯。以及对数据库的查询操作。整个流程还是很复杂的。我觉得有一定的测试意义。

初步测试结果,比之前的框架提高了 3-4 倍的性能。当然,这个测试很初步,不太能说明问题。有很多环节会导致老的框架表现不佳。但在简单的 echo 服务测试中,性能提升就不明显了。

我希望在所有移植工作做完后,再来做详细细致的评测工作。至少我对新的 skynet 可以 100% 把握,可以细致的监控每一行 C 代码。暂时也想不到框架上可以有大的性能提升的余地了。

btw, 在一些简单测试中,通过 profile ,发现其实大量 CPU 花在服务自己的 lua 虚拟机上。占掉了 80% 以上的时间。这些是跟框架(全部用 C 实现)是无关的。要提升这一点,恐怕下一步工作需要把 lua 5.2 迁移到 luajit2 上看看效果了。

August 06, 2012

Skynet 集群及 RPC

这几天老在开会,断断续续的拖慢了开发进度。直到今天才把 Skynet 的集群部分,以及 RPC 协议设计实现完。

先谈谈集群的设计。

最终,我们希望整个 skynet 系统可以部署到多台物理机上。这样,单进程的 skynet 节点是不够满足需求的。我希望 skynet 单节点是围绕单进程运作的,这样服务间才可以以接近零成本的交换数据。这样,进程和进程间(通常部署到不同的物理机上)通讯就做成一个比较外围的设置就好了。

为了定位方便,我希望整个系统里,所有服务节点都有唯一 id 。那么最简单的方案就是限制有限的机器数量、同时设置中心服务器来协调。我用 32bit 的 id 来标识 skynet 上的服务节点。其中高 8 位是机器标识,低 24 位是同一台机器上的服务节点 id 。我们用简单的判断算法就可以知道一个 id 是远程 id 还是本地 id (只需要比较高 8 位就可以了)。

我设计了一台 master 中心服务器用来同步机器信息。把每个 skynet 进程上用于和其他机器通讯的部件称为 Harbor 。每个 skynet 进程有一个 harbor id 为 1 到 255 (保留 0 给系统内部用)。在每个 skynet 进程启动时,向 master 机器汇报自己的 harbor id 。一旦冲突,则禁止连入。

master 服务其实就是一个简单的内存 key-value 数据库。数字 key 对应的 value 正是 harbor 的通讯地址。另外,支持了拥有全局名字的服务,也依靠 master 机器同步。比如,你可以从某台 skynet 节点注册一个叫 DATABASE 的服务节点,它只要将 DATABASE 和节点 id 的对应关系通知 master 机器,就可以依靠 master 机器同步给所有注册入网络的 skynet 节点。

master 做的事情很简单,其实就是回应名字的查询,以及在更新名字后,同步给网络中所有的机器。

skynet 节点,通过 master ,认识网络中所有其它 skynet 节点。它们相互一一建立单向通讯通道。也就是说,如果一共有 100 个 skynet 节点,在它们启动完毕后,会建立起 1 万条通讯通道。

为了缩短开发时间,我利用了 zeromq 来做 harbor 间通讯,以及 master 的开发。蜗牛同学觉得更高效的做法是自己用 C 来写,并和原有的 gate 的 epoll 循环合并起来。我觉得有一定道理,但是还是先给出一个快速的实现更好。


我们的早一个 Erlang 版本,把 client 也看成了 skynet 系统中的特殊节点。这次看来,我认为是不必要的设计。

如果在同一个进程内,通讯和包转发足够廉价的话,完全没必要为统一这种特殊性而多做太多工作。所以在这次新实现中,client 被看成是 gate 这个服务才了解的细节。由 gate 收集 client 的数据流,并转发到内部的其它服务上。同时,我为发送数据单独启动了一类服务。为每个接入 gate 的 client 动态生成一个节点。只要向这个节点发送数据,都加上和 client 间约定的打包协议的包头转发给 client 。

把 client 独立出来,不当作是内部节点处理,可以使我们能专心 RPC 的问题。

skynet 的内部节点之间,有很大程度是请求回应模式的消息传递模式。这种请求回应模式,可以是 RPC 请求,也可以是一些更简单的通讯协议。

在前一个版本中,我们认为,skynet 只需要解决后消息包,如何有序的,从一个节点传输到另一个节点就够了。之后的细节是下一个层次考虑的问题。可是做下去我们发现,不同的服务间如果想协同工作,必须约定一些基本的通讯协议。每个服务使用独立不相同的通讯协议几乎是不可能的。这是因为,每个服务节点只有单一的输入消息源。虽然我们可以识别消息的来源地址,但根据来源地址来区分消息协议种类是不可能的事情。

结果,我们采用了 google proto buffer 。消息包必须用 protobuffer 打包,并有统一的一级结构。这反而是整个设计不那么简洁了。

这次,我归纳了这半年来的使用需求。发现,skynet 不应只处理单个包从某点发送到另一点的任务。既然我们不抽象出连接这个概念,那么就至少需要让 skynet 框架了解怎样回应一个特定的包。

所以,最终我把一个 31 位的 session id 放到了底层。每个服务节点内部都维护了一个单调递增的 session id 记数器。一旦它需要时,可以给它发出的消息携带一个唯一的 id ;同时约定,接收到这个包并加以处理的节点,如果想针对这个消息包做回应,它就应该把这个 session id 发送回来。

为了区分请求包和回应包。约定,请求包的 session id 为负数,回应包的 session id 为正数。不需要回应的包,可以用 0 做 session id 。

这样,我们就可以利用收到的 session id 做数据包的有限分发了。这并没有增加太多的协议上的约定,每个服务可以按自己喜爱的方式设计协议。它们可以要求请求它的服务的人按自己的协议发送请求,它可以正确的回应。同时,它需要使用其它远程服务时,则按对方服务的协议来通讯。


接下来,在 lua 的封装层做很少量的工作,就可以让这套机制运转起来了。也就是根据收到的 session id 做一下分发。利用 coroutine ,做远程请求时,记录下产生的 session id ,yield 出来,把线程挂起;待到收到携带有相同 session id 的反馈包,把挂起的线程唤醒即可。

我写了一个简单的 key-value 设置和查询的内存数据库作为范例。起名叫 SIMPLEDB ,可以从 client 发起查询请求:“ GET xxx ”,或是更新请求:“ SET xxx yyy ”。agent 收到请求后,会转发到 SIMPLEDB ,并把结果反馈给 client 。有兴趣的同学可以看看相关的 lua 代码。

当然,lua 在整套系统中并不是必备设施。如果你愿意,也可以写出相同功能的其它动态语言(例如 python )的对接模块来。

August 01, 2012

Skynet 开源

最近两天是我们项目第二个里程碑的第一个检查点。我们的服务器在压力测试下有一些性能问题。很多方面都有一个数量级的优化余地,我们打算先实现完功能,然后安排时间重构那些值得提升性能的独立模块。

我最近两周没有项目进度线上的开发任务。所以个人得以脱身出来看看性能问题。前几天已经重新写了许多觉得可能有问题的模块。在前几天的 blog 里都有记录。

虽然没有明显的证据,但是感觉上,我们的服务器底层框架 skynet 有比较大的开销。这个东西用 Erlang 开发的,性能剖析我自己没有什么经验。总觉得 Erlang 本身代码基有点庞大,不太能清晰的理解各个性能点。

其实底层框架需要解决的基本问题是,把消息有序的,从一个点传递到另一个点。每个点是一个概念上的服务进程。这个进程可以有名字,也可以由系统分配出唯一名字。本质上,它提供了一个消息队列,所以最早我个人是希望用 zeromq 来开发的。

现在回想起来,无论是利用 erlang 还是 zeromq ,感觉都过于重量了。作为这个核心功能的实现,其实在 2000 行 C 代码内就可以很好的实现。事实上,我最近花了两个整天还不错的重新完成了这个任务,不过千余行 C 代码。当然离现在已有的框架功能,细节上还远远不够,但能够清晰的看到性能都消耗到哪些位置了。其实以后不用这个 C 版本的底层框架,作为一个对比测试工具,这半周时间也是花得很值得的。

我将这两天的工作开源到了 github 上,希望对更多人有帮助。从私心上讲,如果有同学想利用这个做开发,也可以帮助我更快发现 bug 。有兴趣的同学可以在这里跟踪我的开发进度

关于接口,我在上面提到的 blog 中已经列过了。这次重新实现,发现一些细节上不合理的地方,但是不太好修改,姑且认为是历史造成的吧。

在目前的版本里,我还没有实现跨机器通讯,我也不打算讲跨机通讯做到核心层中。而希望用附加服务的方式在将来实现出来。

这个系统是单进程多线程模型。

每个内部服务的实现,放在独立的动态库中。由动态库导出的三个接口 create init release 来创建出服务的实例。init 可以传递字符串参数来初始化实例。比如用 lua 实现的服务(这里叫 snlua ),可以在初始化时传递启动代码的 lua 文件名。

每个服务都是严格的被动的消息驱动的,以一个统一的 callback 函数的形式交给框架。框架从消息队列里取到消息,调度出接收的服务模块,找到 callback 函数入口,调用它。服务本身在没有被调度时,是不占用任何 CPU 的。框架做两个必要的保证。

一、一个服务的 callback 函数永远不会被并发。

二、一个服务向另一个服务发送的消息的次序是严格保证的。

我用多线程模型来实现它。底层有一个线程消息队列,消息由三部分构成:源地址、目的地址、以及数据块。框架启动固定的多条线程,每条工作线程不断的从消息队列取到消息。根据目的地址获得服务对象。当服务正在工作(被锁住)就把消息放到服务自己的私有队列中。否则调用服务的 callback 函数。当 callback 函数运行完后,检查私有队列,并处理完再解锁。

线程数应该略大于系统的 CPU 核数,以防止系统饥饿。(只要服务不直接给自己不断发新的消息,就不会有服务被饿死)

由于我们是在同一个进程内工作的。所以我对消息传递做了一点优化。对于目前的点对点消息,要求发送者调用 malloc 分配出消息携带数据用到的内存;由接受方处理完后调用 free 清理(由框架来做)。这样数据传递就不需要有额外的拷贝了。


除了核心功能,我们还需要提供一些基础功能才可以做点真正的事情。

一个是简单的黑洞(blackhole),当消息没有接收者时,它可以接受到消息,并服务清理消息占用的内存。

一个是简单的错误信息记录器(logger)。内部错误信息不应该用简单的 printf 输出,这是因为在多线程模型下,这样会造成混乱。用一个独立服务,讲 log 信息串行化要清晰的多。有必要的话,可以加工这些信息。

启动新的服务和杀掉服务我把它们做到了框架内,以 skynet command 的形式提供。按原本的项目,应该有一个额外的服务管理器的东西来做这些事情。但我发现,大多数情况下,我希望知道我启动的服务的地址,以方便做后续操作。如果用一个管理器服务的形式来工作,虽然可以简化核心,但必须建立一套 RPC 协议出来。这次我不打算在核心层约定 RPC 规范,所以就选择放在了核心指令内。

Timer 及时间服务是一项基础功能。所以我实现在了框架内。特别是 timeout 为 0 的特例,是不进入 timer 队列,而是直接进入消息队列。这次我提供了 1/1000 秒的时间精度,以及 1/100 精度的 timeout 回调,对于游戏服务感觉是够用了。

对于 MMO 的基础需要,我提供了 gate 的独立服务,用来处理大量的外部链接。这个是 前段时间用 epoll 实现的 。稍微做了些小修改就用上了。

这个只解决读外部链接的问题,暂时还没有实现发送的部分,接下来的时间我会完善它。

其工作方式是,启动 gate 服务后,根据启动参数, listen 一个端口。接受连接上来的所有外部连接。gate 会为每个连接赋予一个唯一 id 号。注意,这个 id 号是尽量不复用的。在 skynet 的生命期内是单调递增的。这是因为在这样的多服务并发的复杂系统内,短期复用 id 是很危险的一件事。我用了一个简单的方法(保证不冲突的 hash 表)来解决高效的映射关系。

gate 会默认将所有外部连接的相关消息(连入,退出,有数据到来)发送给一个叫 watchdog 的服务。

并且,它接受一些控制指令,可以主动断开外部连接;或是把这个特定外部连接的数据绑定到另一个不是 watchdog 的地址。

在目前的范例中,watchdog 用 lua 实现。当一个外部连接接入,它会启动一个类型为 agent 的服务(也是用 lua 编写),并通知 gate 绑定这个外部连接的数据到新启动的 agent 上。

关于外部连接的 client , 我简单的要求, 它必须是按一个个数据包发送数据过来, 每个数据包有一个两字节的大头数字表示包长. 我给出了一个 client.c 做简单的测试工作。


lua 服务是另一项基础设施,但不属于核心部分。如果你喜欢,也可以用 python 等其它动态语言替换掉。

这里叫 snlua ,以和系统内的 lua 区分。同时我提供了一个 skynet 的 lua 库,可以给 lua 程序实现。当然,如果不是在 snlua 环境中 require 这个 skynet 库的话,是不能正确工作的。


和之前 erlang 的版本比较,我在设计上做了一些修改。比如,并没有在核心层规定通讯协议,而之前默认一定用 protobuffer 来做消息通讯。

基础服务间的控制指令,目前基本都用简单文本协议。

还没有实现大量细节的配置表,以及组播、跨机通讯等等。这些繁琐的工作可能要花掉我接下来几周的时间,才可能无缝的接入现在已有的系统。