« February 2024 | Main | April 2024 »

March 22, 2024

重构 ltask 的任务调度器

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 是分成两套,分别针对两种不同的业务。但随着开发进程,我们越来越分不清期间的界限,最终还是希望两者可以统一起来。但这就让中间层变得更复杂了。

如果我们仔细考虑,独占线程服务要解决的问题,其实有三个方面:

  1. 服务调用了会阻塞的系统 API 。例如,timer 服务就是依靠系统 sleep 来控制时间节奏的;IO 服务会阻塞在 select (或 epoll wait )上。这些 api 会一直占据着系统线程,让这个线程在等待系统调用返回前无法由调度器分配其它任务。

  2. 有些 C 库的 API 只允许在同一个线程调用(可能它内部使用了 TLS ),所以光保证一个服务的任务串行不够的。工作线程可能把同一个服务的任务调度到不同线程上。部分 C API 还要求必须在主线程调用。

  3. 像 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 代码也被大量的简化。

March 01, 2024

以非阻塞方式执行一个函数

在 skynet 中,服务之间并行运行,而每个服务自身的业务都是串行的。一个服务由开发者自行切分成多个时间片,每个时间片串行运行在不同的工作线程上。最常见的做法是在每个服务中运行一个 Lua 虚拟机,用 coroutine 切分时间片,这样从编写代码的角度看,任务是连续的。

这个设计的目的是,让开发者轻松享受到多核带来的并发性能优势,同时减轻编写多线程程序带来的心智负担。

用过 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 。