« February 2017 | Main | April 2017 »

March 25, 2017

skynet cluster 模块的设计与编码协议

skynet 在最初的设计里,希望做一个分布式系统,抹平 actor 放在本机和处于网络两端的差别。所以,设计了 master/slave 模式。利用 4 个字节表示 actor 的地址,其高 8 位是节点编号,低 24 位是进程(节点)内的 id 。这样,在同一个系统中,不管处于哪个进程下,每个 actor (在 skynet 中被成为服务)都有唯一的地址。在投递消息时,无需关心目的地是在同一个进程内,还是通过网络来投递消息。

随后,我发现试图抹平网络和本地差异的想法不那么靠谱。想把一个分布式系统做得(和单一进程同样)可靠,无论如何都简单不了。而 skynet 的核心希望可以保持简单稳定。所以我打算把分布式的支持放在稍上一点的层次实现。

先来说说同一进程下的服务通讯和跨网络的通讯到底有什么不同。

  1. 进程内的内存是共享的,skynet 是用 lua 沙盒来隔离服务状态,但是可以通过 C 库来绕过沙盒直接沟通。如果一个服务生产了大量数据,想传给您一个服务消费,在同一进程下,是不必经过序列化过程,而只需要通过消息传递内存地址指针即可。这个优化存在 O(1) 和 O(n) 的性能差别,不可以无视。

  2. 同一进程内的服务从底层角度来说,是同生共死的。Lua 的沙盒可以确保业务错误能够被正确捕获,而非常规代码不可控的错误,比如断电、网络中断,不会破坏掉系统的一部分而另一部分正常工作。所以,如果两个 actor 你确定在同一进程内,那么你可以像写常规程序那样有一个共识:如果我这个 actor 可以正常工作,那么对端协作的另一个 actor 也一样在正常工作。就等同于,我这个函数在运行,我当然可以放心的调用进程内的另一个函数,你不会担心调用函数不存在,也不会担心它永远不返回或是收不到你的调用。这也是为什么我们不必为同一进程内的服务间 RPC 设计超时的机制。不用考虑对方不相应你的情况,可以极大的简化编写程序的人的心智负担。比如,常规程序中,就没有(非 IO 处理的)程序库的 API 会在调用接口上提供一个超时参数。

  3. 同一进程内所有服务间的通讯公平共享了同一内存总线的带宽。这个带宽很大,和 CPU 的处理速度是匹配的。可以基本不考虑正常业务下的服务过载问题。也就是说,大部分情况下,一个服务能生产数据的速度不太会超过另一个服务能消费数据的速度。这种情况会造成消费数据的服务过载,是我们使用 skynet 框架这几年来 bug 出现最多的类型。而跨越网络时,不仅会因为生产速度和消费速度不匹配造成过载,更会因为传递数据的带宽和生产速度不匹配而过载。如果让开发者时刻去考虑,这些数据是投递到本地、那些数据是投递到网络,那么已经违背了抹平本地和网络差异这点设计初衷。

所以我认为,除非你的业务本来就是偏重 IO 的,也就是你根本不打算利用单台硬件的多核心优势来增强计算力,抹平本机和网络的差异是没有意义的。无论硬件怎样发展,你都不可能看到主板上的总线带宽和 TCP 网络的带宽工作在同一数量级的那一天,因为这是物理基本规律决定的。

当你的业务需要高计算力,把 actor 放在一台机器上才可以正常的发挥 CPU 能力去合作;如果你的系统又需要分布式扩展,那么一定是有很多组独立无关的业务可以平行处理。这两类工作必须由构架系统的人自己想清楚,规划好怎么部署这些 actor ,而不可能随手把 actor 扔在分布式系统中,随便挑台硬件运行就够了。

恰巧网络游戏服务就是这种业务类型。多组服务器、多个游戏场景之间交互很弱,但其中的个体又需要很强的计算力。这就是 skynet 切合的应用场景。


