« July 2013 | Main | September 2013 »

August 26, 2013

去掉 full userdata 的 GC 元方法

根据 Lua 文档中的说法,lightuserdata 比 fulluserdata 要廉价一些。那么,其中的区别在哪里呢?

空间开销上,fulluserdata 是一个 GC 对象,所以比 lightuserdata 要多消耗一点内存,这点内存往往对程序不造成太大的影响。

时间开销上,fulluserdata 在访问它时和 lightuserdata 并无太大区别,它们都只能通过元方法才能在 Lua 中使用。所有 lightuserdata 共用一个元表,不如 fulluserdata 灵活,在元表访问效率上却是几乎相同的。对程序性能有影响的部分在于它们对 GC 环节的开销不同。

fulluserdata 本身是一个 GC 对象,所以在扫描的时候要复杂一些。它可能有附带的 uservalue 需要扫描,但不设置 uservalue 几乎就没有额外的扫描开销了。当 fulluserdata 有 gc 元方法后,就给 GC 流程增加了额外的负担。GC 模块需要额外记录一个链表来串接起所有有 gc 元方法的对象,推迟到 gc 的最后环节依次调用。

对于对延迟相当敏感的游戏程序来说,最容易造成运行过程中瞬间延迟增加,却又很难控制的部分就是 GC 了。所以我们在开发中经常需要关注怎样合理的使用 Lua 避免 GC 的负担过大。

前些年我在 blog 上给出过一个用 lightuserdata 模拟 userdata 的 GC 过程的方法

如果直接使用 userdata ,那么就应该尽量直接用 lua_newuserdata 分配出整个 C 结构,且避免结构里面有额外的指针引用的内存。C 对象的构造接口用 int object_init(struct object *) 就好过 struct object * object_create() ,因为后者一般需要 lua_newuserdata 分配一个指针,再把 struct object * 放进去。这就使得你必须附加一个 gc 元方法才能保证没有内存泄露。

但有时候,有些复杂的结构不可能把整个对象放在一块连续内存中,那么还能有什么技巧呢?

我在改进 skynet 的 lua socket 库的时候,碰到了这个问题。

需求是这样的:skynet 提供了一个用 C 编写的异步 socket 库,所有 socket 请求都是通过一个消息队列分发回来的。我希望封装成 Lua 版的 api 时可以去掉这些。我需要给每个 socket 对象绑定一个数据队列,一旦有 socket 数据发进来就串在队列上,然后再逐个解析。

https://github.com/cloudwu/skynet/blob/master/lualib-src/lua-socket.c 里我定义了这样的数据结构:

struct buffer_node {
    char * msg;
    int sz;
    struct buffer_node *next;
};

struct socket_buffer {
    int size;
    int offset;
    struct buffer_node *head;
    struct buffer_node *tail;
};

需要封装的是 struct socket_buffer 结构,它里面引用了一个链表 struct buffer_node 。每组 socket 数据会以 struct buffer_node 的形式从底层产生,被挂接到 struct socket_buffer 的链表中。在运行过程中,随着程序运行,处理过的 socket 数据又会被释放。

我干脆一次申请了大块内存保存多个 struct buffer_node ,暂时用不到的内存,把它们串成一个 freelist 放在 lua 的一张表中,不到 lua vm 关闭前不释放。而所有需要传入 struct socket_buffer 的地方,都再传一个存放有 freelist 的 lua table 负责管理新创建以及需要销毁的 struct buffer_node

如此,封装 struct buffer_nodestruct socket_buffer 成为 lua 的 userdata 就都不需要 gc 元方法了。

当然,这个方法仅仅只是保证最终没有内存泄露,socket_buffer 依旧需要一个显式的关闭操作。这个道理跟 socket fd 需要显式关闭而不能等 GC 再关闭一样。


这个技巧还可以用于树结构的管理。就不具体展开了。

