Skip to content

ActorModel

云风 edited this page Mar 20, 2024 · 4 revisions

基于 Actor 的并发模型

skynet 这个项目中,我们积累了十多年关于 Actor model 的经验。它和 Lua 结合的非常好,构建一个并发环境,这个模式是一个不错的选择。

skynet 不能直接在 windows 下编译,在维护 skynet 的过程中,又产生了许多新的想法。客户端环境和服务器面对的问题有所不同,我想试试一些别的思路,所以创建了 ltask

ltask

ltask 是一个独立于 Ant 的项目。一开始并不是为 Ant 设计的,但后来整合进了 Ant ,并为之完善。它可以看成是一个客户端版本的 skynet ,api 基本相同,设计上也遵循了 actor model 。但有一些地方和 skynet 不太一样:

ltask 是一个库,并没有像 skynet 那样设计成一个框架。所以从形式上它可以像 Ant 的其它模块那样引入。但是,毕竟它引入了多虚拟机以及多线程,这是原生 Lua 不具备的东西。这个库还是有许多和常规库相比特殊之处。和 skynet 相比,区别在于 skynet 用库的形式内嵌了 Lua ,主线程框架是由 skynet 的 C 代码实现的;而 ltask 可以用标准的 Lua 解释器以库的形式导入,用 Lua api 启动。

ltask 没有内置网络线程,因为对于游戏客户端来说,网络的性能不那么重要,也不需要去处理海量网络连接的问题。如果类比 skynet ,我们可以把原来内置在 skynet 内核中的网络线程和计时器线程拿出来,变成了 ltask 的普通服务。

在使用 skynet 的这些年中,我们发现最容易出现的问题是服务过载。某个特定服务接收消息的速度远超过处理这些消息的速度。ltask 换了个角度面对这个问题:在 ltask 中发送消息也是会阻塞的,避免以超过对方处理能力的速度发送消息。

ltask 放弃了用 Lua 之外的方法实现服务。其实 skynet 也只是保留了理论上的可能性,实践中也极少直接用 C/C++ 实现服务。所以,ltask 只有 lua api ,通讯协议的编码规则也是统一的,并简化了服务的实现方法。

ltask 为游戏客户端优化,更强调任务调度的低延迟,这有助于提高游戏帧率;而 skynet 用于服务器,更偏重于吞吐量,即单位时间所完成的任务总量。

actor model

如果你了解 skynet ,那么就不需要多 ltask 做过多介绍。如果不了解,那么就应该从 actor model 开始理解。

ltask 提供了一个并发模型,让程序可以运行在多个并行的任务上。这样便可以充分使用现代处理器的多核。并行是降低实时应用(游戏是最重要的实时应用之一)延迟的重要手段。如果我们能尽可能拆解相关性很小的任务,让它们同时工作,从同步点即每个渲染帧的提交时刻看,间隔时间就会下降。表现出来就是游戏帧率上升。

但天下没有免费的午餐。并发编程很难,难到大多数程序员都无法轻易写出正确工作的程序。多年的实践表明,使用 actor 模型更容易写出正确的并发程序。

所谓 actor 模型,就是把系统分为多个职责不同的 actor 。每个 actor 专心负责它力所能及的事情。在 skynet 和 ltask 里,我们称呼它为服务。服务只能靠消息和其它服务交流。每个服务有一个信箱,它执行序都是一个消息分发器:查看信箱有什么投递过来的消息,根据消息做不同的任务。处理的过程中,如果需要其它服务的协作,就给对应服务的信箱投递一条消息。

服务之间没有任何共享状态,消息是唯一的沟通手段。这样的模式,避免了过去基于共享状态锁的并发模型的很多问题。

Lua 有非常轻量的虚拟机作为沙盒隔离同一个进程内的多个服务,非常适合实现 actor 模型。使用它给我们带来的好处不仅仅是并行带来的延迟下降,还天然让一个复杂的程序拆解成若干高内聚低耦合的服务。每个服务不仅运行流程互不干扰、状态独立,甚至内存都是完全隔离的。

