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);
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);
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);
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 死锁预防
死锁是驱动开发中最头疼的问题之一。四个必要条件:互斥、持有并等待、不可剥夺、循环等待。只要破坏其中一个,就能预防死锁。
我个人总结了几个实战经验:
- 锁的顺序要固定:如果多个线程需要获取多个锁,必须按相同的顺序获取。我习惯给每个锁编号,代码里按编号从小到大获取。
- 尽量用单锁:能用一把锁解决的问题,别用两把。我见过太多为了"性能优化"引入多锁,结果死锁频发的案例。
- 避免锁中锁:在持有锁的情况下再去获取另一个锁,这是死锁的高发区。如果实在避免不了,一定要保证获取顺序一致。
- 使用超时机制:KeWaitForSingleObject可以指定超时时间。我建议在调试阶段都加上超时,这样死锁时至少能打印日志,而不是系统直接挂死。
我曾经踩过的一个坑:写一个文件过滤驱动时,我在一个回调函数里获取了互斥体A,然后调用了另一个函数,那个函数又去获取互斥体B。另一个线程正好反过来——先拿B再拿A。结果在特定IO路径下,两个线程互相等对方释放锁,系统直接卡死。后来我强制规定:所有锁的获取顺序必须按地址从低到高,才彻底解决了这个问题。
5.7 如何选择同步机制
选哪种同步机制,主要看三个因素:临界区执行时间、当前IRQL、是否需要跨线程通信。
| 同步机制 | 适用IRQL | 临界区时长 | 特点 |
|---|---|---|---|
| 自旋锁 | <= DISPATCH_LEVEL | 极短(微秒级) | 不睡眠,忙等待 |
| 互斥体 | PASSIVE_LEVEL | 较长 | 可递归,可超时 |
| 快速互斥体 | PASSIVE_LEVEL | 中等 | 比互斥体快,不可递归 |
| 内核事件 | <= DISPATCH_LEVEL | 不限 | 用于线程间通信 |
嗯,最后说一句——同步机制没有银弹。我见过有人不管三七二十一全用自旋锁,结果多核系统上性能惨不忍睹。也见过有人全用互斥体,结果在DISPATCH_LEVEL下直接蓝屏。选对工具,比用好工具更重要。