1. 丘奇数
lambda演算是图灵等价的,用lambda可以模拟自然数,其中最常见的是邱奇数:
0 = λf.λx.x
1 = λf.λx.f x
2 = λf.λx.f (f x)
3 = λf.λx.f (f (f x))
简单点说,就是用函数f在x上作用了几次来表示该数字为几。λf.λx.f x作用了一次,所以该数为1;λf.λx.f (f x)作用了两次,所以该数为2,;以此类推。
在plang里,lambda的定义完全照搬上面的形式,只做两处修改:1. 参数可以有多个,包含在括号内,比如λf.λx.f x表示成\(f,x).f x 2. 作用次数明写在函数后,比如λf.λx.f (f x)表示成]\(f,x).f^2 x。\(f,x).x与\(f).\(x).x是完全等价的。
在丘奇数的基础上可以定义后继函数
SUCC = λn.λf.λx.f(n f x)
该lambda输入丘奇数n(也就是f在x上作用了n次),返回n+1(也就是再多作用一次)。在plang里表示成
var SUCC = \(n,f,x).f^(n+1) x
同样的,加法和乘法的定义是
PLUS = λm.λn.λf.λx.m f (n f x)
MULT = λm.λn.λf.m(n f)
在plang里表示成
var PLUS = \(m, n).\(f,x).f^(m+n) x
var MULT = \(m,n).\(f,x). f^(m*n) x
//或者var MULT = \(m,n).\(f,x). f^m (f^n x)
2. 谓词逻辑
lambda也可以模拟谓词逻辑,与自然数类似,该类逻辑基于丘奇布尔值之上。TRUE和FALSE的定义如下
TRUE := λx y.x
FALSE := λx y.y
丘奇布尔值用左和右来表示真假,如果输入两个参数返回左边,则该值为真;返回右边则为假。在plang里定义如下
var TRUE = \(x,y).x
var FALSE = \(x,y).y
基于丘奇布尔我们可以定义谓词逻辑如下:
AND := λp q.p q FALSE
OR := λp q.p TRUE q
NOT := λp.p FALSE TRUE
IFTHENELSE := λp x y.p x y
以AND为例:
AND TRUE FALSE = (λp q.p q FALSE) TRUE FALSE = TRUE FALSE FALSE = (λx y.x) FALSE FALSE = FALSE
AND TRUE TRUE = (λp q.p q FALSE) TRUE TRUE = TRUE TRUE FALSE = (λx y.x) TRUE FALSE = TRUE
AND FALSE FALSE = (λp q.p q FALSE) FALSE FALSE = FALSE FALSE FALSE = (λx y.y) FALSE FALSE = FALSE
AND FALSE TRUE = (λp q.p q FALSE) FALSE TRUE = FALSE TRUE FALSE = (λx y.y) TRUE FALSE = FALSE
非常明显,就是不断用实参代替形参产生lambda body的过程。在plang里定义如下
var TRUE = \(x,y).x;
var FALSE = \(x,y).y;
var AND = \(p,q).p q FALSE;
var OR = \(p,q).(p TRUE q);
var NOT = \(p).(p FALSE TRUE);
var IFTHENELSE = \(p,x,y).(p x y);
值得注意的是IFTHENELSE,如果p=TRUE则选择两个参数里的左边那个也就是x,如果p=FALSE则选择右边那个也就是y,正好是if的语义所在。
3. 递归和lazy eval
纯粹lambda演算的递归有些复杂,要用到不动点理论,所以我偷了点懒,在plang里除了lambda还有具名函数
defun foo(n, m)
{
return IFTHENELSE (ISZERO n) m ( foo (PREF n) (ADD m TWO) );
};
以上就是函数foo的定义,用来计算n的倍数。函数内部并非必须只有一条语句(这就是我加一个return关键字的原因),比如上述函数改写成这样也是可以的
defun foo(n, m)
{
var a = ISZERO n;
var b = PREF n;
var c = ADD m TWO;
return IFTHENELSE a m ( foo b c );
};
一个函数最终具有的值由return语句代表的值决定。如果没有return语句,则由最后一条语句决定。
眼尖的同学已经发现了这是个尾递归,可以优化成循环。plang里的计算都是lazy eval,比如foo函数,在n不为ZERO时返回的值不是最终值,而是带有 (foo b c)语句和当前环境拷贝的特殊值,等到有人要用该值(比如要
print到屏幕上,或者要被输入实参做计算时),用while循环不停的eval (foo b c)函数,以及该函数返回的结果,直到能得出实际值为止。这个过程不会让调用栈溢出,但是while循环过程中会不停的创建新环境(用来对形参做约束)。
lazy eval的另一个好处是可以短路求值,比如下面的语句
IFTHENELSE (AND TRUE FALSE) (IO print “hello") (IO print "world");
如果要把IFTHENELSE的三个参数全部求完值再返回,那么"hello" 和 "world"两个字符串势必会被全部打印出来,laze eval则只会打印正确的那个。
4. IO
本人最喜欢的语言是Haskell,Haskell作为纯函数式语言解决IO的办法是把它们包在IO Monad里,很纯很巧妙,但坦白讲难用的要死。plang里没有这么高级的货色(其实是本人水平还未够班),还是在编译器里内置了一些IO的操作。
比如在屏幕上打印的语句是
IO print "hello, world"
plang把IO函数作为一个特殊函数,也就是说,你要是自己定义了一个函数叫IO,那么编译器会抛异常。IO函数的第一个参数是io命令,目前只print和readline两个,不过以后相加的话也很容易的。一个小的echo程序如下
defun main()
{
var a = IO readline;
IO print a;
return a;
};
顺便说一句,main函数也是特殊函数,其他函数只有被调用到了才会去解释内部细节,main函数则会被自动调用到。
5. 环境
所谓的环境,就是符号和值之间的对应表,比如这段代码段
var TRUE = \(x,y).x;
var FALSE = \(x,y).y;
var AND = \(p,q).p q FALSE;
var OR = \(p,q).(p TRUE q);
var NOT = \(p).(p FALSE TRUE);
var a = AND TRUE FALSE;
var b = OR TRUE FALSE;
var c = AND (OR TRUE FALSE) FALSE;
var ZERO = \(f).\(x).f^0 x;
var ONE = \(f).\(x).f^1 x;
var N = \(f).\(x).f^n x;
var IFTHENELSE = \(p,x,y).(p x y);
defun foo(x, y)
{
var c = \(p,q).x;
return c;
};
defun main()
{
var ret = IFTHENELSE (AND TRUE FALSE) (IO print HELLO) (IO print WORLD);
var ret1 = IFTHENELSE FALSE (IO print hello1) (IFTHENELSE TRUE (IO print hello2) (IO print hello3));
IO print HELLO WORLD;
IO print TRUE;
IO print a;
var a = (foo FALSE TRUE);
var b = (a FALSE FALSE);
IO print b;
return (foo TRUE FALSE);
};
它所生产的环境如下
TRUE: ((p1,p2)->(p1))[1]
FALSE: ((p1,p2)->(p2))[1]
AND: ((p1,p2)->(p1,p2,b3))[1]
OR: ((p1,p2)->(p1,b2,p2))[1]
NOT: ((p1)->(p1,b2,b3))[1]
a: ((p1,p2)->(p2))[1]
b: ((p1,p2)->(p1))[1]
c: ((p1,p2)->(p2))[1]
ZERO: ((p1)->((p2)->(p1,p2)))[0]
ONE: ((p1)->((p2)->(p1,p2)))[1]
N: ((p1)->((p2)->(p1,p2)))[n]
IFTHENELSE: ((p1,p2,p3)->(p1,p2,p3))[1]
foo: __function__[1]
main: __function__[1]
foo和main是函数,main函数在全局环境生成后会自动被调用,foo则不会,只有main函数执行到(foo FALSE TRUE);语句时才调用。调用foo函数时会生成新的环境,在该环境里符号'x'绑定在FALSE上,符号'y'绑定在TRUE上。
返回的节点包含了自己所依赖的环境,在上述代码里,main函数里的符号a绑定在一个lambda \(p,q).x;上,而该lambda里的x符号绑定在FALSE上。每个节点都拷贝有一份自己的环境,这么做很浪费空间,但可以确保所有的自由变量都是有值的,比如下面的语句
defun foo(a)
{
var b = \(x,y).a;
return b;
};
var f1 = foo 10;
var f2 = foo 20;
IO print ( f1 1 2);
IO print ( f2 1 2);
打印的结果是
10
20
符号查找先从节点自带的环境开始,找不到则接着找全局环境,依然找不到就抛异常。
6. 实现
语法分析部分园子里的装配脑袋 有介绍,并且龙书上写的无比详细就差把伪代码翻译成真实代码了,再加上lex,yacc等东西成熟度非常高,也就没什么好说的了。目前plang的实现是解释执行的,难度比编译执行小很多。一个程序段分为很多的语句,解释器逐条解释,每解释完一条往环境里丢一个映射。遇到函数就先留一个占位的,等到真被调用到在逐句执行函数里的语句,直到碰到return或最后一个语句。当一个lambda或函数要求值时,先生成一个新的环境,然后绑定形参到实参上,在返回body的计算结果。以语句"IO print ( AND TRUE FALSE );"为例,计算的过程如下:
a. print 的参数是一个特殊节点,拥有( AND TRUE FALSE )语句,所以要循环对该节点求值
b. 查找符号“AND", 在全局环境里,是一个lambda \(p,q).p q FALSE;
c. 生成新环境e1, p约束到TRUE, q约束到FALSE,返回的节点是"TRUE FALSE FALSE"语句,该节点的环境是e1
d. 符号TRUE在全局环境中找到,是一个lambda \(x,y).x,新生成环境e2, x约束到FALSE,y约束到FALSE,返回节点FALSE,该节点的环境是e2。
e. 求值并未结束,继续查找符号"FALSE",在全局环境里找到是一个lambda,求值完成,while循环跳出
f. 打印结果。
7. 小结
这真的只是试水的东西,没怎么好好设计,代码也写的很乱。不过做一轮下来对一些以前在书里看到的知识有了直观的了解,也算是有收获吧。最后附上运行图一张:

在大约一年前提到过,我想做这样一件事:打通windows上的int 80中断,让原生的linux程序也可以在windows上跑。中间因为公司项目紧,再加上idt的一个小问题困惑了我很久,所以搁置了一段时间。最近觉得周末实在闲的慌又把这项目捡起来了,并且在某次抽烟的时候突然想到“每个处理器都有自己的idt表”这一小常识,idt的问题也就很顺利的解决了。接下来的事情就变得很顺了,按照原定计划先把所有的系统调用转到用户态然后从cygwin1.dll里再过一遍,大约熬了两天夜,一个小小的demo就完成了。
代码参考了很多的开源项目,比如cygwin, glibc, linux kernel, 还有一个死了很久的项目 LINE。这事说起来也好玩,我最初想做这个项目的时候,压根没到sourceforge上去找过,包括实现原理,项目名称等都是自己想出来的。结果做了一半到sourceforge上一搜,居然有个一摸一样想法的东西早在2001年就存在了,连名字都想的一样。然后再到google上一搜,无数人都想到过类似的方案了,而且名字无一例外的打算叫成“LINE”。不得不说在开源的世界里想真正的创点新还真是困难。所幸sourceforge上的这个项目老的不成样了,是2001年的,看代码只能在win98上跑,而且是半成品,连编译都通不过,所以我做的这些事情也不是完全没价值。
不过看我这个项目仍然继承了sourceforge上这个LINE的很多东西,比如把系统调用反射回用户态这事,我原来的打算是插一个APC反射回去,看了下他们的做法,是直接把调用栈上保存的返回地址改成了用户态的一个SyscallHandler,有点类似缓冲区溢出注入的技术,这个方法毫无疑问的更合理更优雅。再比如,绝大多数的系统调用转回到用户态后只是简单的把参数包一包就转cygwin上去了,这部分代码属于纯体力劳动,我就很不客气的也照抄过来了。总之,之前我把这个项目定性为我的“创新”,现在我把它定性为LINE项目的后续开发,虽然少了重新发明轮子的快感,不过坦白我也已经过了追求这种快感的年龄了。
在开发过程中碰到的主要问题有三个:idt的问题,某些较新的linux系统调用没法实现的问题,以及路径问题。我之前设置int 80中断的时候,都是在一个IOCTL里直接就调sidt指令,以为这么做就能改掉系统idt表,实际上由于每个处理器有自己的idt表,这么干只能改IOCTL发起者所在处理器上的idt表,到了运行调试的时候一个int 80中断的发起者到底是不是在该处理器上根本没法保证,所以有很长一段时间里我就处于这么一个状况:设置好idt后一调试,好的;再调试,异常了。搞的我很莫名其妙。后来我在IOCTL处理函数里往每个处理器都插一个DPC,然后在DPC里改idt,这种时好时坏的情况就再也没发生了。
等到用户态的响应函数写的差不多,简单的linux程序比如hello world等都能跑了后,我就开始从原生linux上抓真正的程序过来试。结果一跑问题就来了:linux 2.5.29 及以后的版本新增了几个系统调用set_thread_area, get_thread_area 等等,是为了对NPTL做支持,这几个函数没办法在windows上模拟出来(至少我现在没想到办法),所以我就留空了,直接返回-ENOSYS (事实上190号以后的系统调用我全留空了)。但是现在的glibc还非用它不可,set_thread_area返回-ENOSYS后,glibc就直接打印error退出了。没办法,我只好在虚拟机上装了个redhat 6.0, 然后把那上面的程序和类库考出来用。RH 6.0 那可是史前时代的东西了,不会用到那么高级的东西,应该能跑。
最后还剩一个问题:windows的路径和*nix的路径表示完全不一致,比如linux上的程序想打开/lib/libc.so.6这个文件,在windows上根本就找不到。解决这个问题的方法倒是简单粗暴:我在open等文件操作的系统调用里做了一个小改动,凡是以‘/’打头的路径名,我就把第一个’/’去掉让他变成相对路径,然后再用windows的open函数就能顺利的打开了。直到目前为止这个小技巧还是奏效的,从RH 6.0上拷过来的基本命令都能跑,有图为证:
当然,这东西现在还只是demo而已,问题还不少。比如跑bash还是有点小问题:bash能启动,但是在bash里打ls命令又是文件未找到。再比如只能在32位机器上跑,再比如很不稳定等。总之,后续还有很多工作要做。
源代码目前还没脸放出来,等整理完后会放到google code上,沿用GPL v2。
上回我们留下一个未解的问题,就是当一个IRP的CancelRoutine没有被设置时,CancelIo操作会失败,系统中有可能会留下永远都不会被complete的IRP。在Threaded IRP和non-threaded IRP一节中我们有谈到irp分为线程相关和非线程相关两种。倘若一个永远不complete的irp是非线程相关的,情况会稍微好一点,顶多系统中泄露了一个资源。倘若该irp是线程相关的,那事情就大了。thread IRP由IoManager生成并保留在线程的IRP队列里,负责处理该IRP的驱动在收到下层驱动的Complete事件后不会主动收回IRP的资源而是继续complete给IoManager,由IoManager负责回收,并从线程IRP列表中删除该IRP。一个线程在退出前会遍历等待IRP队列里所有的IRP,直到它们全部被complete为止。倘若其中有一个irp永远不complete,那么线程就永远不退出,无论是ExitThread也好还是_endthreadex也好还是什么邪恶的暴力擦除数据强退也好,全都不顶用。线程不退出,进程也不能销毁(题外话:进程资源的回收动作由最后一个线程退出后发起,所谓的杀进程,其实是用apc给所有线程发起退出操作)。更糟糕的是,操作系统的关机过程都会被堵住,除了关电源,没有其他办法恢复,这一点简直比BSOD还糟糕。我们知道由user mode发起的IO操作最后都会翻译成threaded irp,这就是为什么我在7.1大谈特谈user mode线程的原因:这个陷阱连user mode程序也会掉进去。Bad dog!
要解决这一点方法很简单目标很明确,那就是防止“永远不complete的irp”这种东西出现。一般的做法是加个线程或者timer并设置超时时间,时间一到就cancel这个irp。如果irp由user mode程序发起,那么就调用CancelIo;如果irp由驱动发起,则是调用IoCancelIrp。所有这些动作要生效的大前提是你的irp有CancelRoutine的存在,否则一切都是白搭。所以这里我有个经验要跟大家分享:任何时候都给你的irp设置CancelRoutine,并在CancelRoutine里Complete你的IRP!为方便起见我们选non-threaded irp做个例子,所有的代码都在内核态,免得各位看官看示例代码还要做上下文切换。以下便是代码:
Sending thread:
IoSetCancelRoutine(Irp, MyCancelRoutine); devext->SentIrp = Irp;
Canceling thread:
if (devext->AllocatedIrp != NULL) {
IoCancelIrp(devext->SentIrp);
}
cancel routine里的内容都是标准步骤,不赘述。看起来已经完美无缺了,可惜拿到测试组一跑就BSOD,系统抱怨说一个irp被free了两次,肯定是有地方被疏忽了,对,我们很好的处理的例外情况,却漏掉了常规情况:irp也是可以正常complete的!假如我们的CompleteRoutine是这样的:
Completion routine:
PIRP irp; irp = devext->SentIrp; devext->SentIrp = NULL; IoFreeIrp(irp);
它和CancelRoutine里用到了同一个irp,这是典型的多线程重入问题,需要加锁保护。修改后的代码如下:
Sending thread:
KeAcquireSpinLock(&devext->SentIrpLock, ...); devext->SentIrp = Irp; KeReleaseSpinLock(&devext->SentIrpLock, ...);
Canceling thread:
KeAcquireSpinLock(&devext->SentIrpLock, ...);
if (devext->AllocatedIrp != NULL) {
IoCancelIrp(devext->SentIrp);
}
KeReleaseSpinLock(&devext->SentIrpLock, ...);
Completion routine:
PIRP irp; KeAcquireSpinLock(&devext->SentIrpLock, ...); irp = devext->SentIrp; devext->SentIrp = NULL; KeReleaseSpinLock(&devext->SentIrpLock, ...); IoFreeIrp(irp); return STATUS_MORE_PROCESSING_REQUIRED;
又是一个完美的程序,半分钟修掉一个BSOD,还有比这更爽的吗?结果一测试问题更大:系统挂起没有任何反应了。经验告诉我们这是一个死锁:Cancel thread获得spin lock后调用IoCancelIrp,IoCancelIrp最终进入CancelRoutine,而CancelRoutine则调用了IoCompleteIrp并进入Complete routine并试图再次获得spin lock,而该死的spin lock在同一条线程里也是会死锁的,这就是最终原因。
问题出在rip的完成上。设置了Cancel routine和Complete routine后,有两个点可以做irp的完成和回收动作,而这两个点只能有一个被执行。借用网上某牛的代码描述我们可以看到有以下几种情况:
// No cancellation: // Cancelable-->Completed // // Cancellation, IoCancelIrp returns before completion: // Cancelable --> CancelStarted --> CancelCompleted --> Completed // // Canceled after completion: // Cancelable--> Completed -> CancelStarted // // Cancellation, IRP completed during call to IoCancelIrp(): // Cancelable --> CancelStarted -> Completed --> CancelCompleted
这跟同步还是两回事,同步是指两个点不能同时摸这个irp,一个摸完换另一个则是可以的,而我们要达到的目标是只要irp被其中的任何一个摸过了,另一个就不能再去摸它。为了达到这个目的,我们需要增加额外的变量记录irp被摸了几次这个信息.改造后的cancel过程如下
if (InterlockedExchange((PVOID)&touched, IRPLOCK_CANCEL_STARTED) == IRPLOCK_CANCELABLE) {
//
// You got it to the IRP before it was completed. You can cancel
// the IRP without fear of losing it, because the completion routine
// does not let go of the IRP until you allow it.
//
IoCancelIrp(irp);
//
// Release the completion routine. If it already got there,
// then you need to complete it yourself. Otherwise, you got
// through IoCancelIrp before the IRP completed entirely.
//
if (InterlockedExchange(&touched, IRPLOCK_CANCEL_COMPLETE) == IRPLOCK_COMPLETED) {
IoCompleteRequest(irp, IO_NO_INCREMENT);
}
}
而改造后的complete过程则如下
if (InterlockedExchange((PVOID)&touched, IRPLOCK_COMPLETED) == IRPLOCK_CANCEL_STARTED) {
//
// Main line code has got the control of the IRP. It will
// now take the responsibility of completing the IRP.
// Therefore...
IoFreeIrp(Irp);
return STATUS_MORE_PROCESSING_REQUIRED;
}
简单点说就是在中间加入能表示状态信息的变量touched表征现在所处的状态,cancelable, cancel started, cancel complete, completed四个状态相互协调保证complete rip不会被调用两次。如同在tip 5里提到过的,这也是口耳相传下来的范式,基本上有cancel rip的地方都得这么写。
虽然内核开发人员从一开始就要考虑多线程的问题,但用户态开发人员曾经有过一段美好的生活:他们只需关心一条线程(多半是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操作是失败的,前面讲的那些恐怖故事还是会发生。关于这一点,我打算下次再说。