我在 skynet 的核心层之上,设计了 cluster 模块。它大部分用 lua 编写,只有通讯协议处理的部分涉及一个很小的 C 模块。用 Lua 编写可以提高系统的可维护性,和网络通讯的带宽相比,Lua 相对 C 在处理数据包的性能降低是微不足道的。

它的工作原理是这样的:

在每个 skynet 节点(单个进程)内,启动一个叫 clusterd 的服务。所有需要跨进程的消息投递都先把消息投递到这个服务上,再由它来转发到网络。

在 cluster 集群中的每个节点都使用一个字符串来命名,由一个配置表来把名字关联到 ip 地址和端口上。理论上同一个 skynet 进程可以监听多个消息入口,只要用名字区分开,绑定在不同的端口就可以了。

为了和本地消息做区分,cluster 提供了单独的库及一组新的 API ,这个库是对 clusterd 服务通讯的浅封装。当然,也允许建立一个代理服务,把代理服务它收到的消息,绑上指定名字,转发到 clusterd 。这样就和之前的 master/slave 模式几乎没有区别了。

在 skynet 1.0 版中,是不支持 cluster.send 的。也就是不能直接向 cluster 单向推送消息,而必须使用 cluster.call 也就是请求回应方式。这么做是考虑以下几个因素:

  1. 推送实质上是可以用请求回应替代的,因为你可以简单忽略掉回应。如果担心 call 操作阻塞住执行流程,那么可以使用 skynet.fork 来调用 cluster.call ,或是另外写一个服务来代理推送。即,向这个服务 send 消息,让代理服务来阻塞调用。

  2. cluster 有一个显式的网络过程,我希望使用者可以明白这一点,而不是把它藏起来。对于网络推送,就必然存在失败的可能:对方节点宕机、tcp 连接意外断开(这在云主机上尤其常见)都有可能发送不到,所以必须有一个回应机制来反馈这种异常。固然可以实现一套可靠的不丢消息的消息队列,但我认为应该在更高的层次来做,否则为保证消息队列的可靠性而做出的性能损失以及实现复杂度的提升是不太划算的。而 cluster.call 则可以直接抛出 error 通知调用者失败,用的人清晰了解这种失败的可能性,迫使用的时候做出失败处理逻辑。而 cluster.send 从 api 设计上就无法留这个余地。

但近一年的实践表明,无论是我们自己公司的人使用、还是第三方 skynet 用户,都迫切的需要 cluster.send 语义,这使我对上面的想法做出妥协,打算在 skynet 1.1 中正式引入 cluster.send 。


最初的实现比较随意。打算在原有基础上打个小补丁,尽量对老代码和流程不做修改。所以我扩展了 skynet.ret 的实现,让它可以根据消息的 session 是否为 0 来判断是不是一个单向推送(send),如果不需要返回则什么都不做。这样,只要约定,任何消息都写上 skynet.ret 回应,由框架来决定是否真的要回应就好了。这样,cluster.send 依旧利用 cluster.call 去请求,对方对于跨节点的推送也可以做出回应。发起请求方再把回应包扔掉即可。

换句话说,cluster 间的通讯协议并没有修改,无论是 cluster.call 还是 cluster.send 都是发起的一样的网络请求,都要求对方回应。一个服务在响应本地推送消息时,调用 skynet.ret 会直接忽略;而响应网络推送时,是经由 clusterd 转发过来的请求,(由于协议限制,clusterd 无法区分是一个推送还是一个请求),skynet.ret 则做出一个回应。最后回应包会转回发起方时被扔掉。

最开始用这个模式来使用 cluster.send 的同学,由于了解整个历史,所以理解并接受这个用法。但新同学还是容易漏掉这处细节。skynet.send 和 cluster.send 的这个小区别确实挺让人意外的,我也不认为在文档中写清楚就够了,毕竟大部分人是不读文档的。为了遵循最小意外原则,我打算修正 cluster 通讯协议,明确区分 send 和 call 的网络包。

