« 桌面游戏的分类 | 返回首页 | 手机游戏交互的两点改进 »

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

这两天遇到一个任务调度算法引起的性能问题,花了颇多精力排查和解决。问题出在我写的 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 的后续偷调到新的位置执行。虽然牺牲了一点性能,但减少了延迟。

Comments

Lua本身有一个协程的库,这是一种异步多线程,是时间片方案,背后是Capi,可以采用Capi方案,把一个协程,实例化成为一个真正的多线程,这个需要修改Lua的源代码,或者写一个dll(SO)库,来替换原来的Lua协程API。
STL标准库重新设计多线程的机制,并不是为了线程多,也不是为了如何分配任务,关键的关键在于同步锁部分,这是核心的核心,这部分是一个超高速的队列,这部分本质是一个异步多线程,超高速队列无论是处理速度还是权限都是远远超过普通线程调度器的,还有openMPI方案,这部分设计之复杂,搞明白了,硅谷混也不在话下了。
汇编化后的Lua的速度,能飞起来,直接撼动C++。
没有弱的语言,只有弱的程序员。
Lua的table由三项构成,index,key,value,利用Index的元方法修改Index的值,可以把一个表用来存储汇编语句,比如,mov A B ,汇编指令一般也就是几十个而已。这样Lua的表就存储了一个可以修改的汇编代码,有必要的话,把这段代码传递给汇编的连接器,生成可执行代码去执行。 ^O^
2009年,某大型软件公司,当着300多程序员的面,某总裁跟我争论一个技术问题,结果输的心服口服,我下午被开除,而他一个月后自己辞职,太没面子了。哎。
依我看,现在组建一个100个节点的openMPI编译的多线程的C++的库很容易,每台5000元的机器,100台就是50万,可以支持的线程数是3000万个左右,所以硬件这么便宜,实在不行就jvm解决。
特殊说明,进入阻塞状态的线程,既不会去抢同步锁,也不会进入垃圾收集的区域。
我又来了,很多人不欢迎我,我来说说java是如何做的,比如jvm,我打算计算一个PI的1000万位,那么我设定线程数是10000个,但是,JVM会根据内存,比如1G内存,会分配500个同步锁,这个锁500毫秒发放一次,一次500个,没500毫秒,这10000个小精子就必须去抢这个500个锁,抢到锁的活下来,进入下一个计算,抢不到的就等垃圾回收,就这样,这叫锁饥饿模型。
游戏客户端线程调度问题建议使用perfetto(for android)之类的工具定位看看,可以更容易发现问题;即使按你的理想情况,也可能在某一帧里,CPU7(超大核)串行执行了1号和2号工作线程的任务,这很多时候取决于线程唤醒的时机和调度策略(特别是活跃线程数接近甚至大于核心数的情况)
过度设计了,一个消息队列,每个工作线程自己去竞争锁获取任务就足够了。非常公平
要是能自动挂起执行时间长的任务, 貌似可以改善这个问题? 在业务代码中插入一个让出执行权的检查(虽然有点突兀), 或者魔改下lua
刚才又想了一下,单纯建立分层机制在多线程下可能并不能保证C一定在A、B执行完之后才能执行(虽然能保证A、B能先于C被调度,但无法保证C执行时A、B能一定执行完毕)。 细化一下需求,原文中主业务服务需要其它所有任务执行完成后才能执行,那么结合RTS的帧同步理念+分层是否能满足需求? 引入帧同步产生的性能开销我认为应该可以通过一些手段将影响降低到最小。至于执行第一层再执行第二层导致的线程工作空档应该是能接受的,因为需求就是这样。
看完了整篇文章,总结一下需求:1、希望某些服务的任务能固定在某个工作线程运行,未指定的可以随机工作线程执行。2、某些任务有后续任务,期望能立马执行后续任务。3、某些任务需要其它某些任务全部执行完再执行。 根据这3点,看这样行不行: 1、建立分层机制,也就是优先级机制。如果任务C需要A、B都完成,只需要把A、B放到层1、C放到层2 2、任务可选设置dispatchRule,如dispatchRule有效,调度器根据 dispatchRule 进行hash到具体的工作线程,否则随机调度。 3、每个任务补充后续任务列表,工作线程执行完后任务后,如发现有后续任务直接pop出来执行
感觉应当分工作池了,不同的工作池调度行为和运行的任务的特征有所不同
我觉得可预知的耗时逻辑,应该直接放到独立线程执行就好。 现在移动设备都是8核CPU了,线程多开点没问题吧
simple is best.
小萌新看完后想说的是,感觉大佬在作茧自缚.......越搞越复杂.....小萌新甚至有预感,过几天说不定又会发生之前没推演到的可能性.....(小萌新在边上指指点点....)

Post a comment

非这个主题相关的留言请到:留言本