« July 2019 | Main | September 2019 »

August 23, 2019

让 lua 运行时动态切换操作系统线程

最近我们在开发引擎时遇到一个和操作系统有关的问题,想了个巧妙地方法解决。我感觉挺有意思,值得记录一下。

在 ios 上,如果你的程序没能及时处理系统发过来的消息(比如触摸消息等),系统有机会判定你的程序出了问题,可能主动把进程杀掉。

完全自己编写的应用程序,固然可以把处理消息循环放在最高优先级。即使有大量耗时操作,也可以通过合理的安排代码,不让消息处理延后。但作为引擎,很难阻止使用者阻塞住主线程做一些耗时的操作。所以,通常我们会把窗口消息循环和业务逻辑分离,放到两个不同的线程中。这样,消息处理线程总能及时的处理系统消息。

在 windows 上,允许程序在任何一个线程做窗口消息循环。但在 ios (以及 Mac)上似乎不行。窗口消息循环必须在主线程中运行。

我们的引擎的 C/C++ 代码全部以 Lua 的扩展库出现,引擎的主体框架是 Lua 来调度的。也就是说,允许使用者用任何 Lua 解释器来启动引擎。其中就有我们自己扩展的平台无关的线程库。每个线程都有一个独立的 lua vm 运行独立的 Lua 代码,线程之间使用 channel 通讯。

问题出在这里:

窗口消息循环是一个需要对使用者隐藏的信息。封装良好时,用户根本不需要感知到它的存在。如果窗口模块需要使用多线程,那么也只是这个模块自己的事情。用户如果不想使用多线程,那么他也不需要了解线程模块的存在。用户的业务逻辑最好是放在 Lua 解释器启动时创建出来的那个 VM 中。因为这个 VM 有很多从启动开始就一直保有的上下文信息:例如命令行参数,渲染器对象,等等。

这里的矛盾在于:

用户业务代码期望运行在 Lua 解释器启动创建的主 VM 中。

窗口管理模块期望运行在独立线程中。

窗口管理模块期望运行在操作系统意义上的主线程中。

每个操作系统线程上运行的 Lua VM 是独立的。


我一开始认为这些矛盾是无解的。除非自己定义一个特殊的 Lua 启动器。因为自定义的 Lua 解释器肯定可以更灵活的调度 Lua VM 。比如,skynet 就自定义了 Lua 运行时的行为,它可以在同一进程中调度上万个 Lua VM ,却可以分配它们运行在有限的操作系统线程上。甚至同一个 VM 可以这个时候在这个操作系统线程,那个时候在另外一个。VM 可以和系统线程无关,自由迁移。

skynet 能做到这点,是因为它调度 Lua 运行行为是先把业务逻辑变成一次次的消息回调,让每次调用结束后,Lua VM 上的调用栈都是干净的,不依赖 C 的运行栈。然后再用 coroutine 把离散的调用变成逻辑上连续的线,用户感受不到这种离散性。

换成任意的(尤其是标准 Lua )解释器,这就很难做到了。

标准 Lua 解释器,用一个叫 pmain 的 C 函数入口去运行一段 Lua 代码,一旦这段代码运行完毕,pmain 就返回,进程也就退出了。所以在任意时间点,Lua 的调用栈都不可能是干净的,它永远处于至少一个 lua call 没有返回的状态。如果我们在运行过程中切换系统线程,也就同时切换了 C 的调用堆栈,那么这个 pmain 可能永远无法正确返回了。

但我昨天想到,这里其实有转机。所以给线程库增加了这么一个 api :

thread.fork(func1, func2_source)

这个 api 的语义在于,运行 fork ,会产生两个并行的运行流。func1 是当前 vm 的一个闭包,它可以正常地读写当前 vm 的所有状态;func2 是一个独立的运行流,它以源代码形式提供,会由线程库创建出一个独立的新的 vm ,加载代码并运行在另一个操作系统线程上,和 func1 并行。func2 不可以访问原有的 vm ,必须用 channel 通讯。

只有当 func1 和 func2 都执行完(或抛出异常),fork 才返回。 fork 的返回值就是 func1 的返回值(因为它们在同一个 vm 中),会丢弃 func2 的返回值或异常。func1 如果抛出异常,也会导致 fork 抛出异常,但抛出的时机也会等待 func2 运行完毕。

传统的实现方法,会安排 func2 运行在额外的操作系统线程上。但是,其实这不是唯一的实现方法。

我们其实可以让 func1 运行在额外线程,而 func2 所在的新的 vm 保持在当前线程运行。由于 func1 和 func2 都用 pcall 限制起来,所以它们都无法跳出 fork 的管理范围。我们在为 func1 启动新线程时,只需要把当前的 L 传给新线程,而当前线程继续运行的 func2 是用新的 L 运行代码,其实它们互不干涉。