下面记录一下 cluster 通讯协议编码,以备日后查阅。通过了解编码协议,也能理解为什么在 cluster 通讯层次上,光靠判断 session 是否为 0 来区分推送和请求包是不够的。

在 cluster 间的一条 TCP 连接上,两端是严格分为请求方和回应方。比如 A 发起连接到 B ,那么只能是 A 向 B 提出请求,B 回应它;如果 B 想向 A 发起请求的话,需要由 B 向 A 再建立一条通道。

请求协议是这样编码的:

首先是两个字节的包头,高位在第一个字节、低位在第二个字节。采用大端编码是和 skynet 的 gate 模块一致。

第三个字节是一个 type ,可以区分这是一个小的完整包,还是一个大包的一部分;请求地址是 32bit id 还是一个字符串。

type 为 0 ,表示这是一个不超过 32K 字节的完整包,目的地址是一个 32bit id 紧跟在后面第 4 到 7 字节。接下来 8 到 11 字节是 session 号;如果 session 为 0 表示这是一个推送包,不需要回应;否则需要根据 session 来匹配回应包。12 字节开始就是包的内容。

type 为 1 或 0x41 ,表示这是一个超过 32K 字节的请求(1)或推送(0x41),这个包本身没有数据内容,只有地址、sesson 和包的总长度。分别以此编码在后面 12 字节内。其中长度采用的是小端编码的 32bit 数字。

type 为 0x80 ,和 0 类似,但地址是一个字符串而不是 32bit 的 id 。字符串最长不超过 255 字节,用一个字节的长度加字符串内容编码在原来 4 字节 id 的位置。

type 为 0x81 或 0xc1 时,对应 1 和 0x41 ,只是把地址从 id 换为字符串。

type 为 2 或 3 时,表示这是一个长消息中的一部分。包内容为 4 字节的 session 和具体的数据。 2 表示还有后续的数据,3 表示这是最后一段数据。

回应的编码协议简单一些:

WORD size (big endian)
DWORD session
BYTE type
    0: error
    1: ok
    2: multi begin
    3: multi part
    4: multi end
PADDING msg
    type == 0, error msg
    type == 1, msg
    type == 2, DWORD size
    type == 3/4, msg

这里,超过 32K 的数据包需要分成小包传输,而分开的小包依赖 session 来区隔,所以必须让每组长消息都有唯一 session 。这样就必须额外在 type 字段来区分是请求还是推送。

March 20, 2017

skynet 1.1 发布候选版本

skynet 1.0 于 2016 年 8 月 1 日正式发布,到今天已经有 7 个多月了。这段时间积累了很多小修改,我想是时候发布 1.1 版了。

很高兴这段时间 skynet 社区继续壮大,有更多的公司选择基于 skynet 开发。

现打算在下个月以目前 github 仓库 master 分支为基础发布 1.1 正式版,这两周如果同学们还有什么问题请尽快提 issue 。

下面是从 1.0 开始积累的更新:

  • debug console : 可用户指定绑定 ip 。
  • debug console : 增加 call 指令向服务发送消息。
  • debug console : 反馈 inject code 的错误信息。
  • debug console : 修改命令确认信息,方便自动化处理。
  • sharedata : 增加 flush 。
  • sharedata : 增加 deepcopy 。
  • cluster : 增加 send 。
  • cluster : 支持绕过配置文件直接传递配置表。
  • skynet : 增加 state 指令查询服务的 cpu 开销。
  • skynet : wakeup 保证次序。
  • httpc : 支持 timeout 。
  • mongo driver : sort 支持多个 key 。
  • bson : 对 string 类型做 utf8 编码检查。
  • daemon 模式 : 可正确输出错误信息。
  • sproto : 支持定点数。
  • sproto: 支持 binary 类型。
  • jemalloc : 更新到 4.5.0
  • lua : 更新到 5.3.4

还有一些次要的 bugfix 及代码调整没有列出。

