« August 2023 | Main | October 2023 »

September 22, 2023

一个任务调度算法引起的性能问题

这两天遇到一个任务调度算法引起的性能问题,花了颇多精力排查和解决。问题出在我写的 ltask 这个 lua 多任务库上。ltask 最初是对 skynet 的一些反思中开始的,最初只是想换一种思路实现 skynet :做一个库而不是框架、更少的锁竞争、避免服务因为消息队列堆积而过载……

后来、我们游戏引擎开始尝试基于 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 的后续偷调到新的位置执行。虽然牺牲了一点性能,但减少了延迟。

September 19, 2023

桌面游戏的分类

所有在桌面玩的游戏都算作桌面游戏。几乎所有的人都玩过,比如象棋、围棋、扑克。如果不计这些传统的抽象游戏,我玩现代桌面游戏已经有十多年了。过去,是和朋友一起玩,而最近几年,更多的是和家人(小孩)一起玩。和许多不玩现代桌游的人想象的不一样,虽然电子游戏脱胎于桌面游戏,但桌面游戏却并没有被淘汰,反而一直在推陈出新,每年都有许多新的佳作面世。

玩桌游这么些年,我发现桌游其实可以分出几个子类。像我这些各种桌游都玩的玩家很多,但有相当一部分人专注于特别一个子类,对其它类的桌游兴趣不大。有时,隐隐觉得不同子类之间还有一些鄙视链存在。

我们很多时候提到桌游,并不指大多数人都会玩的棋牌(象棋、扑克、麻将等)。其实,这些的确和在桌游店里玩到的桌游有很大的不同,它们历史悠久,早已没有知识版权的保护。这类棋牌游戏可算作桌面游戏的一个大的子类,即抽象类桌游。可以说,人人都是桌游玩家,想在身边找出一个从没玩过棋牌的人恐怕很难。但也不是所有抽象类游戏都是古老的棋牌,也有很多近年类的新作相当有趣。比如我很喜欢的 Azul (花砖物语)就在家经常开。

我们还可以把专门为 6 岁以下儿童玩的桌游归为另一个子类,儿童类桌游。如果成人玩这些游戏的话,恐怕会因为缺乏挑战而索然无味。我家娃还小的时候,我有几年特别关注这类游戏,想带着娃玩。如果娃太小的话,多半只能玩玩物理类的游戏、敲砖块、搭积木之类。现在娃大了,这些游戏早就束之高阁。一些供成人玩的著名桌游有时也会把规则裁剪掉,出一些儿童版本:卡坦岛、卡卡颂、石器时代这些都有儿童版。

当娃大一点,在家就有很多游戏可以选择了。这类游戏往往会贴上家庭游戏的标签。另一种是朋友聚会活跃气氛的聚会类游戏。在 boardgamegeek 上,家庭游戏和聚会游戏是两个大的分类。我觉得没必要分开。风靡一时的狼人杀、三国杀、剧本杀等一系列杀就是聚会游戏的典型。酒吧里的骰子游戏(同时也是一种抽象类游戏)也是这类游戏中最为普及的。说起杀人类游戏,我最喜欢的是抵抗组织:阿瓦隆,规则严谨,玩起来颇有策略性。

另一个大的子类是(卡牌)构筑类游戏。最著名的就是万智牌。这类游戏通常需要玩家在当局游戏外(购买)收集卡牌,构筑自己的牌库,然后再和对手玩游戏。也有一些不和对手玩,而是单人或协作性质的。也未必是卡牌的形式,像战锤系列,就需要玩家在游戏外收集大量的军队模型。这类游戏颇有深度,单款游戏就可以玩上数年甚至十年以上。

还有一个小众的群体是兵棋。它有通常包括设计好的地图、推演用的抽象棋子、以及整套推演规则。通过回合制进行战争模拟。它现在甚至在真实战争中实战应用,而不仅仅停留在桌游游戏中。兵棋玩起来繁杂,入坑不易,如果桌游有鄙视链的话,这算是鄙视链顶端的存在。现在也有一些对兵棋轻量化的改良,例如战争之道 Battle Lore 我就挺喜欢的。

最接近大部分电脑游戏的桌游是 RPG 。为了和电脑游戏区分开,现在通常把桌面上进行的称为 TRPG 。这种游戏往往是围绕一个故事主题展开,玩家按故事背景设计规则,扮演故事中的角色。这类玩家把玩游戏称为跑团。但我觉得还有许多桌游也可以归到这个子类中。例如,瘟疫危机的传承版,也可以一组人长期玩下去(可以连续玩上十多盘,持续几个月时间);近年来还有像魔镇惊魂 Arkham horror 这样的组队一起玩的主题游戏也可以归为此类。

剩下的就是花样繁多的策略类桌游了。也有人称它们为德式桌游,欧式桌游等。它们的特点就是单局几十分钟到数小时,每局游戏之间相互独立,需要使用策略来玩。大部分属于对抗性游戏,参与的玩家之间有胜有负。也有一部分游戏是相互协作性质的,共同达成目标。如果不想和人打交道,或找不到玩友,也有不少游戏设计有单人模式,一个人就可以挑战系统。关于这部分桌游,五花八门,往下还可以再细分更多分类。等下次再从桌游的游戏机制方面展开来谈。

