5、内核同步机制:自旋锁、互斥体、快速互斥体、内核事件、等待与唤醒、死锁预防

同步机制,说白了就是让多个线程别打架。我刚开始写驱动时,觉得这玩意儿就是加个锁嘛,后来被坑了几次才明白——选错同步原语,系统直接蓝屏给你看。

Windows内核里提供了好几种同步工具,各有各的脾气。咱们一个一个捋清楚。

5.1 自旋锁(Spin Lock)

自旋锁是最轻量的锁。它的特点就是——等锁的时候不睡觉,一直在那转圈检查。你想想看,如果锁被占用的时间极短,那让线程去睡眠再唤醒,反而更费时间。所以自旋锁适合保护那些执行时间极短的临界区。

⚠️ 重要提醒:自旋锁持有期间,绝对不能做以下事情:
  • 睡眠或阻塞
  • 申请内存(可能触发缺页中断)
  • 访问分页内存
  • 调用任何可能阻塞的函数

我在项目中遇到过一位同事,在自旋锁里调了ExAllocatePoolWithTag,结果系统在压力测试下频繁死锁。查了两天才找到原因——缺页中断导致线程被切换,另一个线程也在等同一个锁,直接死锁。

// 自旋锁使用示例
KSPIN_LOCK mySpinLock;
KLOCK_QUEUE_HANDLE lockHandle;

// 初始化
KeInitializeSpinLock(&mySpinLock);

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

// 临界区代码——必须极短!
mySharedData->counter++;
mySharedData->flags |= FLAG_UPDATED;

// 释放锁
KeReleaseSpinLock(&mySpinLock, lockHandle);
💡 我的习惯:自旋锁保护的代码块,我一般控制在10条指令以内。超过这个量,我就会考虑换用其他同步机制。

5.2 互斥体(Mutex)

互斥体是可等待的同步对象。它和自旋锁最大的区别是——等锁的时候线程会进入等待状态,不占CPU。适合保护那些执行时间较长、或者可能阻塞的临界区。

互斥体有个特点:它支持递归获取。也就是说,同一个线程可以多次获取同一个互斥体而不会死锁。嗯,这里要注意,每次获取都要对应一次释放。

// 互斥体使用示例
KMUTEX myMutex;

// 初始化
KeInitializeMutex(&myMutex, 0);  // 第二个参数0表示非递归

// 等待获取(可以指定超时时间)
NTSTATUS status = KeWaitForSingleObject(
    &myMutex,
    Executive,      // 等待原因
    KernelMode,     // 等待模式
    FALSE,          // 是否可被Alert打断
    NULL            // 超时时间,NULL表示无限等待
);

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

关键区别:互斥体只能在IRQL = PASSIVE_LEVEL下使用。而自旋锁可以在DISPATCH_LEVEL下使用。这是由它们的实现机制决定的。

5.3 快速互斥体(Fast Mutex)

快速互斥体是Windows内核里一个很有意思的存在。它本质上是一个改良版的互斥体,比标准互斥体更快,但功能上有些限制。

我记得第一次看到这个API时还纳闷——为什么叫"快速"?后来看了WRK源码才明白,它内部用了更轻量的同步机制,省掉了一些不必要的检查。

// 快速互斥体使用示例
FAST_MUTEX fastMutex;

// 初始化
ExInitializeFastMutex(&fastMutex);

// 获取
ExAcquireFastMutex(&fastMutex);

// 临界区
sharedBuffer->length = newLength;
RtlCopyMemory(sharedBuffer->data, sourceData, newLength);

// 释放
ExReleaseFastMutex(&fastMutex);
⚠️ 注意:快速互斥体不支持递归获取。同一个线程如果连续获取两次,会直接死锁。我曾经在代码审查时抓到过这种bug——一个函数调用了另一个函数,两个函数都试图获取同一个快速互斥体。

5.4 内核事件(Event)

内核事件是线程间通信的基本工具。一个线程等待事件,另一个线程设置事件来唤醒它。说白了就是"你好了叫我"的机制。

事件有两种类型:

  • 同步事件(Synchronization Event):唤醒一个等待线程后自动复位
  • 通知事件(Notification Event):唤醒所有等待线程,需要手动复位
// 内核事件使用示例
KEVENT completionEvent;