总结:把碎片结构放到一个 userdata 构成的 freelist 池中,然后从 userdata 里引用池内的结构。这样就可以避免给每个 userdata 指定 GC 方法来释放其中的链表或树节点。

把所有内存都交给 lua 去管理(这里提到的内存分配都是利用的 lua_newuserdata ,它是被 lua 管理起来的)对 GC 也更加友好。Lua 可以更清楚的了解你的程序用掉了多少内存以合理调配 GC 的进度。

August 24, 2013

Skynet 的一次大更新

Skynet (关于 skynet 的更多 blog 见右侧导航条上的 skynet tag) 的设计受我在 2006 年做过的一个卡牌游戏服务器影响很重,后来又受到 2008~2011 年期间 Erlang 的影响。多年的经验也让我背上许多思想包袱,以前觉得理所当然的东西,后来没来得及细想就加入了 skynet 里面。

最近项目稳定下来,并且开始了第一个手游项目,虽然带点试验性质,毕竟也是第 2 个我们自己正式使用 skynet 的项目了。做第 2 个服务器项目的晓靖同学对 skynet 提出了不少想法和疑问,让我重新考虑了以前的设计。最近花了一个月时间重写了大量的代码,为下一步重构底层设计做好准备。

在一个稍有历史的活着的项目上做改造是不容易的,时刻需要考虑向前兼容的问题,又不想因为历史包袱而放弃改良的机会,我只能尽力而为了。


最初我认为 skynet 是为分布式运算设计的,没有用 erlang 的主要原因是因为我们有大量的业务逻辑需要(习惯)用命令式语言编写。所以最早的 skynet 版本是基于 erlang 的,并把 lua 嵌入了 erlang 。或许只是我们的实现不太好,总之结果性能表现很糟糕。放弃直接用 erlang 编写业务逻辑,而是透过 C driver 到 lua 中去解析消息请求回应加大了中间层的负担。慢慢的我发现,erlang 带给我们的好处远不如坏处多。

除了性能,我们的代码充满了不同语言不同风格:有用 erlang 实现的,有用 lua 的,还有用 C 的。为了跨语言,又不得不定义了统一的消息格式(使用 google protobuffer)。这些加起来变得厚重很难维护。

最终我决定用 C 重写底层的代码,并重新考虑 skynet 底层模块的设计定位。


其实,对于我们的网游服务器来说,分布式运算,以及分布式运算的稳定性并不是第一位的。我们更需要的是充分挖掘多核处理器的单机性能以达到高实时的相应速度。分布式运算和并行运算显然不是同一类问题。skynet 只要专注于并行处理就可以了,顺带支持一下分布式可以说是为以前的设计做的兼容。

事实上,即使在过去基于 erlang 的版本,我们依然编写了两个独立的系统间通讯的模块,而不全部依赖于 erlang 自己的分布式设计。这个模块一直沿袭到现在依然存在于我们的项目中,让 skynet 自己的分布式节点管理显得有点多余。

skynet 专注点应该是一个单进程内的任务调度器,以及消息分发器。虽然理论上 skynet 可以嵌入各种不同的虚拟机,比如更多人爱用的 python 。但限于它目前的用户量太小,主要开发工作也是我一个人在做,我只能按我自己的喜好单维护 Lua 的版本。经过一年多的开发,我发现即使为了性能考虑,我也很少再自己直接用 C 去开发 skynet 的服务了。我可以写一个 Lua 的 C 模块挂在一个 Lua 服务上嵌入 skynet 中使用。Lua 就成了 skynet 事实上的标配。

Lua VM 所占用的额外空间小,以及启动新的 Lua VM 速度快就成了 Lua 最大的优势。虽然它在共享只读数据方面还比不上 Erlang ,但命令式语言这点很受我们的开发人员欢迎。Lua 的 coroutine 有很小的内存开销,对于 Lua 5.2 来说,不到 300 字节,这比 Golang 的 goroutine 还要小的多,而功能上甚至更超一筹。