March 17, 2017

Lua 调试器

又一篇谈 Lua debugger 的 blog 了。但这次,并不是我的个人作品 :) 。

去年底我写了 如何优雅的实现一个 lua 调试器 。正如我的 blog 中所写:“不过期待它短期内发展成为一个图形式的漂亮交互调试器可能有点不现实,除非做前端的朋友有兴趣来完善它。”

ok 。这次,真的有人来完善它了。

我公司的前端大神突然对实现一个 lua debugger 产生了兴趣。他觉得既然 chrome 可以用来调试 javascript ,那么魔改一下后,调试 lua 也完全没有问题。利用几个月的业余时间,他完成了这么个东西:

https://mare.js.org/

ps. 不愧是做前端出身啊,开源项目的主页比 skynet 好看多了。

March 14, 2017

sproto 的一些更新

sproto 是我设计的一个类 google protocol buffers 的东西。

在很多年前,我在我经手的一些项目中使用 google protocol buffers 。用了好几年,经历了几个项目后,我感觉到它其实是为静态编译型语言设计的协议,其实并没有脱离语言的普适性。在动态语言中,大家都不太愿意使用它(json 更为流行)。一个很大的原因是,protobuffers 是基于代码生成工作的,如果你不使用代码生成,那么它自身的 bootstrap 就非常难实现。

因为它的协议本身是用自身描述的,如果你要解析协议,必须先有解析自己的能力。这是个先有鸡还是先有蛋的矛盾。过去很多动态语言的 binding 都逃不掉引入负责的 C++ 库再加上一部分动态代码生成。我对这点很不爽,后来重头实现了 pbc 这个库。虽然它还有一些问题,并且我不再想维护它,这个库加上 lua 的 binding 依然是 lua 中使用 protobuffer 的首选。

protobuffer 自身有很长的历史,历史带来了包袱,实际上在我放弃 protobuffer 后不久,google 也大刀阔斧的更新了 3.0 版,对之前 2.x 版做了很多必要的,不兼容的改进。

我仿照 protobuffer 的基本理念设计了 sproto ,设计上的考虑在之前的 blog 中已经写了很多。

sproto 的设计理念之一:保证基本能用的基础上足够简单,不增加太多特性。如果要增加新特性,都是仔细考虑过,并保持不破坏兼容性。最近一段时间,我给 sproto 加了两个特性,这里简单介绍一下:

其一,sproto 没有内置的浮点数类型。因为我觉得大多数项目中,传输浮点数的必要性都不大。如果必须传输的话,可以用字符串,或二进制串来替代。由传输的双方来共同约定保证一致性。

但是,有时候传输小数的需求依然存在。我认为定点数可以是一种选择,而且不必破坏原有的协议,也不必增加新的类型。只需要在 sproto 的协议描述中加一些备注。

之前给 sproto 加字典支持就是靠备注实现的

这次我选择在 integer 类型后面加一个备注,比如写 integer(2) 就表示,这个整数类型是一个有两位十进制小数位的定点数。两端在解析同样的协议时,需要在编码的时候 * 100 取整再编码,解码的时候 /100 还原。

如果有一端使用老版本的实现,那么可以在跟上层自行乘除 100 来兼容。

第二,sproto 用 string 类型来传输字符串和二进制串。它可以自然对应到 lua 中的 string 类型。但是,很多其它语言中,可阅读的 string 和 binary 串是两种不同的类型。其 string 类型需要额外指定编码。

sproto 缺乏 binary 串的描述,会在 python c# 等语言中遇到一些麻烦。

考虑之后,我决定给 sproto 增加 binary 类型,但为了兼容,把它作为 string 的一个子类型实现。在新版的 sproto 中,你可以在协议文件中定义 binary 字段。但编码时,还是按 string 编码的,不会破坏兼容性。

但新版本的代码在解码的时候,会根据协议定义,正确的通知 binding 层,这是一个 binary 串。