让 lua 运行时动态切换操作系统线程
最近我们在开发引擎时遇到一个和操作系统有关的问题,想了个巧妙地方法解决。我感觉挺有意思,值得记录一下。
在 ios 上,如果你的程序没能及时处理系统发过来的消息(比如触摸消息等),系统有机会判定你的程序出了问题,可能主动把进程杀掉。
完全自己编写的应用程序,固然可以把处理消息循环放在最高优先级。即使有大量耗时操作,也可以通过合理的安排代码,不让消息处理延后。但作为引擎,很难阻止使用者阻塞住主线程做一些耗时的操作。所以,通常我们会把窗口消息循环和业务逻辑分离,放到两个不同的线程中。这样,消息处理线程总能及时的处理系统消息。
在 windows 上,允许程序在任何一个线程做窗口消息循环。但在 ios (以及 Mac)上似乎不行。窗口消息循环必须在主线程中运行。
我们的引擎的 C/C++ 代码全部以 Lua 的扩展库出现,引擎的主体框架是 Lua 来调度的。也就是说,允许使用者用任何 Lua 解释器来启动引擎。其中就有我们自己扩展的平台无关的线程库。每个线程都有一个独立的 lua vm 运行独立的 Lua 代码,线程之间使用 channel 通讯。
问题出在这里:
窗口消息循环是一个需要对使用者隐藏的信息。封装良好时,用户根本不需要感知到它的存在。如果窗口模块需要使用多线程,那么也只是这个模块自己的事情。用户如果不想使用多线程,那么他也不需要了解线程模块的存在。用户的业务逻辑最好是放在 Lua 解释器启动时创建出来的那个 VM 中。因为这个 VM 有很多从启动开始就一直保有的上下文信息:例如命令行参数,渲染器对象,等等。
这里的矛盾在于:
用户业务代码期望运行在 Lua 解释器启动创建的主 VM 中。
窗口管理模块期望运行在独立线程中。
窗口管理模块期望运行在操作系统意义上的主线程中。
每个操作系统线程上运行的 Lua VM 是独立的。
我一开始认为这些矛盾是无解的。除非自己定义一个特殊的 Lua 启动器。因为自定义的 Lua 解释器肯定可以更灵活的调度 Lua VM 。比如,skynet 就自定义了 Lua 运行时的行为,它可以在同一进程中调度上万个 Lua VM ,却可以分配它们运行在有限的操作系统线程上。甚至同一个 VM 可以这个时候在这个操作系统线程,那个时候在另外一个。VM 可以和系统线程无关,自由迁移。
skynet 能做到这点,是因为它调度 Lua 运行行为是先把业务逻辑变成一次次的消息回调,让每次调用结束后,Lua VM 上的调用栈都是干净的,不依赖 C 的运行栈。然后再用 coroutine 把离散的调用变成逻辑上连续的线,用户感受不到这种离散性。
换成任意的(尤其是标准 Lua )解释器,这就很难做到了。
标准 Lua 解释器,用一个叫 pmain 的 C 函数入口去运行一段 Lua 代码,一旦这段代码运行完毕,pmain 就返回,进程也就退出了。所以在任意时间点,Lua 的调用栈都不可能是干净的,它永远处于至少一个 lua call 没有返回的状态。如果我们在运行过程中切换系统线程,也就同时切换了 C 的调用堆栈,那么这个 pmain 可能永远无法正确返回了。
但我昨天想到,这里其实有转机。所以给线程库增加了这么一个 api :
thread.fork(func1, func2_source)
这个 api 的语义在于,运行 fork ,会产生两个并行的运行流。func1 是当前 vm 的一个闭包,它可以正常地读写当前 vm 的所有状态;func2 是一个独立的运行流,它以源代码形式提供,会由线程库创建出一个独立的新的 vm ,加载代码并运行在另一个操作系统线程上,和 func1 并行。func2 不可以访问原有的 vm ,必须用 channel 通讯。
只有当 func1 和 func2 都执行完(或抛出异常),fork 才返回。 fork 的返回值就是 func1 的返回值(因为它们在同一个 vm 中),会丢弃 func2 的返回值或异常。func1 如果抛出异常,也会导致 fork 抛出异常,但抛出的时机也会等待 func2 运行完毕。
传统的实现方法,会安排 func2 运行在额外的操作系统线程上。但是,其实这不是唯一的实现方法。
我们其实可以让 func1 运行在额外线程,而 func2 所在的新的 vm 保持在当前线程运行。由于 func1 和 func2 都用 pcall 限制起来,所以它们都无法跳出 fork 的管理范围。我们在为 func1 启动新线程时,只需要把当前的 L 传给新线程,而当前线程继续运行的 func2 是用新的 L 运行代码,其实它们互不干涉。
虽然 func1 和之前的运行流已经不在一个操作系统线程了,但它们依旧是同一个 VM ,用户其实完全不会感知到不同。
这样就完美的解决了这个问题。