很多同学问我,为什么 skynet 默认的 RPC 机制没有超时处理。我的回答是,如果你非要做超时,可以用现有机制模拟出来。现在的 skynet lua api 可以 sleep ,可以 wakeup 一个 sleep 的 coroutine ,可以 fork 一个 coroutine ,这些加起来足够实现一个带超时机制的 RPC 调用来。但我选择不直接提供,因为,如果每次 RPC 调用都考虑超时失败的情况的话,其带来的复杂度远远超过了 RPC 带来的便捷。

如果我们把 skynet 的一个进程看成一体的,那么它和以前传统的单进程服务器没有两样。进程内部的 RPC 调用其实不是 RPC 调用,并没有出进程嘛。使用 Lua 已经可以利用 Lua VM 这个沙盒防御大部分逻辑错误,让服务间的调用产生异常时请求者可以知晓。如果一端不返回,那么一定是代码写错了。这跟调用一个函数死循环,或是多线程程序死锁并没有区别,我们需要的是 debug 而不是用超时来防御。为了方便 debug ,skynet 已经提供了许多性能剖析的模块,有点已经放在开源版本里,有的还不太成熟,只是自己项目在用,等有机会整理后也会开源。


另一个常见的问题是,为什么 skynet 的调度模块使用的是一个简单粗暴的处理方法。就是根据配置在一开始启动了固定数量的系统线程,组成一个工作线程池。它们一旦开工就老死不相往来。工作线程之间是没有任何消息通讯和状态同步的。它们唯一做的事情就是不停的从活跃的服务集中取一个出来,读取属于这个服务的消息队列中的第一个消息,处理它,然后(若消息队列不为空)把这个服务放回服务集中去。如果系统中没有需要处理的服务,它就简单的休眠 0.1 秒。

最后这个若无可处理之服务,就休眠 0.1 秒,被很多同学诟病。但是,除了保持简单这个理由外,它跟网游服务器的特点强相关。MMORPG 通常是保持数千个长连接,为几千个用户持续服务几个甚至几十个小时。这些连接上的数据频率并不高,一般一秒就 2K~20K 的数据。但 MMORPG 的内部逻辑非常复杂,对 CPU 要求很高。每个连接上推送来一个数据包,往往需要变成几十个内部请求在服务器内部处理。处理流水线也经常会超过 5,6 个环节。

如果服务器处理不过来,就会反过来限制外部连接上的数据。试想,如果玩家都看不见周围的人,他如何发大招去攻击他们呢?

所以,对于一个 MMORPG 服务器,大部分工作线程都处于热状态是常态。一旦负荷降下来,及时对单个玩家偶然发生 100ms 的延迟,也绝对比高负载下他的延迟要低,所以对用户体验来说,这是没有问题的。skynet 要做的是,不要让 cpu 空转浪费掉处理能力就行了。

不过,最近我还是对上面的设计做了一点修改,这来源于 skynet 的设计的一些变更,具体下面会展开。只是修改结果并不算特别理想,虽然可以提高在低负载下的响应速度,但高负载下的承载能力反而下降了一点。


最开始,我只想让 skynet 做好消息分发和任务调度的事情就够了。消息只是用来沟通任务进程的手段。一切都和系统关系不大。专注于单进程内的任务协作,我们可以做的很高效。一个服务把请求数据组织好,直接就可以把数据指针传递给流水线的下一个环节。中间可以省略掉数据拷贝的开销,流水线上的每个环节都可以直接对数据流做操作,只要能保证同时只有一个服务在处理就够了。这是比多进程模型而言最大的优势。

后来我发现,如果让 skynet 不仅仅做好 MMORPG 服务器做一类工作,比如扩展到 web 开发领域的话,外源消息,也就是从系统 socket 来过来的消息的比重会增加。也就是说,如果我不在底层把外源消息和内部消息做区分的话,我很难让外源消息也有同样的反应速度。