对 Lua 这种动态语言,GC 造成的延迟是游戏这种实时业务必须面对的问题。很多其它的引擎一旦嵌入 python javascript mono 等虚拟机,都难以减轻虚拟机 gc 带来的偶尔停顿。但如果把程序分成到多个虚拟机中,每个虚拟机承载的对象数量都会相应下降,对应的 GC 开销也随之下降。更好的消息是:内存负担大的服务,例如资源管理服务,反而是低频运作的,完全不受高频渲染帧的影响。这样的服务完全可以忽略 GC 高延迟带来负面影响。

Lua 还有完善的 coroutine 机制,可以更容易实现由消息串起来来异步业务。不会像 javascript 那样陷入 callback 地狱,也不需要额外的 Promise 库来实现错误的传播。写起来更接近自然思路。

此外,独立虚拟机对阻止错误蔓延非常有效。快速开发时,bug 在所难免,独立虚拟机让定位 bug 更简单。分拆任务到不同的虚拟机中,会引导程序员写小的程序。我们用简明短小的程序去面对游戏程序的复杂性:不要写大程序,专注于一次只解决一个问题,并把它做对做好。

Ant 的服务划分

Ant 引擎基于 ltask 拆分了很多服务,这里简述一下现有的服务划分:

IO 服务

io 服务是最早启动的服务,它是 VFS 的基础。

对于运行在移动设备上的游戏,io 服务会在启动时尝试连接开发机的 fileserver ,通过自定义协议同步虚拟文件系统中的文件。程序的其它服务需要读取文件时,都会给 io 服务发送消息,它会将 io 请求置入队列循序完成。连接 fileserver 是可选的,一般只在开发期间使用。待游戏发布后,所有数据都会打包在 App 里,这时候,io 服务从 App 中的 zip 包内读取数据,或是从之后下载的 patch 包内补充本地 cache 。

值得一提的是,fileserver 像 ssh 服务那样,提供 tunnel 功能,它可以帮助游戏 App 开启一些调试端口。开发期间,开发者只需要把调试器连入 fileserver ,就可以经由 fileserver 转发给程序内的 IO 服务。这样就可以避免在手机上直接监听调试端口。目前引擎支持 Lua 调试器 就是用这个通道从开发机上直接调试移动设备上的游戏程序;引擎内置了一个简单的 webserver 当作开发控制台,也可以选择这个通道(或是直接监听手机上的端口)。

Timer 服务

和 skynet 不同,定时器服务并没有内置在 ltask 中,而是以一个服务的形式提供。引擎另外提供了 ant.timer ant.timeline 等高层封装。我们可以选择用 timer 服务驱动这些高层模块,也可以使用渲染帧等其它机制。

窗口管理服务

大多数平台都是基于系统窗口来分发系统消息的。一个游戏,需要先创建系统的窗口,再从窗口中获得渲染设备,然后才可以渲染图像。系统窗口通过消息来访问交互设备:例如鼠标、键盘、触摸屏等。Ant 引擎使用一个服务来处理这些和系统打交道的工作。

某些系统比较特殊:例如在 iOS 中,系统创建的窗口的处理逻辑必须位于游戏进程的主线程中。从 ltask 的使用角度看,没有主线程这个概念。所有服务都是平等的。但为了解决 iOS 这样的限制,ltask 可以将某个服务绑定在系统主线程中。这个窗口管理服务就是这个特殊的东西。

子进程管理服务

这是开发环境的专有服务。在开发环境中,需要调用外部程序编译多媒体资产。这些外部程序的运行过程会交给这个服务管理。

渲染器主控服务

Ant 使用了 bgfx 做图形 API 的抽象层。bgfx 支持多线程渲染,可以在多个线程中并行提交图形指令。我们只需要为需要调用图形指令的服务分配独立的 bgfx encoder 对象即可,细节被封装在 bgfx 的 lua binding 中。但 bgfx 的多线程渲染需要一个总的同步点,即,每个渲染帧结束时,在一个地方调用帧提交指令。所以就有了这样一个服务。

它会利用 ltask 的机制,等待所有使用图形 api 的服务做完一帧的工作,然后在同步点告诉 bgfx 一帧完成。

游戏主服务