// 初始化(通知事件)
KeInitializeEvent(&completionEvent, NotificationEvent, FALSE);

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

// 线程B:设置事件
KeSetEvent(&completionEvent, IO_NO_INCREMENT, FALSE);

// 手动复位
KeResetEvent(&completionEvent);
💡 避坑指南:我曾经在中断服务例程(ISR)里调用KeSetEvent,结果忘了设置优先级提升参数。导致唤醒的线程迟迟得不到调度,整个系统响应变慢。正确的做法是:在DISPATCH_LEVEL以上调用KeSetEvent时,第二个参数用IO_NO_INCREMENT。

5.5 等待与唤醒机制

Windows内核提供了多种等待函数,最常用的是KeWaitForSingleObject和KeWaitForMultipleObjects。它们可以让线程等待一个或多个对象变为 signaled 状态。

这里有个重要的概念——等待队列。每个同步对象内部都有一个等待队列,记录了哪些线程在等它。当对象变为 signaled 时,内核会从队列中取出线程来唤醒。

// 等待多个对象
KEVENT *events[2];
events[0] = &event1;
events[1] = &event2;

// 等待任意一个对象变为signaled
NTSTATUS status = KeWaitForMultipleObjects(
    2,                  // 对象数量
    (PVOID *)events,    // 对象数组
    WaitAny,            // 等待类型:WaitAny或WaitAll
    Executive,
    KernelMode,
    FALSE,
    NULL,
    NULL
);

if (status == STATUS_WAIT_0) {
    // event1被触发
} else if (status == STATUS_WAIT_1) {
    // event2被触发
}

5.6 死锁预防

死锁是驱动开发中最头疼的问题之一。四个必要条件:互斥、持有并等待、不可剥夺、循环等待。只要破坏其中一个,就能预防死锁。

我个人总结了几个实战经验:

  1. 锁的顺序要固定:如果多个线程需要获取多个锁,必须按相同的顺序获取。我习惯给每个锁编号,代码里按编号从小到大获取。
  2. 尽量用单锁:能用一把锁解决的问题,别用两把。我见过太多为了"性能优化"引入多锁,结果死锁频发的案例。
  3. 避免锁中锁:在持有锁的情况下再去获取另一个锁,这是死锁的高发区。如果实在避免不了,一定要保证获取顺序一致。
  4. 使用超时机制:KeWaitForSingleObject可以指定超时时间。我建议在调试阶段都加上超时,这样死锁时至少能打印日志,而不是系统直接挂死。

我曾经踩过的一个坑:写一个文件过滤驱动时,我在一个回调函数里获取了互斥体A,然后调用了另一个函数,那个函数又去获取互斥体B。另一个线程正好反过来——先拿B再拿A。结果在特定IO路径下,两个线程互相等对方释放锁,系统直接卡死。后来我强制规定:所有锁的获取顺序必须按地址从低到高,才彻底解决了这个问题。

5.7 如何选择同步机制

选哪种同步机制,主要看三个因素:临界区执行时间、当前IRQL、是否需要跨线程通信。

同步机制 适用IRQL 临界区时长 特点
自旋锁 <= DISPATCH_LEVEL 极短(微秒级) 不睡眠,忙等待
互斥体 PASSIVE_LEVEL 较长 可递归,可超时
快速互斥体 PASSIVE_LEVEL 中等 比互斥体快,不可递归
内核事件 <= DISPATCH_LEVEL 不限 用于线程间通信

嗯,最后说一句——同步机制没有银弹。我见过有人不管三七二十一全用自旋锁,结果多核系统上性能惨不忍睹。也见过有人全用互斥体,结果在DISPATCH_LEVEL下直接蓝屏。选对工具,比用好工具更重要。

内核同步机制选择流程图 进入临界区 当前IRQL > PASSIVE_LEVEL ? 使用自旋锁(Spin Lock) 临界区执行时间 是否极短? 是否需要递归获取? 或需要超时等待? 使用互斥体(Mutex) 使用快速互斥体(Fast Mutex) 自旋锁保护极短代码 💡 核心原则:锁的粒度要小,持有时间要短 死锁预防:固定锁获取顺序 + 避免锁中锁 + 调试阶段加超时
公众号:蓝海资料掘金营,微信deep3321