内部消息是从内部处理流程中发出的,所以一旦产生,系统内一定至少有一个工作线程是活跃的(不然这条消息是如何产生的?),所以这条消息一定会被即时处理。

而外部消息的存在是 skynet 底层所不知道的。过去我用了一个叫 gate 的服务来处理外部连入的连接。它用启动一个 epoll (在 mac/bsd 上使用 kqueue)循环检查外部 socket 上的数据,并分布给内部服务。无论怎样实现都逃不了一个问题,gate 本身还需要处理 skynet 内部的请求,所以它不可能用 epoll 不断的死等,有消息过来就立即分发。对于高数据,长连接的环境,这不是问题。epoll 上永远有新的数据过来,gate 也不会休息。但在低负载环境下,一旦外部连接引发的一系列处理流程做完后,新的外部请求还没有发起的话,整个 skynet 都会陷入最长 0.1s 的休眠状态。(由于多线程的存在,往往是交替 sleep 的,所以实际休眠时间比 0.1s 要短)

这看似不是特别严重的问题,影响的仅仅是某些特定低负载环境下的性能测试跑分而已。只是感觉上有点怪,负载高,反而系统响应速度变快了。光是这个问题,简单粗暴的解决方法是减少系统空闲时的休眠时间,比如从 0.1 秒调整到 0.01 秒甚至更短就好了。所以单就这个问题是不会让我下决定重写几千行代码的。


促使我做大改动的原因是,目前处理外部连接数据的代码已经散布在 skynet 各处,当初我认为这并不重要,每个服务自己写好就够了,skynet 只关注内部消息分发,这个命题到底有没有问题?我最近思考的结果是,只要是一个在持续工作的系统,就一定持续的外部输入。skynet 要解决的问题并不是一开始准备好所有的输入,运算出结果,输出退出。所以我不应该在最底层回避持续外部输入这个问题。

而且操作 socket 的代码和系统强相关,不适合每个服务单独编写。所以之前我已经把 socket 处理收敛到 gate 以及 socket 两个服务中去了,但运行时用户依然会开启多份 gate 实例,在系统中跑多个 epoll 循环,对总体性能是有影响的。面对 CPU 高负载的系统,我们应该尽量减少系统调用,把 CPU 时间的充分利用放在用户态完成。这促使我重新编写 skynet 的 socket 相关代码,把它们从中间层移到底层去。

这个 socket 处理模块可以塞死在一个 epoll 循环上,一有外部消息就构造一个数据结构,并把其指针直接放到 skynet 的内部消息队列中。作为一个底层模块,它可以实现的更高效。对 skynet 动大手术前,我先实现了一个独立的 socket-server 模块。然后整合入 skynet 。

最终的结果还不错,新的 socket 库可以自己 listen/accept 外部连接了,这使得原有的 gate 显得多余。因为 socket 库更加灵活,不用固定分包协议,也减少了一个中间环节而更高效。我用新的 socket 库按以前的协议重写了 gate 。这个版本的 gate 仅仅是做在数据流上分包的处理,不直接操作系统 socker。它仅用于向前兼容,之后不会再推荐用它了。

最后我做了一点简单的性能测试,用 lua 基于新的 lua 版 socket 库编写了一个符合 Redis 的 ping 协议的服务器。就是接收到一个以 PING\r\n 结束的数据包时,回应一条 +PONG\r\n 。

使用 redis-benchmark -t ping -n 100000 -c 10 做了个简单的测试。

可想而知,使用 C 语言编写的 Redis 服务器一定可以在这个测试中得到最高分,因为这个协议处理非常简单,完全是考虑 IO 处理的能力。在我一台旧机器上,redis 可以跑到 40k qps 。

skynet 的测试程序不算差,达到了 33k qps 的成绩。我认为和 redis 的差距在于,我写的 lua socket 库需要把数据压入 lua vm ,这需要多做一次内部复制。且 lua 语言本身也不如 C 语言高效。换成 luajit 后,skynet 的成绩也可以达到 40k qps 了。

