这个设计的目的是,让开发者轻松享受到多核带来的并发性能优势,同时减轻编写多线程程序带来的心智负担。
用过 skynet 的应该都碰到过:当我们在服务中不小心调用了一个长时间运行而不返回的 C 函数,会独占一个工作线程。同时,这个被阻塞的服务也无法处理新的消息。一旦这种情况发生,看似是无解的。我们通常认为,是设计问题导致了这种情况发生。skynet 的框架在监测到这种情况发生时,会输出 maybe in an endless loop
。
如果是 Lua 函数产生的死循环,可以通过发送 signal 打断正在运行运行的 Lua 虚拟机,但如果是陷入 C 函数中,只能事后追查 bug 了。
]]> 那么,如果我原本就预期一段 C 代码会运行很长时间,有没有可能从底层支持以非阻塞方式运行这段代码呢?即,在这段代码运行期间,该服务还可以接收并处理新的消息?在很长时间里,我认为在保证前面说的严格约束条件下,无法实现这个特性。这个约束就是,skynet 的服务必须以串行方式运行。但最近,我发现其实用一点巧妙的方式,还是有可能做到的。但我们需要重新审视约束条件。
我们约束了 skynet 的单个服务以串行方式运行,指的是,所有对服务 context 的操作都是串行的。如果是一个 Lua 服务,这个 context 应包括 Lua VM 。但是,如果一个需要长期运行的 C 函数并不需要访问 context (包括 Lua 虚拟机),而实现者自己能保证函数自身没有竞态问题,那么,在运行这段代码的同时,让另一个工作线程继续处理同一个服务,其实是满足条件的。
假设让 skynet 提供两个函数:skynet_yield()
和 skynet_resume()
。
当我们调用 yield 时,通知框架结束当前服务的时间片。这时,该服务的工作线程阻塞在服务的回调函数上,但我们依然可以关闭时间片。同时,框架可以额外启动一个备用的线程,补充临时减少的处理能力。这个服务被放回调度队列中,运行其它工作线程处理它的后续消息(即,可以继续调用服务的处理函数)。
等长期任务执行完毕,它并没有离开同一个工作线程的同一个 C 调用栈,但这时调用 resume ,框架则去检查当前服务是否正在被其它服务处理。如果有,等其它处理线程处理完毕后,不要归还服务进调度队列,由 resume 调用者继续后续的流程。
这样,我们通过 yield/resume api 拥有了在不离开当前工作线程而临时切分时间片的能力。只要实现者自己保障 yield 和 resume 之间的线程安全问题就够了。
从 Lua 的角度看,如果预期一个 C 函数调用可能是长期的,那么就在这个 C 函数中加入 yield 和 resume ,隔开耗时的部分,并保证被隔出的部分不会访问 Lua 虚拟机即可。
对这种特制过的 C 函数的调用,使用上看起来就是远程调用了一个系统服务,让出了 Lua 虚拟机并等待回应。但这个系统服务实际上是在当前的 C 调用栈上执行的。
这种略显诡异的方法,其实我在 ltask 中就实现过 。
接下来如果我给 skynet 增加这个特性,看起来可以做到之前难以完成的任务。
比如说,网络线程其实可以实现为一个常规服务,而不必像现在这样放在 skynet 的内核中。目前这样做是因为网络处理部分会阻塞在 epoll 的 wait api 上。当等待新的网络消息期间,它无法正常处理 skynet 的内部消息。
一旦有这样的特性,我们只需要把 wait 夹在 yield 和 resume 之间就可以了。
封装一些现成的自带阻塞 api 的 C 库也会更容易:我们可以直接接入官方的 db driver ,而不必把它们的 io 部分换成 skynet 的专有 api 。
]]>最近在按前段时间拟定的思路重构编辑器。在这个过程中对 vfs 有了一些新想法。短期内不打算把工作重心放到重构 vfs 上面,先记录一下。
最早设计 vfs 的时候,是从网络文件系统的角度看待它的。我把它设想为一个类似 git 的组织方式,带版本控制的网络文件系统。所以,很多设计思路都是延续这个而来。但是,经过了这些年的数次重构,我对最初的思路产生了一些怀疑。
其中,最重要的一条:在游戏运行时,游戏程序看到的 vfs 是一个树结构的不变快照。这样,它像 git 一样,就可以用一个 Merkle tree 的 hash 值就可以代表这个快照,也可以方便的通过网络同步它。
为了实现编辑器,我们在这个设计上打了一些补丁,让编辑器可以在运行时动态的修改它。而我今天反思,“不变快照” 这一点是否是多余的?或者并不需要这个约束,也可以用简单的方案实现现在所有的功能。
]]> 经过一些思考后,我认为可以用这样一个新方案:vfs 是一个在运行时可以动态增删的树结构,而这个树结构只存在于内存中,不需要持久化。一开始,vfs 只有一个空的根目录。
在运行时,需要不断的对 vfs 进行增改。把空的根目录扩展出更多的文件。我们可以设置一个路径 path 为一个特定的 hash 值。如果这个 path 中的任意一个子目录不存在,都应该被立刻创建出来。如果 path 对应的文件已经存在,则覆盖掉原有的值。即:vfs 是一颗树,每个叶节点都是一个 hash 值。
可以把 vfs 树中任意一个 hash 值绑定内容。这个内容可以是内存数据块,也可以是一个本地文件(加上时间戳)。hash 值是数据内容的 sha1 ,所以同一个 hash 值只可以(也只需要)绑定一次。
另有一个模块,可以递归计算本地文件目录:这包括所有文件及子目录的 hash 。生成一张表。如果程序在本地启动,不连接文件服务器,那么在启动时,计算本地目录的 hash ,用 3 中提到的 api 把相关 hash 全部绑定,再将 vfs 的根替换为计算好的 root hash 即可。
如果连接文件服务器,那么可以向文件服务器请求当前任意 path 的 hash ,以及任意 hash 对应的内容。并在本地 cache hash 的内容。这个环节和现在已有的实现完全相同。
遵循以上的方案,vfs 就不必遵循不变快照的假设。而是在运行时可以任意增删查改。和现有的方案相比:vfs 模块变成了管理一个纯内存数据结构,而不涉及网络同步,和外部数据如何存放于本地文件系统中无关。
]]>大多数 log 生产者是在第三方库的 callback 函数中调用的,比如 bgfx ,如果写 log 不够快的话,就会阻塞渲染。这个 callback 需要自己保证线程安全。因为 bgfx 支持多线程渲染,所以写 log 的 callback 可能在不同的线程触发。
过去在实现 bgfx 的 luabinding 时,我实现了一个简单的 mpsc 队列,get_log
这个函数就是那个单一消费者,它取出队列中所有的 log 信息,返回到 lua 虚拟机中。
它是用 spin_lock
实现的。这两天,我想应该可以实现一个更通用的无锁版本。
我觉得一个无锁结构的 log 系统需要两个 ringbuffer 。
我们缓存的 log 条目数目上限估计不用太大,4096 或许是个合适的数字:即,每帧不会产生超过 4000 条 log 。那么就用一个 4096 的固定数组即可。
实现这么一个 ringbuffer 需要有两个 64bit 变量,head 和 tail 。其中 tail 被多个生产者共享,所以它必须是原子变量,让多个生产者依次尾进头出这个队列 ring buffer。head 只由唯一消费者控制,不需要原子变量。写入数据保持这样的流程:
这里只需要记录 meta 信息,而不是 log 的文本。这里的 meta 信息只这一条 log 的实际内容在另一个 ringbuffer 中的 offset 和 size 。写入 meta 信息时,需要先写 offset 再写 size。为什么是这个次序,下面会展开说。
第二个 ringbuffer 记录 log 的文本内容,可以用一个更大的队列,比如 64K 。这个 ringbuffer 只需要一个 64bit 的原子变量 ptr 。而将 log 文本写入 buffer 只需要下列的流程:
也就是说,我们把 log 文本写入一个固定长度的 ringbuffer 时,只要不断的推进 ptr 指针,然后写入数据即可,不用考虑是否覆盖了旧数据。
而 log 的消费者负责检查数据是否还在 ringbuffer 中,或是已经被覆盖丢失。这个检查条件非常简单: offset + 64K 小于 ptr 表示该 offset 处的内容已经不在内存中。因为持有引用方记住的 offset 和 ringbuffer 自己的 ptr 都是 64bit 单调递增的,而内存中只保存有 ptr 之前 64k 的内容,比较它们两个值就能知道数据是否有效。
在第一个 ringbuffer 每个条目的 meta 信息中,我们保存有数据在第二个 buffer 中的 offset 和 size 。读取后便可以校验读到的数据是否有效。
唯一一个读取 log 的消费者可遵循这样的流程:
我简单实现了一下:
https://gist.github.com/cloudwu/e8cc734a31dd01b439d8d131acc361c3
尚未测试。而且就我写并发代码,尤其是无锁结构,是很容易出错的。所以以上代码仅供参考。它的确很简单,如果有 bug 也应该很快能发现。
]]>具体见 文档中 的 dbgText* 系列函数。
随着我们的游戏引擎中越来越多的信息需要展示,直接使用这些 api 就越发简陋了。最近萌发的想法是干脆使用 imgui 来绘制调试信息界面。但我又觉得保留 bgfx 自带的这个文本模式也有一些好处。
]]> 这个周末,孩子被爷爷奶奶带回老家去了。难得有不需要陪娃的一天。我周六一大早起来就在想写点什么。最开始的想法是,使用基于 ncursors 的 UI 库。翻了一下没看见什么特别喜欢的。而且 bgfx 的文本模式并不是一个终端,可能还需要先把它改造成一个 VT100 终端先。
在 github 上搜索了一番,我用 VT100 Emulator 找到一些简单的库,没见到开箱即用的。感觉自己实现一个 VT100 终端也不算太复杂。大约 400 行代码就够了。主要是实现 ansi escape code ,有了这个,就能对接 ncursors 或 pdcursors 之类的库,然后文本界面 TUI 的选择也有很多。
然后,我发现了 imtui 这样一个有趣的玩具。它给 imgui 加了一个文本模式的 backend 。看起来还是挺炫酷的。但仔细一看,项目不太活跃很久没更新了。翻了下实现,也就几百行代码,花了半个小时就懂了。
imgui 输出的是 draw list ,即绘图指令列表。backend 把这些绘图指令传给真正的图形 api 画出来就好了。但是,这些绘图指令包含的是顶点数据流,而丢失了最初想画什么这个信息。
比如,UI 上的文字,在 draw list 里看到的就是两个三角形;菜单上的箭头,变成了一个三角形;圆形则变成了很多很多三角形……
如果你想在文本模式下重现这些图案,在不修改 imgui 的代码的前提下,只能靠猜。猜 draw list 里那些三角形到底在干什么。然后把 draw list 的顶点流切分开,还原成更高阶的绘图指令。imtui 这个库在这方面做的并不算太好,我一下就想到了许多猜测的方案,要廉价很多,更好实现。
花了一个上午,我模仿 imtui 自己写了一个新的 imgui 的 backend ,我是这样猜测的:
我为字体定义了一个特有的 texture id ,如果 draw list 里用到这个 id ,就一定是在绘制文字。然后,我使用自定义字形,把 ascii 码都从 imgui 的默认字体中替换掉,换成一个个 1x1 一个像素的字模,并把 ascii 写到贴图上。这样,在 drawlist 里发现文字绘制的时候,我直接根据 uv 取字体贴图,就能取到文本的 ascii 码。
然后,drawlist 里相邻两个三角形如果能构成一个矩形的话,就认为是背景框。在文本缓冲区上画带颜色的背景框还是很简单的。btw, imtui 里还真的写了一个三角形光栅化的代码,我觉得完全没有必要。
有些非矩形的三角形,也很容易判断出是什么方向的箭头,转换为文本字符。其它复杂的集合图形就直接扔掉。
等我把这一切做完,单独测试了一下新的 imgui backend 输出一屏幕的文本图案后。我对整个方案又产生了怀疑。如果做下去,去对接 bgfx api 倒是不难,但是,让 imgui 输出一大堆三角形,再想办法反向解析回来,又有多少意义呢?
其实,我并不需要一个完整的可交互的 UI 界面啊。只是为了调试时在屏幕展示一些信息而已。之前不好用,是因为没有做一个方便的版面编排接口。imgui 我倒是用过一段时间,它的 api 非常好用,尤其是 table api ,可以在屏幕上任意切分区块,在里面安放控件。
如果我只需要这么一个版面控制模块,并只支持文本输出的话,好像就解决了问题。
仔细想了一下,实现似乎也不难。我迅速扔掉了上午写的代码,下午重新实现了一套新的文本信息排版的库。最难的 API 设计部分 ImGui 已经做好了,抄就可以。具体实现几百行代码就能搞定。
https://github.com/cloudwu/textcell
到晚饭时间,基本就写完了。这种一天就能搞定的小玩具,真的是周末最好的消遣。
]]>2022 年,我们启动了第一个用这个引擎开发的游戏项目,它是一个和日本公司合作的动作游戏。后来,这个项目没有走下去就取消了。之后,因为我们的引擎开发组喜欢 Factorio ,便想用自己的引擎在手机上重现一个 Factorio Like 的游戏,这一干就是一年多。
现在,游戏的技术部分基本完成,可以验证引擎的可用性(功能完整、性能达标),只是游戏性方面还有不少路要走。简单说就是还不太好玩。
从一开始,我就希望以开源模式经营这个游戏引擎,但同时又觉得没有得到验证的东西不适合拿出来。既然引擎已经初步可用,现在就应该迈开这一步了。
]]> 毕竟引擎是公司的资产,原来我是自己创业公司的老板,但现在公司已经被阿里收购。开源并不是我一个人可以做的决定。最近一段时间,我争取到了公司的支持。甚至,公司不仅同意我将整个引擎项目开源,还愿意把我们正在制作的游戏的代码及其美术资产也捐赠给这个开源项目。这可以方便引擎开源后潜在的用户去理解引擎。关于这个开源引擎,有太多的东西可以写,我这里先摘录一些我在向公司申请开源时的报告中的一些内容。文字上有一些吹嘘之词,也有一些未经核实的臆断,但总体上可以反应我的看法,一家之言,姑且听之:
目前市场上有两大成熟的游戏引擎:Unity 和 Unreal 。为什么有商业游戏引擎,却依然选择自研游戏引擎?Unreal 目前开放了源代码,而 Unity 我们公司也采购了其源代码。所以,掌握源码并不是主要原因。
我们公司赖以生存的网络游戏项目,和 Unity/Unreal 这些游戏引擎所面对的游戏项目,有很大的本质不同。即,几乎所有的网络游戏产品,都处于长期维护状态,并不以创意多产为主的。商业游戏引擎,它给产品带来的便利在于可以速成游戏原型,而在长期维护方面并无太多优势。一旦产品进入稳定期,更多的是需要在产品上慢慢维护和打磨,或是更换非程序资源以较小代价制作衍生产品。针对特定产品,拥有一个易于维护的游戏引擎,成本是逐步下降的。无论是 Unity 还是 Unreal ,它们的维护成本都极其巨大。我们甚至已经放弃了 Unreal 的维护工作,专注于 Unity 的维护,但依然成本高昂。对 Unity 增加项目需要的特性、性能优化、相对一个按项目定制的引擎来说要考虑的问题更繁杂。
维护 Unity ,不光是我们公司独有问题。国内各大游戏公司都花了极大的人力做这件事。最流行的游戏:王者荣耀、原神等都声称对 Unity 做了极大的改造,几乎全部更换了其核心组件。这也是自然的选择:一个全功能的游戏引擎,必然包含了大量单个游戏所不需要的部分,这些都会成为长期的负担,而游戏运营时间越长,它所需要的引擎功能越单一,维护人员需要聚焦在特别需要的功能或优化上,对原版引擎做改造以适应。
Unity 原本并不为我们这类游戏网络所设计,它的资源包打包更新、针对移动设备的优化、和脚本语言(Lua)的整合,从来都是各家国内游戏公司所面临的痛点。国内大厂几乎都购买了 Unity 源码,但至今只听说层出不穷的补丁方法,而未见有对这些改造彻底的方案(包括我公司)。拥有源码的访问和修改权限,并不能解决这类问题。
开源界最近些年也有一些开源引擎,例如 Godot 。但是,我们的优势在于,更理解我们的游戏项目到底要什么,并可以依据我们的理念掌控引擎的发展方向,而不是像 Godot 那样,企图复刻一个开源版的 Unity 。我们从 2000 年开始开发网络游戏,几个大的成功项目均使用自研的引擎代码:例如西游系列、陌陌争霸、三国志战略版。只是它们均为 2D 项目(随着项目发展加入少许 3D 技术),这是因为 3d 技术的门槛要高的多,需要更多的积淀。
业内,游戏引擎技术每隔几年就有新的想法涌现,然后对已有的引擎做大的改造。开始越早的引擎,历史包袱越多,改造就越困难。例如 Unity 推行的 DOTS 技术就是为了解决它低下的性能问题,但好些年了也未能彻底推翻旧框架。后来的引擎可以轻装上阵。我们的开发团队即有长年(超过 20 年)的技术积累,又能不受老架构的束缚。ANT 本身也经历了 6 年的开发,踩了太多的坑,交了许多的学费,这样才获得了技术上的自信。
维护良好的引擎能随着公司的发展获得越来越大的成本效益。自研引擎的弊端在于开启阶段的风险(很可能无法按质完成),初始投入巨大;而这一步,ANT 已经迈过去了。
游戏引擎属于产业的基础设施。游戏产业早就过了通过秘密掌握更好的基础设施以获得竞争优势的时代。基础设施最适合开源。例如操作系统领域,几乎所有提供公众服务的系统都跑在开源内核上。越是基础设施,越需要更好的性能,更高质量的实现。增加开发人员对提高质量并无收益,它需要的是领域专业人员。而领域专业人员在整个世界范围都是稀缺品,并非靠招聘就能解决,一个好的开源项目更容易吸引到这样的人才加入。
同类别的开源项目也有竞争,竞争的是用户和高质量的开发人员。而开源游戏引擎(尤其是 3D )因为门槛很高,竞争并不激烈, 在国内圈子甚至是空白。所以现在做此开源项目有先发优势。一个良好的开源项目长期运营,必须凝聚人和项目,要持续有人用、有人持续开发。一旦人捆绑上去,项目就会像滚雪球一样壮大。我们并不需要掌控技术本身的秘密来获得技术壁垒,掌握项目的主导性就可以获益良多。开源项目的良性发展会不断促进项目本身的质量,远超闭源项目。项目的高质量是极其珍贵的。
Skynet 就是一个极好的正面案例。
Skynet 是一个为网络游戏服务的基础框架。 2012 年 7 月开始编写第一行代码,同年 8 月就以开源模式维持到今天。它为我们公司所有的游戏项目提供了高质量的底层保障,有很多 Bug 甚至是未在我们自己的项目上出现之前就被解决掉的。这得益于它有大量的公司外用户。因为它一直以开源形式开发和发展,吸引了大量的用户。现在在 github 上拥有了 12.4K Star 4.1K Fork 以及 123 个 contributors ,大多数 contributor 不是我们自己公司的同事;部分 Contridutor 后来加入了我们公司。
因为 skynet 被各个公司的项目采用,它的应用面远超我们一家公司的应用场合。这使得 skynet 的发展过程中,会非常仔细的考虑各种边角,保持它的精简,避免它演变为屎山。这么多年没有演化为屎山又反过来给它带来更多的用户。目前 skynet 不光是用于游戏服务器领域,在视频网站、路由器管理方面,也有不少应用。
从搜索引擎的结果看, Skynet 在国内网络游戏圈子,已经是众多中小公司的选择。在招聘网站上,很多游戏公司招聘都会有限考虑有 skynet 经验的候选人。从这个角度,它也帮我们节省了巨大的人力成本。我们因 skynet 树立的技术品牌,吸引了高质量的人才;新加入的员工在进入公司之前就用丰富的 skynet 经验,可以更快的融入工作。
对于程序员来说,也更希望在职业生涯中使用一项活的技术,而不是换了公司,换了行业,就放弃了过去的积累。为开源项目工作可以获得更大的成就感,成就感对顶尖的程序员来说至关重要。
ANT 专注于移动平台,在移动平台上,不光要保持帧率更要节省能耗。这会导致引擎结构设计上有所配合,这些是我们在设计时时刻考虑的方面。而且,ANT 区别于 Unity ,它尽可能的让开发工作随时保持在真实移动设备上运行,这对在移动设备上做出更好的交互体验,意义重大。
ANT 基于 Lua 开发,有极低的理解成本,和极高的动态可定制性。以往基于 Lua 这类动态语言开发的基础设施中,最难解决是性能问题,而性能恰巧又已被 ANT 解决的相当不错。ANT 在手机上有极好的性能,我们用它开发的游戏,在及其复杂的场景下,iPhone 12 上依然可以小于每帧 10ms 的速度渲染。对于同等复杂度的场景,Unity 实现同等的效率将非常困难。
因为有几十年的网游维护经验,我们深知资源打包更新这些对项目的重要性。ANT 在这方面做了彻底的支持,方便客户端更新。方便很多美术、策划一起共同创作。
ANT 一开始就设计成易于定制的,不用的特性可以轻松的去掉、渲染管线可以轻松改写。甚至可以方便拓展到非游戏应用中。例如腾讯的 QQ 客户端就集成了 Unreal 引擎,而这项集成至少会增加 App 数十兆的体积;而 ANT 引擎本身只需要链接 1M 的二进制模块。
已经开发了 6 年,使用这个引擎开发的游戏有一年历史,技术部分已经全部完成(游戏体验部分还需要调整),性能方面超出预期。
目前只有 iOS 版本完全可用。引擎可以在 Windows 上运行,但缺乏一些和 PC 相关的支持(比如接入 Steam ,更好的键盘鼠标支持)。Mac 版本略有不足,Android 版本尚未完成。
引擎缺少引导性的文档,需要在开源发布前编写一些基本的指导。
目前的游戏代码并不是很好的引擎示例,因为它跟随着引擎最近几次重构,残留了许多不太好的实践。而内置例子只用于测试,还相当不完善。我们还需要制作一个更轻量的 Demo 展示引擎特性。
今天,我已经将 Ant Engine 的私有仓库公开。感兴趣的同学可以自由访问。但这个项目尚未正式发布,该有的文档和实例都很不完善。这是我们正在开发中的游戏使用的引擎,所以至少它是可用的,同时也可能随时被修改。
我会在过年前多写一些文章介绍引擎的结构和设计思路,这也是 skynet 开源初期我所做的事情。等年后,再将我们的游戏代码仓库也开放出来。
]]>一年多前,我写过一篇 blog 介绍了一些优化的工作。
最近,在游戏开发的使用中,我们又发现了一些性能热点,最近在着手优化。这一篇 blog 记录一下其中的一个优化点。
]]> 按目前引擎的抽象,每个 style 其实是一组 attrib 的列表。而单个 attrib 则是一个 k/v 对。k 虽然使用上是一个字符串,但实际上会被转换为 [0,127] 的数字 id 。也就是说,引擎只支持一百来个不同的 key 。就目前 RmlUI 定义的 css 规范来说,够用了。而 value ,也是一个字符串。但对于不同的 key 有不同的结构。对 stylecache 模块来说,它并不关心其结构,全部视为字符串。这个字符串根据不同的 key ,可能是布尔量、数字、字符串、也可以是一组数字、甚至是一个字典这类复杂结构。
RmlUI 是用 C++ 编写的,我们把 style 从字符串取出时,转换为一个 C++ 可以方便访问的类。过去,这是通过序列化和反序列化进行的。即,当我们把 attrib 从 style 中取出时,将 attrib 通过反序列化变成一个 C++ 对象。这个对象可以方便的被 C++ 代码使用。
目前,这个反序列化过程发现有潜在的性能问题,值得优化一下。大致有两个方向:
其一,直接把 C++ 对象映射为连续内存,然后将内存视为字符串被 stylecache 管理起来。这样,就省去了反序列化的过程,也无需在每次访问 attrib 的数据时构造新的 C++ 对象(这个对象可以视为数据块的访问器)。
这一步我们已经差不多做好了,的确提升了不少性能。
但是复杂结构,比如字典对象,就很难被映射到连续内存块中。这是因为,复杂数据结构需要额外的索引信息。这些信息在 C++ 语境下,很可能是一些指针。指针是很难被复制和移动的,它难以被视为字符串。
所以,我考虑了第二个方向:不拒绝额外的访问器对象,同时对访问器做缓存。
如果我们把一个复杂的数据结构看成有两种形态,一是一个字符串;二是一个 C++ 对象;两者的信息是完全等价的,当它们都是不变量时,后者就是前者的访问器。
如果 C++ 对象可以通过字符串调用反序列化接口创建出来,又可以调用序列化接口变成一个字符串,那么就可以在两个心态间自由转换。通过这种转换,我们可以简化信息本身的生命期管理。
字符串的优势是可以自由的制作副本,计算 hash ,去重,比较等等;劣势是难以访问内部的数据子结构。
所以,我们对外的接口提到 attrib 时均可以 C++ 对象的形式提供数据,这样可以方便用户数据其内部数据结构。但,当我们需要在内部储存一个 attrib ,则可以以序列化后的字符串形式保存下来,同时 cache 一个对应的 C++ 访问器对象的内部 handle。这个 handle 可以较小,例如,当我们使用一个 16bit handle 时,内部其实最多 cache 64K 个访问器。因为访问器和字符串数据可以自由转换,当 cache 失效时,随时都能重建。
注意,这里没有提到访问器对象的生命期管理。实际上,我们需要调用者提供一个销毁方法,这样才能在 cache 满后销毁掉不用的访问器。但用户完全可以额外给访问器增加引用计数的管理。它的好处是,当通过接口取得一个 attrib 的访问器后,可以通过增加引用来持有它,下次把这个对象再重新传回 stylecache 模块(同时减一次引用)。持有和复用特定 attrib 的成本就是 O(1) 的了。
即:如果从外部传入一个新的 attrib ,stylecache 模块先把它序列化为字符串,持有这个字符串,这样就无需和外部商定其生命期如何管理(信息被复制了一次)。
如果从 stylecache 获取一个 attrib ,得到的是一个生命期仅延续到下一次 api 调用前的访问器,用户不需要关心它的生命期。但接下来如何立刻将访问器指针传回 stylecache 模块,则有可能在内部 cache 中找到,这样就可以跳过序列化流程。零拷贝。
大致是这样的,用户先定义三个接口:
typedef void * accessor_t; // 访问器,可以是一个 C++ 对象 accessor_t (*create)(const char *, size_t); // 从字符串构造访问器 void (*release)(accessor_t); // 销毁访问器 size_t (*serialize)(accessor_t, char buf[], size_t buf_sz); // 将访问器序列化为字符串
style 层面的接口需要交换 attrib 数据时,都采用访问器类型。所有的接口都约定为:对于输入参数,调用者自己负责输入访问器的生命期;对于输出参数,返回的访问器对象的生命期至少可以维持到下一个 api 调用。这样即没有增加生命期管理的成本,又可以额外提供一个 C++ 访问器对象供用户使用。
我写这篇 blog 是因为觉得以上方案有一定的通用性,值得记录一下:
在以往的 C++ 方案中,如果你想将一个 C++ 对象视为一个基础类型,常见的方案是给它定义一大堆接口:增加引用、减少引用、复制、计算 hash 、对象比较……
这样,这个对象才能被传递、复制、储存在容器中,以及作为字典的 key ,做类似字符串 intern 这样的去重(即相同的值在系统中只保留一份)。
我觉得传统方案过于复杂,需要额外编写大量的代码,且在生命期管理上很容易犯错。而这篇提到的方法,只需要为对象实现序列化、反序列化以及销毁三个接口就够了。而且减少了生命期管理的复杂度。
]]>我谈到,我们的引擎主要专注于给移动设备使用,那么优化的重点并不在于提高单帧渲染的速度,而在于在固定帧率下,一个比较长的时间段内,怎样减少计算的总量,从而降低设备的能耗。当时我举了几个例子,其中有我们已经做了的工作,也有一些还没做但在计划中的工作。
我提了一个问题:如果上一帧某个像素被渲染过,而若有办法知道当前帧不需要重复渲染这个像素,那么减少重复渲染就能减少总的能耗。这个方法要成立,必须让检查某个像素是否需要重复渲染的成本比直接渲染它要低一个数量级。之所以现存的商业引擎都不在这个问题上发力,主要是因为它们并没有优先考虑怎么给移动设备省电,而要做到这样的先决条件(可以廉价的找到不需要重新渲染的像素),需要引擎本身的结构去配合。
]]> 我没有在分享会上谈细节,是因为我不大想谈还没有做出来而只是在构想中的东西。但我同时承诺会写一篇 blog 展开说一下,便有了现在这篇。我们尚未去实现这个想法是因为目前引擎的性能已经被优化到比较满意的程度,而完善游戏本身要重要得多。对于只有 3,4 个人的小团队来说,必须推迟一些不重要的工作。
我们的引擎虽然是用 Lua 编写的,但性能瓶颈目前在 GPU 而不是 CPU 上 。比如,开启 PreZ 这个流程,先把几何信息提交到显卡,减少部分重复的像素着色器的运算,就能明显的看出性能的提高。
PreZ 是一个非常流行的优化方法:我们先把需要绘制的对象写入 Z-Buffer ,这样就可以得到当前帧和屏幕对应的每个像素的 Z 值。然后在后续的渲染中,只要没有半透明的像素,都可以先和这个计算好的 Z 相比较,如果它将被后续像素覆盖,那么就不必运行它对应的像素着色器。
PreZ 的算法很简单,就是把场景多遍历并渲染一次即可得到所需的 Z-Buffer 。那么,有没有什么廉价的方法可以得到一张蒙版图,让当前帧相对上一帧并没有改变的像素都在蒙版上标记出来呢?我们可以想象,如果光照情况在帧间没有发生改变(这很常见),摄像机也无变化(除了 fps ,这也很常见),其实每一帧不变的像素其实占有很大的比例。即使是 fps ,摄像机也不是全程逐帧运动的。从节能角度看,我们减少了一个时间段内的大量像素着色器的重复运算,就将是个非常成功的优化。
如何用廉价的方法得到这样一个蒙版:蒙版上的 1 表示这帧不需要绘制的像素,0 表示需要绘制的。这个蒙版不需要生成的非常精确,任何本该是 1 的地方变成 0 都是可以接受的,反之则会导致 bug 。每帧结束后,不要清除 backbuffer ,而在绘制阶段,每个绘图指令都带上这个蒙版(就好比带上 PreZ 生成的 Z-Buffer 一样)就可以渲染出正确的结果。
为了简化问题,我们可以先不考虑阴影(后面再谈有阴影的问题)。那么,每个绘图指令都是对屏幕空间指定像素的直接修改。最终,屏幕上每个像素都被若干绘图指令修改了多次。正如 PreZ 可以帮助快速剔除特定像素上的多个不必要的绘制过程而只保留正确的那一个那样,我们也可以用类似的方法来在每一帧的开头生成蒙版。
为了表述方便,我们把绘图指令分为两类,红色和黑色。红色表示,这是一条上帧没有出现过的绘图指令;黑色表示,这条绘图指令上一帧出现过。这个信息,只要引擎合理的设计,是很容易知道的:任何一条绘图指令,它都能知道其参数是否和上一帧相比发生了变化。如果我们把红黑两色绘制到屏幕上,那么,任何一个像素只要出现过至少一次红色,它最终就是需要绘制的,而如果全为黑色,它就很可能可以保留上次的绘制结果。
有什么简单的途径可以知道某个像素在什么时候没有红色却也需要重新绘制呢?答案是实际绘制上这个位置的黑色的次数。只要次数完全相同,就能保证这个像素一定和上一帧完全相同。
这个算法很容易实现。我们每帧将 buffer 清零。对于黑色绘图指令,在光栅化时把对应的像素加 1 ;而红色绘图指令则加一个极大数。最终,我们比较 buffer 和上一帧 buffer ,将极大值以及和上一帧不同的值设置为蒙版上的 1 ,就得到了变化蒙版。
20 多年前,我在风魂这个 2D Engine 中实现过类似算法。在西游系列的游戏中运用,性能比同时期的 2D 图形引擎好不少,就是因为它可以剔除很多当前帧不必要重复渲染的像素。当然,当时我是在 CPU 中实现的这个算法,而今天改到 GPU 中去做也不算麻烦。
同样,除了 GPU 层面,我们还可以在 CPU 也运用类似算法,减少一些多余的图形指令。只需要把 backbuffer 按比例缩小(比如每个轴缩小 64 倍),得到一个粗略的网格。然后把每个绘图指令涉及的 mesh 投影这个 buffer 上的 AABB 矩形计算出来,用同样的方法把绘图指令记录在网格的每个格子上。最终,我们可以剔除掉那些当前帧和上一帧完全相同的格子。这些格子可以用来得到一个更粗粒度的蒙版,同时剔除掉对应的绘图指令。
阴影怎么办?
我倾向于为每个可以接收阴影的物件单独生成一张独立的较小的阴影图,而不是像传统方式那样,将所有的场景物体渲染去一张非常大的单一阴影图上。
初看这个独立阴影图的想法会觉得性能无论上时间还是空间上都难以接受。因为如果有 n 个物体需要接收阴影,有 m 个物体会投射阴影,那么就需要绘制 n * m 次,并生成 n 张阴影图。
但实际上,单独的阴影图会比流行的 CSM 等算法更简单,也更容易提高单个物体阴影的精度。而大多数情况下,只要场景上的物件大小不是差距很大,且分布均匀的话(第三人称视角的游戏中非常常见),每个物体只会接收其周围很少几个物体的投影。而一个物体受哪些物体的投影影响这个信息,在帧间通常变化很少,所以筛选过程并不需要每帧全部重新计算。所以,生成 n 张阴影图的成本远远不到 O (n * m) ,应该可以优化到 O(n Log m) 左右。
如果再考虑做以上相同的帧间 cache ,对于每个物件单独的阴影图(也只是它材质的一部分),很可能下一帧并不需要重新生成,只需要投影它的有限几个物件没有改变即可。整体的(尤其是能耗)成本很有可能比传统方式更小。
]]>而对于使用自家专有引擎开发出来的游戏,却少见有特别的编辑器。比如以 Mod 丰富见长的 P 社游戏,新系列都使用一个名叫 Clausewitz 的引擎,玩家们在之上创造了大量的 Mod ,却不见有特别的编辑器。Mod 作者多在文本上工作,在游戏本身中调试。游戏程序本身就充当了编辑器:或许只比游戏功能多一个控制台而已。在这类引擎上开发,工作模式还是基于命令行。
]]> 游戏引擎中的那个编辑器无疑是引擎开发中耗工时最多的部分。我们自己研发游戏游戏已有 5 年 ,其中编辑器完全重做了两次,目前第二个人维护着第三版。在这几年里,我一直在思考:游戏引擎到底需要一个怎样的编辑器、它应该用来解决怎样的问题。在 20 多年前,我写风魂的时候,它受 Allegro 的影响最多。当时,只要封装出 API ,解决图形、声音、键盘、鼠标、系统窗口等的底层调用就解决了游戏开发中最难的部分。后来,到 2001 年开发大话西游,我根据游戏的需求为引擎写了几个小工具,用来编辑场景、2D 动画等等,支撑了游戏开发。那些工具是为游戏定制的,同样定制的还有一些对应的程序模块。我认为不属于引擎的范畴。
后来,各大游戏公司纷纷转向了商业游戏引擎,家酿引擎越来越少了。我在 2005 年时开发 3D 引擎时,也受那些商业引擎的影响,觉得游戏引擎必须要有一个大而全的编辑器。如果缺少这个,似乎没有人愿意用它开发游戏。2017 年底,我重启游戏引擎计划时,依旧觉得,开发一个编辑器非常重要,否则游戏引擎很难吸引开发者。
但是,别的引擎有什么,我们就应该做什么。这绝对不是一个好主意。因为复制似是而非的特性并不能真正解决问题。我们首先需要理解问题。一个功能丰富的游戏引擎编辑器看起来是为了减少编码 (low code ?) ,降低游戏开发的难度,让不太会写程序的人充分发挥他们的创意。
但这并不符合我们自己的需求。因为,我们项目组的所有人都有丰富的编程经验,code 并不是难事,不用会、少用 code 做同样的事反而增加了开发难度。对于一个软件项目来说,开发者必须是第一用户,Dogfooding (吃自己的狗粮) 对软件开发尤为重要。在软件开发这么多年中,我学到的最重要的一点就是:如果一个特性你不常用到,那么就应该立刻从代码中删除,直到以后用到了再加回来。所以,我们编辑器的第一次完全重构就是因为抛弃了复刻一个 Unity 编辑器的想法。我们做引擎绝对不能因为别人有什么而做什么,用户也不会因为这个引擎也有同样的功能而选择它。如果我们自己开发游戏不会用 low code 的模式开发,那么我们就不应该做一个以减少编码为目的的编辑器。
那么,是不是意味着我们的游戏引擎不需要一个丰富的可视化编辑器?只需要把 API 设计好,可以方便的用代码构建游戏就够了?
有一段时间,我们在引擎开发上是这样的:用简单的几十行代码就可以搭出一个小 demo ,测试或展示某个特性。但这会让引擎停留在渲染层上,离做游戏还很远。最糟糕的是,这些 demo 代码中充斥着 magic number :摄像机的角度、灯光的参数、硬编码的文件名…… 不可忽视的是:游戏中大量的内容是以数据形式表达的,而不是代码。数据最终呈现的是画面效果,它们需要根据视觉效果调整。一个可以快速启动的程序能够改善调整这些数据的体验;但通过文本编辑器修改这些数值绝不是高效的方法。所以、我们需要可视化编辑器。
游戏引擎的编辑器:是一种用来产生游戏数据的可视化工具。这些数据如果可以用更成熟的工具产生,那么就不必将功能集成在编辑器中。例如,我们并不需要引擎的编辑器做 3d 建模,也不需要有笔刷像 photoshop 那样绘制贴图;同样,集成一个代码编辑器编辑脚本的意义也不大。它最重要的作用在于把代码逻辑和数据分离。一个好的编辑器可以产生出数据,然后引擎的代码只需要读入这些数据就可以创建出游戏中的实体。
同时,引擎的 API 应该为之简化。在缺乏编辑器时,引擎 API 层提供的大量 API 都是用来让代码可以正确的构造游戏实体。这些 API 反应了各种数据是如何控制每个细节的。但有了编辑器创作好的数据后,数据已经代码运行之前就组织成应有的复杂结构,所以,引擎只需要提供单个 API 加载这些数据就够了。所谓编辑器,编辑器的就是某种预制件(prefab),预制了最终运行时的数据结构。游戏的大部分数据是用于视觉表现的,所以需要一个可视化的手段编辑和呈现。
编辑器产生的数据是引擎运行时的输入。这些数据应该是易读的,但不必是易于(用文本编辑器)编辑的。我们设计了一个专有格式 来描述游戏引擎中的大部分数据。尽量把代码逻辑(用 lua 编写)和数据分离。引擎提供的 API 中很少有特别细节的控制接口,所以,脱离编辑器制作一个游戏 demo 是很难的。因为开发者缺乏直接控制构建数据的 API ,难以硬编码摄像机位置、灯光信息、角色的空间状态在代码里,最佳的途径是加载一个编辑器编辑出来的预制件。这样,也促进了我们吃自己的狗粮。避免编辑器成为游戏引擎的一个边缘子项目。当然,因为编辑器产生的数据还是文本的,硬去手写一个预制件也不算太困难。
在搭建了最终的编辑器框架后,我们更多的是在实际游戏项目开发中遇到问题,就顺手给编辑器加一点功能。例如,编辑一个摄像机运镜的轨迹、写太阳一天的日夜循环模拟,方便美术调整光照变化的参数,等等。这些功能都是为实际游戏服务的,并没有打算做成通用引擎的一部分。
鉴于我们现在开发的游戏中并没有使用物理特性,前两个月便把曾经集成好的物理模块又去掉了。同时去掉的当然还有相应的编辑器功能。而预制件的动画及特效的时间轴编辑则在不断完善,因为美术总有一些需求,原来是在外部工具(例如 blender )中编辑好再想办法把数据导入引擎,慢慢的却发现在我们自己的编辑器中编辑有时更方便。只维护现在游戏项目用得到的特性,我想这才是一个好的状态。
]]>但当游戏发行(也就是我们正在准备的工作),我们还是需要把所有资源打包,并当版本更新时,一次性的下载更新补丁更好。
之前一直没时间做这方面的工作,直到最近才考虑这个问题。我们到底应该设计一个怎样的补丁更新系统。
]]> 我不是第一次设计这玩意,早在 20 多年前我就为大话西游设计过一个。但我这次想重新思考这个问题,用一些更标准的技术来做,比如,使用标准的 zip 包格式,而不是自己重新设计。当然,怎么把文件打包是次要问题,主要问题是怎么解决版本间的差异更新。用户可能停留在不同的版本上,都应该可以正确更新到最新的版本。如有可能,还应该支持版本回滚。
传统的方法是用一个递增的版本号,打包时,仅打包版本间的差异。用户要更新版本时,下载从本地版本到最新版本间的所有 patch 文件,按严格的次序依次打包。我觉得这个方法固然没什么大问题,但不是特别好。因为它不够健壮,缺失一个 patch 就会让升级无法完成。而频繁的版本更迭会导致太多的 patch 。虽然可以定期打包一个全量的包来阻止太多的 patch 文件,但也只是个不太干净的补救手段。版本回滚和分支版本发布都会比较麻烦。
我们的 vfs 系统其实是一棵 Merkle tree 。每个文件的文件名就是它内容的 hash 值。而整棵树的根的 hash 值就是一个天然的版本号。(btw, 它天然是防篡改的。)所谓打包,就是把当前版本的整棵树的文件打包为一个包文件。这个文件的文件名可以就是它的根的 hash ,也就是版本号。
所以,版本号不需要是递增的数字,这样,从一个版本切到另一个版本,也不用区分是更新、还是回滚、亦或是分叉。git 就是这样管理版本的,我们的 vfs 也一样,只不过现在要处理如何打包补丁的问题。
所谓补丁,我们是为了减少更新的带宽,减少用户设备上的存储空间。因为 vfs 中文件的文件名就是内容的 hash 。所以找到补丁和上个版本的差异,只是找到那些新增的文件即可。假设在打包机器上已经有很多历史版本的包,那么,我们需要做的就是用当前版本的完整列表和历史版本包文件内列表相比较,找到新增文件数量最少的那个,并打包新增加的文件即可。
在包里面,可以在补上一点元信息:这个包是补丁包,它的完整版本还依赖另一个版本 hash 。
用户在更新时,一旦需要切到某个特定版本(更新服务器上有所有版本的列表以及建议的最新版本),就下载那个版本的 hash 名的文件即可。下载后,检查元信息,看看所依赖的版本 hash 本地是否存在,如果不存在,再重复前面的过程。
这样更新的好处是,完全兼容平时开发中的 vfs 同步。如果我们用开发版本同步过某些历史版本(这些版本未必发布过更新补丁),再下载更新补丁的话,也能顺利的找到需要的补丁文件,把本地资源补全到完整版本。
这个方案中,不再区分完整版本包和补丁包。它们都代表了某个特定版本,只不过包内数据全或不全。我们在包的元信息中记录三样信息:
这个版本的根 hash 是哪个文件。一般同时是包自己的文件名,但这个信息不应该依赖包的文件名,所以也记录在包内的元信息里。这样,包文件名就可以任意发挥。
这个包的数据不完整的话,数据还依赖哪(几)个 hash 版本。
这个包依赖哪个版本的二进制执行文件。这个通常是源代码的 git hash 版本号。因为执行文件是不打包在资源包里的,所以需要单独注明,已便运行时校验。
简单通用的方法就是用 Lua 内建的 string 类型表示内存块。比如 Lua 原生的 IO 库就是这么干的。读取文件接口返回的就是字符串。但这样做有额外的内存复制开销。如果你用 Lua 编写一个处理文件的程序,即使你的处理函数也是 C 编写的模块,也会复制大量的临时字符串。
我们的游戏引擎是基于 Lua 开发的,在文件 IO 上就封装了自己的库,就是为了减少这个不必要的字符串复制开销。比如读一个贴图、模型、材质等文件,最后把它们生成成渲染层用的 handle ,数据并不需要停留在 Lua 虚拟机里。但是,文件 IO 和资源组装(比如贴图构造)的部分是两个不同的 C 模块,这就需要有效的内存交换协议。
]]> 我们又不想让所有的 C 模块统一依赖同一个自定义的 userdata 类型。例如 bgfx 的 Lua binding 就是一个通用模块,不一定只在我们这个游戏引擎中使用。引入一个特定的 userdata 感觉不太好。所以,我倾向于协定一个数据交互的协议,而不是共同依赖同一个库实现的特定用户类型。
首先,用 string 交换内存块肯定是最通用的协议,它的问题是低效,有无谓的内存拷贝,多余的对象需要通过 gc 清理。
我们很早就给几乎所有的 C 库增加了 raw userdata 的支持:即把不带 metatable 的 userdata 视为普通的 string 。userdata 和 string 在 Lua 的内部实现中也非常类似,均可以表达一个带长度的内存块,区别在于 userdata 的数据是可变的,string 的数据是不变的。
我在很多自己编写的 C 库中增加了第三种协议,用一个 lightuserdata + integer 表示一个内存地址和长度。比如 skynet 的 C 库就支持这种协议。这个协议的问题有两个,其一参数变成了两个,和单个 string 或 userdata 不一样,处理起来非常麻烦;其二,无法管理 lightuserdata 的生命期。
为了解决生命期管理问题,在实现 bgfx lua binding 时,我又增强第三种协议:在内存地址和长度之后,允许再增加一个叫 lifetime 的 object 。如果需要管理生命期,Lua 侧就把这个对象引用住,不再使用那个地址后,就解开引用。当这个 lifetime object 是 string 时,我们就可以用前面的 lightuserdata 指定字符串内的子串,而不需要真正构建一个新的字串对象了;这个lifetime object 也可以是带 gc 元方法的 table 或 userdata ,负责最后释放内存指针。
今天,我们又重新审视了这个问题。动机是这样的:
过去,我们在每个线程(独立虚拟机)中分别做 IO 。这样,我们自己实现的 IO 库可以使用上面的第二种协议返回一个 userdata ,传递给其它模块使用。最近,我们想把 IO 全部挪到唯一的 IO 线程做,它读取数据后,再传递给请求方。这样,就涉及虚拟机间的数据传递。
在上面第二方案中,raw userdata 必须在同一虚拟机内创建再使用,无法接收外部传来的数据。而换成第三方案(在我们现在的游戏引擎中并未使用过)又没有很好的解决第一个问题:多于一个参数和单个 string / userdata 不同,会让协议实施起来很麻烦。
考虑再三后,我觉得可以引入第四个方案:用单个 lua object 承担内存地址、长度、生命期管理三项数据。
简单说,我们需要一个 tuple ,把三元组打包在一起。Lua 中可以用来表示 tuple 的有三种东西,table (array) ,userdata + uservalue ,function closure 。因为 raw userdata 已经放在第二方案中使用,我不想和它冲突,那么可选的就是 table 和 function 了。我觉得 function 最为合适。
当传递一个 function 时,我们用 lua_call(L, 3, 0);
调用它,就可以拿到一个三元组。前两个就是内存地址(lightuserdata)和长度(integer);第三个是可选项,用来管理这个地址的生命期。进一步,当这个生命期对象是另一个 function 时,我们还可以直接在使用完内存后调用一下这个关闭函数,解除内存的引用;或者(当它不是 function)和前面第三方案一样,依赖这个对象的 gc 清理内存。
最典型的是 RPG 类游戏的人物属性面板。通常需要在面板上显示 3D 人物模型。通常还可以旋转这些模型,让玩家 360 度观看。我们目前的游戏类似 Factorio ,没有 Avatar ,但点开建筑的信息面板时,也需要把建筑的 3D 模型动态展现出来。
最初,我们没去细想 3D 渲染怎么和已有的 RmlUI 结合在一起,直接把模型渲染在 UI 层之上。相当于在 UI 模块外开了个后门。UI 上只需要把位置空出来,等 UI 渲染完后,再叠加 3D 模型上去。但这样做的坏处是很明显的:3D 模型无法和 UI 窗口有一致的层次结构。
后来,我们额外构造了一个 render target ,改造了一点 RmlUI ,让它可以支持一个矩形区容纳这个 rendertarget 的画布 。这样,3D 模型渲染就比较好的和 UI 模块融合在一起。但是需要单独编写 UI 上 3d 元素的相关代码,尤其是管理它 ( rendertarget )的生命期。
最近,我希望在 UI 上增加更多 3d 模型。它们仅仅是用来取代原来的 2D 图片。从 UI 角度看,这些就应该是图片,只不过这些图片并不是文件系统中的图片文件,而是运行时由 3d 渲染模块生成的。如果继续沿用目前的图片方案,我们就多出一些开发期处理这些预渲染图片的维护成本。但是,如果直接使用已有方法的话,那个看起来临时的解决方案又有点不堪重负。
]]> 由于前段时间我们已经重构了贴图管理模块,引擎中所有贴图均纳入同一个线程的同一 Lua 虚拟机内管理。我突然想到,如果 UI 认为这些 3D 模型应该是一张图片,那么,它们其实就是图,而不应该是画布。图片目前是用一个字符串指定的本地文件路径标注的,但和网页技术一样,这个字符串应该是 URL 才对。URL 的前缀可以指明资源的来源,我们可以从本地文件系统中获取,也可以用 http 协议向服务器索取,当然也可以让 3d 渲染器渲染出来。先介绍一下,我们重构的贴图管理模块做了些什么。
现在引擎中一切引用贴图的模块,都会用一个 handle 指代一张贴图。而渲染底层,我们使用的是 bgfx 。在最初,这个 handle 就是 bgfx 的 texture 对象的 handle 。再重构之后,我们把它换成了一个间接层的 id 。把这个 id 提交到渲染层时,需要用一个 C API 转换为 bgfx handle 再提交渲染。任何模块都不再长期持有底层 handle ,而是每帧都做这个转换。
然后,我们在 ltask 中开辟了一个独立的服务管理所有的贴图 handle 。所有贴图都可以立刻创建,得到一个 handle ,但数据的加载却可以是异步完成的。也就是说,任何用到贴图的模块都可以同步立刻加载贴图,但一开始只是一张纯色的替代图,在几帧之后,贴图管理服务(线程)加载好了数据,它才被替换成真正的图片。而且,贴图管理服务有权不告知使用者而自行把它认为不再使用的贴图从内存中清除。这使用的是一个简单的 LRU 算法,只要一定时间内,使用者没有提交贴图去渲染就会被清理。
每张贴图都是以一个字符串做唯一索引的,如果这个字符串是本地的文件路径。那么,当贴图管理器自行删除了一张很久没被使用过的贴图后,如果又有人想渲染它,那么,管理器就会依靠这个字符串把它加载回来。
后来,我们把这里的字符串从本地文件路径改成了 URI ,加上了协议名,这样就不局限于从文件系统加载了。如果我们另外定义个叫贴图渲染器的协议,那么它也可以从特定的渲染器生成这张图片。
到这里,在 UI 上显示一个 3d 模型就变成了顺利成章的事情。 RmlUI 并不需要关心图片是怎么生成的,它只是去问贴图管理器要了一个叫 render://name 的东西。而贴图管理器也并不直接渲染图片,它根据协议名,把加载图片的请求转发到了 render 服务。而 name 并不需要携带所有渲染图片的一切状态参数,比如摄像机的角度,光照信息。这些是别处直接和 render 服务沟通好的。写在 UI 描述的 css 文本中的只是一个简单的名字字符串。
其实并不限于静态的图片,一个每帧都在变换的动画图和静态图并没有区别。因为 RmlUI 并不关心贴图 handle 背后到底是什么。如果有一天我们需要在 UI 上插入视频播放,依然会按图片处理,只不过图片的协议名会改成 video:// 罢了。
]]>VFS 的设计动机是方便把开发机磁盘上的数据同步到运行设备(通常是手机)中。传统游戏引擎的做法通常是建一个叫做资产仓库的东西,在开发期间不断添加维护这个仓库。需要把游戏部署在运行设备时,再打包上传过去。因为传统游戏引擎在开发期间一般直接在开发机上运行,所以打包上传(从开发机转移游戏资产)并不频繁。
而我们的游戏引擎特别为手机游戏开发而设计,我们不可能直接在手机设备上开发,所以开发机一是和运行机分离的。为了提高开发效率,所以我们设计了 VFS 系统。可以通过网络同步资源仓库。
]]> 为了方便持续开发,vfs 被设计成带版本管理的。这里,我们借鉴了 git 的做法:所有仓库中的文件都以其内容的 hash 值为索引。当文件发生改变时,就产生了一个新索引项。而内容相同的文件,无论它的文件名是什么,放在什么目录下,在仓库中都只有唯一一份。vfs 可以把本地文件系统的任意目录嫁接在一个虚拟目录树中。这里 采用了 mod 机制 ,可以把两个目录按优先级合并从 vfs 上的同一个虚拟目录。
vfs 的目录结构是一颗 Merkle tree 。仓库中的每个目录是一个文本,内容是它所包含的所有文件的真实文件名以及文件 hash 值的列表。因为 vfs 仓库在游戏运行期间是不变的。整个 vfs 仓库根目录的 hash 就可以看成整个仓库的版本号。vfs 中的任意改动,都会产生一个新的版本,这可以方便我们做差异同步(类似 git),开发期可以随时通过网络把本地数据差异同步到手机上,也可以直接 patch 包,一次把手机上的仓库更新到最新版。
和 git 不同,对于游戏引擎,我们面临着一个难题:游戏仓库中有两类数据,一种是静态数据,它们直接存放在本地文件系统中;另一类是资源,例如贴图、模型等,开发目录中保存的是它们的源文件,而运行时需要先离线处理一下才可以使用。以贴图为例,开发目录下可能是一个 png 文件,但运行时,我们需要根据最终设备,把它做有损压缩转换为 ASTC 格式。
许多传统的引擎是这样解决这个问题的:开发期间开发者通过编辑器把源文件导入仓库,这时就产生了一组在开发机上运行期最终可以使用的数据。这些数据组织在本地的仓库数据库中。而等发布到手机时,再针对手机设备做一次转换,这就是所谓的打包流程。这个过程通常很耗时,实际开发时,往往需要专门用一台机器定期打包(每日构建)。
我不喜欢这样的方案。因为这样的资源仓库很难做版本管理。发布流程很费时,开发时虽然把资源编译过程分散到小段时间,但其耗时有时也会影响开发。如果我们直接对源文件做版本管理,那么每次导入游戏运行仓库的时间开销就更难以接受了。
所以,我们的 vfs 选择了惰性编译资源的方案:即在运行时需要某个资源,才由 vfs 的 fileserver 触发编译过程。fileserver 可以不在特定开发机上,所以也能方便的横向扩展。
资源源文件和运行时数据一般是 n:m 的关系。一组源数据可以对应为多个目标文件。而一组源文件可以用单个源文件里写清引用了外部哪些文件。很多标准化的数据文件也是这样干的,例如,gltf 格式中,可以把依赖的贴图文件放在外部。这样,源数据和运行时文件就是 1:n 的关系。
在 vfs 中,本地数据源的资源文件可以看成是一个软连接,它对应到另一个目录树(的 n 个文件)。所谓编译资源,就是根据这个源文件生成这个目录树。但如果我们真的把资源看成一棵子树(硬连接)的话,就很难计算整个 Merkle tree 了。因为计算 Merkle tree 需要每个节点的 hash 。当资源在根据需要才编译时,编译之前是无法得到其 hash 的。这是游戏资源管理的一个难题。有过传统引擎打包经验的开发者都知道,全量打包少则几分钟,多则几小时。完全不适合开发期边改边用。
我们的 vfs 目前的做法是在结构上把静态文件和资源文件分在两个平级的子树上,同属一个总的根。静态文件中可以有软连接,软连接是一个路径字符串,指向资源文件子树的一个结点。在开发期,没有用过的资源节点在资源子树上是不存在的。运行时发现一个软连接不存在时,可以向 vfs 的 fileserver 提一个请求,要求它生成对应的资源子树。新的资源树增加或修改了节点后,虽然整个树发生了变化,但是静态文件的那颗子树是不会变的。运行期会切换到仓库新的根,但静态文件部分不需要刷新,资源部分已存在的部分也不会变化,只需要通过软连接取到新增加的结点就可以了。
长话短说:vfs 虽然是不变的,但可以通过生成新的版本修改它。我们把资源和静态文件分开,通过软连接关联起来。静态文件仓库是不变的,资源仓库版本会在开发期的运行时变化。这个变化仅限于子树节点的增减,而单组数据本身的数据版本在进程退出前保持不变。
]]>我们的游戏引擎的大部分是用 Lua 开发的,这也意味着文件系统中不光有游戏用的资源素材,还包括了代码本身。甚至包括了虚拟文件系统自身的实现。这个东西比一开始想的要麻烦,我们这几年不断地修改它,直到最近。比如一开始认为最麻烦的自举部分 ,在去年就去掉了,为的就是减少系统的复杂度。
]]> 简单说两句自举的问题:常规的考虑是有一个自更新的机制,在发现需要增加需求或修补已有的 bug 时,可以不经过 app 重新打包的流程将新版本更新并持久化在外存中,并可以在自举过程中完成更新顺利初始化新版本的系统并无缝启动引擎(文件系统一定要先于整个引擎完成初始化)。但由于 iphone 手机这样的特殊环境,我们很难启用多个进程完成这个这个工作,甚至难以重启自身进程,所以做起来非常麻烦。另外,一旦更新失败,还需要一定的自恢复机制,保证在不重新安装 app 的前提下,回滚到上一个版本。
我们最初的实现虽然基本完成了任务,但在启动阶段引入如此繁杂的过程总是让人不安。所以我们在去年一次重构中去掉了这些,换成了一个无法自更新的简单版本。但是,更新问题依然需要解决。大多数情况下,我们选择重新打包和发布 app ;但也可以选择在内存中更新虚拟文件系统(而不是永久性的更新磁盘上的版本)。这得益于 Lua 的动态性,即使启动完毕,我们以后可以在后面的业务代码中,修改已在内存中的 api 实现。另外,虚拟文件系统工作在 ltask 这个多任务框架上,它的核心部件是一个独立的 IO 线程,在独立的 Lua 虚拟机内,这也方便了在内存中单独更新它。一旦有问题,因为不涉及外存,也就不必回滚。
btw, ltask 固然用独立虚拟机增进了 vfs (虚拟文件系统)的健壮,同时也带来了一些麻烦:因为 ltask 的很大一部分也是 lua 实现的,它们的代码本身是放在 vfs 中。这个麻烦也是我们简化 vfs 的自举部分的动机。
今天这篇 blog 想展开另外一个问题:我们设计 vfs 的初衷是为游戏引擎服务,而游戏引擎在使用文件系统时基于了一个假设:这个文件系统是只读的,如有存档等需求,会把这些文件保存在 vfs 之外。也就是说,对游戏来说,vfs 仓库在游戏启动那一刻,所有的文件结构都已经确定了。基于这点,我们才能用简单的方法实现它,并给它加上类似 git 的版本管理机制。在 vfs 中,目录结构是一颗 Merkle tree ,我们可以通过 hash 值获取到任何一个版本的文件或目录。但这都基于目录树在运行过程中不会发生变化。
但在开发过程中,我们发现,有不少基于这个引擎开发的应用程序会打破这个规则:编辑器就是一个典型的例子。编辑器开起来一定会修改本地文件,而这些文件最终又会变成引擎需要读取的资源。另一个例子是,vfs 是一个 C/S 结构,引擎的运行时部分是 C ,它会把 vfs 的文件 cache 在本地的物理文件系统中,但仅仅是一个 cache ;这些文件的源头是 S 通过网络同步过来的。我们为这个 S 开发了一个叫 fileserver 的工具。看起来,这个 fileserver 也会打破:在运行过程中 vfs 仓库不会变化,这条约定。
由于这样的现状,我们只好开发了两个版本的 vfs 模块,一个是上面提到的 C/S 结构中的 C ,另外一个不需要 S 而直接访问本地文件系统,它们的 api 是几乎一致的(后者多一些修改本地文件的方法)。虽然,两个版本已经相安无事的同时运行了几年,但在前几年,我们主要在 PC 上做开发,贪图方便,大家均使用后一版本(它更方便调试);只有在手机上,才运行前一个完整版本(需要 fileserver 支持)。这两者在实现上毕竟有所不同,所以,经常会出现两个版本的某些行为不一致。
我为这个问题苦恼了很久。一直在致力于统一成同一个版本。但如何解决 vfs 需要读写这个问题呢?我并不想为了游戏运行时不需要的写特性,而放弃那个简单的仓库不变的设计。
让我们来看看编辑器的具体需求:
编辑器其实是对一个可编辑实体(通常被我们成为 prefab 预制件)的修改操作。在编辑过程中,我们还会为 prefab 导入新的外部模型、贴图、编写新的 shader 等等。这些新增的资源原本是在 vfs 中不存在的。而引擎的底层,需要通过 vfs 拿到这些资源以做渲染。
重新审视编辑器需求,我发现其实向文件系统写入文件和目录其实并不是必须的。虽然看起来我们需要修改和增加它们,再让渲染底层把它们读出来。在此之前,我们已经把游戏中用到的资源:模型、贴图、着色器等等分门别类的在独立服务中管理起来了。应用层不在直接持有它们的底层 handle 。这样,可以实现透明的异步加载。渲染层持有一个间接 handle ,背后对应的是一个 vfs 路径。如果实体不在内存中,根据不同的类型,底层会有不同的替代方案,例如,贴图会有一张统一的替代图。这样,实体不必等待所依赖的资源完全加载到内存就可以创建出来,不会因为 IO 阻塞住程序。
所以,这些资源模块所依赖的是一个 vfs 路径,而不是 vfs 系统本身。要解决编辑器难题,最简单粗暴的方法是:所有要编辑的东西,都放在内存里。如果一个 vfs 路径对应的资源数据永远在内存,我们就不再关心在文件系统中的那个文件是否真的存在、是否被修改了。而保存编辑结果这个操作,仅仅只是对使用者编辑成果的备份,我们完成了备份即可,而用户并不关心怎样写入磁盘、到底写去了哪里。
如此,我们就把编辑器所依赖的资源分成了两部分:引擎本身和正在编辑的东西;前者走标准的 vfs 只读镜像,后者只存在于内存中,准确说,在专门的资源管理服务的 lua 虚拟机中。对于在内存中的 vfs 路径,就永远不再从外部读取。这样就没有破坏 vfs 在启动那一刻就不再变化的约定。
另一个有趣的问题是 fileserver 。它本身就是为 C 端提供数据的。看起来它作为 S 端,无法将自己运行在 C 端,也就是它本身的源码无法放在 vfs 仓库里。但细想却也未必。
因为我们的游戏运行时(vfs 的 C 端)本身是可以离线工作的。如果它无法连接到 fileserver ,那么它会完全使用上一次的 cache ,那里面通常有一份仓库的快照。所以,只有我们用另外一个 fileserver 为主 fileserver 提供服务,让它把自身用到的代码快照下来放在自己的 cache 中,后续就不再需要第二个 fileserver 了,直到 fileserver 自身的实现需要更新。
]]>第一,触摸屏上点击特别小的物件时很难精确。去年我提过一个解决方案 ,我们的游戏一直是这样处理的:连续 tap 屏幕同一个位置,会轮询这个位置附近的物件。这样,一次点不准的话,可以多点几次选到想要的焦点。这不仅可以解决小物件密集的时候,tap 不好区分的问题,还可以选到重叠的物件。这个方法其实在鼠标游戏中已有这么使用的了,比如 rimworld 就是用类似方法处理同一个格子上有多个可选物件的。
]]> 但实际用下来,玩家在处理小物件选取时,被培养的最好的操作习惯还是先用双指放大,推进镜头,让小物件在屏幕空间变得很大,然后才去选取。这不仅仅是因为触摸屏不够精确,还因为手指会挡住视线,如果物件过小,很难确定是否选中。对于手指挡住视线的问题,我用额外的一个提示窗口显示在旁边来解决。选取这件事,我想应该照顾用户的操作直觉,并提供遍历。所以我最近换了一种交互方法:当用户长按屏幕,摄像机转到顶视角,并推进。作为一个 3d 游戏,处于视觉美观的原因,我们平常游戏时,摄像机不不是垂直向下的。而近距离选择物件,则用顶视角更好。这样,高大的建筑才不会挡住背后的东西。如果手指不离开屏幕的话,移动手指不再移动摄像机(即不再移动画布),而改为移动选取焦点。操作者可以通过移动手指选取刚刚按下时的地方附近的东西。因为镜头已经推的很近,所以可以轻易选择,物件在镜头内很大,所以也缓解了手指挡住视线的问题。当手指离开,摄像机则原路返回之前的状态。
这里有个小的技术问题:当手指按压的地方不是屏幕中心时,不能简单的旋转并推进相机。因为这是一个 3d 空间,简单的旋转摄像机,会让按压的地方偏离它的屏幕位置。我们应该一边旋转和推进摄像机,一边调整它的位置,保证整个过程中,手指按压的那个地方永远在屏幕的同一个地方。也就是让选取平面上的一个点成为摄像机镜头内的不动点。这是一个并不复杂的数学问题,这里就不展开了。
第二个问题是:当我们用手指移动焦点时,虽然脑子里想着水平或垂直移动,但实现操作却很难保持直线。对于建筑游戏来说,很多情况下我们都希望把建筑修成整齐的一排,公路平直,但手指远没有鼠标好控制,更比不上键盘控制方向。
在很多设计软件中,都有锁定运动轴,锁定平面的方法。你可以拖动一个被锁定的手柄,运动方向被限制在一个轴上,这样就不会因为手抖而移歪了。
直接把它照搬到游戏中是不合适的,尤其是触摸屏操作会更繁琐。我摸索了一天,找到了一个相对舒适的操作方法。当手指按下并移动时,程序监测移动的速度。只有当移动速度慢过一个阈值,且 X Y 两个轴上的移动距离差距较大(即倾向于轴向移动),自动开启轴锁定状态,这个状态一直持续到手指离开为止。
在轴锁定状态中,屏幕上有明显的标识表明处于这个状态,不至于让操作者觉得怪异。而解除锁定只需要放开手指即可。
实测下来,快速的长距离戳动屏幕移动画布不会受锁定的影响,而需要细致操作时,玩家只需要缓慢的向一个轴向移动,移动马上就会锁定在这个轴上,接下来可以放心的在这个轴上对位置,不用担心手抖错位。
]]>后来、我们游戏引擎开始尝试基于 ltask 利用手机设备上的多核,渐渐的便完善起来,也发展出和 skynet 不同的部分。它最近两年一直是围绕移动设备客户端程序优化,所以网络部分并非重点,也就不需要像 skynet 那样把网络模块做在框架底层,而是以一个独立服务存在。而网络 IO 、文件 IO 、客户端窗口这些部分又不适合于其它渲染相关的服务混在一起,因为它们需要和操作系统直接打交道,所以我在 ltask 中又分出了独占线程和共享工作线程两种不同的线程,可以把不同的服务绑在不同的线程上。甚至对于 iOS ,还必须让窗口线程运行在主线程上,而不得不在 ltask 里做特殊的支持。
最近发现的这个问题也是游戏客户端特有的,它很能说明用于游戏服务器的 skynet 和用于客户端的 ltask 在实现侧重点上的不同。
]]> 问题是这样的:我们发现在游戏主逻辑循环中,有一个系统特别简单,但每帧消耗的时间长期统计下来居然达到了 4ms 之多。而我们游戏在此设备上大约每帧 10ms ,也就是占据了 40% 的时长。经过排查后,我发现我们统计时间的函数在 ltask 这种多任务系统下有缺陷,它未能准确统计真实的 cpu 耗时。当我把 skynet 对应的统计函数加到 ltask 后,重新统计发现,这个系统真正的 cpu 开销微不足道,但它在执行过程中让出了控制权,导致主逻辑服务被挂起了很久。而阅读代码发现,这个系统中并没有阻塞调用其它服务等待回应的代码;而只是对外发送了一个消息(并不需要等待回应)。为什么这样的操作会导致服务的挂起呢?打开 ltask 内置的调度器 log ,记录了上千万条 log 后,通过人肉分析找到了原因。
这要从 ltask 与 skynet 的调度算法差异说起:
在 skynet 中,发送消息给其它服务是非阻塞的。它直接把消息投递给了接收者的消息队列。这儿存在两个问题:其一,发送消息会和其它发送者以及接受者竞争同一个消息队列资源;其二,消息队列是无限长的,只受内存总量的限制,这可能导致服务过载。第一点通常不太所谓,因为竞争是局部的,无论用无锁结构还是 spinlock 去解决都可以,不太影响性能。第二点,一个无限长的队列往往掩盖了真正的设计问题,才是我认为要解决的。
ltask 的设计之初,我就限制了消息接收队列的长度。且不为普通服务准备发送队列。每个普通服务已有一个发送槽位,它待发的消息未被处理,服务就会挂起。而发送消息只有全局唯一的调度器操作,用一把大的全局锁代替众多的小锁。同时,为了减少全局调度器锁的竞争,我同时设计了新的调度器策略,即,任何工作线程都可以竞争调度器,无论是谁竞争到,调度器就会为所有工作线程做好所有后续的工作。这样工作线程只需要尝试获取调度器,即使获取不到也没有关系。这个全局锁实际工作起来就只是一个后备,而并不会在各方抢夺中浪费 CPU 。
每个服务实际的工作流程其实是把自身分割成小的任务片段(用 lua 的 coroutine yield 实现)。调度器主要有两项工作:其一,检查已经完成的任务片段是否产生了待发的消息,若有,就将消息发出,并允许它接受下一个任务片段。其二,调度器为所有空闲的工作线程分配下一个任务。
这两项工作其实和工作线程本身的工作是不相关的,工作线程有两个任务槽位,一个是正在运行的任务,一个是即将运行的任务。调度器可以在任何一个工作线程上工作(排在任务之间),它发送消息、分配下一个任务,都不会影响当前工作线程的运行,所以并发是流畅的,工作线程也几乎不用停顿。它只需要在任务之间尝试抢一下调度器运行一下而已,无所谓谁得到,只要有人可以适时执行一下即可。如果调度器没有被运行,后果就是工作线程无法得到后续的新任务,且自身的代发消息无法发出。但只要有任意一个工作线程有空闲,它就一定会运行调度器的;如果所有工作线程都在忙于做任务,那么也就无所谓下一个任务了。
看似很完美,但这里产生了一个新问题:每个工作线程无法干涉自己的下一个任务是什么。如果我们的目的是尽快完成所有的任务,那么谁先谁后就不那么所谓。而调度器实现的相对公平的话,也不会饿死某个服务。ltask 就是绝对公平的:它用一个循环队列来排列服务。ltask 最大化的消除了工作线程间的竞争,这对手机省电来说是非常有利的。
而这次的问题是什么呢?我们有一个重要的主业务服务,它是所有服务中耗时最多的,也就是说,它的总耗时决定了帧率的上限。每一帧的工作中,必须等待所有服务都完成,才能推进到下一帧。我们一共有 9 个这样的服务,其中第二耗时的是特效(粒子系统)。特效服务的特点是,它在每帧中的开销是波动的,每帧开始时,因为一些状态尚未准备好(比如粒子发射器的位置等),它必须等待;一旦它开始运算,任务不可分割。也就是说,它会占据一个大的时间片不能被打断。这个时间片可以和起他任务并行,但必须在帧末前完成。
最佳的结果是永远把主业务和特效调度到两个不同的工作线程上独立运行,它们会有很多时间是并行的。但意外发生时,主业务服务对外发送了一个消息,它让出了控制权,这时,特效突然被插了进来,变成了同一个工作线程的后续,而随后,主业务又被调度回来,但不幸插在了之后。本来可以并行的两个重头任务串行了。
一开始,我认为尽量安排同一个工作线程执行同一个服务就好了,只要察觉该服务有后序事情要做即可。skynet 就用类似的做法减少竞争:skynet 的某些工作线程会尽可能的处理同一个服务的消息队列,另一些则公平的轮询不同的服务以防止有服务饿死。但 ltask 里做类似的改动却不成立:因为发送消息是和调度器相关的。无法不让出服务的控制权就把消息发送走。btw ,ltask 里有一个绕过这个限制直接投递消息的方法。实现后并没有使用,因为它会启用前面说到的后备锁,增加调度器的竞争。
我昨天的修改是在调度器分配完任务后,检查分配的合理性,让同一个工作线程尽可能的获得同一个服务。但似乎没有解决问题。这和调度器被调度的随机性有关。每个工作线程在完成当前任务时并不一定获取调度器获取下一个任务,它的下一个任务可能是其它人通过调度器给它分配的。而一旦它发现自己有下一个任务,就立刻运行了。当调度器和工作线程并发时,调度器想调整任务可能已经晚了。而只有执行前序任务的那一个工作线程才知道它之后倾向于做什么,它不获取调度器就没办法把这个信息传递出去。
今天我又尝试另一种修改方法:当一个任务完成时,发现自己有对外发送消息,就想办法把自己锁定住,不让别人给自己分配新任务。然后自己抢到调度器,把后续任务设为延续同一个服务。这增加了相当的复杂度(增加了更多的状态,还需要保证线程安全),还增加了竞争。我修改完后,在游戏中偶发死锁,查了几个小时也没有真正定位。果断回退了修改。
最后,我发现问题其实可以从另一个角度解决,那就是对工作线程的休息策略。之前在设计时,我期望不要太频繁的唤醒已经在休息的工作线程。也就是让事情不多时,尽量只激活够用数量的工作线程即可。我们的游戏每帧耗时 10ms ,而锁定 30fps 的话,每间隔 10ms 就有平均 23 ms 的休息时间,只需要一两个工作线程做一些不那么耗时的工作即可。这样可以极大的节省手机的电池。假设有四个服务有任务要处理,我们只需要两个工作线程即可,两个正在运行的服务,和两个待运行的槽位。但,如果我们临时唤醒两个工作线程,它们只要去偷调那两个待运行槽位的话,实时性要高得多。
虽然这么修改没有解决服务对调工作线程的问题:理想情况下,我希望 A 服务永远在 1 号工作线程运行、B 服务永远在 2 号工作线程;但如果调度器把 B 分配给了 1 ,而阻塞了 A 的后续执行,使得 A B 串行。但是,更多的临时唤醒工作线程可以把 A 的后续偷调到新的位置执行。虽然牺牲了一点性能,但减少了延迟。
]]>