有一个主服务运行着 ECS 框架。所有难以拆分的任务都放在这个服务中。这个服务是帧驱动的,就是一个 tick 一个 tick 的执行 ECS 框架中指定的 pipeline 。同时提供和少量消息请求入口供调试和 UI 交互使用。

资源管理服务

游戏渲染并不直接使用 io 服务读取数据。多媒体资产:模型、着色器、贴图、声音、动画、特效等等被成为分为不同类别的资源。资源是由资源管理服务负责加载和卸载,它会利用 io 服务加载数据,而其它服务,主要是游戏主服务则和资源管理服务打交道。

有些资源类型可以完全透明的做异步加载处理。比如贴图,当一张贴图不在内存时,我们可以用张白色贴图顶替它,程序可以正常的运行。所以,当收到贴图加载请求时,把加载请求放在资源管理服务的加载队列中即可。一旦贴图加载完毕,可以把正确的数据替换之前的顶替贴图,无缝替换。

基于这一点:程序不需要关心某张贴图是真实数据还是替代品都可以保持运行正确,我们就不必让使用者明确的提出贴图的销毁请求。常规使用时,引擎的其它部分不需要关心什么时候不再使用某张贴图。资源管理服务发现一张内存中的贴图在很长一段时间无人触碰,就直接将它销毁掉,如果以后又被使用,再重新进入异步加载队列。贴图往往是游戏中最消耗内存的资源,用这样的方法管理它们,可以轻易的把它们的内存占用总量控制在合理的范围内。

资源管理服务还提供了调试窥测接口,可以通过外部服务的请求获取这些贴图的状态:是否在内存,消耗了多少显存,存活了多长时间等等。我们的内置 webserver 就可以通过这些接口访问这些信息,甚至可以让资源管理服务把内存中的贴图生成 png 格式的数据在 webserver 中展示出来。同样,这些接口也可以帮助使用者更细致的控制贴图的加载过程:有时候,你不喜欢贴图的异步加载流程,不希望玩家看到关卡用到的贴图逐渐加载的过程,而是希望做一个 loading 界面,那么用这些接口完全可以做到。

其它资源,或多或少的可以做类似处理。例如着色器在加载好之前就可以先不渲染、特效未加载好就先不播放等等。也有一些资源必须同步加载。

资源管理服务就是一个专心做好不同类型资源管理工作的服务。它也会把一些任务分拆到其它服务中。

例如,我们有一个功能是在 UI 上展示一张用 3d 渲染器渲染出来的图片。从 UI 的角度看,它就是一张贴图,但实际上这张贴图并不是从 VFS 的文件中加载得来的。针对这个需求,我们扩展了一种贴图类型,叫做动态生成图片。启用这个功能后,会开启一个新的服务,专门用于渲染这些贴图。而资源管理服务遇到动态生成的贴图类型时,就会把请求分发给这个新服务。

特效服务

Ant 使用了 Effekseer 作为特效模块。effekseer 是一个独立的第三方库,它本身不负责图形指令,而是通过一个 bgfx 的 binding 层完成渲染。这个非常独立的。

特效的计算非常消耗 CPU 。在我们的游戏中使用了大量的特效,会消耗平均每帧 5ms 左右的 CPU 时间。如果和主服务放在一起,就和其它 CPU 开销串行在一起。但特效又是一个非常独立的模块,只要一次配置好发射器的初始状态,它就可以独立运转。每帧只需要更新每个发射器的位置即可(如有变化)。所以我们非常轻松的就把它从主服务移到了一个独立服务中。这样,游戏的每帧延迟就下降了,提高了帧率。

如果有性能上的需要,特效的模拟过程(纯 CPU)可以用和渲染帧不同的帧率运行。比如,渲染帧运行在 60 fps ,而特效的模拟帧跑在 30 fps 下,即间隔一帧跑一次模拟;或者是渲染帧上不封顶,而特效模拟以固定帧率;这只需要对这个服务做稍许的修改就能达到。

声音服务

音乐和音效部分,Ant 接入了 fmod 。这个更容易独立出来。

游戏界面服务

Ant 的游戏 UI 的处理方式和大多数游戏引擎不同。游戏 UI 跑在完全独立的环境中,独立的虚拟机,独立的服务(线程)。

