说到多线程的Bug

刚才提到了多线程,想起来以前遇到的bug,记录下免得以后忘记了。

起因是一个小哥试图优化多线程的loading,在没看完其他代码的情况下,觉得应该在idle的时候把线程的priority降低,给其他更重要的革命工作让出CPU。

然后他没想到的是,loading会调用::Sleep(),结果就是idle之后常睡不醒,优先级太低了其他革命工作都忙得不行,哪有机会让loading出来说话。

他更没想到的是,其他革命工作是依赖于loading的结果的,毕竟革命也要吃饭,没有loading的数据啥也干不了。

所以结果就是loading被负优化,反而变得更慢了,整个游戏也变得更慢。老婆听我讲了这个bug之后做了个特别棒的比喻:

导游带一堆客人去景点,然后让客人自己排队入园。客人问导游你不进去么?导游说我去很多次了你们排着我在外面歇着。没想到门票都在导游手里,一堆人在门口嚷嚷半天谁也进不去,于是都等着了。

所以说优化要先做benchmark,脑补的优化基本都是负优化,没有benchmakr数据支持,你优化个毛线?这事儿我没少干过,就不多吐槽了。

但是必须强调下,文中的优化小哥可不是我,我是修bug的那个。俗话说的好,前人栽树后人乘凉,前人挖坑后人吃瘪。革命工作嘛,总是会有这样那样的问题,别抱怨了好好搬砖吧。

程序员的执念

昨天在看一个吉里吉里(krkr)Timer问题,开启Timer后,游戏会随机崩溃,报错说Timer已经被删除。

题外话,我个人是很讨厌Timer这种东西的。我见过很多糟糕的Timer设计,而且它容易被滥用。使用者会产生一种“我在做并行逻辑”的错觉,而且大部分设计者在设计有关Timer的逻辑时,并没有完全想明白。随口设计出诸如“延迟x秒发生这个,再延迟y秒发生那个”,当这些逻辑交织在一起的时候,无论是设计还是实现都会非常困难,edge case很多,最终导致产生不可预期的结果。

但Timer本身不复杂,对于krkr这样针对电子小说的游戏引擎更是如此。我随口说了句总不至于多线程吧?毕竟krkr内部的解释器是单线程的,所以Timer触发的逻辑肯定得在主线程里执行。但这样一来解释不了Bug的成因,为何会出现删除Timer后OnTimer函数仍然被调用,并且在OnTimer里面操作Timer的时候又说Timer已经被删除?我脑补了两个原因:

1、删除Timer不会终止它的OnTimer函数,这种设计是引擎缺陷。

2、OnTimer触发后就无法被取消了,这种设计也是有问题的。

脑补当然没用,看代码就好了。于是我翻出krkr的源码,找到Timer的实现,还真是多线程……然后就脑补了20种多线程的常见问题。等读完源码,发现逻辑虽然饶了几圈,但bug是没有的,大致上是这样的过程:

krkr首先启用一个单独的线程来处理Timer触发的逻辑,它通过::Sleep来控制Timer的等待(很不精确,但省CPU)。被触发的Timer通过PostMessage传递给隐藏Win32 HWND,然后在独立的UtilWndProc执行逻辑。因为UtilWndProc是由主线程创建的WindowProc callback,所以和解释器处在同一个线程。在这个消息处理函数中,Timer逻辑被丢给了自带的Event模块,Event模块执行实际的OnTimer。krkr保证了如果一个Timer被删除,它在Event中注册的所有OnTimer事件同时也会被删除。已经在等待队列的Timer,检查到自己被删了就直接return,什么都不会发生。

我个人觉得这个Timer实现的太复杂了,可能20多年前CPU宝贵,krkr没有一个持续tick的函数,只能通过多线程来实现吧。而windows里这样传递消息也是krkr固有的模式,其他人提到过:http://bbs.niratea.org/test/read.cgi/krkr/1323994344/389

总之我假设的两个问题都是不存在的。这样一来就是上层逻辑的问题了,于是仔细看了看,发现了真正的原因(WTF,为什么一开始不看?):

1、删除一个krkr对象,并不会自动把对象引用置为void。这个操作非常c++,但对于自带gc的脚本引擎来说还是挺猎奇的。这个错误的假设造成了一些逻辑问题。

2、Timer被多次创建,且保存在parent的成员变量上。这样新创建的Timer会导致旧的Timer引用丢失,而gc并不会第一时间删除这些timer。一旦新的Timer被删除,旧的OnTimer函数被触发的时候,旧的OnTimer函数里通过成员变量访问的“自身”已经是新的Timer,所以会出现问题。

修复很简单,每次创建新Timer前记得删除旧的即可。

这个问题其实可以通过引擎的设计来避免,毕竟用户可能会犯错。OnTimer如果能传入自己,用户就没有必要通过额外的方式保存Timer的引用,这样OnTimer可以保证操作的timer对象肯定是有效的。用户即使重复创建Timer,造成的结果也无非是Timer被重复触发,而不是抛出异常。另外带gc的脚本引擎提供删除功能真的好么?20年前内存大概也非常宝贵吧。

当然,正确的使用Timer才是解决问题的根本。不过我之前就说过,Timer是个容易被误用的模块。尤其是游戏,大部分基于Timer的设计逻辑都是经不起推敲的(个人看法),能少用就少用吧。