自旋鎖
------------------------------------------------------
自旋鎖是專為防止多處理器並發而引入的一種鎖,它在核心中大量應用於中斷處理等部分(對於單一處理器來說,防止中斷處理中的並發可簡單採用關閉中斷的方式,不需要自旋鎖)。
如果被保護的共用資源只在進程上下文訪問,使用訊號量保護該共用資源非常合適,如果對共巷資源的訪問時間非常短,自旋鎖也可以。但是如果被保護的共用資源需要在中斷上下文訪問(包括底半部即中斷處理控制代碼和頂半部即非強制中斷),就必須使用自旋鎖。
自旋鎖最多隻能被一個核心任務持有,如果一個核心任務試圖請求一個已被爭用(已經被持有)的自旋鎖,那麼這個任務就會一直進行忙迴圈——旋轉——等待鎖重新可用。要是鎖未被爭用,請求它的核心任務便能立刻得到它並且繼續進行。自旋鎖可以在任何時刻防止多於一個的核心任務同時進入臨界區,因此這種鎖可有效地避免多處理器上並發啟動並執行核心任務競爭共用資源。
事實上,自旋鎖的初衷就是:在短期間內進行輕量級的鎖定。一個被爭用的自旋鎖使得請求它的線程在等待鎖重新可用的期間進行自旋(特別浪費處理器時間),所以自旋鎖不應該被持有時間過長。如果需要長時間鎖定的話, 最好使用訊號量。
自旋鎖的基本形式如下:
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位元組對齊。