目录

Windows DPC、APC的作用和区别

正文

DPC机制有几个典型的用途。比如,一个驱动的ISR在比较高的IRQL运行(Device IRQLs),而如果长时间在高IRQL执行,会导致低IRQL的任务没办法执行,为了保证效率,不紧急的、没必要在高IRQL执行的任务可以放在低IRQL执行,而DPC 就为驱动提供了这一机制,驱动可以插入一个DPC并指定回调让该DPC执行,当处理器的IRQL降到DISPATCH_LEVEL以下的时候,处理器便会检查自己的DPC队列,从中取出DPC,执行其对应的回调。每个处理器都有自己的DPC队列,默认情况下 DPC会被插入到当前处理器的DPC队列中,并发起一个DISPATCH_LEVEL的软件中断(软件中断通过HAL的一个函数来发起),在IRQL降到低于DISPATCH_LEVEL的时候,便会分发该DISPATCH_LEVEL的软件中断,该中断的处理例程会把当前处理器 DPC队列中的DPC一个个取出来执行,直到队列为空。另外一个典型的用途就是时间中断处理例程(一个高IRQL的例程)在发现时间片消耗完了,会插DPC去执行线程调度,切换线程。其他的还有定时器超时、中断其他处理器当前线程的执行(把DPC插入目标处理器的DPC队列,这种DPC我们称为targeted DPC,即定向DPC)等。

DPC要么是发起DISPATCH_LEVEL软件中断,在中断例程中从队列中取出并执行,要么是线程调度的时候顺带检查队列并取出来执行的,此时上下文处于哪个线程是不确定的,且由于处于和线程调度器是同一IRQL级别,故决不能触发线程调度,这意味着调用系统服务、访问分页内存、等待某个对象等操作都不能做。相比于DPC,APC是附属于线程的,每个线程有一个对应的APC队列,我们可以插入APC 到某个线程的APC队列,当该线程得到执行且IRQL低于APC_LEVEL时,APC就会从队列中被取出并执行。由于APC是附属于线程的,故当APC的回调运行时,我们能明确知道当前所处的上下文,且由于IRQL低于DISPATCH_LEVEL,故可以调用系统服务、访问分页内存、等待某个对象等等。

APC有内核APC和用户APC两种,前者代码在内核层,后者代码在用户层,用户层的代码只能插入用户APC。其中,内核APC又被分成特殊APC和普通APC两种,也就是一共三种,三者的优先级分别是:特殊APC > 普通APC > 用户APC,在低优先级APC运行过程中,高优先级的APC可以抢占它的运行。特殊APC运行在APC_LEVEL,普通APC运行在PASSIVE_LEVEL,用户APC由于是用户层代码,故也运行在 PASSIVE_LEVEL(当然,优先级比普通APC低)。

一个线程的ETHREAD控制结构中有字段用于控制该线程是否执行APC,不同的字段控制不同类型的APC,可以通过这些字段来屏蔽APC的执行,比如 KeEnterGuardedRegion、KeEnterCriticalRegion就是利用这些字段来屏蔽APC的,具体的,KeEnterGuardedRegion通过 --KTHREAD.SpecialApcDisable 来屏蔽特殊APC以及所有比特殊APC低优先级的APC,即全部APC了(有点废话了,主要该字段名字不叫AllApcsDisable),KTHREAD.SpecialApcDisable在小于0时会被屏蔽,由于是通过自减和自加来屏蔽和解除屏蔽的,故 KeEnter/LeaveGuardedRegion是可以嵌套调用的,类似的, KeEnterCriticalRegion则是通过 --KTHREAD.KernelApcDisable 来屏蔽普通 APC及用户APC的,也可以嵌套调用。注意,把一个处理器的IRQL提升到APC级别并不一定能阻止一个线程所属APC的执行(我们称为线程A),因为IRQL是处理器的属性,它只能阻止线程A的APC在该处理器执行,但是考虑在多处理器环境下,如果有另外一个IRQL低于APC_LEVEL的处理器被调度去执行线程A,那么线程A的 APC还是可以在这个处理器执行的。

不同于内核APC,用户APC的执行需要目标线程的同意,具体的,只有当一个线程处于alertable wait state时,用户APC才会被取出来执行,而一个线程进入 alertable wait state是需要它调用特定函数并明确指定要进入alertable wait state才行,即需要该线程同意用户APC的执行(相比之下,内核APC则没有这个限制)。一个线程进入alertable wait state的方法有很多种,比如调用 SleepEx指定第二个参数bAlertable为TRUE,调用WaitForSingleObjectEx指定第三个参数bAlertable为TRUE等。

下面举用户APC的一个使用示例。

使用用户APC实现异步I/O

这节仅会大致描述下利用用户APC实现异步I/O的过程,如果想更深入了解APC的使用,请参考文章:Parallel Programming with C++

假设我们要写一个GUI应用,其中需要涉及文件的读写,而ReadFile等文件读写 API默认是同步的,如果我们在GUI线程中调用同步操作会导致界面失去响应,如果还坚持使用同步文件读写,那可选的方案就是创建一个额外的线程去进行同步的文件读写,读写完成后再通知GUI线程来获取数据,但是这样的问题在于多线程引入了额外的复杂性,有没有不用多线程又不会阻塞界面的方法?有,其中之一就是使用ReadFileEx,该函数为异步函数,接收一个完成回调,当读操作完成后会调用我们提供的完成回调,然后我们就可以在回调中读取结果了。但是需要注意的是,该回调只会在调用ReadFileEx的线程中执行,不会在其他线程中执行(即不会有多线程问题),但是回调的执行要求线程处于alertable wait state 状态,否则回调不会执行,到这里,你应该已经猜到了,它底层使用的是用户 APC,完整流程大概是:我们调用ReadFileEx发起I/O请求,I/O请求被发往对应的驱动程序,驱动程序和外部存储设备进行通讯并完成目标数据的读取,此时驱动程序得通知我们的线程数据已经读取完了,它的做法是插入用户APC到我们的线程,而我们的线程可以调用SleepEx等函数进入alertable wait state以等待回调的执行。到这里,读者可能会有疑问,如果调用SleepEx一直处于睡眠状态,那GUI线程不是无法执行,如果读取时间很长,那界面还是长时间处于无响应状态,答案是,我们不会让SleepEx一直处于睡眠状态,一般设计方式是:设计一个事件循环,仅在合适的时间等待回调的完成且等待有时间上限,这次没等到,下次循环轮到了再等,这样界面就不会长时间处于无响应状态了。

参考文章

  1. Microsoft Windows Internals, Fourth Edition
  2. Parallel Programming with C++