驱动研究日记-内核同步
自旋锁
------------------------------------------------------
自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,不需要自旋锁)。
如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共巷资源的访问时间非常短,自旋锁也可以。但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。
自旋锁最多只能被一个内核任务持有,如果一个内核任务试图请求一个已被争用(已经被持有)的自旋锁,那么这个任务就会一直进行忙循环——旋转——等待锁重新可用。要是锁未被争用,请求它的内核任务便能立刻得到它并且继续进行。自旋锁可以在任何时刻防止多于一个的内核任务同时进入临界区,因此这种锁可有效地避免多处理器上并发运行的内核任务竞争共享资源。
事实上,自旋锁的初衷就是:在短期间内进行轻量级的锁定。一个被争用的自旋锁使得请求它的线程在等待锁重新可用的期间进行自旋(特别浪费处理器时间),所以自旋锁不应该被持有时间过长。如果需要长时间锁定的话, 最好使用信号量。
自旋锁的基本形式如下:
spin_lock(&mr_lock);
//临界区
spin_unlock(&mr_lock);
因为自旋锁在同一时刻只能被最多一个内核任务持有,所以一个时刻只有一个线程允许存在于临界区中。这点很好地满足了对称多处理机器需要的锁定服务。在单处理器上,自旋锁仅仅当作一个设置内核抢占的开关。如果内核抢占也不存在,那么自旋锁会在编译时被完全剔除出内核。
简单的说,自旋锁在内核中主要用来防止多处理器中并发访问临界区,防止内核抢占造成的竞争。另外自旋锁不允许任务睡眠(持有自旋锁的任务睡眠会造成自死锁——因为睡眠有可能造成持有锁的内核任务被重新调度,而再次申请自己已持有的锁),它能够在中断上下文中使用。
快速互斥对象
有利的一面,快速互斥在没有实际竞争的情况下可以快速获取和释放。不利的一面,你不能递归获取一个快速互斥对象。即如果你拥有快速互斥对象你就不能发出APC,这意味着你将处于APC_LEVEL或更高的IRQL,在这一级上,线程优先级将失效,但你的代码将不受干扰地执行,除非有硬件中断发生。
表. 内核互斥和快速互斥的比较
内核互斥 | 快速互斥 |
---|---|
可以被单线程递归获取(系统为其维护一个请求计数器) | 不能被递归获取 |
速度慢 | 速度快 |
所有者只能收到“特殊的”内核APC | 所有者不能收到任何APC |
所有者不能被换出内存 | 不自动提升被阻塞线程的优先级(如果运行在大于或等于APC_LEVEL级),除非你使用XxxUnsafe函数并且执行在PASSIVE_LEVEL级上 |
可以是多对象等待的一部分 | 不能作为KeWaitForMultipleObjects的参数使用 |
表. 快速互斥服务函数
服务函数 | 描述 |
---|---|
ExAcquireFastMutex | 获取快速互斥,如果必要则等待 |
ExAcquireFastMutexUnsafe | 获取快速互斥,如果必要则等待,调用者必须先停止接收APC |
ExInitializeFastMutex | 初始化快速互斥对象 |
ExReleaseFastMutex | 释放快速互斥 |
ExReleaseFastMutexUnsafe | 释放快速互斥,不解除APC提交禁止 |
ExTryToAcquireFastMutex | 获取快速互斥,如果可能,立即获取不等待 |
ExAcquireFastMutex等待互斥变成有效状态,然后再把所有权赋给调用线程,最后把处理器当前的IRQL提升到APC_LEVEL。IRQL提升的结果是阻止所有APC的提交。ExAcquireFastMutexUnsafe不改变IRQL。在使用这个“不安全”的函数获取快速互斥前你需要考虑潜在的死锁可能。必须避免运行在同一线程上下文下的APC例程获取同一个互斥或任何其它不能被递归锁定的对象。否则你将冒随时死锁那个线程的风险。
如果你不想在互斥没立即有效的情况下等待,使用“尝试获取”函数:
ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL); BOOLEAN acquired = ExTryToAcquireFastMutex(FastMutex); |
如果返回值为TRUE,则你已经拥有了该互斥。如果为FALSE,表明该互斥已经被别人占有,你不能获取。
为了释放一个快速互斥并允许其它线程请求它,调用适当的释放函数:
ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL); ExReleaseFastMutex(FastMutex); |
或
ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL); ExReleaseFastMutexUnsafe(FastMutex); |
快速互斥之所以快速是因为互斥的获取和释放步骤都为没有竞争的情况做了优化。获取互斥的关键步骤是自动减和测试一个整数计数器,该计数器指出有多少线程占有或等待该互斥。如果测试表明没有其它线程占有该互斥,则没有额外的工作需要做。如果测试表明有其它线程拥有该互斥,则当前线程将阻塞在一个同步事件上,该同步事件是fastmutext对象的一部分。释放互斥时必须自动增并测试计数器。如果测试表明当前没有等待线程,则没有额外的工作要做。如果还有线程在等待,则互斥所有者需调用KeSetEvent函数释放一个等待线程。
互锁运算
在WDM驱动程序能调用的函数中,有一些函数可以以线程安全和多处理器安全的方式执行算术运算。这些例程有两种形式,第一种形式以Interlocked为名字开头,它们可以执行原子操作,其它线程或CPU不能干扰它们的执行。另一种形式以ExInterlocked为名字开头,它们使用自旋锁。
表 互锁运算服务函数
服务函数 描述
InterlockedCompareExchange 比较并有条件地交换两个值
InterlockedDecrement 整数减1
InterlockedExchange 交换两个值
InterlockedExchangeAdd 加两个值并返回和
InterlockedIncrement 整数加1
ExInterlockedAddLargeInteger 向64位整数加
ExInterlockedAddLargeStatistic 向ULONG加
ExInterlockedAddUlong 向ULONG加并返回原始值
ExInterlockedCompareExchange64 交换两个64位值
InterlockedXxx函数可以在任意IRQL上调用;由于该函数不需要自旋锁,所以它们还可以在PASSIVE_LEVEL级上处理分页数据。尽管ExInterlockedXxx函数也可以在任意IRQL上调用,但它们需要在大于或等于DISPATCH_LEVEL级上操作目标数据,所以它们的参数需要在非分页内存中。使用ExInterlockedXxx的唯一原因是,如果你有一个数据变量,且需要增减该变量的值,并且有时还需要用其它指令序列直接访问该变量。你可以在对该变量的多条访问代码周围明确声明自旋锁,然后仅用ExInterlockedXxx函数执行简单的增减操作。
InterlockedXxx函数
InterlockedIncrement向内存中的长整型变量加1,并返回加1后的值:
LONG result = InterlockedIncrement(pLong);
pLong是类型为LONG的变量的地址,概念上,该函数的操作等价于C语句:return *pLong,但它与简单的C语句的不同地方是提供了线程安全和多处理器安全。InterlockedIncrement可以保证整数变量被成功地增1,即使其它CPU上的线程或同一CPU上的其它线程同时尝试改变这个整数的值。就操作本身来说,它不能保证所返回的值仍是该变量当前的值,甚至即使仅仅过了一个机器指令周期,因为一旦这个增1原子操作完成,其它线程或CPU就可能立即修改这个变量。
InterlockedDecrement除了执行减1操作外,其它方面同上。
LONG result = InterlockedDecrement(pLong);
InterlockedCompareExchange函数可以这样调用:
LONG target;
LONG result = InterlockedCompareExchange(&target, newval, oldval);
target是一个类型为LONG的整数,既可以用于函数的输入也可以用于函数的输出,oldval是你对target变量的猜测值,如果这个猜测正确,则newval被装入target。该函数的内部操作与下面C代码类似,但它是以原子方式执行整个操作,即它是线程安全和多处理器安全的:
LONG CompareExchange(PLONG ptarget, LONG newval, LONG oldval)
{
LONG value = *ptarget;
if (value == oldval)
*ptarget = newval;
return value;
}
换句话说,该函数总是返回target变量的历史值给你。此外,如果这个历史值等于oldval,那么它把target的值设置为newval。该函数用原子操作实现比较和交换,而交换仅在历史值猜测正确的情况下才发生。
你还可以调用InterlockedCompareExchangePointer函数来执行类似的比较和交换操作,但该函数使用指针参数。该函数或者定义为编译器内部的内联函数,或者是一个真实的函数,取决于你编译时平台的指针宽度,以及编译器生成内联代码的能力。下面例子中使用了这个指针版本的比较交换函数,它把一个结构加到一个单链表的头部,而不用使用自旋锁或提升IRQL:
typedef struct _SOMESTRUCTURE {
struct _SOMESTRUCTURE* next;
...
} SOMESTRUCTURE, *PSOMESTRUCTURE;
...
void InsertElement(PSOMESTRUCTURE p, PSOMESTRUCTURE* anchor)
{
PSOMESTRUCTURE next, first;
do
{
p->next = first = *anchor;
next = InterlockedCompareExchangePointer(anchor, p, first);
}
while (next != first);
}
每一次循环中,我们都假设新元素将连接到链表的当前头部,即变量first中的地址。然后我们调用InterlockedCompareExchangePointer函数来查看anchor是否仍指向first,即使在过了几纳秒之后。如果是这样,InterlockedCompareExchangePointer将设置anchor,使其指向新元素p。并且如果InterlockedCompareExchangePointer的返回值也与我们的假设一致,则循环终止。如果由于某种原因,anchor不再指向那个first元素(可能被其它并发线程或CPU修改过),我们将发现这个事实并重复循环。
最后一个函数是InterlockedExchange,它使用原子操作替换整数变量的值并返回该变量的历史值:
LONG value;
LONG oldval = InterlockedExchange(&value, newval);
正如你猜到的,还有一个InterlockedExchangePointer函数,它交换指针值(64位或32位,取决于具体平台)。
ExInterlockedXxx函数
每一个ExInterlockedXxx函数都需要在调用前创建并初始化一个自旋锁。注意,这些函数的操作数必须存在于非分页内存中,因为这些函数在提升的IRQL上操作数据。
ExInterlockedAddLargeInteger加两个64位整数并返回被加数的历史值:
LARGE_INTEGER value, increment;
KSPIN_LOCK spinlock;
LARGE_INTEGER prev = ExInterlockedAddLargeInteger(&value, increment, &spinlock);
value是被加数。increment是加数。spinlock是一个已经初始化过的自旋锁。返回值是被加数的历史值。该函数的操作过程与下面代码类似,但除了自旋锁的保护:
__int64 AddLargeInteger(__int64* pvalue, __int64 increment)
{
__int64 prev = *pvalue;
*pvalue = increment;
return prev;
}
注意,并不是所有编译器都支持__int64整型类型,并且不是所有计算机都能用原子指令方式执行64位加操作。
ExInterlockedAddUlong与ExInterlockedAddLargeInteger类似,但它的操作数是32位无符号整数:
ULONG value, increment;
KSPIN_LOCK spinlock;
ULONG prev = ExInterlockedAddUlong(&value, increment, &spinlock);
该函数同样返回被加数的加前值。
ExInterlockedAddLargeStatistic与ExInterlockedAddUlong类似,但它把32位值加到64位值上。该函数在本书出版时还没有在DDK中公开,所以我在这里仅给出它的原型:
VOID ExInterlockedAddLargeStatistic(PLARGE_INTEGER Addend, ULONG Increment);
该函数要比ExInterlockedAddUlong函数快,因为它不需要返回被加数的加前值。因此,它也不需要使用自旋锁来同步。该函数的操作也是原子性的,但仅限于调用同一函数的其它调用者。换句话说,如果你在一个CPU上调用ExInterlockedAddLargeStatistic函数,而同时另一个CPU上的代码正访问Addend变量,那么你将得到不一致的结果。我将用该函数在Intel x86上的执行代码(并不是实际的源代码)来解释这个原因:
mov eax, Addend
mov ecx, Increment
lock add [eax], ecx
lock adc [eax 4], 0
这个代码在低32位没有进位的情况下可以正常工作,但如果存在着进位,那么在ADD和ADC指令之间其它CPU可能进入,如果那个CPU调用的ExInterlockedCompareExchange64函数复制了这个时刻的64位变量值,那么它得到值将是不正确的。即使每个加法指令前都有lock前缀保护其操作的原子性(多CPU之间),但多个这样的指令组成的代码块将无法保持原子性。
链表的互锁访问
Windows NT的executive部件提供了三组特殊的链表访问函数,它们可以提供线程安全的和多处理器安全的链表访问。这些函数支持双链表、单链表,和一种称为S链表(S-List)的特殊单链表。我在前面章中已经讨论过单链表和双链表的非互锁访问。在这里,我将解释这些链表的互锁访问。
如果你需要一个FIFO队列,你应该使用双链表。如果你需要一个线程安全的和多处理器安全的下推栈,你应该使用S链表。为了以线程安全和多处理器安全的方式使用这些链表,你必须为它们分配并初始化一个自旋锁。但S链表并没有真正使用自旋锁。S链表中存在顺序号,内核利用它可以实现比较-交换操作的原子性。
用于互锁访问各种链表对象的函数都十分相似,所以我将以函数的功能来组织这些段。我将解释如何初始化这三种链表,如何向这三种链表中插入元素,如何从这三种链表中删除元素。
初始化
你可以象下面这样初始化这些链表:
LIST_ENTRY DoubleHead;
SINGLE_LIST_ENTRY SingleHead;
SLIST_HEADER SListHead;
InitializeListHead(&DoubleHead);
SingleHead.Next = NULL;
ExInitializeSListHead(&SListHead);
不要忘记为每种链表分配并初始化一个自旋锁。另外,链表头和所有链表元素的存储都必须来自非分页内存,因为支持例程需要在提升的IRQL上访问这些链表。注意,在链表头的初始化过程中不需要使用自旋锁,因为此时不存在竞争。
插入元素
双链表可以在头部或尾部插入元素,但单链表和S链表仅能在头部插入元素:
PLIST_ENTRY pdElement, pdPrevHead, pdPrevTail;
PSINGLE_LIST_ENTRY psElement, psPrevHead;
PKSPIN_LOCK spinlock;
pdPrevHead = ExInterlockedInsertHeadList(&DoubleHead, pdElement, spinlock);
pdPrevTail = ExInterlockedInsertTailList(&DoubleHead, pdElement, spinlock);
psPrevHead = ExInterlockedPushEntryList(&SingleHead, psElement, spinlock);
psPrevHead = ExInterlockedPushEntrySList(&SListHead, psElement, spinlock);
返回值是插入前链表头(或尾)的地址。注意,被插入的链表元素地址是一个链表表项结构的地址,这个地址通常要嵌入到更大的应用结构中,调用CONTAINING_RECORD宏可以获得外围应用结构的地址。
删除元素
你可以从这些链表的头部删除元素:
pdElement = ExInterlockedRemoveHeadList(&DoubleHead, spinlock);
psElement = ExInterlockedPopEntryList(&SingleHead, spinlock);
psElement = ExInterlockedPopEntrySList(&SListHead, spinlock);
如果链表为空则函数的返回值为NULL。你应该先测试返回值是否为NULL,然后再用CONTAINING_RECORD宏取外围应用结构的指针。
IRQL的限制
你只能在低于或等于DISPATCH_LEVEL级上调用S链表函数。只要所有对链表的引用都使用ExInterlockedXxx函数,那么访问双链表和单链表的ExInterlockedXxx函数可以在任何IRQL上调用。这些函数没有IRQL限制的原因是因为它们在执行时都禁止了中断,这就等于把IRQL提升到最高可能的级别。一旦中断被禁止,这些函数就获取你指定的自旋锁。因为此时在同一CPU上没有其它代码能获得控制,并且其它CPU上的代码也不能获取那个自旋锁,所以你的链表是安全的。
注意
--------------------------------------------------------------------------------
DDK文档中关于这条规则的陈述过于严格,它认为所有调用者必须运行在低于或等于你的中断对象DIRQL之下的某个IRQL上。实际上,并不需要所有调用者都在同一IRQL上,同样也不必限制IRQL必须小于或等于DIRQL。
最好在代码的一个部分使用ExInterlockedXxx互锁函数访问单链表或双链表(不包括S链表),在另一部分使用非互锁函数(InsertHeadList等等)。在使用一个非互锁原语前,应该提前获取调用使用的自旋锁。另外,应该低于或等于DISPATCH_LEVEL级访问链表。因为自旋锁不可以递归获得。例如:
// Access list using noninterlocked calls:
VOID Function1()
{
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
KIRQL oldirql;
KeAcquireSpinLock(spinlock, &oldirql);
InsertHeadList(...);
RemoveTailList(...);
...
KeReleaseSpinLock(spinlock, oldirql);
}
// Access list using interlocked calls:
VOID Function2()
{
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
ExInterlockedInsertTailList(..., spinlock);
}
第一个函数必须运行在低于或等于DISPATCH_LEVEL上,因为这里需要调用KeAcquireSpinLock函数。第二个函数的IRQL限定原因是这样的:假定Function1在准备访问链表阶段获取了自旋锁,而获取自旋锁时需要把IRQL暂时提升到DISPATCH_LEVEL级,现在再假定在同一CPU上有一个中断发生在更高级的IRQL上,然后Function2获得了控制,而它又调用了一个ExInterlockedXxx函数,而此时内核正要获取同一个自旋锁,因此CPU将死锁。导致这个问题的原因是允许用同一个自旋锁的代码运行在两个不同的IRQL上:Function1在DISPATCH_LEVEL级上,而Function2在HIGH_LEVEL级上。
共享数据的非互锁访问
如果你要提取一个对齐的数据,那么调用任何一个InterlockedXxx函数就可以正确地做到。支持NT的CPU必然保证你能获得一个首尾一致的值,即使互锁操作发生在数据被提取前后的短暂时间内。然而,如果数据没有对齐,当前的互锁访问也会禁止其它的互锁访问,不至于造成并发访问而取到不一致的值。想象一下,如果有一个整数,其大小跨过了物理内存中的缓冲边界,此时,CPU A想提取这个整数,而CPU B在同一时间要在这个值上执行一个互锁加1操作。那么即将发生的一系列事情可能是:(a) CPU A提取了含有该值高位部分的缓冲线,(b) CPU B执行了一个互锁增1操作并向该值高位部分产生了一个进位,(c) CPU A接着提取了包含该值低位部分的缓冲线。确保这个值不跨过一个缓冲界限可以避免这个问题,但最容易的解决办法是确保该值按其数据类型的自然边界对齐,如ULONG类型按4字节对齐。