« 欧陆风云 5 的经济系统 | 返回首页

嵌入主线程消息循环的任务调度器

最近在网友协助下把 soluna port 到包括 wasm 在内的非 windows 平台。其间遇到很多难题,大多是多线程环境的问题。因为 soluna 的根基就是基于 ltask 的多线程调度器,如果用单线程实现它,整个项目的意义就几乎不存在,所以它是把项目维护下去必须解决的问题。

好在 lua 有优秀的 coroutine 支持,它可以把运行流程抽象成数据,而 Lua 本身并未限制数据的具体储存方式,所以完全可以存在于内存堆中,脱离于 C 栈存在,这为各种在 C 环境下的多线程难题开了后门。C 语言依赖栈运行代码逻辑,而栈绑定于线程,线程调度通常由操作系统完成,所以用常规方式无法让代码跨线程运行:即,无法通过常规手法让一段代码的流程前半段在一个线程运行,而用另一个线程运行后半段;但是,在 C 上建立一个 Lua 层,则很容易绕开这个限制,只用标准方法就可以自由控制程序运行流程。

上一次发现利用一些技巧就可以完成一些看似不可能却的确可行的调度方式是 多线程串行运行 Lua 虚拟机

简单复述一下当时的需求:

希望可以在单个 Lua 虚拟机内模拟多线程并发。当一个 Lua 的 coroutine 运行到 C 函数中时,若此刻 C 函数希望阻塞等待一个 IO 请求,常规的方法是 yield 回 Lua 虚拟机,让调度器持有一个 Lua coroutine 的状态,待完成 IO 请求后,再由调度器 resume 这个 coroutine 。这样做的难题是,运行到一半的 C 函数,上下文状态还在 C 所属线程的栈中,一旦 yield 回 Lua 虚拟机,必须放弃 C 栈上的状态,并在下次 resume 时可以重建。这通常难以实现,这也是为何 Lua 的 coroutine C api 难以理解又很难使用的原因。尤其使用第三方 C 库,几乎没可能适配。

另一个折中的方法是让 Lua 虚拟机在 C 函数中阻塞,硬等到 IO 操作完成。但在阻塞过程中,无法使用这个 Lua 虚拟机。若使用者期待 Lua 虚拟机中多个 coroutine 以多线程方式并行工作,恐怕会失望。即使其它 coroutine 的业务和 IO 完全无关,一个 IO 阻塞操作会让它完全无法并行工作。

变通的方式是(在编译时)打开 Lua 的线程锁。在调用 IO 阻塞前解开线程锁,只要 IO 操作本身不涉及对 Lua State 的操作,那么 Lua 解释器在调用 C 函数前的那一刻会解开线程锁,这样就可以允许阻塞操作过程中,Lua 虚拟机可以执行其它操作。

线程锁本身依赖系统线程库的调度器。不适合像 ltask 这样自己实现任务调度(即在有限个系统线程下调度远超系统线程数的任务)。但是,我们可以配合 ltask 实现类似的锁机制。这就是之前这个 patch 实现的东西:Lua 层调用可能阻塞的 C 函数前加锁通知 ltask 调度器,在 C 函数中,用户主动在阻塞操作前解锁。ltask 的调度器在 C 函数返回前就将虚拟机提前放回调度表。当阻塞操作完成后,重新加锁会等待调度器完成(如果有)正在运行的在同一个 Lua 虚拟机上的任务完成。这样,整个 Lua 虚拟机实质上还在串行运行其中的任务。而使用者看起来在一个 coroutine 尚未 yield 之前就开始运行另一个 coroutine ,直到其它 coroutine yield 后再继续未完的工作。同一个 Lua 虚拟机的多个 coroutine 是在多个操作系统线程上完成的,但却保持串行。

这个 patch 最终并未合并进 ltask ,因为我觉得它对使用者有更高的要求。但经此,我开了不少脑洞,明白在必要时牺牲一些复杂度就可以完成一些超乎寻常的任务。


这次我面临的是新的问题:sokol 并未设计成线程安全。api 不能并发。一开始我并不想使用复杂的解决方案,以为只要保证 sokol 不并发就够了。期间遇到的问题是 Windows API 死锁 ,也很容易绕过。

对于图形 API ,我只是简单的将图形 API 调用都塞在同一个 render 服务中。并在主线程的 sokol 回调函数中利用一个信号量和渲染过程同步。虽然 Direct3D ,Matal ,Vulkan 这些为多线程设计的底层图形 API 这么用都没有问题,但 OpenGL (在 Linux 上开启)却将状态放在当前线程上。一开始,我们通过额外调用 MakeCurrent 绕开限制,但在我们向 wasm 移植时却遇到障碍。

最终,我还是希望找到一个方法让所有图形 API 的调用都真正从主线程,也就是 sokol 提供的 callback 函数中发起。而不是用信号量同步,让它们在其它工作线程运行。

难题在于,主线程是通过事件消息循环驱动的,没有全部的控制权。不适合在其上实现任务调度器。一个任务调度器最好有所有时间片的控制权,它才好简单有效的分配时间片,没有任务时可以休眠而不是在事件循环没有新事件时强制休眠。我不想为这种特别的工作方式改造 ltask 的任务调度器,让主线程的事件回调函数伪装成一个功能不完整的特殊工作线程。我实际需要的是:把一个 Lua 虚拟机内的特定任务分配给主线程回调函数运行,在没有这种特定任务时,其它任务还是交给 ltask 做常规调度。

细想之下,解决方法和上一个需求有异曲同工之处:Lua 在启动这种特殊任务(必须在主线程回调函数内运行)前通知调度器。这时把虚拟机暂时移出调度表,而在主线程的回调函数中(通过信号量)发现有新任务到来,就接手处理特殊片段。处理完毕后,再把它归还给调度器。

通过这个方案,我们顺利把 soluna port 到 wasm 环境,同时简化了 Linux/OpenGL 实现。当我了解到 wasm 上有 pthread api 和原生 web worker api 两套多线程 api 后,我又信心满满的想用 worker api 来实现。但最终未能如愿。具体讨论在这个 issue 中 ,倒不是完全做不到,而是我觉得不应该牺牲太多复杂度。比如把 soluna 中所有的 IO 操作都转发到主线程中运行(这是 web worker 的限制所在,也是 wasm pthread 原本要解决的问题)。


昨天发现了上面解决方案实施中的一点纰漏:虽然给 ltask 打了个洞,可以在系统主线程夺过指定任务运行,但在交换控制权回调度器时,忽略了 ltask 的所有工作线程可能因为没有任务而全部休眠的可能性。仅仅把任务推回(线程安全的)任务队列是不够的。还需要重启调度器(如果处于休眠状态)。具体讨论见这个 issue

ps. 自从搬家后,我的 Linux 机器一直没有开机。昨天为了在 Linux 环境下测试,才重新装起来。bug 虽然重现,但视乎在我的机器上更为严重:一旦程序失去响应,整个系统都卡住了,甚至冷启动都没用。直接把机器弄死,而且五分钟内都开不了机(BIOS 进不去,屏幕无信号)。我怀疑是显卡驱动的 bug ,因为太久没升级系统,头一次升级还失败了,pacman 报告出现依赖问题拒绝更新。强删了几个 Electron (这个毒瘤)的几个历史版本后,系统升级才得以继续。最后更新了最新版的 Nvidia 包似乎就一切正常了。

Post a comment

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