最近我们在开发引擎时遇到一个和操作系统有关的问题,想了个巧妙地方法解决。我感觉挺有意思,值得记录一下。
在 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 ,用户其实完全不会感知到不同。
这样就完美的解决了这个问题。