September 11, 2023

特效接口的重构

上次提到,经过数据分析发现,我们引擎(游戏)目前特效系统占了很大的 CPU 比例。虽然特效的计算已经放在独立线程,不影响帧率,但 CPU 的开销会导致电池消耗,最终会引起手机热量上升,最终让手机降频。所以,当时想了一些牺牲准确性的方案来做优化:即,不在镜头内的特效不运算。让视觉裁剪同时也裁剪掉特效粒子片的计算。

最近做了这方面的重构工作。其中的难点在于:特效模块是第三方的,并不完全贴合我们的引擎设计,而短期内又没有重新实现或改造的计划。

其中最麻烦的一点是,我们引擎有一个挂钩 (hitch) 的特性。场景中可以有很多挂钩挂接的是同一个子树。这样,相同的对象就会被渲染多次,状态完全相同,只是位置因挂钩的不同而不同。我们引擎也针对它做了一些优化,比如可以把多个部件用 instance 绘制优化。

对于引擎内部支持的模型,可以很方便的基于 Hitch 做批量提交。内部数据结构只有一份,但可以在每帧绘制多次。但是,当 Hitch 挂接的的部件中有特效时,特效模块并不支持复制多份多次提交绘制。我们只能在特效模块内创建多个相同的对象,且这些对象的内部状态是独立的,而我们又需要让它们永远保持状态一致,这会导致实现上的一些麻烦。

好在当我们场景上需要有多个相同的物件,都带着相同的特效时,按引擎的定义它们是完全一致的。虽然我们创建了多个特效对象,但并不需要严格的和屏幕对象持久对应。比如,这一帧屏幕上有四个状态相同的烟雾,而下一帧有一个移除了镜头看不见了,另一个移入了镜头,其它三个可能移动了位置,总数没有变化。我们并不需要在下一帧找回那三个位置发生改变的烟雾和上一帧的对象对应起来。引擎只需要了解,这四个烟雾对应新的位置各在哪里。

特效对象的内部状态是一帧帧推进的,每帧状态是基于一个 delta 量从上帧变化而来。特效模块没有提供 clone 对象的方法,所以为了保证完全一致,只能重新发射并从头推进到当前进度。这是一个相当耗时的操作,我考虑之后决定牺牲一定的准确性:即,当镜头中多出一个特效时,可能是从头发射的,而不是和之前的保持一致。当然,如果不用 Hitch 复制特效的话,也能保持正确。

重构后的实现用了一个独立的系统封装场景中存在的特效,每个特效在内部可以有多份实例。每一个渲染帧,场景节点是特效时,和模型一样,需要提交当前的矩阵到对应的模块:模型进入的是模型渲染模块,而特效进入的是特效系统。特效系统每帧的工作是收集每个特效当前帧被提交了多少次。镜头外被裁剪掉的特效不需要渲染(甚至不需要计算状态,这是我们游戏的特别优化)。被 Hitch 多次引用的同一特效会被以多个不同的场景矩阵提交,这时,启用特效系统内部的缓存,复用上一帧的相同特效对应(并对应的推进状态)。如果这一帧数量比上一帧多的话,会发射(Clone)一个新的特效出来;如果少的话,则可以选择删掉多余的对象或暂时把多余的设为不可显示。


在重构过程中,我们反思了之前一版提供的接口语义:如果想发射一个特效,就创建出一个 Entity ,在消失后再销毁它。销毁过程可以是主动进行,也可以自动销毁。经过这段时间的使用,我觉得这个接口语义是不对的。我们创建的 Entity 应该是特效的发射器而不是发射出来的特效。而发射器是不应该频繁创建和销毁。发射器在不工作时,它应该只占用空间,而没有 CPU 开销。我们应只在需要时让发射器发射。

基于这个思路,在开发时用编辑器创建的预制件 Prefab 中,我们应该把特效发射器都在里面制作好。某些预制件动画的特定帧会触发特效:比如,工作的烟囱在工作时会冒烟;烟雾就是一个粒子特效。在过去的实现中,动画关键帧会创建一个烟雾特效 Entity 出来;而现在的版本则应该在 Prefab 中制作好烟雾特效发射器,动画关键帧在运行时驱动该发射器发射。

通常,我们并不需要在运行时控制特效发射器发射出来的特效。但在编辑环境,需要细致的控制它。暂停、加速、减速、播放到特定帧…… 。发射器返回发射后的特效 Handle ,并提供额外的接口控制它,这是底层特效库提供的方案。但对于应用层来说,区分发射器对象和特效对象就过于细节易用错。现在的方案是,把这些控制接口直接放在发射器上,即如果想控制特效,直接操作发射器,这些操作都转发到发射器发射的所有特效对象上。如果发射器发射了多个特效,它们同时存在,这些操作就反应在所有存在的特效上。