让我有一点小郁闷的是,如果我简单去掉新写的代码中 socket-server 模块中对 skynet worker 线程唤醒的调用,那么性能可以直接从 33k 上升到 37k qps 。反复核查的结果是,大量的 pthread_cond_signal 调用消耗掉了许多 CPU 时间。即使我反复优化,减少不必要的 signal 操作也弥补不回这点性能损失。我想,这就是提升复杂度带来的性能支出吧 —— 原本 worker 线程是不需要和外部同步任何状态的,现在可以接收外部信号而增加了沟通成本。

公司的几个同学纷纷做了别的框架下的性能测试。他们用的测试机器性能要好一些。

在另一台较好的机器上,redis 跑出了 180k qps 的高分。skynet 则可以达到 120k qps ,jit 版提升不太明显,大约可以增加 10k qps 。erlang 的版本也可以跑到 180k qps 左右,不过 erlang 版写的比较简陋,协议实现的不完全对,对 ping 协议的分包做的不完整,估计做对了以后会略微有一点损失。

基于 python 的 gevent 这方面性能表现比较差,只有 30kqps 左右。可能跟 python 性能比较低有关。如果换成 pypy 的话,可以提升到 60kqps ,这远比 luajit 对 lua 的提升幅度高的多。


后面我的工作计划:

一 是把 skynet 的多机支持从底层拿掉,改到中间层去支持。这样可以简化掉底层代码。实际上我们自己的 MMORPG 项目在开发中,发现 skynet 底层的分布式支持做的远远不够,本身就需要在上面堆砌更多的代码才能做好的。比如一个玩家的 agent 服务,必须和他所属的 map 服务在同一个进程中才可以获得最快的反应速度。这让我必须支持 agent 从一个进程迁移到新的进程,而不可能维持在 skynet 中的唯一地址。

我们在开发中总是要计较一个关联服务是在同一个进程内,还是在别处。企图透明化服务的物理位置是不现实的。所以还不如就放在上层去支持。

二 是系统从底层中去掉为服务命名的支持。因为查询名字有一定的开销,大部分人在用的时候都是先把名字查好,以后在直接用数字地址发送消息。底层做的为名字服务缓存消息的功能不算特别完备,所以也没有人去用。同步全局名字这件事情放在底层做也是吃力不讨好的。

三 是加强服务的生命期管理。目前只对服务消失后做了简单的消息通知,还没有把这个特性利用起来。

四 是继续补完 socket 库的计划中功能。把 socket 的生命期和具体服务绑定在一起。可以在服务消失后自动关闭他所有的连接。这个设计已经做了,但是还没有实现。另外转移 socket 的所有权也是需要做的事情。之前 gate 的 forward 功能太粗糙,很难在发出转移指令后,但转移成功之前这段时间把事情做干净。


以上是一点流水账式的记录。鉴于这次改动的几千行代码,就不马上把改动push 到 github 上了。等我在公司内部项目中跑几天,观察一下是否有明显的 bug 再放出。正在使用 skynet 的同学们也请留意这次更新,请善待 bug 和它们的小伙伴们 :)

这个月底,我应邀参加在北京举行的软件开发大会。会和大家分享一下 skynet 的设计。好借这个机会推广我们这个开源项目。只有更多人用它,才会发展的更好。ppt 我已经写好了,放在这里供下载

注:新写的 gate 转发 client 的发送包协议有所改动,把要转发的包的目的 id 放在了包尾(以前是包头),需要修改对应的 client 模块。不过新的设计可以直接利用 skynet 的 socket 库发送数据,所以 gate 的转发功能仅仅是兼容而已。gate 的其它协议没有变化。

August 15, 2013

读了一点 go 的源码

首先是 runtime 里的 hashmap ,想看看 go 的 hashmap 和 lua 的有什么区别。

