服务的创建和退出问题
TL;DR 系统中每个和系统活得一样久的单元(服务),都应该提供一个关闭接口,而不是释放接口。关闭只做必要操作,不必释放资源,不必和其它单元协调。整个系统退出时,只需要命令所有单元关闭,然后让世界戛然而止。
最近在和同事一起完善 ltask 。这个项目可以看成是对 skynet 的一个回顾。我们打算把它用在客户端引擎中。
在 ltask 中,原来在 skynet 里用 C 实现的 timer network 等线程都被移到了 lua 中实现。它们被成为 exclusive service ,会独占一个操作系统线程,但之外的部分和普通服务并没有太大区别。
因为这样一些基础服务也挪到了 lua 中实现,以前在 skynet 中天然而成的层级被模糊了。在 skynet 中,这些独占线程都是先于其它服务启动,而晚于大多数服务退出。它们的启动和退出流程都在 C 代码中固定下来。
当我们这次在 ltask 中试图像普通服务那样去对待这些独占线程时,处理整个进程退出的过程遇到了很多麻烦。
缘由是,这些计时器、网络、日志这些服务提供的是一些基础设施。基础设施如果实现不当,会让使用这些基础的服务在退出流程中功能缺失。设施之间还有很强的依赖关系,例如,在(手机)客户端,日志服务就依赖网络服务的功能,同时也依赖计时器。而所有服务都有可能在退出环节写日志。
在整理进程退出的代码时,修了好几个退出卡住的 bug 后,我好好的反思了设计。
当一个服务可以用名字(而不仅仅是 id )查找时,它应该区别于匿名服务。这类具名服务应该是被认为是和进程共存亡的。即,只要进程不退出,它们就不应该退出。如果是需要运行时自我更新(热更新),那么需要的是重启,而不是退出再重新创建。也就是从业务层看,如果需要从名字去找到一个特定的服务,它完全可以 cache 这个服务的 id ,且认为该服务的生命期不会短于自己。若没有错误实现,不用处理服务(退出)消失的情况。
这可以大大简化服务的实现。而剩下的难点只剩下整个进程如何优雅的退出。
进程的退出势必涉及那些在业务层面看起来永不退出的服务相继关闭。退出流程是一个充满繁杂依赖关系的复杂过程,一不小心就会死锁。我在 skynet 的应用实践中就反复意识到这点。所以 skynet 并没有在服务退出上做太多机制,而一贯主张:在服务退出前给一条消息,让它有机会去完成最少的必要操作,然后关闭整个虚拟机。
对于网络游戏服务器来说,必要操作可能只包括数据落地和关闭对外连接。这和 C++ 的 RAII 机制不同,不是所有的对象(服务)都一定要有一个构造流程和一个完全对应的析构流程;并非每个创建操作都应该严格对应一个逆向的释放操作。而仅需要考虑,当服务关闭,哪些事情是必须要做的,不要纠结其它的细节。
回头来看这次写的 ltask 。我设计成:一切具名服务都只需要响应一个可选的关闭请求。关闭只会发生一次,发生在进程退出的阶段。而关闭不等于退出,不必在关闭请求中做任何资源释放操作。关闭操作后,服务应保留相应新请求的能力,至于它是完成后续请求,还是给请求抛错,是实现者的选择,不做规范。
ltask 只要检测到 root 服务(第一个启动的服务)退出,就会认为进程需要退出。这点和 skynet 不同,skynet 的进程退出条件是所有服务均退出,现存服务数量为 0 。
这样,进程退出的权力唯一的赋予了 root 服务。root 服务可以在进程退出流程的第一阶段,向所有具名服务(无论是独占线程还是共享线程)都发送关闭请求。确认它们均关闭后,自己退出。而框架在检测到 root 退出后,会让整个世界所有服务戛然而止:没有完成的请求会立刻中断,运行了一半的 coroutine 永远不会运行下去。服务之间不会再互发任何消息,只剩下虚拟机的关闭过程。或许在关闭虚拟机的过程中,还会因为 gc 方法运行少量 lua 代码,但不会再触发调度器的任何行为。能做的事情就是释放资源。
至于具名服务的启动,就可以由名字查询服务来惰性启动。和 skynet 不同,服务的命名不再放在框架底层,而是用一个名字服务来管理。名字服务不光管理了具名服务的名字,还负责了启动它们的职责;同时也代理了关闭流程(由 root 管理)。