善意提醒

如果您打开本站很慢,布局排版混乱,并且看不到图片,那么可能是因为您还没有掌握用科学的方法上网的本领。

2017-03-06

作死的 TerminateThread

有一天早上,我收到了一个来自同事的 Dump 文件。打开一调试,报错信息如下:
0x77B16BB9 (ntdll.dll) (XXX.dmp 中)处有未经处理的异常:  0xC0000005:  读取位置 0x1C7695E8 时发生访问冲突。
我一般首先会去看 CallStack,因为 PDB 都有,所以得到的情况很清晰:
ntdll.dll!RtlpWakeByAddress()
ntdll.dll!RtlpUnWaitCriticalSection()
ntdll.dll!_RtlLeaveCriticalSection@4()
XXX.dll!RecMutex::unlock()
XXX.dll!LockT<RecMutex>::~LockT<RecMutex>()
……
这个调用栈看得我有点糊涂。这个 RecMutex 来自 ICE 的最新版本,我从 1.3.0 版就开始用 ICE,用到现在的 3.6.3。这种基础的代码我有信心不会有问题,也不会用出问题来。何况 RecMutex 只是对 CRITICAL_SECTION 的一个简单封装,代码并不多,也并不复杂。我这次在自己项目代码中用之前恰好看过一遍,也认为这里面不会有什么问题。
具体到 RecMutex::unlock(),其实也就是干了这个事情:
void RecMutex::unlock() const
{
if (--_count == 0)
{
LeaveCriticalSection(&_mutex);
}
}
这个问题并不是每次都能重现。于是可以肯定跟「多线程」有关系。很显然,LeaveCriticalSection() 这种级别的 Win32 API 调用绝对不会存在着会导致 Crash 的 Bug,否则微软早就死翘翘了,所以问题肯定出在那个 _mutex 上。然而,既然 if 语句能进来,那么 this 指针一般来说是对的。因为是 Release 版本,优化导致了 LocalVar 的显示不是那么可靠,不过跟踪 ESP 的情况最终验证了我的猜测是对的——至少到这个时候,栈还没有坏掉。

这个 _mutex 就是一个 CRITICAL_SECTION。那么我就很头大了。多线程出问题,一般是临界区被搞坏了。然而 CRITICAL_SECTION 本身就是用来保护临界区的。它什么情况下会被搞坏掉呢?

接下来的汇编代码,Debug 起来就很有些难度了。我只能大致确认,在 _RtlLeaveCriticalSection()中,_mutex 还没有坏掉。但在 RtlpWakeByAddress() 中就肯定坏了。至于 RtlpUnWaitCriticalSection() 的情况就不是很清楚了。

到此时,三个小时已经过去了。正面 Debug 似乎走入了泥沼,现在只剩下「程序不会闹鬼,一定要查个水落石出」的精神还在支撑着我。起来上了个厕所,我换了条思路:问 Google。
Google 果然厉害,哪怕只是简单的 关键词搜索,第一条搜素结果就 直指要害

其实第二条搜素结果也很正确,不过 Reddit 毕竟还是没有 StackOverflow 专业。这个问题所描述的现象,跟我遇到的是一模一样。而且给我 Dump 文件的那个同事用的 OS 的确是 Win10
。我最感激的是这哥们问题的开头一段几乎直接就回答了我的疑惑:TerminateThread。该死的!肯定有人在做这种事情。

我当然是知道的。TerminateThread 会造成各种各样千奇百怪的问题。正常的应用层操作的确不太可能造成 CRITICAL_SECTION 本身被搞坏(除了把栈写坏),然而 TerminateThread 这种东西是一定会干得出来的。大多数程序员都知道 TerminateThread 不靠谱,所以都不会去用,这个已经几乎成为常识了。于是有一个坑爹的后果就是:一旦有人真的这样干了,正常人全部懵圈,因为没人想到会是这个原因。多谢这哥们把这种问题给问了出来,否则我可能还要花更多的时间在它上面。

接下来的事情就很简单,找出谁在用 TerminateThread。其实也没那么简单,因为我找了一圈,都没发现相关的代码。我都有点开始怀疑自己是不是碰到了别的情况。这个时候我突然想到:会不会是别人的库里面有问题呢?

我当然用到了一些开源第三方库,不过我相信它们不会犯这种错误。它们就算要用到 TerminateThread,也肯定是作为程序退出之前的强制性措施来使用,正常运行情况下是绝对不会这样用的。

相比较而言,我比较不信任其它组同事写出来的「中间件」。一问之下,那个写「中间件」的同事,也用了另外一个组提供给他的一些开发库。再追着问,果然,那个开发库里面对线程的封装,在退出「超时」的情况下调用了 TerminateThread。

至此,水落石出了。这个 Dump 文件的确就是在调用了那个中间件的关闭接口之后不远的地方产生的,并且产生之前确实很明显地出现了「超时」的现象——界面卡住了约 5 秒没有动。可想而知,由于停线程超时,于是就去调用了 TerminateThread。那个开发库的编写者大概以为,这样就可以让程序继续跑下去。然而这只是他的一厢情愿。用过 TerminateThread 之后,系统的行为就会是 undefined 的了。现在可能不会 Hang,但是接下来会出现各种莫名其妙的错误,到那个时候必然将会更难调试。这种「保证线程可以被停掉」的代码我宁可不要。浪费时间,不就是在浪费生命么?

在最后,再次警告大家:珍爱生命,远离 TerminateThread。

2 条评论: