6. 同步与并发:IRQL级别、自旋锁、互斥体、信号量、内核事件对象

驱动开发里,并发问题是个绕不开的坎儿。你想想看,一个驱动可能同时被多个线程调用,中断随时可能发生,CPU 也可能在多核上并行执行你的代码。如果不做同步,数据乱掉、蓝屏崩溃,那都是家常便饭。

我个人习惯,在写任何驱动之前,先想清楚两个问题:这段代码会在什么 IRQL 下运行? 以及 多个执行路径会不会同时访问同一块数据? 想明白了,再选合适的同步机制。

6.1 IRQL 级别:驱动世界的优先级

IRQL(Interrupt Request Level)是 Windows 内核里用来管理中断优先级的一套机制。说白了,它决定了当前代码能不能被其他中断打断。

常见的 IRQL 级别有这么几个:

IRQL 级别 名称 说明
PASSIVE_LEVEL 0 普通线程执行级别,可被任何中断打断
APC_LEVEL 1 异步过程调用级别,比普通线程高一点
DISPATCH_LEVEL 2 调度级别,线程调度和 DPC 在此运行
DIRQL 3~26 设备中断级别,硬件中断处理程序在此运行
HIGH_LEVEL 31 最高级别,几乎不可打断

嗯,这里要注意:IRQL 越高,能做的事情越少。比如在 DISPATCH_LEVEL 及以上,你就不能访问分页内存,也不能等待用户模式的对象。我曾经在项目里踩过这个坑——在 DPC 例程里试图等待一个事件,结果系统直接蓝屏。从那以后,我写代码前都会先检查当前 IRQL。

核心原则:IRQL 决定了你能用什么同步机制。PASSIVE_LEVEL 下几乎什么都能用,DISPATCH_LEVEL 下只能用自旋锁,再往上就只能用中断屏蔽了。

6.2 自旋锁:轻量级的守护者

自旋锁是最基础的同步原语。它的原理很简单:当一个 CPU 核心想要获取锁时,如果锁被占用了,它就在那里不停地循环检查(也就是「自旋」),直到锁被释放。

为什么叫「自旋」?因为线程不会睡眠,而是忙等。这在多核系统上效率很高,因为上下文切换的开销可能比自旋几圈还大。但缺点也很明显——浪费 CPU 时间。

使用自旋锁的典型场景:

  • 保护短小的临界区(几十条指令以内)
  • 在 DISPATCH_LEVEL 级别下保护共享数据
  • 多核之间同步对全局变量的访问

来看一个例子:

// 初始化自旋锁
KSPIN_LOCK mySpinLock;
KeInitializeSpinLock(&mySpinLock);

// 获取锁(会提升 IRQL 到 DISPATCH_LEVEL)
KIRQL oldIrql;
KeAcquireSpinLock(&mySpinLock, &oldIrql);

// 临界区代码
mySharedCounter++;

// 释放锁(恢复原来的 IRQL)
KeReleaseSpinLock(&mySpinLock, oldIrql);

我个人习惯,在获取自旋锁后尽量少做事。锁的持有时间越短,其他 CPU 等待的时间就越短。我曾经见过一个同事在自旋锁保护的区域里做了内存分配,结果系统性能直接崩了——因为内存分配可能触发缺页中断,而自旋锁下是不允许的。

警告:自旋锁保护的区域里,绝对不能做以下事情:

  • 访问分页内存(可能触发缺页)
  • 调用可能阻塞的函数(如 KeWaitForSingleObject)
  • 执行耗时操作(如大量计算或 I/O)

6.3 互斥体:线程间的排他访问

互斥体(Mutex)和自旋锁不同,它允许线程在等待时进入睡眠状态。这意味着它只能在 PASSIVE_LEVEL 下使用,但好处是不浪费 CPU 时间。

互斥体的特点:

  • 支持递归获取(同一个线程可以多次获取同一个互斥体)
  • 支持超时等待
  • 可以跨进程使用(命名互斥体)
  • 等待时会降低 IRQL 到 PASSIVE_LEVEL

代码示例:

KMUTEX myMutex;
KeInitializeMutex(&myMutex, 0);  // 第二个参数 0 表示不递归

// 等待互斥体
NTSTATUS status = KeWaitForSingleObject(
    &myMutex,
    Executive,      // 等待原因
    KernelMode,     // 等待模式
    FALSE,          // 是否可被 APC 打断
    NULL            // 无限等待
);

if (NT_SUCCESS(status)) {
    // 临界区代码
    // ...
    
    // 释放互斥体
    KeReleaseMutex(&myMutex, FALSE);
}

我记得有一次调试一个文件系统驱动,发现某个线程卡住了。查了半天,原来是互斥体被一个线程获取后,另一个线程在等待,但获取锁的线程因为异常退出了,导致互斥体永远无法释放。从那以后,我写代码都会加上超时机制,避免死锁。

小技巧:互斥体适合保护那些需要长时间持有的资源,比如设备上下文或全局状态。如果临界区很短,用自旋锁更合适。

6.4 信号量:控制并发数量

信号量(Semaphore)和互斥体有点像,但它不是排他的。信号量维护一个计数器,允许多个线程同时访问资源,只要不超过设定的最大值。

典型的应用场景:

  • 限制同时访问某个硬件的线程数量
  • 管理有限数量的资源池(比如缓冲区)
  • 生产者-消费者模式

信号量的使用:

KSEMAPHORE mySemaphore;
KeInitializeSemaphore(&mySemaphore, 5, 10);  // 初始5个,最大10个

// 等待信号量(减少计数)
KeWaitForSingleObject(&mySemaphore, Executive, KernelMode, FALSE, NULL);

// 使用资源
// ...

// 释放信号量(增加计数)
KeReleaseSemaphore(&mySemaphore, IO_NO_INCREMENT, 1, FALSE);

嗯,这里有个细节:KeReleaseSemaphore 的第二个参数是优先级提升量。如果你希望等待的线程尽快运行,可以设一个非零值。但一般情况下,设 IO_NO_INCREMENT 就够了,避免不必要的优先级反转。

6.5 内核事件对象:线程间的信号通知

事件对象(Event)是最简单的同步原语之一。它只有两种状态:有信号(Signaled)和无信号(Non-Signaled)。线程可以等待一个事件变成有信号状态,然后继续执行。

事件有两种类型:

  • 自动重置事件:当一个线程等待成功后,事件自动变回无信号状态
  • 手动重置事件:需要手动调用 KeResetEventKeSetEvent 来改变状态

来看一个典型用法:

KEVENT myEvent;
KeInitializeEvent(&myEvent, NotificationEvent, FALSE);  // 手动重置,初始无信号

// 线程 A:等待事件
KeWaitForSingleObject(&myEvent, Executive, KernelMode, FALSE, NULL);

// 线程 B:触发事件
KeSetEvent(&myEvent, IO_NO_INCREMENT, FALSE);

事件对象在驱动里用得特别多。比如,一个线程发起 I/O 请求后,可以等待一个事件,等硬件中断处理程序完成操作后设置事件,唤醒等待的线程。这种模式在驱动里叫「异步完成通知」。

我曾经写一个网络驱动时,用事件来同步数据包的接收和发送。一开始用的是自旋锁,结果发现 CPU 占用率居高不下。换成事件后,等待的线程可以睡眠,CPU 占用率一下就降下来了。

总结一下:

  • 自旋锁:轻量、忙等、适合短临界区、DISPATCH_LEVEL 可用
  • 互斥体:重量、睡眠等待、支持递归、仅 PASSIVE_LEVEL
  • 信号量:控制并发数量、适合资源池管理
  • 事件:信号通知机制、适合异步完成场景

选择哪种同步机制,取决于你的 IRQL 级别、临界区长度、以及是否需要睡眠等待。没有银弹,只有最适合当前场景的方案。

同步机制选择决策图 进入临界区 当前 IRQL 是多少? DISPATCH_LEVEL 使用自旋锁 PASSIVE_LEVEL 继续判断 临界区长度? 自旋锁 互斥体/信号量/事件 起始 决策 结果

这张图是我自己总结的决策流程。每次写同步代码前,先走一遍这个流程,基本不会出错。当然,实际项目中可能还有更多细节要考虑,比如优先级反转、死锁检测等,但掌握了这些基础,你已经能应对 90% 的场景了。

公众号:蓝海资料掘金营,微信 deep3321