« 悠长假期 | 返回首页 | 用回 google.com »

正确的向 WinProc 传递 lua_State 指针

在 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 堆栈。

Comments

对,还有Java的匿名类也是。现在写的程序都是一个函数里面嵌入很多匿名类,匿名类里面实现的接口的方法里面再嵌入一堆匿名类……

其实有了closure之后coroutine的意义就不大了,用closure来实现事件处理和异步调用也很方便。

梦幻是用 python 驱动 的,coroutine 是我用 C 写的,工作在 C 的 engine 层面,跟脚本无关。

在合适的地方就会需要合适的解决方案。的确屏幕上所有会动的东西都有一个 coroutine 在管理。

梦幻在立项的时候要求的机器最小配制是 64M 物理内存,最后也的确做到了。只有在一开始精打细算,随后几年才可能在不段的 patch 和增加海量资源的同时,也不必有核心程序员的照料。

2000个coroutine,如果用线程来模拟不需要开2000个线程吧,一般在运行时只需要几个线程就可,运行完后归还给线程池即可,就算线程池中有20个线程,内存开销也不大.<br>
把一个coroutine的逻辑转换成线程的话,我会使用以下的方法:<br>
coroutine {<br>
code1;<br>
yield();<br>
code2;<br>
yield();<br>
code3;<br>
}<br>

我会向线程池申请一个线程执行code1,在code1结束时再申请一个线程执行code2,申请完后code1线程将归还给线程池.同理code2执行完后会申请code3,code2线程本身也会归还给线程池.为了避免同时申请多个线程而导致线程池拥塞,线程池本身应该有同步机制.因此在运行时刻不会有2000个的巨量线程数.当然code1和code2,code3线程之间的数据必须使用一个共享的数据结构,而不能像coroutine那样使用堆栈了.


2000 个 coroutine…… 为什么需要用到这么多……难道是给每一个精灵都开一个coroutine吗……如果这样的话,用线程肯定是不行的,Windows下的线程是很昂贵的。Lua的动态增加栈空间是挺有用的。

1M 内存 (._.!) 说的轻松。

你知道梦幻西游的 client ,默认最少开了 2000 个 coroutine 吗? 2G 地址空间看在 windows 下能不能申请的到。

嘿嘿,就算是默认的1M栈空间又怎么样,这点内存还浪费得起。老是提及开销,是不是太小气了,有过早优化之嫌哦。

使用线程池有内存的开销,必须为每个线程分配至少 4k 的栈空间。为了足够安全,占用的地址空间应该有 8K 。

使用线程池应该就不会有线程产生和消亡的开销吧,并且将来还有多核技术,也都是使用线程机制.

估计云风使用coroutine是为了把一些异步操作封装成同步的。

以WOW的宏为例,假设执行一个宏调用几个函数,做完一系列动作:转身180度,向前跑5米,坐下。

这几个函数都是调用了之后就阻塞了,等到完成了动作才返回。但是如果用Lua来驱动游戏的话,你可不能让游戏定住吧?所以就切换coroutine,若干帧以后动作完成再切换回来并且返回。如果不用coroutine,实现方式可能就是执行一个宏就开一个线程,创建一个event,然后等待。

coroutine容易出错是真的。在一些特殊情况,用coroutine在进行一个异步操作请求以后切换,然后异步操作完成以后在切换回来,这样可以把异步操作的请求和回调写在同一个函数里面,能缩短代码。

如果用coroutine来取代线程,则相当于是自己进行线程切换来代替锁。

对前一种用法,确实有他的好处。相比先保存需要的上下文数据到一个结构,然后在额外的回调里面在使用之前的结构来说,效率有时候用coroutine更高,代码有时候用coroutine也会更清晰,是可能有用的。

对后一种用法,其实这往往引发更多的问题,比较得不偿失。

本例中由于 coroutine 引发的问题,个人认为最佳解决方案是增加一个 lua api 可以从主线程获取正在活动的 state 指针即可。这样,只需要把最初打开的 L 放到 WinProc 的 USERDATA 中即可。不这样做的原因在于 lua 社区不轻易接受接口的增修;但自己的 VM 当不必考虑这个。

脚本层次实现 coroutine 比用 os 的 fiber 要轻量的多,有无可比拟的优势。

to atry and cloud:<br>
经过对coroutine深入地讨论,我发现coroutine虽然看上去很酷,其实却有可能引发许多错误.受此启发,我在我的虚拟机CSMachine实现中将彻底抛弃coroutine机制,因为coroutine的本质就是一个由程序员自己创建的伪线程.现在直接使用线程机制更能与操作系统(包括windows和linux)紧密结合.使用内存映射技术和线程池技术可以很容易模拟出虚拟机上的线程模型.而coroutine.resume可以直接产生一个线程来运行.应该会更简单些吧.不过此时好像还要解决一个变量同步访问的问题.总之与操作系统更接近些总是没错.我这里的操作系统是指windows和x86上的linux.至于其它操作系统我不太会去留意.

to waynezhang:
云风那个问题的关键,其实不关Lua什么事,不过是因为Lua导致crash,如果是C coroutine,可能是不会crash,但是却会有线程重入问题,比如说在WinProc里面改写了一个共享变量,而外面某一层根本不知道他调一个Win32 API会有这样的副作用。

因为Windows能保证同一个HWND对应的WinProc始终在一个线程里面被调用,但是他却不保证也没办法保证是同一个coroutine。

这如果要怪,就只能怪SendMessage设计要求是同步的,而SendMessage又使用的实在太广泛了。如果是在同一个线程里面调用SendMessage,那么这个消息不会放到消息队列,而是直接在内层调用WinProc,问题就出在这里。(因为SendMessage是同步的,微软不得不这么做,如果不这么做,同一个线程没有办法等待自己去调用DisptchMessage)。

To cloud:
谢谢云风的答复,我平时没有时间阅读lua源码,所以说得不妥请多原谅.现在看来不同的coroutine应该有自己的独立的堆栈,winproc的调用就好比调度了一个新的coroutine,即发生了coroutine之间的切换.因此不能复用同一个堆栈.

to waynezhang, lua coroutine 在 C 层面上来看,resume 操作和普通 C 函数调用的确没有两样。

但是 coroutine 在逻辑上需要两个独立的栈,用来保存逻辑线索的上下文。

这个话题跟本文其实关系不大 :D 也算我在正文中没有把问题解释清楚。

话说回来,你也做虚拟机,不仔细读读 lua 的实现着实可惜了。

(._.!) 楼下的讨论这么欢,但是看起来都没好好看 lua 的 source code 吧。

lua 的索引哪有 256 的限制, lua 的堆栈永远都是一块连续内存,只要内存大,随便索引到哪。

lua 局部变量 256 的限制,来源于 upvalue 256 的限制:ClosureHeader 里只用一个一个 byte 来保存 upvalue 的个数。用 byte 是为了节约空间,并利于数据对齐。仔细看就可以看到,lua 在设计那里的数据结构的时候刚好用了 4 个 byte 变量。

Lua 的堆栈是 Lua 自己的堆栈,执行 byte code 也不依赖 C 的堆栈工作。

问题是出在 lua 的 resume 都是以保护模式解释运行代码,通常它依赖于 longjmp 工作。错误的传入 L ,会没有正确的设置回 longjmp 点。C 里的 longjmp 的一个重要用途是用来恢复 C 堆栈指针,可以从深层次的函数调用中直接弹出,而错误的设置跳转点,则会让程序不能正确的运行。据一个同事分析,如果采用 coco 来跑的话,即使传错了 L 也可以顺利跑下去。这应该是得益于每个 fiber 有独立堆栈,longjmp 就不用局限于向上跳转。这也是我在正文中把最终 crash 的发生归咎到 lua coroutine 没有 C 层面独立堆栈的原因。我认为,无论怎样,这也是种将错就错的做法,还是有隐患的。

lua 的设计个人认为是非常优美的,而且源码也很简短易读,推荐去看,会有许多收获。

to atry, 真正的问题的确在 WinProc 的设计。但是并非 bind 一个参数就可以解决了。因为 WinProc 从来不是被用户直接调用的,如果允许绑一个参数,那么也需要在任何可能间接调用 winproc 的入口点上都重绑一次。

to atry:
我忽然间明白了lua为什么这么做,因为lua的堆栈索引值不超过256(这也就是为什么lua的局部变量不能超过256个),因此不能复用原先的堆栈,即使你申请了1M的堆栈,lua还是不能去访问,索引值达不到,鞭长莫及.所以它每次只能再申请一块新内存来保证自己能鞭长可及.如果这真是事实的话,我只能用丑陋来评价luaVM的设计了.:)

to atry:
如果一个coroutine能够跨线程运行,那么luaVM要做更多的事情,其中一个就是lua自己的堆栈的同步访问,sp值的增加和减少都要设置critical section,以保证多个线程同步访问一个堆栈.

to atry:
这里的一对多的关系是指什么?是一个coroutine对应多个线程呢?还是一个线程对应多个coroutine,如果一个coroutine可以跨线程运行,那么我上面的想法不成立,但如果是一个线程对应多个coroutine的话,我想我的说法有可能成立.因为coroutine与coroutine之间是同步运行,不会破坏堆栈结构.

to atry:
我的问题是:coroutine与coroutine之间既然是同一个线程,因此使用同一个堆栈并不会破坏堆栈结构,一个coroutine执行完成后会自动调整sp值,这和一般的函数调用应该没有什么区别吧.

问题在于C的线程和Lua的coroutine不是一一对应的,往往是一对多的关系

如果堆栈被复用,是不是在同一个线程中不再需要多个L,永远只有一个L?

to waynezhang:
每一个堆栈对应一个coroutine,记录的是每一个coroutine的函数调用关系和局部变量。

如果堆栈被复用,是不是不在同一个线程中不再需要多个L,永远只有一个L?

的确,WinProc的机制的确很不好办。每一次WinProc调用给他不同的coroutine感觉总是不太好的……

我想,对于游戏来说,WinProc主要也都是处理键盘鼠标,并不需要对返回值有什么要求,不如放到自定义的一个消息队列,异步的告诉一个coroutine去处理?

我不明白为什么luaVM会这样设计,难道lua不使用属于自己的独立的堆栈?在同一个线程中,一个活动的L,与不活动的L是不是仅仅指堆栈不同?如果是这样为什么不复用原先的堆栈呢?只要按照常理增加sp值即可.如果堆栈尺寸不够也可使用realloc来增加,为什么一定要另外再申请一块内存来做新的堆栈呢?

反正stdcall是由被调用的函数自己恢复堆栈的。运行时生成的函数直接push一个绝对地址作为参数,然后jmp过去就行了。

归根到底是给一个丑陋的,没有自定义参数的函数bind一个参数的问题。

一个解决办法是自己用机器码来在运行时生成一个WinProc函数,这个函数再额外的push一个运行时的参数,然后jmp到指定的实际上用来处理的C函数里面去

Post a comment

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