天天看點

多線程同步-臨界區(深入了解CRITICAL_SECTION)

深入了解CRITICAL_SECTION

臨界區是一種防止多個線程同時執行一個特定代碼節的機制,這一主題并沒有引起太多關注,因而人們未能對其深刻了解。在需要跟蹤代碼中的多線程處理的性能時,對 

Windows 

中臨界區的深刻了解非常有用。本文深入研究臨界區的原理,以揭示在查找死鎖和确認性能問題過程中的有用資訊。它還包含一個便利的實用工具程式,可以顯示所有臨界區及其目前狀态。

在我們許多年的程式設計實踐中,對于 Win32 臨界區沒有受到非常多的“under the 

hood”關注而感到非常奇怪。當然,您可能了解有關臨界區初始化與使用的基礎知識,但您是否曾經花費時間來深入研究 WINNT.H 中所定義的 

CRITICAL_SECTION 

結構呢?在這一結構中有一些非常有意義的好東西被長期忽略。我們将對此進行補充,并向您介紹一些很有意義的技巧,這些技巧對于跟蹤那些難以察覺的多線程處理錯誤非常有用。更重要的是,使用我們的 

MyCriticalSections 實用工具,可以明白如何對 CRITICAL_SECTION 

進行微小地擴充,以提供非常有用的特性,這些特性可用于調試和性能調整(要下載下傳完整代碼,參見本文頂部的連結)。

老實說,作者們經常忽略 CRITICAL_SECTION 結構的部分原因在于它在以下兩個主要 Win32 代碼庫中的實作有很大不同:Microsoft 

Windows 95 和 Windows NTH嗣侵勒饬街執肟舛家丫⒄鈎龃罅亢笮姹荊ㄆ渥钚擄姹痙直鹞 Windows Me 和 Windows 

XP),但沒有必要在此處将其一一列出。關鍵在于 Windows XP 現在已經發展得非常完善,開發商可能很快就會停止對 Windows 95 

系列作業系統的支援。我們在本文中就是這麼做的。

誠然,當今最受關注的是 Microsoft .NET Framework,但是良好的舊式 Win32 程式設計不會很快消失。如果您擁有采用了臨界區的現有 

Win32 代碼,您會發現我們的工具以及對臨界區的說明都非常有用。但是請注意,我們隻讨論 Windows NT 及其後續版本,而沒有涉及與 .NET 

相關的任何内容,這一點非常重要。

臨界區:簡述

如果您非常熟悉臨界區,并可以不假思索地進行應用,那就可以略過本節。否則,請向下閱讀,以對這些内容進行快速回顧。如果您不熟悉這些基礎内容,則本節之後的内容就沒有太大意義。

臨界區是一種輕量級機制,在某一時間内隻允許一個線程執行某個給定代碼段。通常在修改全局資料(如集合類)時會使用臨界區。事件、多使用者終端執行程式和信号量也用于多線程同步,但臨界區與它們不同,它并不總是執行向核心模式的控制轉換,這一轉換成本昂貴。稍後将會看到,要獲得一個未占用臨界區,事實上隻需要對記憶體做出很少的修改,其速度非常快。隻有在嘗試獲得已占用臨界區時,它才會跳至核心模式。這一輕量級特性的缺點在于臨界區隻能用于對同一程序内的線程進行同步。

臨界區由 WINNT.H 中所定義的 RTL_CRITICAL_SECTION 結構表示。因為您的 C++ 代碼通常聲明一個 

CRITICAL_SECTION 類型的變量,是以您可能對此并不了解。研究 WINBASE.H 後您會發現:

typedef RTL_CRITICAL_SECTION CRITICAL_SECTION;
      

我們将在短時間内揭示 RTL_CRITICAL_SECTION 結構的實質。此時,重要問題在于 CRITICAL_SECTION(也稱作 

RTL_CRITICAL_SECTION)隻是一個擁有易通路字段的結構,這些字段可以由 KERNEL32 API 操作。

在将臨界區傳遞給 InitializeCriticalSection 

時(或者更準确地說,是在傳遞其位址時),臨界區即開始存在。初始化之後,代碼即将臨界區傳遞給 EnterCriticalSection 和 

LeaveCriticalSection API。一個線程自 EnterCriticalSection 中傳回後,所有其他調用 

EnterCriticalSection 的線程都将被阻止,直到第一個線程調用 LeaveCriticalSection 

為止。最後,當不再需要該臨界區時,一種良好的編碼習慣是将其傳遞給 DeleteCriticalSection。

在臨界區未被使用的理想情況中,對 EnterCriticalSection 

的調用非常快速,因為它隻是讀取和修改使用者模式記憶體中的記憶體位置。否則(在後文将會遇到一種例外情況),阻止于臨界區的線程有效地完成這一工作,而不需要消耗額外的 

CPU 

周期。所阻止的線程以核心模式等待,在該臨界區的所有者将其釋放之前,不能對這些線程進行排程。如果有多個線程被阻止于一個臨界區中,當另一線程釋放該臨界區時,隻有一個線程獲得該臨界區。

深入研究:RTL_CRITICAL_SECTION 結構

即使您已經在日常工作中使用過臨界區,您也非常可能并沒有真正了解超出文檔之外的内容。事實上存在着很多非常容易掌握的内容。例如,人們很少知道一個程序的臨界區是儲存于一個連結清單中,并且可以對其進行枚舉。實際上,WINDBG 

支援 !locks 

指令,這一指令可以列出目标程序中的所有臨界區。我們稍後将要談到的實用工具也應用了臨界區這一鮮為人知的特征。為了真正了解這一實用工具如何工作,有必要真正掌握臨界區的内部結構。記着這一點,現在開始研究 

RTL_CRITICAL_SECTION 結構。為友善起見,将此結構列出如下:

struct RTL_CRITICAL_SECTION
{
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread;
HANDLE LockSemaphore;
ULONG_PTR SpinCount;
};
      

以下各段對每個字段進行說明。

DebugInfo 此字段包含一個指針,指向系統配置設定的伴随結構,該結構的類型為 

RTL_CRITICAL_SECTION_DEBUG。這一結構中包含更多極有價值的資訊,也定義于 WINNT.H 中。我們稍後将對其進行更深入地研究。

LockCount 這是臨界區中最重要的一個字段。它被初始化為數值 -1;此數值等于或大于 0 

時,表示此臨界區被占用。當其不等于 -1 時,OwningThread 字段(此字段被錯誤地定義于 WINNT.H 中 — 應當是 DWORD 而不是 

HANDLE)包含了擁有此臨界區的線程 ID。此字段與 (RecursionCount -1) 數值之間的內插補點表示有多少個其他線程在等待獲得該臨界區。

RecursionCount 

此字段包含所有者線程已經獲得該臨界區的次數。如果該數值為零,下一個嘗試擷取該臨界區的線程将會成功。

OwningThread 此字段包含目前占用此臨界區的線程的線程辨別符。此線程 ID 與 

GetCurrentThreadId 之類的 API 所傳回的 ID 相同。

LockSemaphore 

此字段的命名不恰當,它實際上是一個自複位事件,而不是一個信号。它是一個核心對象句柄,用于通知作業系統:該臨界區現在空閑。作業系統在一個線程第一次嘗試獲得該臨界區,但被另一個已經擁有該臨界區的線程所阻止時,自動建立這樣一個句柄。應當調用 

DeleteCriticalSection(它将發出一個調用該事件的 CloseHandle 調用,并在必要時釋放該調試結構),否則将會發生資源洩漏。

SpinCount 僅用于多處理器系統。MSDN 

文檔對此字段進行如下說明:“在多處理器系統中,如果該臨界區不可用,調用線程将在對與該臨界區相關的信号執行等待操作之前,旋轉 dwSpinCount 

次。如果該臨界區在旋轉操作期間變為可用,該調用線程就避免了等待操作。”旋轉計數可以在多處理器計算機上提供更佳性能,其原因在于在一個循環中旋轉通常要快于進入核心模式等待狀态。此字段預設值為零,但可以用 

InitializeCriticalSectionAndSpinCount API 将其設定為一個不同值。

RTL_CRITICAL_SECTION_DEBUG 結構

前面我們注意到,在 RTL_CRITICAL_SECTION 結構内,DebugInfo 字段指向一個 

RTL_CRITICAL_SECTION_DEBUG 結構,該結構給出如下:

struct _RTL_CRITICAL_SECTION_DEBUG
{
WORD   Type;
WORD   CreatorBackTraceIndex;
RTL_CRITICAL_SECTION *CriticalSection;
LIST_ENTRY ProcessLocksList;
DWORD EntryCount;
DWORD ContentionCount;
DWORD Spare[ 2 ];
}
      

這一結構由 InitializeCriticalSection 配置設定和初始化。它既可以由 NTDLL 

内的預配置設定數組配置設定,也可以由程序堆配置設定。RTL_CRITICAL_SECTION 

的這一伴随結構包含一組比對字段,具有迥然不同的角色:有兩個難以了解,随後兩個提供了了解這一臨界區鍊結構的關鍵,兩個是重複設定的,最後兩個未使用。

下面是對 RTL_CRITICAL_SECTION 字段的說明。

Type 此字段未使用,被初始化為數值 0。

CreatorBackTraceIndex 此字段僅用于診斷情形中。在系統資料庫項 

HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution 

Options\YourProgram 之下是 keyfield、GlobalFlag 和 StackTraceDatabaseSizeInMb 

值。注意,隻有在運作稍後說明的 Gflags 指令時才會顯示這些值。這些系統資料庫值的設定正确時,CreatorBackTraceIndex 

字段将由堆棧跟蹤中所用的一個索引值填充。在 MSDN 中搜尋 GFlags 文檔中的短語“create user mode stack trace 

database”和“enlarging the user-mode stack trace database”,可以找到有關這一内容的更多資訊。

CriticalSection 指向與此結構相關的 RTL_CRITICAL_SECTION。圖 

1 說明該基礎結構以及 RTL_CRITICAL_SECTION、RTL_CRITICAL_SECTION_DEBUG 

和事件鍊中其他參與者之間的關系。

多線程同步-臨界區(深入了解CRITICAL_SECTION)

圖 1 臨界區處理流程

ProcessLocksList LIST_ENTRY 是用于表示雙向連結清單中節點的标準 Windows 

資料結構。RTL_CRITICAL_SECTION_DEBUG 包含了連結清單的一部分,允許向前和向後周遊該臨界區。本文後面給出的實用工具說明如何使用 

Flink(前向連結)和 Blink(後向連結)字段在連結清單中的成員之間移動。任何從事過裝置驅動程式或者研究過 Windows 

核心的人都會非常熟悉這一資料結構。

EntryCount/ContentionCount 

這些字段在相同的時間、出于相同的原因被遞增。這是那些因為不能馬上獲得臨界區而進入等待狀态的線程的數目。與 LockCount 和 RecursionCount 

字段不同,這些字段永遠都不會遞減。

Spares 

這兩個字段未使用,甚至未被初始化(盡管在删除臨界區結構時将這些字段進行了清零)。後面将會說明,可以用這些未被使用的字段來儲存有用的診斷值。

即使 RTL_CRITICAL_SECTION_DEBUG 

中包含多個字段,它也是正常臨界區結構的必要成分。事實上,如果系統恰巧不能由程序堆中獲得這一結構的存儲區,InitializeCriticalSection 

将傳回為 STATUS_NO_MEMORY 的 LastError 結果,然後傳回處于不完整狀态的臨界區結構。

臨界區狀态

當程式執行、進入與離開臨界區時,RTL_CRITICAL_SECTION 和 RTL_CRITICAL_SECTION_DEBUG 

結構中的字段會根據臨界區所處的狀态變化。這些字段由臨界區 API 

中的簿記代碼更新,在後面将會看到這一點。如果程式為多線程,并且其線程通路是由臨界區保護的公用資源,則這些狀态就更有意義。

但是,不管代碼的線程使用情況如何,有兩種狀态都會出現。第一種情況,如果 LockCount 字段有一個不等于 -1 

的數值,此臨界區被占用,OwningThread 字段包含擁有該臨界區的線程的線程辨別符。在多線程程式中,LockCount 與 RecursionCount 

聯合表明目前有多少線程被阻止于該臨界區。第二種情況,如果 RecursionCount 是一個大于 1 

的數值,其告知您所有者線程已經重新獲得該臨界區多少次(也許不必要),該臨界區既可以通過調用 EnterCriticalSection、也可以通過調用 

TryEnterCriticalSection 獲得。大于 1 的任何數值都表示代碼的效率可能較低或者可能在以後發生錯誤。例如,通路公共資源的任何 C++ 

類方法可能會不必要地重新進入該臨界區。

注意,在大多數時間裡,LockCount 與 RecursionCount 字段中分别包含其初始值 -1 和 

0,這一點非常重要。事實上,對于單線程程式,不能僅通過檢查這些字段來判斷是否曾獲得過臨界區。但是,多線程程式留下了一些标記,可以用來判斷是否有兩個或多個線程試圖同時擁有同一臨界區。

您可以找到的标記之一是即使在該臨界區未被占用時 LockSemaphore 字段中仍包含一個非零值。這表示:在某一時間,此臨界區阻止了一個或多個線程 — 

事件句柄用于通知該臨界區已被釋放,等待該臨界區的線程之一現在可以獲得該臨界區并繼續執行。因為 OS 

在臨界區阻止另一個線程時自動配置設定事件句柄,是以如果您在不再需要臨界區時忘記将其删除,LockSemaphore 字段可能會導緻程式中發生資源洩漏。

在多線程程式中可能遇到的另一狀态是 EntryCount 和 ContentionCount 

字段包含一個大于零的數值。這兩個字段儲存有臨界區對一個線程進行阻止的次數。在每次發生這一事件時,這兩個字段被遞增,但在臨界區存在期間不會被遞減。這些字段可用于間接确定程式的執行路徑和特性。例如,EntryCount 

非常高時則意味着該臨界區經曆着大量争用,可能會成為代碼執行過程中的一個潛在瓶頸。

在研究一個死鎖程式時,還會發現一種似乎無法進行邏輯解釋的狀态。一個使用非常頻繁的臨界區的 LockCount 字段中包含一個大于 -1 

的數值,也就是說它被線程所擁有,但是 OwningThread 

字段為零(這樣就無法找出是哪個線程導緻問題)。測試程式是多線程的,在單處理器計算機和多處理器計算機中都會出現這種情況。盡管 LockCount 

和其他值在每次運作中都不同,但此程式總是死鎖于同一臨界區。我們非常希望知道是否有任何其他開發人員也遇到了導緻這一狀态的 API 調用序列。

建構一個更好的捕鼠器

在我們學習臨界區的工作方式時,非常偶然地得到一些重要發現,利用這些發現可以得到一個非常好的實用工具。第一個發現是 ProcessLocksList 

LIST_ENTRY 

字段的出現,這使我們想到程序的臨界區可能是可枚舉的。另一個重大發現是我們知道了如何找出臨界區清單的頭。還有一個重要發現是可以在沒有任何損失的情況下寫 

RTL_CRITICAL_SECTION 的 Spare 

字段(至少在我們的所有測試中如此)。我們還發現可以很容易地重寫系統的一些臨界區例程,而不需要對源檔案進行任何修改。

最初,我們由一個簡單的程式開始,其檢查一個程序中的所有臨界區,并列出其目前狀态,以檢視是否擁有這些臨界區。如果擁有,則找出由哪個線程擁有,以及該臨界區阻止了多少個線程?這種做法對于 

OS 的狂熱者們比較适合,但對于隻是希望有助于了解其程式的典型的程式員就不是非常有用了。

即使是在最簡單的控制台模式“Hello World”程式中也存在許多臨界區。其中大部分是由 USER32 或 GDI32 之類的系統 DLL 建立,而這些 

DLL 很少會導緻死鎖或性能問題。我們希望有一種方法能濾除這些臨界區,而隻留下代碼中所關心的那些臨界區。RTL_CRITICAL_SECTION_DEBUG 

結構中的 Spare 字段可以很好地完成這一工作。可以使用其中的一個或兩個來訓示:這些臨界區是來自使用者編寫的代碼,而不是來自 OS。

于是,下一個邏輯問題就變為如何确定哪些臨界區是來自您編寫的代碼。有些讀者可能還記得 Matt Pietrek 2001 年 1 月的 Under The Hood 專欄中的 LIBCTINY.LIB。LIBCTINY 所采用的一個技巧是一個 LIB 

檔案,它重寫了關鍵 Visual C++ 運作時例程的标準實作。将 LIBCTINY.LIB 檔案置于連結器行的其他 LIB 

之前,連結器将使用這一實作,而不是使用 Microsoft 所提供的導入庫中的同名後續版本。

為對臨界區應用類似技巧,我們建立 InitializeCriticalSection 的一個替代版本及其相關導入庫。将此 LIB 檔案置于 

KERNEL32.LIB 之前,連結器将連結我們的版本,而不是 KERNEL32 中的版本。對 InitializeCriticalSection 

的實作顯示在圖 2 中。此代碼在概念上非常簡單。它首先調用 KERNEL32.DLL 中的實際 

InitializeCriticalSection。接下來,它獲得調用 InitializeCriticalSection 的代碼位址,并将其貼至 

RTL_CRITICAL_SECTION_DEBUG 結構的備用字段之一。我們的代碼如何确定調用代碼的位址呢?x86 CALL 

指令将傳回位址置于堆棧中。CriticalSectionHelper 代碼知道該傳回位址位于堆棧幀中一個已知的固定位置。

實際結果是:與 CriticalSectionHelper.lib 正确連結的任何 EXE 或 DLL 都将導入我們的 DLL 

(CriticalSectionHelper.DLL),并占用應用了備用字段的臨界區。這樣就使事情簡單了許多。現在我們的實用工具可以簡單地周遊程序中的所有臨界區,并且隻顯示具有正确填充的備用字段的臨界區資訊。那麼需要為這一實用工具付出什麼代價呢?請稍等,還有更多的内容!

因為您的所有臨界區現在都包含對其進行初始化時的位址,實用工具可以通過提供其初始化位址來識别各個臨界區。原始代碼位址本身沒有那麼有用。幸運的是,DBGHELP.DLL 

使代碼位址向源檔案、行号和函數名稱的轉換變得非常容易。即使一個臨界區中沒有您在其中的簽名,也可以将其位址送出給 

DBGHELP.DLL。如果将其聲明為一個全局變量,并且如果符号可用,則您就可以在原始源代碼中确定臨界區的名稱。順便說明一下,如果通過設定 

_NT_SYMBOL_PATH 環境變量,并設定 DbgHelp 以使用其 Symbol Server 下載下傳功能,進而使 DbgHelp 

發揮其效用,則會得到非常好的結果。

MyCriticalSections 實用工具

我們将所有這些思想結合起來,提出了 MyCriticalSections 程式。MyCriticalSections 

是一個指令行程式,在不使用參數運作該程式時可以看到一些選項:

Syntax: MyCriticalSections <PID> [options]
Options:
/a = all critical sections
/e = show only entered critical sections
/v = verbose
      

唯一需要的參數是 Program ID 或 PID(十進制形式)。可以用多種方法獲得 PID,但最簡單的方法可能就是通過 Task 

Manager。在沒有其他選項時,MyCriticalSections 列出了來自代碼子產品的所有臨界區狀态,您已經将 

CriticalSectionHelper.DLL 

連結至這些代碼子產品。如果有可用于這一(些)子產品的符号,代碼将嘗試提供該臨界區的名稱,以及對其進行初始化的位置。

要檢視 MyCriticalSections 是如何起作用的,請運作 Demo.EXE 程式,該程式包含在下載下傳檔案中。Demo.EXE 

隻是初始化兩個臨界區,并由一對線程進入這兩個臨界區。圖 3 顯示運作“MyCriticalSections 2040”的結果(其中 2040 為 Demo.EXE 的 

PID)。

在該圖中,列出了兩個臨界區。在本例中,它們被命名為 csMain 和 yetAnotherCriticalSection。每個“Address:”行顯示了 

CRITICAL_SECTION 的位址及其名稱。“Initialized in”行包含了在其中初始化 CRITICAL_SECTION 

的函數名。代碼的“Initialized at”行顯示了源檔案和初始化函數中的行号。

對于 csMain 臨界區,您将看到鎖定數為 0、遞歸數為 

1,表示一個已經被一線程獲得的臨界區,并且沒有其他線程在等待該臨界區。因為從來沒有線程被阻止于該臨界區,是以 Entry Count 字段為 0。

現在來看 yetAnotherCriticalSection,會發現其遞歸數為 3。快速浏覽 Demo 代碼可以看出:主線程調用 

EnterCriticalSection 三次,是以事情的發生與預期一緻。但是,還有一個第二線程試圖獲得該臨界區,并且已經被阻止。同樣,LockCount 

字段也為 3。此輸出顯示有一個等待線程。

MyCriticalSections 擁有一些選項,使其對于更為勇敢的探索者非常有用。/v 

開關顯示每個臨界區的更多資訊。旋轉數與鎖定信号字段尤為重要。您經常會看到 NTDLL 和其他 DLL 

擁有一些旋轉數非零的臨界區。如果一個線程在獲得臨界區的過程中曾被鎖定,則鎖定信号字段為非零值。/v 開關還顯示了 

RTL_CRITICAL_SECTION_DEBUG 結構中備用字段的内容。

/a 開關顯示程序中的所有臨界區,即使其中沒有 CriticalSectionHelper.DLL 簽名也會顯示。如果使用 

/a,則請做好有大量輸出的準備。真正的黑客希望同時使用 /a 和 /v,以顯示程序中全部内容的最多細節。使用 /a 的一個小小的好處是會看到 NTDLL 

中的LdrpLoaderLock 臨界區。此臨界區在 DllMain 調用和其他一些重要時間内被占用。LdrpLoaderLock 

是許多不太明顯、表面上難以解釋的死鎖的形成原因之一。(為使 MyCriticalSection 能夠正确标記 LdrpLoaderLock 執行個體,需要用于 

NTDLL 的 PDB 檔案可供使用。)

/e 開關使程式僅顯示目前被占用的臨界區。未使用 /a 開關時,隻顯示代碼中被占用的臨界區(如備用字段中的簽名所訓示)。采用 /a 

開關時,将顯示程序中的全部被占用臨界區,而不考慮其來源。

那麼,希望什麼時候運作 MyCriticalSections 

呢?一個很明确的時間是在程式被死鎖時。檢查被占用的臨界區,以檢視是否有什麼使您驚訝的事情。即使被死鎖的程式正運作于調試器的控制之下,也可以使用 

MyCriticalSections。

另一種使用 MyCriticalSections 的時機是在對有大量多線程的程式進行性能調整時。在阻塞于調試器中的一個使用頻繁、非重入函數時,運作 

MyCriticalSections,檢視在該時刻占用了哪些臨界區。如果有很多線程都執行相同任務,就非常容易導緻一種情形:一個線程的大部分時間被消耗在等待獲得一個使用頻繁的臨界區上。如果有多個使用頻繁的臨界區,這造成的後果就像花園的澆水軟管打了結一樣。解決一個争用問題隻是将問題轉移到下一個容易造成阻塞的臨界區。

一個檢視哪些臨界區最容易導緻争用的好方法是在接近程式結尾處設定一個斷點。在遇到斷點時,運作 MyCriticalSections 并查找具有最大 

Entry Count 值的臨界區。正是這些臨界區導緻了大多數阻塞和線程轉換。

盡管 MyCriticalSections 運作于 Windows 2000 及更新版本,但您仍需要一個比較新的 DbgHelp.DLL 版本 - 5.1 

版或更新版本。Windows XP 中提供這一版本。也可以由其他使用 DbgHelp 的工具中獲得該版本。例如,Debugging Tools For Windows 下載下傳中通常擁有最新的 DbgHelp.DLL。

深入研究重要的臨界區例程

此最後一節是為那些希望了解臨界區實作内幕的勇敢讀者提供的。對 NTDLL 進行仔細研究後可以為這些例程及其支援子例程建立僞碼(見下載下傳中的 

NTDLL(CriticalSections).cpp)。以下 KERNEL32 API 組成臨界區的公共接口:

InitializeCriticalSection
InitializeCriticalSectionAndSpinCount
DeleteCriticalSection
TryEnterCriticalSection
EnterCriticalSection
LeaveCriticalSection
      

前兩個 API 隻是分别圍繞 NTDLL API RtlInitializeCriticalSection 和 

RtlInitializeCriticalSectionAndSpinCount 的瘦包裝。所有剩餘例程都被送出給 NTDLL 中的函數。另外,對 

RtlInitializeCriticalSection 的調用是另一個圍繞 RtlInitializeCriticalSectionAndSpinCount 

調用的瘦包裝,其旋轉數的值為 0。使用臨界區的時候實際上是在幕後使用以下 NTDLL API:

RtlInitializeCriticalSectionAndSpinCount
RtlEnterCriticalSection
RtlTryEnterCriticalSection
RtlLeaveCriticalSection
RtlDeleteCriticalSection
      

在這一讨論中,我們采用 Kernel32 名稱,因為大多數 Win32 程式員對它們更為熟悉。

InitializeCriticalSectionAndSpinCount 對臨界區的初始化非常簡單。RTL_CRITICAL_SECTION 

結構中的字段被賦予其起始值。與此類似,配置設定 RTL_CRITICAL_SECTION_DEBUG 結構并對其進行初始化,将 

RtlLogStackBackTraces 調用中的傳回值賦予 CreatorBackTraceIndex,并建立到前面臨界區的連結。

順便說一聲,CreatorBackTraceIndex 一般接收到的值為 0。但是,如果有 Gflags 和 Umdh 

實用工具,可以輸入以下指令:

Gflags /i MyProgram.exe +ust
Gflags /i MyProgram.exe /tracedb 24
      

這些指令使得 MyProgram 的“Image File Execution Options”下添加了系統資料庫項。在下一次執行 MyProgram 

時會看到此字段接收到一個非 0 數值。有關更多資訊,參閱知識庫文章 Q268343“Umdhtools.exe:How to Use Umdh.exe to Find Memory 

Leaks”。臨界區初始化中另一個需要注意的問題是:前 64 個 RTL_CRITICAL_SECTION_DEBUG 

結構不是由程序堆中配置設定,而是來自位于 NTDLL 内的 .data 節的一個數組。

在完成臨界區的使用之後,對 DeleteCriticalSection(其命名不當,因為它隻删除 RTL_CRITICAL_SECTION_ 

DEBUG)的調用周遊一個同樣可了解的路徑。如果由于線程在嘗試獲得臨界區時被阻止而建立了一個事件,将通過調用 ZwClose 來銷毀該事件。接下來,在通過 

RtlCriticalSectionLock 獲得保護之後(NTDLL 以一個臨界區保護它自己的内部臨界區清單 — 

您猜對了),将調試資訊從鍊中清除,對該臨界區連結清單進行更新,以反映對該資訊的清除操作。該記憶體由空值填充,并且如果其存儲區是由程序堆中獲得,則調用 

RtlFreeHeap 将使得其記憶體被釋放。最後,以零填充 RTL_CRITICAL_SECTION。

有兩個 API 要獲得受臨界區保護的資源 — TryEnterCriticalSection 和 

EnterCriticalSection。如果一個線程需要進入一個臨界區,但在等待被阻止資源變為可用的同時,可執行有用的工作,那麼 

TryEnterCriticalSection 正是您需要的 API。此例程測試此臨界區是否可用;如果該臨界區被占用,該代碼将傳回值 

FALSE,為該線程提供繼續執行另一任務的機會。否則,其作用隻是相當于 EnterCriticalSection。

如果該線程在繼續進行之前确實需要擁有該資源,則使用 EnterCriticalSection。此時,取消用于多處理器計算機的 SpinCount 

測試。這一例程與 TryEnterCriticalSection 類似,無論該臨界區是空閑的或已經被該線程所擁有,都調整對該臨界區的簿記。注意,最重要的 

LockCount 遞增是由 x86“lock”字首完成的,這一點非常重要。這確定了在某一時間内隻有一個 CPU 可以修改該 LockCount 

字段。(事實上,Win32 InterlockedIncrement API 隻是一個具有相同鎖定字首的 ADD 指令。)

如果調用線程無法立即獲得該臨界區,則調用 RtlpWaitForCriticalSection 

将該線程置于等待狀态。在多處理器系統中,EnterCriticalSection 旋轉 SpinCount 

所指定的次數,并在每次循環通路中測試該臨界區的可用性。如果此臨界區在循環期間變為空閑,該線程獲得該臨界區,并繼續執行。

RtlpWaitForCriticalSection 

可能是這裡所給的所有過程中最為複雜、最為重要的一個。這并不值得大驚小怪,因為如果存在一個死鎖并涉及臨界區,則利用調試器進入該程序就可能顯示出 

RtlpWaitForCriticalSection 内 ZwWaitForSingleObject 調用中的至少一個線程。

如僞碼中所顯示,在 RtlpWaitForCriticalSection 中有一點簿記工作,如遞增 EntryCount 和 

ContentionCount 字段。但更重要的是:發出對 LockSemaphore 的等待,以及對等待結果的處理。預設情況是将一個空指針作為第三個參數傳遞給 

ZwWaitForSingleObject 

調用,請求該等待永遠不要逾時。如果允許逾時,将生成調試消息字元串,并再次開始等待。如果不能從等待中成功傳回,就會産生中止該程序的錯誤。最後,在從 

ZwWaitForSingleObject 調用中成功傳回時,則執行從 RtlpWaitForCriticalSection 

傳回,該線程現在擁有該臨界區。

RtlpWaitForCriticalSection 必須認識到的一個臨界條件是該程序正在被關閉,并且正在等待加載程式鎖定 

(LdrpLoaderLock) 臨界區。RtlpWaitForCriticalSection 一定不能 

允許該線程被阻止,但是必須跳過該等待,并允許繼續進行關閉操作。

LeaveCriticalSection 不像 EnterCriticalSection 那樣複雜。如果在遞減 RecursionCount 

之後,結果不為 0(意味着該線程仍然擁有該臨界區),則該例程将以 ERROR_SUCCESS 狀态傳回。這就是為什麼需要用适當數目的 Leave 調用來平衡 

Enter 調用。如果該計數為 0,則 OwningThread 字段被清零,LockCount 被遞減。如果還有其他線程在等待,例如 LockCount 

大于或等于 0,則調用 RtlpUnWaitCriticalSection。此幫助器例程建立 

LockSemaphore(如果其尚未存在),并發出該信号提醒作業系統:該線程已經釋放該臨界區。作為通知的一部分,等待線程之一退出等待狀态,為運作做好準備。

最後要說明的一點是,MyCriticalSections 程式如何确定臨界區鍊的起始呢?如果有權通路 NTDLL 

的正确調試符号,則對該清單的查找和周遊非常簡單。首先,定位符号 RtlCriticalSectionList,清空其内容(它指向第一個 

RTL_CRITICAL_SECTION_DEBUG 結構),并開始周遊。但是,并不是所有的系統都有調試符号,RtlCriticalSectionList 

變量的位址會随 Windows 

的各個版本而發生變化。為了提供一種對所有版本都能正常工作的解決方案,我們設計了以下試探性方案。觀察啟動一個程序時所采取的步驟,會看到是以以下順序對 NTDLL 

中的臨界區進行初始化的(這些名稱取自 NTDLL 的調試符号):

RtlCriticalSectionLock
DeferedCriticalSection (this is the actual spelling!)
LoaderLock
FastPebLock
RtlpCalloutEntryLock
PMCritSect
UMLogCritSect
RtlpProcessHeapsListLock
      

因為檢查程序環境塊 (PEB) 中偏移量 0xA0 

處的位址就可以找到加載程式鎖,是以對該鍊起始位置的定位就變得比較簡單。我們讀取有關加載程式鎖的調試資訊,然後沿着鍊向後周遊兩個連結,使我們定位于 

RtlCriticalSectionLock 項,在該點得到該鍊的第一個臨界區。有關其方法的說明,請參見圖 4。

多線程同步-臨界區(深入了解CRITICAL_SECTION)

圖 4 初始化順序

小結