虽然 func1 和之前的运行流已经不在一个操作系统线程了,但它们依旧是同一个 VM ,用户其实完全不会感知到不同。

这样就完美的解决了这个问题。

August 16, 2019

游戏引擎中的资源生命期管理问题

最近我们开发中的游戏引擎在修理资源管理模块中的 bug 时,我提出了一些想法,希望可以简化资源对象的生命期管理。

其实这个模块已经被重构过几次了。我想理一下它的发展轨迹。

最开始,我们不想太考虑资源的生命期问题,全部都不释放。当然,谁都明白,这种策略只适合做 demo ,不可能用在产品中。

因为我们整个引擎的框架是用 lua 搭建,那么,最直接的想法就是利用 lua 自带的 gc 来回收那些不被引用的资源对象。我不太喜欢这个简单粗暴的方法。因为首先, gc 不会太及时,其次 gc 方法触发的时机很难控制,容易干扰正常的运行流程。图形显示模块是时间敏感的,如果因为资源释放占用了 cpu 的话,很容易变成肉眼可查的卡顿。

另一个促使我们认真考虑资源管理模块的设计的原因是,当我们从 demo 过渡到现实世界的大游戏场景时,过多的资源量触发了 bgfx 的一个内部限制:如果你在一个渲染帧内调用了过多资源 api (例如创建新的 buffer texture 等),会超出 bgfx 的多线程渲染内部的一个消息管道上限,直接让程序崩溃。

所以我们不得不比计划提前实现资源的异步加载模块,它属于资源管理模块的一部分,所以也就顺理成章的考虑整个资源管理模块的设计。

我们一开始实现了一个中规中矩的引用计数方案。资源永远都被 ECS 中的 C 引用,且永远没有对外引用,所以并没有循环引用的问题。引用计数一定可以正确的管理资源的生命周期。一旦引用计数为 0 ,把课回收资源放到一个集合里,交给一个 system 处理即可。

但我直觉上不喜欢在一个基于 lua 这样自带 gc 的语言构建的框架中使用一个蹩脚的引用计数机制。而且,移动设备是一个内存受限的环境,我认为基于业务上不再对资源引用与否来觉得是否可以释放资源不是最好的管理方式。

我认为可以把资源分为两类:一,从 IO 获取的资源,它们有唯一的名字(文件名)。这类资源即使从内存释放,也可以重新加载回内存。二,根据其它数据由代码生成的资源,如果销毁不太容易重建。

第一类资源是大头,我认为它们实际上可以随时从内存销毁,释放内存供其它使用。部分资源类型还有替代方案:例如贴图,我们可以用统一空白贴图临时顶替使用。

针对第一类资源,生命期管理就不必基于它是否在内存中还有引用决定,而应该由是否很久没有使用决定。一个长期未使用的资源对象,无论在 ECS 中是否还有 C 对其引用,资源管理模块都有权销毁它,直到下次使用它时再通过异步加载模块读回。

而第二类资源就麻烦一些。如果我们随意删除,就很难重建(因为失去了当初创建它的上下文)。我们决定在内存富裕的情况下,永远保留这类资源。在迫不得已的时候,再在条件允许时删除它。最早的实现又用回了引用计数方案,但很快又去掉了。我们的 ECS 框架很容易遍历所有的资源,所以在必要的时候确定一个资源对象是否还有 C 在引用并不复杂。所以不必额外做繁杂的加减引用操作。

后来,在经过一些讨论后。我又从 imgui 的设计中得到了新的灵感:

其实,当初我们也考虑过给每种第二类资源提供一个回调函数,在销毁后调用一下就能重建回来。但并不是总能简单的写出这个创建函数。例如,如果一张贴图是用场景上的一个摄像机渲染出来的,那么这个回调函数就涉及相关场景对象了。一个简单的闭包函数很可能破坏掉 ECS 的设计原则。

但是,如果我们反过来想,如果每帧都主动创建这种资源呢?好比 imgui 并不保存控件的状态,每帧都去画一下那个控件一样。这样就不会影响 ECS 的设计原则,可以用自然的方式去创建动态资源。之后,我们就可以再这个基础上 cache 上一帧的结果,而避免每帧都创建。

这样设计后,第二类资源对象就和第一类对象一样,可以在任意时刻销毁。资源管理模块只需要按 LRU 算法淘汰超出内存阙值的资源就够了。

August 02, 2019

资源文件的转换问题

我们上周在游戏引擎上面的工作中遇到一些 bug ,涉及到过去的一些设计问题。维持讨论了几天解决该问题的方案。今天终于把最终方案确定了下来,值得做一个记录。

bug 出在游戏资源文件的转换上面。

游戏里用到的资源通常需要一个导入资源库的过程,例如你的原始贴图是一个 png 文件,但是引擎需要的是对应运行平台的压缩格式,windows 上是 dxt ,手机上是 ktx 等等。这个过程,在 Unity 等商业引擎中,是放在资源导入流程中。

我们的引擎把这个转换过程放在虚拟文件系统这个层次。这个设计决策是因为,我感觉统一导入资源是个痛点,用的人通常需要等待导入过程。Unity 用了 cache server 来解决这个痛点,但我认为 cache server 也存在一些设计问题 ,这个会在后面再展开一次。

我更希望转换过程是惰性的,直到最终运行需要的资源才需要转换。

在我们的设计中,所有需要转换的资源,都有一个后缀为 .lk 的同名文件放在文件系统中。它描述了怎样加工原始素材,它的作用和 Unity 的 .meta 文件基本一致。

我们的虚拟文件系统在发现一个文件有 .lk 时,会在请求该文件的时候调用构建模块转换源文件。

我们一开始假设的前提是,一个源文件加对应的 lk 文件,在加平台参数,三者的内容就决定了最终生成的文件是什么。所以前三者的 hash 就能用于转换过程的 cache 。最近发现,这个前提是不成立的,导致了 bug 的产生。

原因是:对于 shader 文件,它其实是一种代码,类似 c/c++ 代码。编译一个 shader 其实是依赖很多文件的。所以光有一个 shader 源文件无法准确的 cache 结果:例如,我们修改了 shader 中 include 的另一个文件,但是 fileserver 并不知道,虚拟文件系统返回了 cache 结果,而没有重新编译。


一开始想解决这个问题时,我想放弃惰性构建这个机制。即,把资源转换和 fileserver 分离。这样,修改了源文件,就由资源转换模块去构建这个资源文件。客户端永远认为在运行时资源已经是构建好的(和 Unity 一致)。

这个方案最为简单,但在组内讨论的时候很快被否决了。因为这样又倒退回去了,并没有解决原先想解决的痛点。经过几个方案的讨论,我们最终找到了比较合理的方法。

以 shader 为例,假设有一个 shader 文件叫 a.sc ,有一个 a.sc.lk 指明了 sc 该如何构建。我们的 fieserver 在收到 build 请求的时候,会无条件的重新编译 a.sc 在指定平台上的结果,并把结果文件的 hash 返回。这一步是不做任何 cache 的。

但是,新的方案中,如果你请求了 a.sc 在 ios 上的版本,那么,构建模块会在虚拟文件系统中添加一个叫做 a.sc.lk.ios 的构建脚本文件,详细记录了 a.sc 在 ios 上的构建方法,和构建过程中的依赖关系,包括依赖文件的路径,和当前这些依赖文件的 hash 。

那么,这个 a.sc.lk.ios 文件,其实就唯一确定了一个编译好的目标文件。因为任何一个依赖文件的修改,都会导致文件内容的变更(hash 值变了)。

这个文件在当此运行会话中,对 fileserver 的客户端是不可见的。这是因为我们假定运行过程中,所有文件在一颗 merkle tree 上,是不可变更的。但是新的会话就能看见这个新增的 .ios 文件了。

一旦客户端看得到 .ios 文件,它就可以用这个 .ios 文件的 hash 去请求编译的结果。由于每个 .ios 文件的内容都能唯一确定一个编译结果,这样,fileserver 就能对结果做 cache 。

.ios 文件不是资源编译的结果,而是编译的参数。它在数据仓库里就可以有多份,而编译的结果只需要保存最终的一份。大多数情况下,用户只会请求最新的一份编译结果,但万一用户请求过期的版本,fileserver 也可以重新生成出来。

这有点像 github 的大文件储存方案,不在 git 主仓库里保存大的二进制文件,而保存了一个唯一的 url ,把文件放在了另一个服务中。在这里,这另一个服务就是编译资源的模块。


这个机制做到了惰性编译资源,又可以合理的 cache 资源的编译结果。和 Unity 的 cache server 不同,它用来索引 cache 的 key 其实是编译资源的完整过程,包括了编译的依赖关系。

所以,编译模块,完全可以跨项目的 cache 这个过程。即,如果你一张贴图用在一个项目中,被编译过一次;当你把这张贴图复制到新项目使用时,是不需要重新编译的。而 Unity 中同一张贴图即使在同一个项目中换个位置,都会导致 guid 变化,从而让 meta 文件变化,致使资源重新编译。

文件服务还知道所有的需求,所以它完全有能力在你请求 ios 版本的同时,预期你还会在以后请求 andriod 版本。所以 fileserver 还有能力利用闲置时间去提前生成那些尚未请求的版本。而 Unity 的 cache server 则是一个纯粹的 key / value 服务,完全不可能做到这些。