深入淺出Win32多線程程序設計之線程通信
線程之間通信的兩個基本問題是互斥和同步。
線程同步是指線程之間所具有的一種制約關係,一個線程的執行依賴另一個線程的消息,當它沒有得到另一個線程的消息時應等待,直到消息到達時才被喚醒。
線程互斥是指對於共享的操作系統資源(指的是廣義的"資源",而不是Windows的.res文件,譬如全局變量就是一種共享資源),在各線程訪問時的排它性。當有若干個線程都要使用某一共享資源時,任何時刻最多隻允許一個線程去使用,其它要使用該資源的線程必須等待,直到佔用資源者釋放該資源。
線程互斥是一種特殊的線程同步。
實際上,互斥和同步對應着線程間通信發生的兩種情況:
(1)當有多個線程訪問共享資源而不使資源被破壞時;
(2)當一個線程需要將某個任務已經完成的情況通知另外一個或多個線程時。
在WIN32中,同步機制主要有以下幾種:
(1)事件(Event);
(2)信號量(semaphore);
(3)互斥量(mutex);
(4)臨界區(Critical section)。
全局變量
因爲進程中的所有線程均可以訪問所有的全局變量,因而全局變量成爲Win32多線程通信的最簡單方式。例如:
int var; //全局變量 UINT ThreadFunction(LPVOIDpParam) { var = 0; while (var < MaxValue) { //線程處理 ::InterlockedIncrement(long*) &var); } return 0; } 請看下列程序: int globalFlag = false; DWORD WINAPI ThreadFunc(LPVOID n) { Sleep(2000); globalFlag = true; return 0; } int main() { HANDLE hThrd; DWORD threadId; hThrd = CreateThread(NULL, 0, ThreadFunc, NULL, 0, &threadId); if (hThrd) { printf("Thread launched/n"); CloseHandle(hThrd); } while (!globalFlag) ; printf("exit/n"); } |
上述程序中使用全局變量和while循環查詢進行線程間同步,實際上,這是一種應該避免的方法,因爲:
(1)當主線程必須使自己與ThreadFunc函數的完成運行實現同步時,它並沒有使自己進入睡眠狀態。由於主線程沒有進入睡眠狀態,因此操作系統繼續爲它調度C P U時間,這就要佔用其他線程的寶貴時間週期;
(2)當主線程的優先級高於執行ThreadFunc函數的線程時,就會發生globalFlag永遠不能被賦值爲true的情況。因爲在這種情況下,系統決不會將任何時間片分配給ThreadFunc線程。
事件
事件(Event)是WIN32提供的最靈活的線程間同步方式,事件可以處於激發狀態(signaled or true)或未激發狀態(unsignal or false)。根據狀態變遷方式的不同,事件可分爲兩類:
(1)手動設置:這種對象只可能用程序手動設置,在需要該事件或者事件發生時,採用SetEvent及ResetEvent來進行設置。
(2)自動恢復:一旦事件發生並被處理後,自動恢復到沒有事件狀態,不需要再次設置。
創建事件的函數原型爲:
HANDLE CreateEvent( LPSECURITY_ATTRIBUTES lpEventAttributes, // SECURITY_ATTRIBUTES結構指針,可爲NULL BOOL bManualReset, // 手動/自動 // TRUE:在WaitForSingleObject後必須手動調用ResetEvent清除信號 // FALSE:在WaitForSingleObject後,系統自動清除事件信號 BOOL bInitialState, //初始狀態 LPCTSTR lpName //事件的名稱 ); |
使用"事件"機制應注意以下事項:
(1)如果跨進程訪問事件,必須對事件命名,在對事件命名的時候,要注意不要與系統命名空間中的其它全局命名對象衝突;
(2)事件是否要自動恢復;
(3)事件的初始狀態設置。
由於event對象屬於內核對象,故進程B可以調用OpenEvent函數通過對象的名字獲得進程A中event對象的句柄,然後將這個句柄用於ResetEvent、SetEvent和WaitForMultipleObjects等函數中。此法可以實現一個進程的線程控制另一進程中線程的運行,例如:
HANDLE hEvent=OpenEvent(EVENT_ALL_ACCESS,true,"MyEvent"); ResetEvent(hEvent); |
定義臨界區變量
CRITICAL_SECTION gCriticalSection; |
通常情況下,CRITICAL_SECTION結構體應該被定義爲全局變量,以便於進程中的所有線程方便地按照變量名來引用該結構體。
初始化臨界區
VOID WINAPI InitializeCriticalSection( LPCRITICAL_SECTION lpCriticalSection //指向程序員定義的CRITICAL_SECTION變量 ); |
該函數用於對pcs所指的CRITICAL_SECTION結構體進行初始化。該函數只是設置了一些成員變量,它的運行一般不會失敗,因此它採用了VOID類型的返回值。該函數必須在任何線程調用EnterCriticalSection函數之前被調用,如果一個線程試圖進入一個未初始化的CRTICAL_SECTION,那麼結果將是很難預計的。
刪除臨界區
VOID WINAPI DeleteCriticalSection( LPCRITICAL_SECTION lpCriticalSection //指向一個不再需要的CRITICAL_SECTION變量 ); |
進入臨界區
VOID WINAPI EnterCriticalSection( LPCRITICAL_SECTION lpCriticalSection //指向一個你即將鎖定的CRITICAL_SECTION變量 ); |
離開臨界區
VOID WINAPI LeaveCriticalSection( LPCRITICAL_SECTION lpCriticalSection //指向一個你即將離開的CRITICAL_SECTION變量 ); |
使用臨界區編程的一般方法是:
void UpdateData() { EnterCriticalSection(&gCriticalSection); ...//do something LeaveCriticalSection(&gCriticalSection); } |
關於臨界區的使用,有下列注意點:
(1)每個共享資源使用一個CRITICAL_SECTION變量;
(2)不要長時間運行關鍵代碼段,當一個關鍵代碼段長時間運行時,其他線程就會進入等待狀態,這會降低應用程序的運行性能;
(3)如果需要同時訪問多個資源,則可能連續調用EnterCriticalSection;
(4)Critical Section不是OS核心對象,如果進入臨界區的線程"掛"了,將無法釋放臨界資源。這個缺點在Mutex中得到了彌補。
互斥
互斥量的作用是保證每次只能有一個線程獲得互斥量而得以繼續執行,使用CreateMutex函數創建:
HANDLE CreateMutex( LPSECURITY_ATTRIBUTES lpMutexAttributes, // 安全屬性結構指針,可爲NULL BOOL bInitialOwner, //是否佔有該互斥量,TRUE:佔有,FALSE:不佔有 LPCTSTR lpName //信號量的名稱 ); |
Mutex是核心對象,可以跨進程訪問,下面的代碼給出了從另一進程訪問命名Mutex的例子:
HANDLE hMutex; hMutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, L"mutexName"); if (hMutex){ … } else{ … } |
相關API:
BOOL WINAPI ReleaseMutex( HANDLE hMutex ); |
使用互斥編程的一般方法是:
void UpdateResource() { WaitForSingleObject(hMutex,…); ...//do something ReleaseMutex(hMutex); } |
互斥(mutex)內核對象能夠確保線程擁有對單個資源的互斥訪問權。互斥對象的行爲特性與臨界區相同,但是互斥對象屬於內核對象,而臨界區則屬於用戶方式對象,因此這導致mutex與Critical Section的如下不同:
(1) 互斥對象的運行速度比關鍵代碼段要慢;
(2) 不同進程中的多個線程能夠訪問單個互斥對象;
(3) 線程在等待訪問資源時可以設定一個超時值。
下圖更詳細地列出了互斥與臨界區的不同:
信號量是維護0到指定最大值之間的同步對象。信號量狀態在其計數大於0時是有信號的,而其計數是0時是無信號的。信號量對象在控制上可以支持有限數量共享資源的訪問。
信號量的特點和用途可用下列幾句話定義:
(1)如果當前資源的數量大於0,則信號量有效;
(2)如果當前資源數量是0,則信號量無效;
(3)系統決不允許當前資源的數量爲負值;
(4)當前資源數量決不能大於最大資源數量。
創建信號量
HANDLE CreateSemaphore ( PSECURITY_ATTRIBUTE psa, LONG lInitialCount, //開始時可供使用的資源數 LONG lMaximumCount, //最大資源數 PCTSTR pszName); |
釋放信號量
通過調用ReleaseSemaphore函數,線程就能夠對信標的當前資源數量進行遞增,該函數原型爲:
BOOL WINAPI ReleaseSemaphore( HANDLE hSemaphore, LONG lReleaseCount, //信號量的當前資源數增加lReleaseCount LPLONG lpPreviousCount ); |
打開信號量
和其他核心對象一樣,信號量也可以通過名字跨進程訪問,打開信號量的API爲:
HANDLE OpenSemaphore ( DWORD fdwAccess, BOOL bInherithandle, PCTSTR pszName ); |
互鎖訪問
當必須以原子操作方式來修改單個值時,互鎖訪問函數是相當有用的。所謂原子訪問,是指線程在訪問資源時能夠確保所有其他線程都不在同一時間內訪問相同的資源。
請看下列代碼:
int globalVar = 0; DWORD WINAPI ThreadFunc1(LPVOID n) { globalVar++; return 0; } DWORD WINAPI ThreadFunc2(LPVOID n) { globalVar++; return 0; } |
運行ThreadFunc1和ThreadFunc2線程,結果是不可預料的,因爲globalVar++並不對應着一條機器指令,我們看看globalVar++的反彙編代碼:
00401038 mov eax,[globalVar (0042d3f0)] 0040103D add eax,1 00401040 mov [globalVar (0042d3f0)],eax |
在"mov eax,[globalVar (0042d3f0)]" 指令與"add eax,1" 指令以及"add eax,1" 指令與"mov [globalVar (0042d3f0)],eax"指令之間都可能發生線程切換,使得程序的執行後globalVar的結果不能確定。我們可以使用InterlockedExchangeAdd函數解決這個問題:
int globalVar = 0; DWORD WINAPI ThreadFunc1(LPVOID n) { InterlockedExchangeAdd(&globalVar,1); return 0; } DWORD WINAPI ThreadFunc2(LPVOID n) { InterlockedExchangeAdd(&globalVar,1); return 0; } |
InterlockedExchangeAdd保證對變量globalVar的訪問具有"原子性"。互鎖訪問的控制速度非常快,調用一個互鎖函數的CPU週期通常小於50,不需要進行用戶方式與內核方式的切換(該切換通常需要運行1000個CPU週期)。
互鎖訪問函數的缺點在於其只能對單一變量進行原子訪問,如果要訪問的資源比較複雜,仍要使用臨界區或互斥。
可等待定時器
可等待定時器是在某個時間或按規定的間隔時間發出自己的信號通知的內核對象。它們通常用來在某個時間執行某個操作。
創建可等待定時器
HANDLE CreateWaitableTimer( PSECURITY_ATTRISUTES psa, BOOL fManualReset,//人工重置或自動重置定時器 PCTSTR pszName); |
設置可等待定時器
可等待定時器對象在非激活狀態下被創建,程序員應調用 SetWaitableTimer函數來界定定時器在何時被激活:
BOOL SetWaitableTimer( HANDLE hTimer, //要設置的定時器 const LARGE_INTEGER *pDueTime, //指明定時器第一次激活的時間 LONG lPeriod, //指明此後定時器應該間隔多長時間激活一次 PTIMERAPCROUTINE pfnCompletionRoutine, PVOID PvArgToCompletionRoutine, BOOL fResume); |
取消可等待定時器
BOOl Cancel WaitableTimer( HANDLE hTimer //要取消的定時器 ); |
打開可等待定時器
作爲一種內核對象,WaitableTimer也可以被其他進程以名字打開:
HANDLE OpenWaitableTimer ( DWORD fdwAccess, BOOL bInherithandle, PCTSTR pszName ); |
實例
下面給出的一個程序可能發生死鎖現象:
#include <windows.h> #include <stdio.h> CRITICAL_SECTION cs1, cs2; long WINAPI ThreadFn(long); main() { long iThreadID; InitializeCriticalSection(&cs1); InitializeCriticalSection(&cs2); CloseHandle(CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ThreadFn, NULL, 0,&iThreadID)); while (TRUE) { EnterCriticalSection(&cs1); printf("/n線程1佔用臨界區1"); EnterCriticalSection(&cs2); printf("/n線程1佔用臨界區2"); printf("/n線程1佔用兩個臨界區"); LeaveCriticalSection(&cs2); LeaveCriticalSection(&cs1); printf("/n線程1釋放兩個臨界區"); Sleep(20); }; return (0); } long WINAPI ThreadFn(long lParam) { while (TRUE) { EnterCriticalSection(&cs2); printf("/n線程2佔用臨界區2"); EnterCriticalSection(&cs1); printf("/n線程2佔用臨界區1"); printf("/n線程2佔用兩個臨界區"); LeaveCriticalSection(&cs1); LeaveCriticalSection(&cs2); printf("/n線程2釋放兩個臨界區"); Sleep(20); }; } |
運行這個程序,在中途一旦發生這樣的輸出:
線程1佔用臨界區1
線程2佔用臨界區2
或
線程2佔用臨界區2
線程1佔用臨界區1
或
線程1佔用臨界區2
線程2佔用臨界區1
或
線程2佔用臨界區1
線程1佔用臨界區2
程序就"死"掉了,再也運行不下去。因爲這樣的輸出,意味着兩個線程相互等待對方釋放臨界區,也即出現了死鎖。
如果我們將線程2的控制函數改爲:
long WINAPI ThreadFn(long lParam) { while (TRUE) { EnterCriticalSection(&cs1); printf("/n線程2佔用臨界區1"); EnterCriticalSection(&cs2); printf("/n線程2佔用臨界區2"); printf("/n線程2佔用兩個臨界區"); LeaveCriticalSection(&cs1); LeaveCriticalSection(&cs2); printf("/n線程2釋放兩個臨界區"); Sleep(20); }; } |
再次運行程序,死鎖被消除,程序不再擋掉。這是因爲我們改變了線程2中獲得臨界區1、2的順序,消除了線程1、2相互等待資源的可能性。
由此我們得出結論,在使用線程間的同步機制時,要特別留心死鎖的發生。