结论就是 go 的比 lua 的实现复杂的多 (lua 的 ltable.c 不到 600 行代码,go 的 hashmap.c 有超过 1500 行)。go 的 hashmap 更注重于空间效率。go 的 map 是有类型的,key value 类型都固定,存在类型描述结构里。key value 的大小在编译期都不固定,但在构造时就可以确定了。

hash 值是一个 uintptr ,在 64 位系统下是 8 字节。key 的 hash 值是不完整保存在 hashmap 的结构中,那样太浪费。Lua 同样也不会把 hash 值保存在表中,但 Lua 的类型很少且固定,所以比较好处理。

go 的 map key 类型可以用户自定义,每次重新调用类型的对应方法计算 hash 值恐怕有性能问题。go 的折中方法是,在 hash 表里只保存 hash 值中的一个字节。

key/value 对以 8 个一组保存在一个叫 Bucket 的结构中,这组 key 的 hash 值和表的大小(2 的整数次幂)取 and 后有相同的值,然后再用高位字节做进一步区分以加快检索效率。如果一个 Bucket 不只 8 对值,就用链表扩展。

注意:0 作为特殊 hash 值对待,表示 Bucket 中这一项为空。如果 hash 值的高字节的确是 0, 就改为 1 。

当表中存放的总量相对表的 slot 数超过一定量后,就扩展 hash 表的 slot 位之前的两倍。这个类似于 lua 的策略。但 lua 是闭散列的,go 是开散列的。扩展阈值 LOAD = 6.5 倍是一个经验数值。

个人判断采用开散列有利于多线程的实现(空间不够时不是必须做扩展)。

目测 go 的 map 是不可以并发写的,但可以并发读。另需解决的问题是迭代过程中的插入。go 有专门的 map 迭代器,这会比 lua 的迭代方法效率更高(Lua 的迭代是无状态的,每次都需要做一次 hash )。但如果在迭代过程中如果表增长了,迭代器就有可能迭代旧数组。

go 的解决方法是同时保存新数组和老数组,直到迭代器销毁才释放旧数组。

是否在迭代中用 hash 表结构中的 flags 控制,为了支持并发读,用了 CAS 指令做无锁设计。


我没读 go 的源码之前有一个疑问,就是 go 支持海量 goroutine 如何解决 stack 空间占用问题的。lua 的 coroutine 没有这个问题是因为 lua 是在虚拟机内运行,自己在 heap 上开辟空间保存 VM 中的 stack ,lua 5.2 中的 coroutine 的基本内存开销仅有 208 字节(64 位系统下)。

但 go 是要编译成本地代码的,并且需要和传统的 C 代码做交互。它需要使用传统的 stack 模型。这样就必须让 stack 大小可以动态增长且不必连续。

事实上 go 的确是这样的,如果你写一个无限递归的 go 函数,它不会像 C 函数那样很快就 stackoverflow ,而仅仅是一点点吃掉内存而已。

解决这个问题的方法是采用一种叫作 Split Stacks 或是 segmented stacks 的技术。我们只需要在函数调用的时候检查当前堆栈的容量,如果快用净就新申请一块内存,并把栈指针指过去。当函数返回后,再把栈指针修改回来即可。

透明的作到这点需要增强编译器,更准确的说,我们增强链接器即可。因为与其在编译时给函数调用前后插入代码,不如在链接过程给链入的函数加一个壳。并且可以通过 #pragma 给链接器提供建议。

具体分析可以见这篇文章。我顺着读到 gcc 对 splitstack 的扩展支持 ,如果能让 gcc 对这个支持良好,那么就可以实现一个不错的 coroutine 库了。

不过我对里面提到的向前兼容的方案有点意见。当调用原来没有经过 splitstack 编译链接过的 C 库时,文章里提到分配一块足够大(64K)的栈空间供其使用。10 年前,我为梦幻西游的客户端实现过一个简单的 coroutine 库,由于需要开辟上千条 coroutine (当时物理内存只保证有 128M ),我只给每条 coroutine 预留了 4k 空间。那么在 coroutine 里像调用传统函数,只需要把 stack 指针切回到当前系统线程的 stack 上即可。

这样做可行是因为,之所以我们需要为 coroutine 保留独立 stack ,是因为 coroutine 中可以通过 yield 保存延续点,以后需要跳回。但传统函数里绝对不可能调用 yield ,我们就可以断言这些函数运行时,当前线程不会有任何其它 coroutine 的执行序混于其间。一个线程的所有 coroutine 都可以共享一个栈空间来执行这些传统函数。


读 go 的源代码时发现一个有趣的东西,那就是 go 自己实现了一个 C 编译器来编译 C 代码。这样可以更好的和 go 的目标代码链接在一起。go 是有一整套包系统的,所以编译器为 C 扩展了带 namespace 的api 语法。

不像 C++ 用 :: 来切分 namespace ,go 的 C 编译器采用了一个非 ascii 的符号 · ,粗看像 . 但却是我们用来分割人名用的小圆点。

我很好奇洋人们怎么输入这个符号。当然对中国人来说方便的很,只需要在中文符号输入状态下按 @ 就够了。

August 06, 2013

如何安全的退出 skynet

Skynet 在最开始设计的时候是没有仔细考虑系统退出的问题的,后来为了检测内存泄露的问题,加入了一个 ABORT 指令可以杀掉全部活着的服务。

安全的退出整个系统,尤其是一个分布式系统,总是一个复杂的问题。我们的内部版为这些做了大量的工作。但我觉得做的不太干净,所以一直没有把代码合并到开源版。

今天晓靖又重提这个退出系统的方案,就又把这个问题拿出来讨论了一下。我的个人观点是,如何安全的退出和业务逻辑相关性很强,在框架中加入大量的通知机制让每个服务自己协调解决即增加了框架的复杂度,又不降低系统的设计难度。我们目前内部版本中增加的服务退出消息以及服务优先级等等这些机构过于复杂,且没有很好的解决问题。

曾经我想过按操作系统的做法,杀掉一个服务先发送一个消息给这个服务,让服务有一小段时间可以做最好的事务处理。等超时后,就强行把服务杀掉。这样显得不够安全,但若等服务自行干净退出又有可能发生死锁。我觉得,对于整个系统都是自己设计构建的话,其实设计人员是能够理解如何安全的关闭系统的。只有小部分服务需要做善后工作,比如数据库读写层需要把尚未写完的数据写入数据库后才能退出;大部分服务是无状态的,它们可以直接清理;还有一部分服务的关闭过程比较复杂,比如网关,就需要先关闭监听端口,再逐个关闭客户链接,最后才能退出自己。

让每个服务自己收到退出消息后想办法处理好退出流程,不如有一个专门掌管系统关闭的服务来统一协调系统关闭退出的流程。目前的 launcher 服务掌管了大部分服务的启动流程,可以稍加改进来管理退出过程。也可以把这一部分业务独立实现。无论怎样,都不要在框架底层来做太多事情。每个有复杂退出流程的服务,应在启动时把自己上报,退出管理器了解所有系统中活跃的服务,按系统的整体设计来决定退出的时候按怎样的次序做好哪些事情。

框架可以做一点点事情,能让这件事情做起来简洁一点。那就是可以指定一个服务可以监听到其它服务的关闭事件,而不需要服务在退出的时候自行汇报。

我没有在框架层把这个服务退出机制设计成 Erlang 那样的 process link 的方式是因为,n:m 的 process link 会使得管理连接的模块实现的过于复杂。用单点接收所有的服务退出消息,同样也可以在这个单点上进一步实现出 Erlang 那样的 process link 机制来。


目前的接口是放在 skynet.lua 里的 skynet.monitor 方法。在启动脚本里调用它启动一个监听服务。我编写了一个简单的监听服务 simplemonitor.lua ,在每个服务退出时输出一行 log 。用户可以改进这个服务做更多的事情。