UI 不可以直接和游戏主服务共享状态,必须通过消息通讯。但 ltask 封装后的 api 将消息请求的过程对使用者透明,所以使用起来还是和普通的函数调用没有太大区别。Ant 使用 RmlUI 的一个自行维护的分支,完全是基于 CSS 的 web 技术,在 Ant 中编写 UI 相关代码和主流的 web 前端非常接近。区别在于,Ant 使用一致的 Lua 语言,而没有使用 javascript 。

游戏 UI 的所有业务都在一个独立的虚拟机内,如果把 UI 看成是一个个页面的话,这些页面共享一个 Lua 虚拟机,所以可以自由的共享信息。但是,UI 和游戏主服务是隔离的。所以,涉及 UI 内部的交互逻辑,只要不访问游戏数据,那么不建议和主服务通讯。例如,点击一个界面上的按钮,如果只是改变了界面上其它部分的样子,就不必把这个点击事件发送到主服务执行;而如果界面上的数据需要和游戏内的数据同步的话,就需要给主服务通过消息交换数据。

这样设计的好处是,通常 UI 可以单独测试,很容易做一个只有 UI 的运行环境。有 bug 也不会蔓延到游戏服务中去。我们还可以单独分析 UI 的 CPU 开销,以及内存占用情况。

从性能角度看,Ant 为 UI 实现了一个简版的浏览器。无论是对 UI 排版、渲染,还是处理 UI 内部的交互逻辑,都是有许多 CPU 开销的。分离到独立线程,自然让游戏的帧率提高了。如有必要,还可以让 UI 和游戏渲染运行在不同的帧率下。有的游戏只运行在一个较低的帧率,但为了交互的舒适感,我们可以让 UI 运行在一个很高的帧率;反之,有时候我们需要游戏有一个很高的帧率,但允许 UI 运行在一个较低的帧率(尤其是 UI 只用于显示,不处理交互),可以节省不少原本花在 UI 上的开销。

webserver

大多数游戏引擎会内置一个控制台用于开发调试。这在 PC 上运行的游戏非常合适,开发者只要敲一下键盘就可以呼出控制台查看引擎状态、输入调试指令。但开发手机游戏,这种方式就不合适了。因为手机没有键盘,触摸屏也远没有鼠标适合开发。

我们在引擎中内置了一个 webserver ,开发者可以轻松的扩展它的功能,用这个 webserver 充当控制台。这个 webserver 以一个可选的独立服务形式存在。

网络连接服务

不是所有的游戏都需要联网。所以网络连接的功能是可选的。

io 服务因为需要在开发期连接 fileserver ,已经实现了网络连接功能。但 api 并未对外。同一个进程的所有网络连接最好是一致管理,我们选择了 io 服务管理它们。但提供一个全功能的网络连接服务不适合内置在 io 服务内,所以 Ant 引擎单独提供了一个可选的网络服务用来和需要建立网络连接的其它服务使用。

下载服务

下载补丁文件是游戏自我更新的必须功能。很多系统都提供 http 下载文件的功能,往往比自己再独立依照 http 协议通过网络连接服务去下载文件要好。这是因为像 iOS 这样的系统,采用系统提供的下载功能可以让下载在后台进行,系统也能帮助你做适当的缓存。

Ant 引擎提供了一个可选的下载服务,通过在不同平台下调用不同平台内置的文件下载 API 下载文件。

用户自定义服务

使用 Ant 引擎的开发者可以非常轻松的自定义服务,只需要添加一个 Package ,并把服务的实现文件放在包中的 service 目录下即可。用 ltask 的 api 就可以启动自定义服务。我们非常鼓励开发者把一些独立的任务放在独立的服务中,这些任务拥有独立的 Lua 虚拟机,可以天然和游戏的其它部分解耦,你可以专心写一段高内聚的小程序。

local web = ltask.uniqueservice "ant.webserver|webserver"

这样一行代码就启动了位于 ant.webserver 包下的名为 webserver 的服务(如果它已经启动过,只会启动一份),你可以在 /pkg/ant.weserver/service/webserver.lua 找到这个服务的实现。

返回值是服务的地址,ltask api 需要这个服务地址和该服务通讯。