wdk tips (7.2): IRP的CancelRoutine
上回我们留下一个未解的问题,就是当一个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的地方都得这么写。