線程的同步
由於同一進程的所有線程共用進程的虛擬位址空間,並且線程的中斷是組合語言級的,所以可能會發生兩個線程同時訪問同一個對象(包括全域變數、共用資源、API函數和MFC對象等)的情況,這有可能導致程式錯誤。屬於不同進程的線程在同時訪問同一記憶體地區或共用資源時,也會存在同樣的問題。因此,在多線程應用程式中,常常需要採取一些措施來同步線程的執行。
需要同步的情況包括以下幾種:
在多個線程同時訪問同一對象時,可能產生錯誤。例如,如果當一個線程正在讀取一個至關重要的共用緩衝區時,另一個線程向該緩衝區寫入資料,那麼程式的運行結果就可能出錯。程式應該盡量避免多個線程同時訪問同一個緩衝區或系統資源。
在
Windows 95環境下編寫多線程應用程式還需要考慮重入問題。Windows NT是真正的32位作業系統,它解決了系統重入問題。而Windows 95由於繼承了Windows 3.x的部分16位代碼,沒能夠解決重入問題。這意味著在Windows 95中兩個線程不能同時執行某個系統功能,否則有可能造成程式錯誤,甚至會造成系統崩潰。應用程式應該盡量避免發生兩個以上的線程同時調用同一個Windows API函數的情況。
由於大小和效能方面的原因,
MFC對象在對象級不是安全執行緒的,只有在類級才是。也就是說,兩個線程可以安全地使用兩個不同的CString對象,但同時使用同一個CString對象就可能產生問題。如果必須使用同一個對象,那麼應該採取適當的同步措施。
多個線程之間需要協調運行。例如,如果第二個線程需要等待第一個線程完成到某一步時才能運行,那麼該線程應該暫時掛起以減少對
CPU的佔用時間,提高程式的執行效率。當第一個線程完成了相應的步驟後,應該發出某種訊號來啟用第二個線程。
關鍵節和互鎖變數訪問
關鍵節 (Critical Seciton) 與 mutex 的功能類似,但它只能由同一進程中的線程使用。關鍵節可以防止共用資源被同時訪問。
進程負責為關鍵節分配記憶體空間,關鍵節實際上是一個CRITICAL_SECTION型的變數,它一次只能被一個線程擁有。線上程使用關鍵節之前,必須調用InitializeCriticalSection函數將其初始化。如果線程中有一段關鍵的代碼不希望被別的線程中斷,那麼可以調用EnterCriticalSection函數來申請關鍵節的所有權,在運行完關鍵代碼後再用LeaveCriticalSection函數來釋放所有權。如果在調用EnterCriticalSection時關鍵節對象已被另一個線程擁有,那麼該函數將無限期等待所有權。
利用互鎖變數可以建立簡單有效同步機制。使用函數InterlockedIncrement和InterlockedDecrement可以增加或減少多個線程共用的一個32位變數的值,並且可以檢查結果是否為0。線程不必擔心會被其它線程中斷而導致錯誤。如果變數位於共用記憶體中,那麼不同進程中的線程也可以使用這種機制。
原子訪問
所謂原子訪問,是指線程在訪問資源時能夠確保所有其他線程都不在同一時間內訪問相同的資源。互鎖的函數家族:
LONG InterlockedExchangeAdd(
PLONG plAddend,
LONG Increment);
這是個最簡單的函數了。只需調用這個函數,傳遞一個長變數地址,並指明將這個值遞增多少即可。但是這個函數能夠保證值的遞增以原子操作方式來完成。
LONG InterlockedExchange(PLONG plTarget, LONG lValue);PVOID InterlockedExchangePointer(PVOID* ppvTarget, PVOID pvValue);
I n t e r l o c k e d E x c h a n g e和I n t e r l o c k e d E x c h a n g e P o i n t e r能夠以原子操作方式用第二個參數中傳遞的值來取代第一個參數中傳遞的當前值。
------------------------------以上為使用者方式同步,以下為核心方式同步---------------------------------
等待函數
等待函數可使線程自願進入等待狀態,直到一個特定的核心對象變為已通知狀態為止。
DWORD WaitForSingleObject(HANDLE hObject,
DWORD dwMilliseconds);
函數Wa i t F o r M u l t i p l e O b j e c t s與Wa i t F o r S i n g l e O b j e c t函數很相似,區別在於它允許調用線程同時查看若干個核心對象的已通知狀態:
DWORD WaitForMultipleObjects(DWORD dwCount, CONST HANDLE* phObjects, BOOL fWaitAll, DWORD dwMilliseconds);
同步對象
同步對象用來協調多線程的執行,它可以被多個線程共用。線程的等待函數用同步對象的控制代碼作為參數,同步對象應該是所有要使用的線程都能訪問到的。同步對象的狀態要麼是有訊號的,要麼是無訊號的。同步對象主要有三種:事件、 mutex 和號誌。
事件對象 (Event) 是最簡單的同步對象,它包括有訊號和無訊號兩種狀態。線上程訪問某一資源之前,也許需要等待某一事件的發生,這時用事件對象最合適。例如,只有在通訊連接埠緩衝區收到資料後,監視線程才被啟用。
事件對象是用 CreateEvent 函數建立的。該函數可以指定事件對象的種類和事件的初始狀態。如果是手工重設事件,那麼它總是保持有訊號狀態,直到用 ResetEvent 函數重設成無訊號的事件。如果是自動重設事件,那麼它的狀態在單個等待線程釋放後會自動變為無訊號的。用 SetEvent 可以把事件對象設定成有訊號狀態。在建立事件時,可以為對象起個名字,這樣其它進程中的線程可以用 OpenEvent 函數開啟指定名字的事件物件控點。
mutex對象的狀態在它不被任何線程擁有時是有訊號的,而當它被擁有時則是無訊號的。mutex對象很適合用來協調多個線程對共用資源的互斥訪問(mutually exclusive)。
線程用 CreateMutex 函數來建立 mutex 對象,在建立 mutex 時,可以為對象起個名字,這樣其它進程中的線程可以用 OpenMutex 函數開啟指定名字的 mutex 物件控點。在完成對共用資源的訪問後,線程可以調用 ReleaseMutex 來釋放 mutex ,以便讓別的線程能訪問共用資源。如果線程終止而不釋放 mutex ,則認為該 mutex 被廢棄。
號誌對象維護一個從 0 開始的計數,在計數值大於 0 時對象是有訊號的,而在計數值為 0 時則是無訊號的。號誌對象可用來限制對共用資源進行訪問的線程數量。線程用 CreateSemaphore 函數來建立號誌對象,在調用該函數時,可以指定對象的初始計數和最大計數。在建立號誌時也可以為對象起個名字,別的進程中的線程可以用 OpenSemaphore 函數開啟指定名字的號誌控制代碼。
一般把號誌的初始計數設定成最大值。每次當號誌有訊號使等待函數返回時,號誌計數就會減 1 ,而調用 ReleaseSemaphore 可以增加號誌的計數。計數值越小就表明訪問共用資源的程式越多。
可用於同步的對象
對象 |
描述 |
變化通知 |
由 FindFirstChangeNotification 函數建立,當在指定目錄中發生指定類型的變化時對象變成有訊號的。 |
控制台輸入 |
在控制台建立是被建立。它是用 CONIN$ 調用 CreateFile 函數返回的控制代碼,或是 GetStdHandle 函數的返回控制代碼。如果控制台輸入緩衝區中有資料,那麼對象是有訊號的,如果緩衝區為空白,則對象是無訊號的。 |
進程 |
當調用 CreateProcess 建立進程時被建立。進程在運行時對象是無訊號的,當進程終止時對象是有訊號的。 |
線程 |
當調用 Createprocess 、 CreateThread 或 CreateRemoteThread 函數建立新線程時被建立。線上程運行是對象是無訊號的,線上程終止時則是有訊號的。 |
另外,有時可以用檔案或通訊裝置作為同步對象使用。
事件核心對象
讓我們觀察一個簡單的例子,以便說明如何使用事件核心對象對線程進行同步。下面就是這個代碼:
// Create a global handle to a manual-reset, nonsignaled event.HANDLE g_hEvent;int WINAPI WinMain(...) { //Create the manual-reset, nonsignaled event. g_hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); //Spawn 3 new threads. HANDLE hThread[3]; DWORD dwThreadID; hThread[0] = _beginthreadex(NULL, 0, WordCount, NULL, 0, &dwThreadID); hThread[1] = _beginthreadex(NULL, 0, SpellCheck, NULL, 0, &dwThreadID); hThread[2] = _beginthreadex(NULL, 0, GrammarCheck, NULL, 0, &dwThreadID); OpenFileAndReadContentsIntoMemory(...); //Allow all 3 threads to access the memory. SetEvent(g_hEvent); ...}DWORD WINAPI WordCount(PVOID pvParam){ //Wait until the file's data is in memory. WaitForSingleObject(g_hEvent, INFINITE); //Access the memory block. ... return(0);}DWORD WINAPI SpellCheck(PVOID pvParam){ //Wait until the file's data is in memory. WaitForSingleObject(g_hEvent, INFINITE); //Access the memory block. ... return(0);}DWORD WINAPI GrammarCheck(PVOID pvParam){ //Wait until the file's data is in memory. WaitForSingleObject(g_hEvent, INFINITE); //Access the memory block. ... return(0);}
當這個進程啟動時,它建立一個人工重設的未通知狀態的事件,並且將控制代碼儲存在一個全域變數中。這使得該進程中的其他線程能夠非常容易地訪問同一個事件對象。現在3個線程已經產生。這些線程要等待檔案的內容讀入記憶體,然後每個線程都要訪問它的資料。一個線程進行單詞計數,另一個線程運行拼字檢查器,第三個線程運行語法檢查器。這3個線程函數的代碼的開始部分都相同,每個函數都調用Wa i t F o r S i n g l e O b j e c t,這將使線程暫停運行,直到檔案的內容由主線程讀入記憶體為止。
一旦主線程將資料準備好,它就調用S e t E v e n t,給事件發出通知訊號。這時,系統就使所有這3個輔助線程進入可調度狀態,它們都獲得了C P U時間,並且可以訪問記憶體塊。注意,這3個線程都以唯讀方式訪問記憶體。這就是所有3個線程能夠同時啟動並執行唯一原因。還要注意,如何電腦上配有多個C P U,那麼所有3個線程都能夠真正地同時運行,從而可以在很短的時間內完成大量的操作。
如果你使用自動重設的事件而不是人工重設的事件,那麼應用程式的行為特性就有很大的差別。當主線程調用S e t E v e n t之後,系統只允許一個輔助線程變成可調度狀態。同樣,也無法保證系統將使哪個線程變為可調度狀態。其餘兩個輔助線程將繼續等待。
已經變為可調度狀態的線程擁有對記憶體塊的獨佔訪問權。讓我們重新編寫線程的函數,使得每個函數在返回前調用S e t E v e n t函數(就像Wi n M a i n函數所做的那樣)。這些線程函數現在變成下面的形式:
DWORD WINAPI WordCount(PVOID pvParam){ //Wait until the file's data is in memory. WaitForSingleObject(g_hEvent, INFINITE); //Access the memory block. ... SetEvent(g_hEvent); return(0);}DWORD WINAPI SpellCheck(PVOID pvParam) { //Wait until the file's data is in memory. WaitForSingleObject(g_hEvent, INFINITE); //Access the memory block. ... SetEvent(g_hEvent); return(0);}DWORD WINAPI GrammarCheck(PVOID pvParam){ //Wait until the file's data is in memory. WaitForSingleObject(g_hEvent, INFINITE); //Access the memory block. ... SetEvent(g_hEvent); return(0);}
當線程完成它對資料的專門傳遞時,它就調用S e t E v e n t函數,該函數允許系統使得兩個正在等待的線程中的一個成為可調度線程。同樣,我們不知道系統將選擇哪個線程作為可調度線程,但是該線程將進行它自己的對記憶體塊的專門傳遞。當該線程完成操作時,它也將調用S e t E v e n t函數,使第三個即最後一個線程進行它自己的對記憶體塊的傳遞。注意,當使用自動重設事件時,如果每個輔助線程均以讀/寫方式訪問記憶體塊,那麼就不會產生任何問題,這些線程將不再被要求將資料視為唯讀資料。
等待定時器核心對象
等待定時器是在某個時間或按規定的間隔時間發出自己的訊號通知的核心對象。它們通常用來在某個時間執行某個操作。
若要建立等待定時器,只需要調用C r e a t e Wa i t a b l e Ti m e r函數:
HANDLE CreateWaitableTimer( PSECURITY_ATTRIBUTES psa, BOOL fManualReset, PCTSTR pszName);
進程可以獲得它自己的與進程相關的現有等待定時器的控制代碼,方法是調用O p e n Wa i t a b l e Ti m e r函數:
HANDLE OpenWaitableTimer( DWORD dwDesiredAccess, BOOL bInheritHandle, PCTSTR pszName);
當發出人工重設的定時器訊號通知時,等待該定時器的所有線程均變為可調度線程。當發出自動重設的定時器訊號通知時,只有一個等待的線程變為可調度線程。
等待定時器對象總是在未通知狀態中建立。必須調用S e t Wa i t a b l e Ti m e r函數來告訴定時器你想在何時讓它成為已通知狀態:
BOOL SetWaitableTimer( HANDLE hTimer, const LARGE_INTEGER *pDueTime, LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine, PVOID pvArgToCompletionRoutine, BOOL fResume);
定時器函數外,最後還有一個C a n c e l Wa i t a b l e Ti m e r函數:
BOOL CancelWaitableTimer(HANDLE hTimer);
這個簡單的函數用於取出定時器的控制代碼並將它撤消,這樣,除非接著調用S e t Wa i t a b l e Ti m e r函數以便重新設定定時器,否則定時器決不會進行報時。
信標核心對象
信標核心對象用於對資源進行計數。它們與所有核心對象一樣,包含一個使用數量,但是它們也包含另外兩個帶符號的3 2位值,一個是最大資源數量,一個是當前資源數量。最大資源數量用於標識信標能夠控制的資源的最大數量,而當前資源數量則用於標識當前可以使用的資源的數量。
信標的使用規則如下:
• 如果當前資源的數量大於0,則發出信標訊號。
• 如果當前資源數量是0,則不發出信標訊號。
• 系統決不允許當前資源的數量為負值。
• 當前資源數量決不能大於最大資源數量。
下面的函數用於建立信標核心對象:
HANDLE CreateSemaphore( PSECURITY_ATTRIBUTE psa, LONG lInitialCount, LONG lMaximumCount, PCTSTR pszName);
通過調用O p e n S e m a p h o r e函數,另一個進程可以獲得它自己的進程與現有信標相關的控制代碼:
HANDLE OpenSemaphore( DWORD fdwAccess, BOOL bInheritHandle, PCTSTR pszName);
通過調用R e l e a s e S e m a p h o r e函數,線程就能夠對信標的當前資源數量進行遞增:
BOOL ReleaseSemaphore( HANDLE hsem, LONG lReleaseCount, PLONG plPreviousCount);
互斥對象核心對象
互斥對象(m u t e x)核心對象能夠確保線程擁有對單個資源的互斥訪問權。
互斥對象有許多用途,屬於最常用的核心對象之一。通常來說,它們用於保護由多個線程訪問的記憶體塊。如果多個線程要同時訪問記憶體塊,記憶體塊中的資料就可能遭到破壞。互斥對象能夠保證訪問記憶體塊的任何線程擁有對該記憶體塊的獨佔訪問權,這樣就能夠保證資料的完整性。
互斥對象的使用規則如下:
• 如果線程I D是0(這是個無效I D),互斥對象不被任何線程所擁有,並且發出該互斥對象的通知訊號。
• 如果I D是個非0數字,那麼一個線程就擁有互斥對象,並且不發出該互斥對象的通知訊號。
• 與所有其他核心對象不同, 互斥對象在作業系統中擁有特殊的代碼,允許它們違反正常的規則(後面將要介紹這個異常情況)。
若要使用互斥對象,必須有一個進程首先調用C r e a t e M u t e x,以便建立互斥對象:
HANDLE CreateMutex( PSECURITY_ATTRIBUTES psa, BOOL fInitialOwner, PCTSTR pszName);
通過調用O p e n M u t e x,另一個進程可以獲得它自己進程與現有互斥對象相關的控制代碼:
HANDLE OpenMutex( DWORD fdwAccess, BOOL bInheritHandle, PCTSTR pszName);
一旦線程成功地等待到一個互斥對象,該線程就知道它已經擁有對受保護資源的獨佔訪問權。試圖訪問該資源的任何其他線程(通過等待相同的互斥對象)均被置於等待狀態中。當目前擁有對資源的訪問權的線程不再需要它的訪問權時,它必須調用R e l e a s e M u t e x函數來釋放該互斥對象:
BOOL ReleaseMutex(HANDLE hMutex);
該函數將對象的遞迴計數器遞減1。
互斥對象與關鍵程式碼片段的比較
就等待線程的調度而言,互斥對象與關鍵程式碼片段之間有著相同的特性。但是它們在其他屬性方面卻各不相同。表9 - 1對它們進行了各方面的比較。
表9-1 互斥對象與關鍵程式碼片段的比較
特性 |
互斥對象 |
關鍵程式碼片段 |
運行速度 |
慢 |
快 |
是否能夠跨進程邊界來使用 |
是 |
否 |
聲明 |
HANDLE hmtx; |
CRITICAL_SECTION cs; |
初始化 |
h m t x = C r e a t e M u t e x(N U L L,FA L S E,N U L L); |
I n i t i a l i z e C r i t i c a l S e c t i o n ( & e s ); |
清除 |
C l o s e H a n d l e(h m t x); |
D e l e t e C r i t i c a l S e c t i o n(& c s); |
無限等待 |
Wa i t F o r S i n g l e O b j e c t(h m t x , I N F I N I T E); |
E n t e r C r i t i c a l S e c t i o n(& c s); |
0等待 |
Wa i t F o r S i n g l e O b j e c t Tr y(h m t x , 0); |
E n t e r C r i t i c a l S e c t i o n(& c s); |
任意等待 |
Wa i t F o r S i n g l e O b j e c t(h m t x , d w M i l l i s e c o n d s); |
不能 |
釋放 |
R e l e a s e M u t e x(h m t x); |
L e a v e C r i t i c a l S e c t i o n(& c s); |
是否能夠等待其他核心對象 |
是(使用Wa i t F o r M u l t i p l e O b j e c t s或類似的函數) |
否 |
線程同步對象速查表
核心對象與線程同步之間的相互關係
對象 |
何時處於未通知狀態 |
何時處於已通知狀態 |
成功等待的副作用 |
進程 |
當進程仍然活動時 |
當進程終止運行時(E x i t P r o c e s s,Te r m i n a t e P r o c e s s) |
無 |
線程 |
當線程仍然活動時 |
當線程終止運行時(E x i t T h r e a d,Te r m i n a t e T h r e a d) |
無 |
作業 |
當作業的時間尚未結束時 |
當作業的時間已經結束時 |
無 |
檔案 |
當I / O請求正在處理時 |
當I / O請求處理完畢時 |
無 |
控制台輸入 |
不存在任何輸入 |
當存在輸入時 |
無 |
檔案修改通知 |
沒有任何檔案被修改 |
當檔案系統發現修改時 |
重設通知 |
自動重設事件 |
R e s e t E v e n t , P u l s e - E v e n t或等待成功 |
當調用S e t E v e n t / P u l s e E v e n t時 |
重設事件 |
人工重設事件 |
R e s e t E v e n t或P u l s e E v e n t |
當調用S e t E v e n t / P u l s e E v e n t時 |
無 |
自動重設等待定時器 |
C a n c e l Wa i t a b l e Ti m e r或等待成功 |
當時間到時(S e t Wa i t a b l e Ti m e r) |
重設定時器 |
人工重設等待定時器 |
C a n c e l Wa i t a b l e Ti m e r |
當時間到時(S e t Wa i t a b l e Ti m e r) |
無 |
信標 |
等待成功 |
當數量> 0時(R e l e a s e S e m a p h o r e) |
數量遞減1 |
互斥對象 |
等待成功 |
當未被線程擁有時(R e l e a s e互斥對象) |
將所有權賦予線程 |
關鍵程式碼片段(使用者方式) |
等待成功((Tr y)E n t e r C r i t i c a l S e c t i o n) |
當未被線程擁有時(L e a v e C r i t i c a l S e c t i o n) |
將所有權賦予線程 |
其他的線程同步函數
1 非同步裝置I / O使得線程能夠啟動一個讀操作或寫操作,但是不必等待讀操作或寫操作完成。例如,如果線程需要將一個大檔案裝入記憶體,那麼該線程可以告訴系統將檔案裝入記憶體。然後,當系統載入該檔案時,該線程可以忙於執行其他任務,如建立視窗、對內部資料結構進行初始化等等。當初始化操作完成時,該線程可以終止自己的運行,等待系統通知它檔案已經讀取。
2 線程也可以調用Wa i t F o r I n p u t I d l e來終止自己的運行:
DWORD WaitForInputIdle( HANDLE hProcess, DWORD dwMilliseconds);
該函數將一直處於等待狀態,直到h P r o c e s s標識的進程在建立應用程式的第一個視窗的線程中已經沒有尚未處理的輸入為止。這個函數可以用於父進程。父進程產生子進程,以便執行某些操作。
3 線程可以調用M s g Wa i t F o r M u l t i p l e O b j e c t s或M s g Wa i t F o r M u l t i p l e O b j e c t s E x函數,讓線程等待它自己的訊息:
DWORD MsgWaitForMultipleObjects( DWORD dwCount, PHANDLE phObjects, BOOL fWaitAll, DWORD dwMilliseconds, DWORD dwWakeMask);DWORD MsgWaitForMultipleObjectsEx( DWORD dwCount, PHANDLE phObjects, DWORD dwMilliseconds, DWORD dwWakeMask, DWORD dwFlags);
這些函數與Wa i t F o r M u l t i p l e O b j e c t s函數十分相似。差別在於它們允許線程在核心對象變成已通知狀態或視窗訊息需要調度到調用線程建立的視窗中時被調度。
4 Wi n d o w s將非常出色的調試支援特性內建於作業系統之中。當偵錯工具啟動運行時,它將自己附加給一個被偵錯工具。該偵錯工具只需閑置著,等待作業系統將與被偵錯工具相關的調試事件通知它。偵錯工具通過調用Wa i t F o r D e b u g E v e n t函數來等待這些事件的發生:
BOOL WaitForDebugEvent( PDEBUG_EVENT pde, DWORD dwMilliseconds);
當偵錯工具調用該函數時,偵錯工具的線程終止運行,系統將調試事件已經發生的情況通知偵錯工具,方法是允許調用的Wa i t F o r D e b u g E v e n t函數返回。
5 S i n g l e O b j e c t A n d Wa i t函數用於在單個原子方式的操作中發出關於核心對象的通知並等待另一個核心對象:
DWORD SingleObjectAndWait( HANDLE hObjectToSignal, HANDLE hObjectToWaitOn, DWORD dwMilliseconds, BOOL fAlertable);