幾乎所有的多線程程式均使用臨界區。您遲早都會遇到一個使代碼死鎖的臨界區,并且會難以确定是如何進入目前狀态的。如果能夠更深入地了解臨界區的工作原理,則這一情形的出現就不會像首次出現時那樣的令人沮喪。您可以研究一個看來非常含糊的臨界區,并确定是誰擁有它,以及其他有用細節。如果您願意将我們的庫加入您的連結器行,則可以容易地獲得有關您程式臨界區使用的大量資訊。通過利用臨界區結構中的一些未用字段,我們的代碼可以僅隔離并命名您的子產品所用的臨界區,并告知其準确狀态。

有魄力的讀者可以很容易地對我們的代碼進行擴充,以完成更為異乎尋常的工作。例如,采用與 InitializeCriticalSection 

挂鈎相類似的方式截獲 EnterCriticalSection 和 

LeaveCriticalSection,可以存儲最後一次成功獲得和釋放該臨界區的位置。與此類似,CritSect DLL 擁有一個易于調用的 

API,用于枚舉您自己的代碼中的臨界區。利用 .NET Framework 中的 Windows 窗體,可以相對容易地建立一個 GUI 版本的 

MyCriticalSections。對我們代碼進行擴充的可能性非常大,我們非常樂意看到其他人員所發現和創造的創新性辦法。

有關文章,請參閱:

Global Flag Reference:Create kernel mode stack trace 

database

GFlags Examples:Enlarging the User-Mode Stack Trace 

Database

Under the Hood:Reduce EXE and DLL Size with LIBCTINY.LIB

Matt Pietrek 是一位軟體架構師和作者。他就職于 Compuware/NuMega 實驗室,身份為 

BoundsChecker 和“分布式分析器”産品的首席架構師。他已經創作了三本有關 Windows 系統程式設計的書籍,并是 MSDN 

Magazine 的特約編輯。他的 Web 站點 (http://www.wheaty.net) 有關于以前文章和專欄的 FAQ 和資訊。

Jay Hilyard 是 Compuware/NuMega 實驗室的 BoundsChecker 

小組的軟體工程師。他、他的妻子和他們的貓是新罕布什爾州的新居民。他的聯系方式為 [email protected] 或 Web 站點 http://www.smidgeonsoft.com。

附近源代碼:

critical section 稱為代碼關鍵段或者臨界區域,它并不是核心對象,不是屬于作業系統維護的而是屬于程序維護的,用它可以解決多線程同步技術。代碼如下:控制台程式模拟賣票系統

[cpp]  view plain copy

  1. #include <windows.h>  
  2. #include <iostream>  
  3. using namespace  std;  
  4. DWORD WINAPI Fun1Proc(  
  5.                       LPVOID lpParameter   // thread data  
  6.                       );  
  7. DWORD WINAPI Fun2Proc(  
  8.                       LPVOID lpParameter   // thread data  
  9.                       );  
  10. int index=0;  
  11. int tickets=100;  
  12. CRITICAL_SECTION  critical_sec; //定義關鍵區域  
  13. void main()  
  14. {  
  15.     HANDLE hThread1;  
  16.     HANDLE hThread2;  
  17.     InitializeCriticalSection(&critical_sec); //初始化關鍵區域  
  18.     hThread1=CreateThread(NULL,0,Fun1Proc,NULL,0,NULL);  
  19.     hThread2=CreateThread(NULL,0,Fun2Proc,NULL,0,NULL);  
  20.     CloseHandle(hThread1);  
  21.     CloseHandle(hThread2);  
  22.     Sleep(4000);      
  23. }  
  24. DWORD WINAPI Fun1Proc(  
  25.                       LPVOID lpParameter   // thread data  
  26.                       )  
  27. {  
  28.     while(TRUE)  
  29.     {    
  30.         EnterCriticalSection(&critical_sec); //進入關鍵代碼區域  
  31.         if(tickets>0)  
  32.         {  
  33.             Sleep(1);  
  34.             cout<<"thread1 sell ticket : "<<tickets--<<endl;  
  35.         }  
  36.         else  
  37.             break;  
  38.         LeaveCriticalSection(&critical_sec); //離開代碼關鍵區域  
  39.     }  
  40.     return 0;  
  41. }  
  42. DWORD WINAPI Fun2Proc(  
  43.                       LPVOID lpParameter   // thread data  
  44.                       )  
  45. {  
  46.     while(TRUE)  
  47.     {  
  48.         EnterCriticalSection(&critical_sec); //進入關鍵代碼區域  
  49.         if(tickets>0)  
  50.         {  
  51.             Sleep(1);  
  52.             cout<<"thread2 sell ticket : "<<tickets--<<endl;  
  53.         }  
  54.         else  
  55.             break;  
  56.         LeaveCriticalSection(&critical_sec); //離開代碼關鍵區域  
  57.     }  
  58.     return 0;  
  59. }  

主要使用的幾個函數是:InitializeCriticalSection(&critical_sec);

                               EnterCriticalSection(&critical_sec);

                               LeaveCriticalSection(&critical_sec);