wdk tips (7.1): 线程的创建和销毁
虽然内核开发人员从一开始就要考虑多线程的问题,但用户态开发人员曾经有过一段美好的生活:他们只需关心一条线程(多半是UI线程)并且不必在乎太多性能问题:即使你在主逻辑里嵌套了无数层循环都没关系,该死的摩尔定律替你搞定一切问题。进入多核时代后,用户态开发人员终于发现了他们忽略很久的,但及其重要的一个技术点:多线程。朋友,好生活已经结束了,欢迎你来到混乱的时代。
我知道现在来写这篇东西似乎不合时宜,因为网上已经有无数文章讨论过多线程问题了,各个社区还开发了一个又一个的线程框架帮你解决烦人的琐事,不过我今天的主要目的是为了引出某个内核开发中的棘手问题(就是7.2要讲的,先按下不表),所以各位看官先放小弟一马,让我把旧事拉出来说说完。
说到多线程,最烦人的其实是同步问题。关于这一点我很赞同osr邮件列表里的joe老师的观点:用户态程序不应该出现(自定义的)锁,任何时候你发现自己需要考虑用锁来同步了,就说明你的设计出了问题。同步这摊子事我有一堆话要说,但不是今天,今天我要说另一个比较容易被忽略的点:线程的创建和销毁。许多人不知道如何正确的创建和销毁线程,我看到过无数错误的写法,程序奇迹般的运行正常,但错的就是错的,现在不出问题,不代表以后不会。
创建
线程创建的api是CreateThread,关于这个api只有一条原则需要注意:绝对不要去用它。让我们把时间回退到上实际70年代,那时c语言刚诞生不久,c run time library也才成型,多任务还是个高级玩意儿,如果当时就有咨询公司这种东西,他们甚至可以靠培训多任务相关技术发大财。很自然的,c运行库的作者没有考虑多线程的问题,他们假设整个c语言程序只有一条线程,没有切换,自然也没有重入,所以c运行库里有数不清的全局变量,errno就是最著名的一个。后来多任务出现了,进程和线程的概念也相继登场,这些全局变量就变得棘手了:它们会被重入。仔细推敲我们可以发现,这些全局变量其实不应该是整个地址空间可见的,而应该每个线程一份拷贝才对。实际上现在的c库就是这么干的,微软的msvcr把errno等东西放在TLS(线程本地存储块)中,创建线程的时候分配,销毁线程的时候回收。但是CreateThread作为系统api才不会管这些屁事呢,人家是系统级的,c运行库跟它没关系,问题就出在这里:你敢说你写的程序不用c运行库,所有的工作都用纯api完成?别扯了,还是听话别碰CreateThread为妙。ms vc中有替代函数_beginthreadex/_endthreadex,任何时候都必须用他们创建销毁线程。如果你用的是其他厂商的c库,就用他们提供的线程函数--不管那是什么东西,有多蠢,用就对了--别碰CreateThread。
销毁
没有哪样东西比线程的销毁更恶心人了。如同上面所说,ExitThread函数绝对不能碰,除此之外还需要注意的是:唯一正确的退出方式就是让它跑完所有代码自然退出。但很多时候自然退出根本就是一个奢望,假如你的主线程需要等所有线程退出后才能做下一件事,那么加一个超时时间就是非常必要的,因为你不能让主线程等太久,况且有些线程(特别是IO相关的线程)退不退的出还是个问题。倘若超时事件真的发生,我们就不得不做一件烂事:强制线程退出。这种做法隐患多多,我能想到的大概有以下几个:
1. 资源泄漏。假如线程开始的时候申请了内存,打开了文件,或者其他任何形式的资源,并且在自然退出前释放资源,那么_endthreadexTerminateThread后这些资源就泄漏了,没有人会去回收他们。
2. 锁的状态。假如线程开始的时候获得mutex,自然退出前释放,那么_endthreadexTerminateThread后mutex就进入ABANDONED状态,其他WaitSingleObject的点会返回WAIT_ABANDONED值。仔细想想你的代码有没有处理这个返回值,多半是没有吧…
3. IO相关的问题。如果你的GetOverlapResult调用将Wait参数置为TRUE,那么在IRP被完成之前它是不会返回的,强行退出线程会引起驱动的误会,驱动以为只要Complete了这个IRP,app就会做某类事情,实际上app没做。更糟的是如果GetOverlapResult调用将Wait参数置为FALSE并在随后的代码里进行有超时的等待,等不到就CancelIO,类似这样:
res = GetOverlapResult(…, FALSE); if( !res ) { if( WAIT_TIMEOUT == WaitForSingleObject(overlap.hEvent, 5000)) { CancelIo(m_hDriver); } }
那么强退线程后CancelIo就有可能没被执行到,IRP可能永远都不会被Complete。没有什么比系统中存在一个永远不会complete的IRP更糟糕的事了,你的进程将永远杀不掉,系统的ShutDown过程也会被挂起,恭喜你拔电源把。
4. appverifier会直接crash进程。开发过程中一直挂着appverifier跑是个好习惯,它会把各种隐患暴露给开发人员。比如强退线程这一条,裸奔的程序还能继续执行下去,最终用户不会知道发生了什么事,但挂着appverifier的程序就会爆炸,烧掉你的硬盘,并引发9级地震。好吧我开玩笑的,但你的manager一定不会容忍crash这一点。
5. 如果调用了需要SEH才能实现的功能: raise/signal
这与TLS不同, 少了一个__try块是不能再后面补上的。
这才是比较严重的问题。
但windows下开发, 使用signal的代码确实不多。(感谢OwnWaterloo提供)
那么到底有没有办法安全的强退线程呢,其实还是有几个的,我能想到的有以下几种:
1. 设置信号通知目标线程退出。比如定义一个BOOL exitThread值,目标线程的大循环就该写成这样
while( !exitThread) { … }
主线程则是这样:
exitThread = TRUE; WaitForSingleObject(m_hThread, INFINITE);
这种做法绝大多数情况下有效,但是有race condition,exitThread会被重入。改成这样会更好一点
while ( WaitForSingleObject(hExitEvent,0) != WAIT_TIMEOUT ) { … }
主线程
SetEvent(hExitEvent); WaitForSingleObject(hThread, INFINITE);
但还是有问题,目标线程的逻辑里如果有调用WaitForSingleObject(…, INFINITE)无限等某事件,那么它还是退不出来。
2. 在主线程里这么干:
SuspendThread(hThread); GetThreadContext(hThread, &THREADCONTEXT); THREADCONTEXT.eip = my_exit; SetThreadContext(hThread, &THREADCONTEXT); ResumeThread(hThread); WaitForSingleObject(hThread, INFINITE);
my_exit里释放资源,退出线程。这种做法除了IO相关问题外全都有效,写起来也很有快感,强行修改线程的执行路径,跟在内核里hook system call似的,太酷了,特黑客的感嚼。我对此的建议是:绝对不要这么做。
3. 把目标线程里的WaitForSingleObject换成WaitForSingleObejectEx,把Alertable参数设成TRUE,在主线程里这么干:
QueueUserAPC( UserAPCProc, hThread, 0 ); WaitForSingleObject( hThread, INFINITE );
UserAPCProc啥事也不用干,空函数就行。执行完QueueUserAPC函数后,目标线程的WaitForSingleObejectEx函数会立刻被唤醒,并返回WAIT_IO_COMPLETION状态。这是windows核心编程的作者jeffrey大牛道出的天机。这段代码写着也很有快感,居然用到APC耶,我是高手有木有!但是我对此的建议还是:绝对不要这么干。
4. 把目标线程里的WaitForSingleObject换成WaitForMultipleObjects,并在目标线程里这么写:
while ( WaitForSingleObject(hExitEvent,0) != WAIT_TIMEOUT ) { … HANDLE events[2]; events[0] = hExitEvent; events[1] = hYourAnotherEventThatHaveToWait; if( WAIT_OBJECT_0 == WaitForMultipleObjects(2, events, FALSE, INFINITE) ) // you have to exit thread { … } }
主线程则是这样:
SetEvent(hExitEvent); WaitForSingleObject(hThread, INFINITE);
这是比较标准的做法,包括IO操作相关的一系列问题都能得到解决。个人建议如果你想让目标线程主动退出,最好采用这个手段,并且确保所有非主要线程里没有WaitForSingleObject这个函数出现。如果想等,就用WaitForMultipleObjects。
到这儿为止该说的差不多说完了,唯一的隐患就在IO那里。正确的退出要求线程从wait函数返回后执行CancelIo操作取消掉你的IRP,这需要驱动配合在IRP里设置CancelRoutine。假如没有CancelRoutine,那么CancelIo操作是失败的,前面讲的那些恐怖故事还是会发生。关于这一点,我打算下次再说。