skynet 之构建决策

此文写于国庆期间,本是想完整描述 skynet 框架,但在写的过程中觉得内容会很长,于是仅仅描述了一个大致的结构。希望借此整理一下自己对 skynet 的认识以及能让刚刚接触 skynet 的读者可以有一个感性的认识。

skynet 是由简悦云风设计的一套轻量高性能游戏服务器框架. 之所以写这样一篇博客, 其一是这个框架实现的很优雅, 做到了底层接口简单正交, 高层抽象便于实现逻辑, 在国内如此只少的游戏服务端开源框架中给青年游戏程序员打开了一扇窗, 很有必要为之写一篇介绍性文章. 其二是云风博客中有关 skynet 的时间线很长, 阅读起来会花费比较多时间, 但其设计理念和过程有助于理解 skynet 的代码, 在总述一遍相信能让刚接触 skynet 的爱好者可以有一个大概的概念. skynet 有 189 个文件, 28957 行代码, 其中 67 个文件是其 C 实现, 共计 16810 行代码, 另外 106 个 lua 文件包含 11150 行代码. 这些源文件按照3个层次结构的方式有效组织在一起, 想要完全弄懂其实并不难.

  1. 基础服务: 网关服务, agent机制, 登陆服务器, 日志服务, 调试控制台组件, launcher服务, 数据中心服务;
  2. 供编写 Lua 服务的便利机制: 底层暴露的 Lua 接口, 组播组件,外部连接模块, coroutine 分发机制, 集群机制, snlua 加载 lua 服务机制, socket 通道组件;
  3. 核心模块: 消息投递, 回调机制, Timer模块, 服务启动机制, 异步网络IO;

除了以上这些带层次的组件, 还有外围便利组件, 例如, 序列化组件、共享内存组件、加密组件、sproto、哈希组件、内存钩子;

通过看代码固然可以了解框架的整体细节, 但这个过程就像在没有地图的情况下想要了解整个城市的规划, 好在云风的博客中提供了详尽的项目发展过程描述, 通过阅读既可以知道整个项目划分成了哪几块, 更是可以了解那些被反复构建的模块, 甚至是那些被删除的模块, 除此以外, 框架中一些生硬的地方我们会知道它缘何如此, 这是为了兼容曾经的哪些设计错误. 现在 skynet 已经是一个非常之完善的框架, 如果你去查看 skynet 的提交历史便可从中知晓 skynet 的演变过程, 曾经遇到过的各种设计错误. 此文的目的也在于此, 不仅仅可以提供 skynet 的鸟瞰图, 更在于揭示 skynet 的设计历史.

核心

skynet 的基本特征是单进程内基于多线程的服务之间的消息投递, 也即我们常提及的 Actor 模型. 每个服务被视作一个 Actor, 它们可以并行(parallel)执行各自的逻辑, 利用本地资源进行决策, 绝不会和其它服务共享状态. Actor 只做三件事情: 发送消息, 创建其它 Actor, 接收消息并执行特定行为. Actor 模型具有天生的并行特性, 能够重复的利用现代 CPU 的多核特性.

事实上 skynet 的初衷便是利用多核 CPU 来构建高性能游戏服务器. 因此, skynet 虽然会涉及到跨节点的消息投递, 但只作为外围组件设计. 在这种设计下, 集群方案不提供完善的容错处理, 即若某一节点无法提供服务时, 没有提供完善的重新启动机制. 也没有完善的服务依赖顺序启动方案, 这得靠手工去做, 亦不提供跨节点热更新操作. 所以 skynet 鼓励的单一进程方案, 这有别于将多个服务放在多个独立进程中的方案.

Actor 模型相较于同步的优点就在于异步调用. 同步方式是一个服务直接调用另一个服务的方法, 并阻塞等待返回, 这种方式编写起来比较简单, 但是缺点也很明显. 第一, 一个服务必须持有另一个服务的引用, 而一旦另一个服出现异常很可能牵连到当前的服务, 服务之间的耦合度很高, 而且相当之脆弱. 第二, 同步方式势必造成吞吐量和响应性下降, 在服务处理完前一个请求之前它不会响应任何接下来的请求, 即便它在处理过程中在等待其它服务的响应也只能干等白白浪费 CPU 处理能力而无法充分利用多核的并行特性. 一旦遇到大量计算繁重的任务, 整个服务器就会宕机.

Actor 模型将发送者和接收者分别隔离在自己的沙盒中, 任何一个服务出现异常都不会波及其它的服务, 即便有一些服务被阻塞, 其它的服务依然可以正常运行. 服务之间只能持有对方的地址, 每个服务仅与此地址交互, 从而将服务去耦. 消息的传递类似于邮件投递, 发送无需关心接收者在不在, 不要求接收者立即处理, 发送者在发送完消息之后就可以转而去处理其它的事务. 当发送出去的消息得到回复之后可以再接着完成这个未完成的工作. 这种模型的一个好处便是即便消息投递失败, 比如地址无效、接收者不在原来的位置、接受者死亡、接收者处理不成功等等各种异常情况都不会使消息发送者失败. 这保证了程序的健壮性.

基于这个优点实现异步通信(asynchronous communication), 消息发送出去之后接受者不一定马上收到, 发送者也无需等待回应后再执行其它的运算. 你所看到的是有大量的消息以无序的方式在所有服务之间流通. 从而提高了系统的吞吐量.

响应性也来自于此, 由于服务之间是松耦合的, 一个服务被阻塞, 其它服务依然可以响应请求. 甚至在同一服务内的多个逻辑, 其中一个若由于等待其它服务的响应而被挂起, 其它的逻辑依然可以继续执行, 此为 coroutine 消息分发带来的好处, 这在下面会谈到.

值得解释一下无序消息流通, 这里的无序指的第一是接收和发送是无序的, 任何一个服务并发地接收消息和发送消息. 第二是任意多个服务发送消息给其它服务是无序的, 服务是并行执行的, 当然消息发送也是并发的. 第三是单个服务内部的多个逻辑之间发送接收消息是无序的, 比如 A 逻辑发送了一个消息之后, 可能在其响应回来前, B 逻辑紧接着发送了消息, 并且得到了响应, 紧接着 B 逻辑可能发送下一个消息, 也有可能 A 逻辑的响应回来而再次发送消息. 这些顺次是不定的. 然而无序中却是带着有序的, 任何一个服务接收其它服务发来的消息都保存在队列中, 一个接着接着一个处理, 并且任何单条逻辑都有序的, 因为它必然是在一个执行序中. 所以虽然消息流通是异步无序的, 当整个系统却是有序高效的.

Actor 模型还有另一个特点, 就是它们是动态生成的, 系统中不是只有固定数量的特定服务, 而且在运行过程中不断生成和卸载. 也就是说一个服务可以给一个尚未生成的服务发消息. 你可以想象你向简悦公司的 HR 投递简历, 而此时有可能简悦公司根本就还没有 HR 或者原来的 HR 离职了. 但是你依然可以投递简历, 依然可以期待回复. 我相信简悦公司会很快给你回复的, 她会招聘一个新的 HR 来处理这封简历. 此时我们用 HR 来指代消息的接收者而不管真正是谁接收, 这就是 skynet 里边的名字服务的由来.

在上文详谈了 skynet 最核心的设计原理 —— 以 Actor 模型来设计消息传递和服务. skynet 的核心任务是将一个数据包从一个服务中发出, 让同一进程内的另一个服务以有序的方式收到, 并以线程安全的方式调用对应的 callback 函数进行处理. skynet 保证任何一个服务的初始化和逻辑处理都是线程安全的, 不相关的服务之间是并行执行的. 一个模块可以启动多份代码相同的服务, 但它们本质上是不同的服务, 它们之间不会相互影响.

服务逻辑的线程安全这一保证在有些情形下会失效. 究其原因在于框架使用 coroutine 分发机制. 如果逻辑处理过程中又向别的服务发送消息, 并阻塞等待响应, 此时这个 coroutine 会被挂起, 而服务紧接着处理下一个逻辑. 如果在刚刚挂起的 coroutine 的逻辑处理中修改了服务的状态, 那么此时这个新的逻辑处理必然看到的服务必然是状态不一致的. 为此框架后来提供了 skynet.queue 来创建一个临界区, 处于临界区中逻辑即便调用了阻塞 api 也不会切换至其它的逻辑.

值得注意的是这里所说的阻塞调用并不是操作系统中的 I/O 阻塞, 如果在操作系统级别阻塞了, 例如网络、文件读写, 那个整个 skynet 进行将会被挂起导致吞吐量下降. 此处的阻塞是进程内部的协程出让, 直到所等待的响应归来再继续运行, 此时其它协程依然可以运行的.

说到这里, 不得不说 skynet 服务实现选用的语言 Lua. 之所以选用 Lua 的原因就在于 Lua State 提供了良好的沙盒, 用于隔离不同服务的执行环境, 并且将绝多数逻辑异常封闭在各个沙盒中而不会波及其它功能模块. 加之 Lua 本身是一个小的语言, 依赖非常少的外部模块, 且与 C 语言只有很薄的胶合层. 用之来实现 skynet 的高层模块既轻量到可以了解系统的任何一个细节, 对于剖析程序甚至在不侵扰语言的情况定制系统有相当大的益处, 又得益于与 C 语言的紧密粘合而高效, 加之具有 coroutine 和函数式编程这样富有表达力的语言特性. 使得选用 Lua 是一个明智的设计决策.

你可以把框架本身想象成一个操作系统, 它有统一的方式来启动/关闭服务、分发消息, 统一的定时机制和其它节点的通信的harbor服务. 并且提供了 18 个指令用于协调所有的服务. 每个服务是一个独立的逻辑单元, 这跟 Erlang 中的进程是类似的, 服务之间通过发送消息来通信, 实际上简悦公司最开始服务器框架就是用 Erlang 语言搭建的, 然而 Erlang 非常之厚重, 难以找到性能热点, 似一个黑盒不利于性能剖析, skynet 正是为了替换原来的框架而出生的. skynet 在启动服务之后会为它分配一个消息队列, 所有来自于其它服务消息均会被入列. 而每个服务也可以通过 skynet 框架将消息投递出去.

Leave a Reply

Your email address will not be published. Required fields are marked *