ltask 是 Ant engine 的基础设施之一,在对 Ant engine profile 的过程中,我们发现了 ltask 的一些值得提升的地方。
我们希望尽可能的提升游戏帧率,缩短渲染每一帧的的时间。因为 Ant engine 是由很多并行任务构成的,任务调度器的策略会直接影响单帧需要的时间。
ltask 虽然和 skynet 想解决的问题是一样的:管理 m 个线程(任务/服务),让它们运行在 n 个 cpu 核心上。而它们的应用场景不同,ltask 目前用在游戏客户端,它由一两个重负荷任务和若干低负荷任务构成,优化目标是低延迟;而 skynet 主要用在服务器上,由数以千计的类似负荷的任务构成,优化目标是高负载。
低延迟和高负载在某些方面是对立的。
对于服务器,为了提高负载能力,应该最简化调度器,最好感觉不到调度本身,并充分利用所有的 CPU 核心。最简单的办法是,为每个 CPU 核心准备一个任务队列,把任务公平的分摊给它们。在任何一个核心快要闲置的时候,就从起他核心的任务队列上匀过一些任务,让它们都不要闲置。也可以用一个大的任务队列,让每个核心都从这里取任务,同时设计一个好的算法减少多个核心取任务时对这个大任务队列的竞争。
对于客户端,如果想降低绝对帧时间,需要做的是让主线任务有最低的延迟:每个步骤在它所依赖的任务准备好后,可以最快时间开始运行。尽量不要耽误主线任务的运作,因为它的执行时长往往决定了一帧所有任务的运行时长。这就需要更复杂的调度器算法,和更多的调度器本身的运行开销。用这些额外开销(以及复杂度)换取更低的延迟。
调度算法会影响主任务的延迟增加,并不是最近才遇到。去年这篇 blog ,就列举了当时碰到的一种情况。
当时用了一个并不彻底的解决方案,最近又想重构这块代码,就重新思考了这个问题。
之前 ltask 和 skynet 不同,区分了两种不同的服务:一种是共享工作线程的,由调度器调度分配时间片;另一种独占系统线程,由操作系统调度。我们把不太好处理的任务都交给了独占线程,期望操作系统可以比我们做的更好。
skynet 里也有独占线程,只不过没有开放接口给开发者使用。它们是 timer 线程,网络线程,以及一个监控线程。ltask 把这个机制分离出来让开发者也可以实现特别的独占线程的服务。这样,像 IO 处理就不必放在 ltask 内部,这样,ltask 可以更纯粹:只需要把多虚拟机多线程这一件事做好即可。
一开始,我们期望独占线程服务尽量简单,它只解决普通服务无法做到的事,而不去做复杂业务。所以,最早的 api 是分成两套,分别针对两种不同的业务。但随着开发进程,我们越来越分不清期间的界限,最终还是希望两者可以统一起来。但这就让中间层变得更复杂了。
如果我们仔细考虑,独占线程服务要解决的问题,其实有三个方面:
服务调用了会阻塞的系统 API 。例如,timer 服务就是依靠系统 sleep 来控制时间节奏的;IO 服务会阻塞在 select (或 epoll wait )上。这些 api 会一直占据着系统线程,让这个线程在等待系统调用返回前无法由调度器分配其它任务。
有些 C 库的 API 只允许在同一个线程调用(可能它内部使用了 TLS ),所以光保证一个服务的任务串行不够的。工作线程可能把同一个服务的任务调度到不同线程上。部分 C API 还要求必须在主线程调用。
像 iOS 这样的系统,把窗口创建完毕后,线程会阻塞在最后一个系统调用上不会返回,直到系统退出。
关于第一点,我在上一篇 blog 就提到过解决方案。值得一提的是,python 的多线程库就是使用的类似的理念:虚拟机和 C/C++ 代码运行的部分是可以分开看待的。这就是为什么,python 虽然有 GIL (全局解释器锁),多线程依然有意义。python 靠 GIL 把虚拟机解释器和所调用的 C API 隔离开了。一旦进入 C 库,就离开了 python 解释器,这时的 C 调用就可以并行运行了(C 代码自己保证线程安全)。GIL 保证了所有 python 解释器运行的 python 代码都是串行的。
一开始,我按这个想法为 ltask 实现了一套 api 。但感觉使用者较难用对。这是因为,我不想把 lua vm 的 lock 打开(相当于 python 的 GIL );如果不打开 VM 的锁,即不靠锁界定 VM 和 C 代码的边界,那么就需要在封装阻塞系统调用时主动指定边界,也就是上一篇 blog 谈到的,提供 yield 和 resume 两个 C api 。不过,这样就需要改写我们已经实现好的 C 库。
就我自己对 Lua VM 的了解,其实 Lua 本身也一定程度上的允许并发,但必须小心对待。它需要:使用独立 coroutine 、不触发 gc 、不构造新对象…… 在这些限制条件下把以上的 C API 再封装一个 Lua API 也不无不可,但使用者一旦滥用,bug 将难以定位。
最后,我放弃了这个方案。回头再来看第一个问题,其实阻塞住工作线程并非不能接受的事。我们可以增加工作线程数量(超过 CPU 核心数量)来缓解处理能力下降的问题。同时写一个更好的调度器算法,针对这种会长时间挂起的工作线程做优化。
第二个问题才是 ltask 一开始未能很好解决的:如何让一个服务永远由同一个系统线程运行。
为此,我重构了 ltask 的调度器,支持了这一特性:将一个服务绑定在特定工作线程上。值得一提的是:虽然大多数游戏引擎都把主要任务固定在一个系统线程上,比如区分渲染线程、物理线程等等。但把这些交给系统调度器并不是最优解。绑定线程(或绑定物理核)不等于低延迟。
让我们做这样一个思想实验:如果一个开发团队面临很多琐碎的开发任务要处理。如果团队中每个人(工作线程)都是多面手,可以处理一切任务。那么,最简单的管理方法就是,每个人闲下来就去找一个可以立刻开始的任务做,做完后再周而复始。
如果,某一开发任务非常重要,它直接影响整体的开发进度,又只有一个开发人员有能力处理的话,应该怎么做呢?
管理者会倾向于把这个任务的所有步骤都绑定在这个特定开发者身上。但是,如果任务做到一个阶段,等着另一个任务完成怎么办?通常不会它闲等着,总得去做点别的什么吧。一旦中间开始了别的事情,就无法立刻放下,待到之前等着的依赖项完成后,也无法立刻接着干了。
从这里可以看到,把任务绑定在某个特定人身上未必是降低延迟的最优解。最好还有另一个可以做这件事的人可以当替补。如果没有这样的替补,绑定特定的工作者要么拖长了最终完成的时间,要么浪费了干这个事的人的产能(让其不能开始工作时空等)。
针对具体的情况做具体的调优需要对要解决的问题有足够的了解。这是自己写任务调度器比系统任务调度有优势的地方。
对于第三个问题,我们最终不打算由 ltask 中解决。因为它是一个唯一的特例,暂时只在 ios 环境上遇到一例。而且 ltask 是一个库,而不是一个框架,所以依旧可以单独为它写一些代码,把这个特殊任务的特殊流程放在 ltask 之外即可。
对于解决以上几个问题,看起来独占线程服务并不是必须的。只需要为共享服务增加绑定(但不是独占)工作线程的能力即可。
昨天,我们完成了重构最后的工作。新版本的 ltask 任务调度器在游戏上工作良好。从 profile 结果看,达到了我们的优化目标。
当然,最开心的是,我可以删掉独占线程服务这个大块的特性。围绕这个特性的诸多支持代码也可以简化。最终,光 C 代码就去掉了 600 多行,Lua 代码也被大量的简化。