正确的向 WinProc 传递 lua_State 指针[摘自:云风的 BLOG]



掌叔
2010-01-13 12:51:26

在 Windows 下写一些关于窗口的程序时,如果在软件中嵌入 lua ,那么就很有可能遇到一个棘手的问题:如果你需要用 lua 来直接响应一些 Windows 消息,那么如何向 WinProc 传递 lua_State ,也就是那个充斥于 lua 代码中的 L 。

在 Lua 的第 4 版及以前,这个问题并不突出。因为大多数情况下,我们并不需要嵌入多余一个的 Lua 虚拟机。而 L 这个指针,从 Lua 虚拟机被创建出来以后,就不会改变。那么我们只需要把 L 保存在一个全局变量中就可以了。若是你的程序是多线程的,并且每个线程都开有独立的虚拟机,把这个全局变量放到 TLS 中就可以完美的解决问题。当然一些全局变量的排斥者,会想到把 L 放到 Window 对象的 USERDATA 中,这也未尝不是一个体面的方法。

但是,从 Lua 5 开始,因为 coroutine 的引入,即使只打开一个虚拟机,我们也会面对不同 L 的问题。这个问题早在去年就困扰过我,我和同事一起也讨论并研究过这个问题,当时得到了一些解决方法。今天,我重构代码,又想起这个话题,觉得有必要把当初的思考、结论和今天的想法纪录下来。

首先、莫要以为只跟窗口打交道的 api 才会引发这个问题。WinProc 这个回调函数在 Windows 的设计中非常特殊,甚至 Sleep 这个 kernel32 中的 api 都有可能触发一次回调。最简单最安全的方法是,为每个 windows api 加个壳,在调用任一 Win32 API 前都设置一下全局或 TLS 中的 L ,利用外部手段将 L 传递给可能被调用的 WinProc 。固然这不太美观,但却安全有效。如果你想更准确的知道哪些 API 有可能触发 WinProc 回调函数,可以参考一下《Windows 核心编程》中关于 Windows 消息的章节,或是云风的拙作 中 Windows 编程的小节。

上面这个方案最终成了我们项目中的解决方案。

另一个可以考虑的方案是在所有的 coroutine.resume 和 coroutine.yield 操作中纪录下 L 的变更,因为对于一个 Lua 虚拟机来说,正在活动的 L 只有唯一一个。如果你想改造的彻底点,可以给 Lua 添加一个新的 API ,让它可以从任何一个 lua_State 取到相关的正在活动的 L 指针。以我对 Lua 源码的理解,增加这个特性并不困难。只需要在主线程(指 Lua coroutine 的主线程)中纪录下 coroutine 变更即可。而原本任何一个 lua coroutine 都是可以取到主线程的 L 指针的,所以并不需要特别复杂的流程就可以找到活动的 state 指针了。

去年我把这个建议提交到 lua maillist 中去时,意见被开发团队拒绝了。有点遗憾,不过这也是我欣赏 lua 的一个重要点,整个开发团队都在尽力避免 lua 成为下一个庞大的东西,任何 api 的增加都是非常谨慎的。另外比较高兴的是通过这件事交了一些朋友,比如 DM2 的开发者。他那个时候正在考虑在将来版本的 dm2 中嵌入 lua 来做插件。和高水平的开源软件作者的交流也给了我不少技术上的启发 :D

话说回来,这里提到的问题,未必能被许多嵌入 lua 的 windows 项目重视。往往传递了错误的 L 但程序也可以正确运行。关于这个,我们就需要追究一下 lua_State 到底是什么了。

说到底, lua_State 中放的是 lua 虚拟机中的环境表、注册表、运行堆栈、虚拟机的上下文等数据。从一个主线程(特指 lua 虚拟机中的线程,即 coroutine)中创建出来的新的 lua_State 会共享大部分数据,但会拥有一个独立的运行堆栈。我们在 WinProc 中调用的 lua 函数,如果不做任何线程切换操作,那么它运行过程对运行堆栈来说就是干净的,不会带来什么,也不会带走什么。只要给它一个合法的 L 就能够正常的运作。致命的错误只发生在线程切换之时。如果代码工作在非激活状态的 L 上,运行上下文就不能正常工作。想像一下,如果你的软件的整个框架由 Lua 解释器驱动,当你从 WinProc 中俯视 C 的调用栈,你一定能发现在很底层曾经有过一次 lua_call 的调用,被传入的 lua_State 很有可能跟你现在拿到的不同。万一你调用的 lua 脚本中出现了 lua_yield 的调用,被 yield 的就不再是正在活动的的线程。而活动的线程并没有结束本次 C 函数调用。这将触犯使用 lua coroutine 的大忌:coroutine 并非真的线程,它并不拥有 C 层面上的堆栈。这一点才是错误传递 L 可能导致程序 crash 的根源所在。

ps. 让 lua 的 coroutine 成为真正的 C coroutine 也并非不可能。Lua JIT 的作者作的 coco 库就是干这个的。它的内部实现采用了 Windows 的 Fiber ,每个 croutine 拥有真正的 C 堆栈。