1. 程式人生 > 其它 >windows核心程式設計之使用者模式下的執行緒同步

windows核心程式設計之使用者模式下的執行緒同步

文章目錄

當所有的執行緒都能夠獨自執行而不需要相互通訊的時候,Microsoft Windows將進入最佳執行狀態。但是,很少有執行緒能夠總是獨自執行。通常建立執行緒是為了處理某些任務,當任務完成的時候,另一個執行緒可能想要得到通知。

系統中所有的執行緒必須訪問系統資源,比如堆、串列埠、檔案、視窗以及無數其他資源。如果一個執行緒獨佔了對某個資源的訪問,那麼其他執行緒就無法完成它們的工作。另一方面,我們也不能讓任何執行緒在任何時刻都能訪問任何資源。設想有一個執行緒正在寫入一塊記憶體,而同時另一個執行緒正在從同一塊記憶體中讀取資料。這就好比是一個人在另一個人讀書的時候修改書中的文字一樣,書中的內容將變得亂七八糟,毫無用處。

在以下兩種基本情況下,執行緒之間需要相互通訊:

  • 需要讓多個執行緒同時訪問一個共享資源,同時不能破壞資源的完整性。
  • 一個執行緒需要通知其他執行緒某項任務已經完成。

執行緒同步包括許多方面,我們會在下面的幾章中進行討論。好訊息是Microsoft Windows提供了許多基礎設施,可以讓執行緒同步變得容易。但壞訊息是我們很難預見一堆執行緒在任一時刻打算做什麼。我們大腦的工作方式不是非同步的,我們習慣一次一步地按次序考慮間題,但這不是多執行緒環境的運作方式。

我最早開始使用多執行緒大概是在192年。一開始,我在編寫程式時犯了許多錯誤,甚至還出版了一些書和雜誌文章,其中不乏與執行緒同步有關的缺陷。現在,我已經比當時熟練得多,雖然還談不上完美,但我相信本書中的一切都不存在缺陷。想要熟練掌握執行緒同步,唯一途徑就是實際使用。在下面幾章中,我們會解釋系統的運作方式,並展示如何以正確的方式線上程間進行同步。現在讓我們面對現實,在積累經驗的過程中我們會犯這樣那樣的錯誤,但這並沒有什麼大不了的。

1. 原子訪問:Interlocked系列函式

執行緒同步的一大部分與原子訪問(atomic access)有關。所謂原子訪問,指的是一個執行緒在訪問某個資源的同時能夠保證沒有其他執行緒會在同一時刻訪問同一資源。現在讓我們來看一個簡單的例子:

// 定義全域性變數
long g_x = 0;

DWORD WINAPI ThreadFunc1(PVOID pvParam){
    g_x++;
    return(0);
}

DWORD WINAPI ThreadFunc2(PVOID pvParam){
    g_x++;
    return(0);
}

程式碼中聲明瞭一個全域性變數gx並將它初始化為0。現在假設我們建立了兩個執行緒,一個執行緒執行ThreadFuncl,另一個執行緒執行ThreadFunc2。這兩個函式中的程式碼完全相同:

它們都把全域性變數gx加1。因此當兩個執行緒都停止執行的時候,我們可能認為gx的值會是2。但真的是這樣嗎?答案是有可能。根據程式碼的編寫方式,我們無法確切地知道gx最終會等於幾,下面就是原因。假設編譯器在編譯將gx遞增的那行程式碼時,生成了下面的彙編程式碼:

image-20210424110145475

我們需要有一種方法能夠保證對一個值的遞增操作是原子操作一—也就是說,不會被打斷。Interlocked 系列函式提供了我們需要的解決方案。雖然這些Interlocked函式非常有用,也很容易理解,但大多數軟體開發人員對它們心存畏懼,並沒有充分地利用它們。所有這些函式會以原子方式來操控一個值。讓我們來看看InterlockedExchangeAdd 以及它用來對LONGLONG型別進行操控的兄弟函式InterlockedExchangeAdd64:

LONG InterlockedExchangeAdd(
  LONG volatile *Addend,
  LONG          Value
);

LONG64 InterlockedExchangeAdd64(
  LONG64 volatile *Addend,
  LONG64          Value
);

還有什麼方法能比這更簡單嗎?只要呼叫這個函式,傳一個長整型變數的地址和另一個增量值,函式就會保證遞增操作是以原子方式進行的。因此我們可以把前面的程式碼改寫成下面的程式碼:

// 定義全域性變數
long g_x = 0;

DWORD WINAPI ThreadFunc1(PVOID pvParam){
    InterlockedExchangeAdd(&g_x,1);
    return(0);
}

DWORD WINAPI ThreadFunc2(PVOID pvParam){
    InterlockedExchangeAdd(&g_x,1);
    return(0);
}

經過這個微小的改動,對息x的遞增會以原子方式進行,我們也因此能夠保證要x最終的值將等於2。注意,如果只想以原子方式給一個值加1的話,也可以使用Interlockedlncrement函式。現在是不是已經感覺好些了?要注意的是,所有執行緒都應該呼叫這些函式來修改共享變數的值,任何一個執行緒都不應該使用簡單的C+語句來修改共享變數:

2. 快取記憶體行

如果想為裝配有多處理器的機器構建高效能應用程式,那麼應該注意快取記憶體行。當CPU從記憶體中讀取一個位元組的時候,它並不只是從記憶體中取回一個位元組,而是取回一個快取記憶體行。快取記憶體行可能包含32位元組(老式CPU),64位元組,甚至是128位元組(取決於CPU),它們始終都對齊到32位元組邊界,64位元組邊界,或128位元組邊界。快取記憶體行存在的目的是為了提高效能。一般來說,應用程式會對一組相鄰的位元組進行操作。如果所有位元組都在快取記憶體中,那麼CPU就不必訪問記憶體匯流排,後者耗費的時間比前者耗費的時間要多得多。

但是,在多處理器環境中,快取記憶體線使得對記憶體的更新變得更加困難。我們可以從下面的例子中體會到這一點。

(1)CPU1讀取一個位元組,這使得該位元組以及與它相鄰的位元組被讀到CPU1的快取記憶體行中。
(2)CPU2讀取同一個位元組,這使得該位元組被讀到CPU2的快取記憶體行中。
(3)CPU1對記憶體中的這個位元組進行修改,這使得該位元組被寫入到CPU1的快取記憶體行中。
但這一資訊還沒有寫回到記憶體。
(4)CPU2再次讀取同一個位元組。由於該位元組已經在CPU2的快取記憶體行中,因此CPU2不需要再訪問記憶體。但CPU2將無法看到該位元組在記憶體中新的值。

這種情形非常糟糕。當然,CPU晶片的設計者非常清楚這個問題,並做了專門的設計來對它進行處理。明確地說,當一個CPU修改了快取記憶體行中的一個位元組時,機器中的其他CPU會收到通知,並使自己的快取記憶體行作廢。因此在剛才的情形中,當CPU1修改該位元組的值時,CPU2的快取記憶體就作廢了。在第4步中,CPU1必須將它的快取記憶體寫回到記憶體中,CPU2必須重新訪問記憶體來填滿它的快取記憶體行。我們可以看到,雖然快取記憶體行能夠提高效能,但在多處理器的機器上它們同樣能夠損傷效能。

最好是始終只讓一個執行緒訪問資料(函式引數和區域性變數是確保這一點的最簡單方式),或者始終只讓一個CPU訪問資料(使用執行緒關係,即thread affinity)。只要能做到其中任何一條,就可以完全避免快取記憶體行的問題了。

3. 高階執行緒同步

如果只需要以原子方式修改一個值,那麼Interlocked系列函式非常好用,我們當然應該優先使用它們。但大多數實際的程式設計問題需要處理的資料結構往往要比一個簡單的32位值或64位值複雜得多。為了能夠以“原子”方式來訪問複雜資料結構,我們必須超越Interlocked系列函式,轉而使用Windows提供的一些其他特性。

前面一節強調了在配備單處理器的機器上不應該使用旋轉鎖,即使在配備多處理器的機器上,在使用旋轉鎖的時候也應該謹慎。原因很簡單,浪費CPU時間是件非常糟糕的事情。因此,我們需要一種機制,它既能讓執行緒等待共享資源的訪問權,又不會浪費CPU時間。當執行緒想要訪問一個共享資源或者想要得到一些“特殊事件”的通知時,執行緒必須呼叫作業系統的一個函式,並將執行緒正在等待的東西作為引數傳入。如果作業系統檢測到資源已經可供使用了,或者特殊事件已經發生了,那麼這個函式會立即返回,這樣執行緒將仍然保持可排程狀態。(執行緒可能並不會立即執行,它是可排程的,系統會根據前一章中描述的規則來給它分配CPU時間。)

如果無法取得對資源的訪問權,或者特殊事件尚未發生,那麼系統會將執行緒切換到等待狀態,使執行緒變得不可排程,從而避免了讓執行緒浪費CPU時間。當執行緒在等待的時候,系統會充當它的代理。系統會記住執行緒想要訪問什麼資源,當資源可供使用的時候,它會自動將執行緒喚醒——執行緒的執行與特殊事件是同步的。

實際情況是,大多數執行緒在大部分情況下都處於等待狀態。當系統檢測到所有執行緒都已經在等待狀態中度過了好幾分鐘的時候,系統的電源管理器將會介入。

需要避免使用的一種方法:

如果沒有同步物件,如果作業系統不能對特殊事件進行監測,那麼執行緒將不得不使用下面介紹的技術來在自己和特殊事件之間進行同步。但是,由於作業系統內建了對執行緒同步的支援,因此我們在任何時候都不應該使用這種方法。

在這種方法中,兩個執行緒共享一個變數,其中一個執行緒不斷地讀取變數的值,直到另一個執行緒完成它的任務為止。

4. 關鍵段

關鍵段(critical section)是一小段程式碼,它在執行之前需要獨佔對一些共享資源的訪問權。這種方式可以讓多行程式碼以“原子方式”來對資源進行操控。這裡的原子方式,指的是程式碼知道除了當前執行緒之外,沒有其他任何執行緒會同時訪問該資源。當然,系統仍然可以暫停當前執行緒去排程其他執行緒。但是,在當前執行緒離開關鍵段之前,系統是不會去排程任何想要訪問同一資源的其他執行緒的。

5. Slim讀/寫鎖

與關鍵段相比,SRWLock缺乏下面兩個特性:

  • 不存在TryEnter(Shared/Exclusive)SRWLock之類的函式:如果鎖已經被佔用,那麼呼叫AcquireSRWLock(Shared/Exclusive)會阻塞呼叫執行緒。
  • 不能遞迴地獲得SRWLOCK。也就是說,一個執行緒不能為了多次寫入資源而多次鎖定資源,然後再多次呼叫ReleaseSRWLock*來釋放對資源的鎖定。

6. 一些有用的竅門和技巧

當我們在使用鎖的時候,比如關鍵段或讀取者/寫入者鎖,應該養成一些良好的習慣並避免一些不太好的做法。下面幾個竅門和技巧會對鎖的使用有所幫助。這些技巧也同樣適用於核心同步物件(會在下一章討論)。

1. 以原子方式操作一組物件時使用一個鎖:

一種常見的情況是多個物件聚在一起會構成一個單獨的“邏輯”資源。例如,每當我們向一個集合中新增元素的時候,可能同時需要對另一個計數器進行更新。為此,無論我們需要對這個邏輯資源進行讀操作還是寫操作,都應該只使用一個鎖。

應用程式中的每個邏輯資源都應該有自己的鎖,用來對邏輯資源的部分和整體的訪問進行同步。我們不應該為所有的邏輯資源都建立單獨的鎖,這是因為如果多個執行緒訪問的是不同的邏輯資源,那麼這樣做會降低可伸縮性:任一時刻系統只允許一個執行緒執行。

2. 同時訪問多個邏輯資源:

有時我們需要同時訪問兩個(或更多個)邏輯資源。例如,應用程式可能需要鎖定一個資源來取出一個元素,同時鎖定另一個資源來把元素加入其中。如果每個資源都有自己的鎖,那麼我們必須使用所有的鎖才能以原子方式完成這個操作。

3.不要長時間佔用鎖:

如果一個鎖被長時間佔用,那麼其他執行緒可能會進入等待狀態,這會影響到應用程式的效能。我們可以用下面這個技巧來把花在關鍵段中的時間降至最低。下面的程式碼會在WM_SOMEMSG訊息被髮送到另一個視窗之前阻止其他執行緒修改gs的值: