48、Windows驱动程序模型笔记(六),同步
关于同步
执行在某线程上下文中的代码在任何时刻都可能被系统夺去控制权。另外,只有在多处理器的计算机上才能真正实现多线程的并发执行。Windows NT为解决一般的同步问题提供了两种方法,一个是中断请求优先级(IRQL)方案,另一个是在关键代码段周围声明和释放自旋锁。IRQL可以避免在单 CPU上的破坏性抢先,而自旋锁可以防止多CPU间的干扰。
图示. 中断请求级
在DISPATCH_LEVEL级和PROFILE_LEVEL级之间是各种硬件中断级。通常,每个有中断能力的设备都有一个IRQL,它定义了该设备的 中断优先级别。WDM驱动程序只有在收到一个副功能码为IRP_MN_START_DEVICE的IRP_MJ_PNP请求后,才能确定其设备的 IRQL。设备的配置信息作为参数传递给该请求,而设备的IRQL就包含在这个配置信息中。
基本同步规则
遵循下面规则,你可以利用IRQL的同步效果:
所有对共享数据的访问都应该在同一(提升的)IRQL上进行。换句话说,不论何时何地,如果你的代码访问的数据对象被其它代码共享,那么你应该使你的代码执行在高于PASSIVE_LEVEL的级上。一旦越过 PASSIVE_LEVEL级,操作系统将不允许同IRQL的活动相互抢先,从而防止了潜在的冲突。然而这个规则不足以保护多处理器机器上的数据,在多处理器机器中你还需要另外的防护措施——自旋锁(spin lock)。如果你仅关心单CPU上的操作,那么使用IRQL就可以解决所有同步问题。但事实上,所有WDM驱动程序都必须设计成能够运行在多处理器的系统上。
IRQL与线程优先级
线程优先级是与IRQL非常不同的概念。线程优先级控制着线程调度器的调度动作,决定何时抢先运行线程以及下一次运行什么线程。然而,当IRQL级高于或等于DISPATCH_LEVEL级时线程切换停止,无论当前活动的是什么线程都将保持活动状态直到IRQL降到DISPATCH_LEVEL级之下。而此时的“优先级”仅指IRQL本身,由它控制到底哪个活动该执行,而不是该切换到哪个线程的上下文。
IRQL和分页
执行在提升的IRQL级上的一个后果是,系统将不能处理页故障(系统在APC级处理页故障)。这意味着:
执行在高于或等于DISPATCH_LEVEL级上的代码绝对不能造成页故障。这也意味着执行在高于或等于DISPATCH_LEVEL级上的代码必须存在于非分页内存中。此外,所有这些代码要访问的数据也必须存在于非分页内存中。最后,随着IRQL的提升,你能使用的内核模式支持例程将会越来越少。
自旋锁
关于自旋锁有两个明显的事实。第一,如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁(deadlock)。自旋锁没有与其关联的“使用计数器”或“所有者标识”;锁或者被占用或者空闲。如果你在锁被占用时获取它,你将等待到该锁被释放。如果碰巧你的CPU已经拥有了该锁,那么用于释放锁的代码将得不到运行,因为你使CPU永远处于“测试并设置”某个内存变量的自旋状态。
关于自旋锁的另一个事实是,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。所以,为了避免影响性能,你应该在拥有自旋锁时做尽量少的操作,因为此时某个CPU可能正在等待这个自旋锁。
关于自旋锁还存在着一个不太明显但很重要的事实:你仅能在低于或等于DISPATCH_LEVEL级上请求自旋锁,在你拥有自旋锁期间,内核将把你的代码提升到DISPATCH_LEVEL级上运行。在内部,内核能在高于DISPATCH_LEVEL的级上获取自旋锁,但你和我都做不到这一点。
内核同步对象
Windows NT提供了五种内核同步对象(Kernel Dispatcher Object),你可以用它们控制非任意线程(普通线程)的流程。
在任何时刻,任何对象都处于两种状态中的一种:信号态或非信号态。
对象 | 数据类型 | 描述 |
Event(事件) | KEVENT | 阻塞一个线程直到其它线程检测到某事件发生 |
Semaphore(信号灯) | KSEMAPHORE | 与事件对象相似,但可以满足任意数量的等待 |
Mutex(互斥) | KMUTEX | 执行到关键代码段时,禁止其它线程执行该代码段 |
Timer(定时器) | KTIMER | 推迟线程执行一段时期 |
Thread(线程) | KTHREAD | 阻塞一个线程直到另一个线程结束 |
图示 内核同步对象
通常,如果在线程执行时发生了软件或硬件中断,那么在内核处理中断期间,该线程仍然是“当前”线程。而内核模式代码执行时所在的上下文环境就是指这个“当前”线程的上下文。为了响应各种中断,Windows NT调度器可能会切换线程,这样,另一个线程将成为新的“当前”线程。
关于阻塞:
当我们处理某个请求时,仅能阻塞产生该请求的线程。
执行在高于或等于DISPATCH_LEVEL级上的代码不能阻塞线程。
一般在驱动程序的派遣函数中阻塞当前线程。
编程中的注意点
1、KeWaitForSingleObject中参数Alertable是一个布尔类型的值。它不同于WaitReason,这个参数以另一种方式影响系统行为,它决定等待是否可以提前终止以提交一个APC。如果等待发生在用户模式中,那么内存管理器就可以把线程的内核模式堆栈换出。如果驱动程序以自动变量(在堆栈中)形式创建事件对象,并且某个线程又在提升的IRQL级上调用了KeSetEvent,而此时该事件对象刚好又被换出内存,结果将产生一个bug check。所以我们应该总把alertable参数指定为FALSE,即在内核模式中等待。如果你的代码执行在DISPATCH_LEVEL级上,则必须指定0超时,因为在这个IRQL上不允许阻塞。
2、KeWaitForMultipleObjects
如果你指定了WaitAll,则返回值STATUS_SUCCESS表示等待的所有对象都进入了信号态。如果你指定了WaitAny,则返回值在数值上等于进入信号态的对象在objects数组中的索引。如果碰巧有多个对象进入了信号态,则该值仅代表其中的一个,可能是第一个也可能是其它。你可以认为该值等于STATUS_WAIT_0加上数组索引。你可以先用NT_SUCCESS测试返回码,然后再从其中提取数组索引:
NTSTATUS status = KeWaitForMultipleObjects(...);
if (NT_SUCCESS(status))
{
ULONG iSignalled = (ULONG) status - (ULONG) STATUS_WAIT_0;
...
}
正处于自己时间片中的线程不能被阻塞。
3、KeSetEvent的第三个参数,我们总指定这个参数为FALSE。具体原因见[2],P115。
4、通知定时器允许有任意数量的等待线程。同步定时器正相反,它只允许有一个等待线程;一旦有线程在这种定时器上等待,定时器就自动进入非信号态。
通过使用定时器的扩展设置函数,你可以请求一个周期性的超时,这种定时器在第一次倒计时时使用duetime时间,到期后再使用period值重复倒计时。
5、作为一个通用规则,你绝不要写同步响应用户模式请求的代码,仅能为确定的I/O控制请求这样做。一般说来,最好挂起长耗时的操作(从派遣例程中返回STATUS_PENDING代码)而以异步方式完成。再有,你不要一上来就调用等待原语。线程阻塞仅适合设备驱动程序中的某几个地方使用。
底线是:使用非警惕性等待,除非你知道不这样做的原因。
5、快速互斥体
内核互斥 | 快速互斥 |
可以被单线程递归获取(系统为其维护一个请求计数器) | 不能被递归获取 |
速度慢 | 速度快 |
所有者只能收到“特殊的”内核APC | 所有者不能收到任何APC |
所有者不能被换出内存 | 不自动提升被阻塞线程的优先级(如果运行在大于或等于APC_LEVEL级),除非你使用XxxUnsafe函数并且执行在PASSIVE_LEVEL级上 |
可以是多对象等待的一部分 | 不能作为KeWaitForMultipleObjects的参数使用 |
APC-asynchronous procedure call
如果你拥有快速互斥对象你就不能发出APC,这意味着你将处于APC_LEVEL或更高的IRQL,在这一级上,线程优先级将失效,但你的代码将不受干扰地执行,除非有硬件中断发生。
6、你只能在低于或等于DISPATCH_LEVEL级上调用S链表函数。只要所有对链表的引用都使用ExInterlockedXxx函数,那么访问双链表和单链表的ExInterlockedXxx函数可以在任何IRQL上调用。这些函数没有IRQL限制的原因是因为它们在执行时都禁止了中断,这就等于把IRQL提升到最高可能的级别。一旦中断被禁止,这些函数就获取你指定的自旋锁。因为此时在同一CPU上没有其它代码能获得控制,并且其它CPU上的代码也不能获取那个自旋锁,所以你的链表是安全的。
参考
[1] 下载DbgView
http://www.sysinternals.com/
[2] Windows驱动程序模型设计