執行緒同步——核心物件(互斥、事件、訊號量、可等待計時器)
三、核心模式下的執行緒同步
- Windows系統中有多種機制可用於執行緒同步,它們一般都被稱之為核心物件(並非全部),一般我們常用的有以下幾種:
- 互斥物件(Mutex)
- 事件物件(Event)
- 訊號量(Semaphore)
- 可等待計時器(Waitable Timer)
0.等待函式
- WaitForSingleObject
等待函式的作用是使一個執行緒進入到等待狀態,直到指定的核心物件被觸發為止,其函式原型如下所示:
DWORD WaitForSingleObject(
_In_ HANDLE hHandle, //核心物件控制代碼
_In_ DWORD dwMiliseconds //等待超時時間(微秒,INFI)
);
/******
***return
WAIT_OBJECT_0: 成功返回
WAIT_TIMEOUT: 超時返回
WAIT_FAILED: 傳入的引數錯誤
*/
在建立執行緒時使用等待函式後,此函式會在等待超時或執行緒結束時返回,因此我們的主執行緒一次只能啟動一個執行緒,從而避免上述例子中多執行緒訪問同一個資料時所引發的問題.
- WaitForMultipleObjects
與WaitForSingleObject類似,唯一的不同之處在於它允許呼叫執行緒同時檢查多個核心物件的觸發狀態
DWORD WaitForMultipleObjects(
DWORD dwCount, // 檢查的核心物件的數量
CONST HANDLE* phObjects, // 核心物件控制代碼陣列
BOOL bWaitAll, // 是否在所有核心物件觸發之後返回
DWORD dwMilliseconds) // 等待時間
/***********
* return
WAIT_FAILED
WAIT_TIMEOUT
// 如果 bWaitAll 是 TRUE ,則返回 WAIT_OBJECT_0
// 如果 bWaitAll 是 FALSE, 則返回值是 WAIT_OBJECT_0和(WAIT_OBJECT_0 + dwCount - 1) 之間的任意一個值,核心物件陣列的下標為:返回值 - WAIT_OBJECT_0
*/
對一些核心物件來說,成功地呼叫 WaitForSingleObject 與 WaitForMultipleObjects 事實上會改變物件的狀態。如:自動重置事件核心物件,當時間物件被觸發的時候,函式會檢測到這一情況,這時它可以直接返回 WAIT_OBJECT_0 給呼叫執行緒。但是,就在函式返回之前,它會使事件變為非觸發狀態——這就是等待成功所引起的副作用。 WaitForMultipleObjects是以原子方式工作的,當函式檢查核心物件的狀態時,任何其他執行緒都不能在背後修改物件的狀態。
1.事件物件
事件(Event) 是線上程同步中最常使用的一種同步物件,而且比其他物件要簡單一些。像其他物件一樣,事件包含了一個使用計數,一個是用來標識自動重置/手動重置的BOOL值,另一個是表示事件有沒有觸發的BOOL值
- 事件物件有兩種狀態,他們分別是:
- 手動狀態:被觸發後所有等待該事件的執行緒都將變為可排程狀態,常用於控制具有較強自定義要求的多執行緒同步環境.
- 自動狀態:被觸發後只有一個等待事件執行緒會變成可排程狀態。
/*關鍵函式*/
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes,//屬性
BOOL hManualReset, //手工重置
BOOL bInitialState, //初始狀態
LPCTSTR lpName //事件物件名稱
);
//注:非手工狀態下,呼叫SetEvent放行一個執行緒後,會自動再次設為無
//訊號狀態,直到再次呼叫SetEvent
/*設定標記為有訊號狀態(釋放等待函式)*/
BOOL SetEvent(
HANDLE hEvent //事件物件控制代碼
);
/*重置標記為無訊號狀態(阻塞等待函式)*/
BOOL WINAPI ResetEvent(
HANDLE hEvent //事件物件控制代碼
);
//開啟核心物件
HANDLE OpenEvent(
DWORD dwDesiredAccess,//對事件物件的請求訪問許可權
BOOL bInheritHandle,//是否能繼承
LPCTSTR lpName //事件物件的名字
);
一個防多開的例子
if (!OpenEvent(EVENT_MODIFY_STATE, TRUE,L"Global\\Text"))
CreateEvent(NULL, TRUE, TRUE, L"Global\\Text");
else
return 0;
示例:
int g_nNum = 0;
HANDLE g_hEventA = nullptr;
HANDLE g_hEventB = nullptr;
DWORD WINAPI ThreadProcA(LPVOID lpParam) {
for (int i = 0; i < 5; i++){
WaitForSingleObject(g_hEventA, INFINITE);
ResetEvent(g_hEventB);
printf("%d ", g_nNum++);
SetEvent(g_hEventB);
}
return 0;
}
DWORD WINAPI ThreadProcB(LPVOID lpParam){
for (int i = 0; i < 5; i++){
WaitForSingleObject(g_hEventB, INFINITE);
ResetEvent(g_hEventA);
printf("%d ", g_nNum++);
SetEvent(g_hEventA);
}
return 0;
}
int _tmain(int argc, _TCHAR* argv[])
{
if (!(g_hEventA = CreateEvent(NULL, TRUE, TRUE, NULL))) return 0;
if (!(g_hEventB = CreateEvent(NULL, TRUE, FALSE, NULL))) return 0;
CreateThread(NULL, 0, ThreadProcA, NULL, 0, nullptr);
CreateThread(NULL, 0, ThreadProcB, NULL, 0, nullptr);
system("pause");
return 0;
}
2. 可等待的計時器核心物件(Waitable Timer)
可等待的計時器(Waitable Timer): 會在某個指定的時間觸發,或每隔一段時間觸發一次。
建立可等待的計時器:
HANDLE CreateWaitableTimer(
PSECURITY_ATTRIBUTES psa,
BOOL bManualReset,
PCTSTR pszName);
開啟獲取一個已經存在的可等待計時器的控制代碼:
HANDLE OpenWaitableTimer(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
在建立可等待的計時器的時候,計時器總是處於未觸發狀態。等我們想要觸發計時器的時候必須呼叫 SetWaitableTimer 函式:
BOOL SetWaitableTimer(
HANDLE hTimer,
const LARGE_INTEGER *pDueTime, // 表示計時器第一次觸發的時間
LONG lPeriod, // 在第一次觸發之後,多長時間觸發一次
PTIMERAPCROUTINE pfnCompletionRoutine, // 計時器函式
PVOID pvArgToCompletionRoutine, // 傳入引數
BOOL bResume); // 是否支援掛起與恢復
取消計時器:
BOOL CancelWaitableTimer(HANDLE hTimer); //取消一個計時器
//第一次觸發時間為2008年1月1日下午1:00,之後每隔6小時觸發一次
HANDLE hTimer;
SYSTEMTIME st;
FILETIME ftLocal, ftUTC;
LARGE_INTEGER liUTC;
hTimer = CreateWaitableTimer(NULL, FALSE, NULL);
st.wYear = 2008;
st.wMonth = 1;
st.wDayOfWeek = 0; //忽略
st.wDay = 1;
st.wHour = 13;
st.wMinute = 0;
st.wSecond = 0;
st.wMilliseconds = 0;
SystemTimeToFileTime(&st, &ftLocal);
// 將本地時間轉換為 UTC 時間
LocalFileTimeToFileTime(&ftLocal, &ftUTC);
// 將 FILETIME 轉換為 LARGE_INTEGER , FILETIME 與 LARGE_INTEGER 二進位制格式一致,但是前者是地址是32為邊界,後者是64位邊界
liUTC.LowPart = ftUTC.dwLowDateTime;
liUTC.HighPart = ftUTC.dwHighDateTime;
SetWaitableTimer(hTimer, &liUTC, 6*60*60*1000,
NULL, NULL, FALSE);
// 觸發一次就不再觸發的計時器,即給 lPeriod引數傳入0就可以了。
建立APC(asynchronous procedure call, 非同步過程呼叫)計時器
// 非同步過程呼叫原型
VOID APIENTRY TimerAPCRoutine(PVOID pvArgToCompletionRoutine, DWORD dwTimerLowValue, DWORD dwTimerHighValue)
{
FILETIME ftUTC, ftLocal;
SYSTEMTIME st;
TCHAR szBuf[256];
ftUTC.dwLowDateTime = dwTimerLowValue;
ftUTC.dwHighDateTime = dwTimerHighValue;
FileTimeToLocalFileTime(&ftUTC, &ftLocal);
FileTimeToSystemTime(&ftLocal, &st);
GetDateFormat(LOCALE_USER_DEFAULT, DATE_LONGDATE,
&st, NULL, szBuf, _countof(szBuf));
_tcscat_s(szBuf, _countof(szBuf), TEXT(" "));
GetTimeFormat(LOCALE_USER_DEFAULT, 0,
&st, NULL, _tcschr(szBuf, TEXT('\0')),
(int)(_countof(szBuf) - _tcslen(szBuf)));
//Show the time to the user
MessageBox(NULL, szBuf, TEXT("Timer went off at..."), MB_OK);
}
void SomeFunc() {
// Create a timer. (It doesn't matter whether it's manual-reset
// or auto-reset.)
HANDLE hTimer = CreateWaitableTimer(NULL, TRUE, NULL);
LARGE_INTEGER li = {0};
SetWaitableTimer(hTimer, &li, 5000, TimerAPCRoutine, NULL, FALSE);
SleepEx(INFINITE, TRUE);
CloseHandle(hTimeer);
}
SetWaitableTimer執行緒必須是由於呼叫 SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx、MsgWaitForMultipleObjectsEx 或 SignalObjectAndWait 而進入等待狀態,APC非同步呼叫才會被呼叫。
3.訊號量
- 訊號量是一種用於管理多個執行緒的複雜同步問題的解決方案,它可以限制某一時間段最多有多少個執行緒可以同時處於執行狀態
- 訊號量實現這個功能的原理是維護了一個計數器,計數器的值可以在0至使用者指定的最大值之間,當一個執行緒完成了對訊號量的等待後,計數器的值增加,當一個訊號量被釋放時,訊號量的計數器減少。
- 當計數器的值為0時,訊號量處於無訊號狀態(阻塞),不為0時則訊號量處於有訊號狀態(釋放)
- 如果我們將訊號量的最大值設為1,那麼它的作用與互斥物件將完全一致
訊號量關鍵函式:
/*建立訊號量*/
HANDLE WINAPI CreateSemaphore(
_In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,//屬性
_In_ LONG lInitialCount, //訊號初始值
_In_ LONG lMaximumCount,//訊號最大值
_In_opt_ LPCTSTR lpName //訊號量名稱
);
/*釋放訊號量*/
BOOL ReleaseSemaphore(
HANDLE hSemaphore, //訊號量控制代碼
LONG lReleaseCount, //釋放的訊號量數量
LPLONG lpPreviousCount //返回訊號量上次值
);
/*開啟訊號量*/
HANDLE WINAPI OpenSemaphore(
DWORD dwDesiredAccess, //對訊號量的請求訪問許可權
BOOL bInheritHandle, //是否允許子程序繼承此控制代碼
LPCTSTR lpName //訊號量名稱
);
示例
int g_nNum = 0;
HANDLE g_hSemaphore = nullptr;
DWORD WINAPI ThreadProc(LPVOID lpParam)
{
for (int i = 0; i < 5; i++)
{
WaitForSingleObject(g_hSemaphore, INFINITE);
printf("%d", g_nNum++);
ReleaseSemaphore(g_hSemaphore, 1, NULL);
}
return 0;
}
int _tmain(int argc, _TCHAR* argv[])
{
if (!(g_hSemaphore=CreateSemaphore(NULL,0,1,NULL)) )
{
return 0;
}
CreateThread(NULL, 0, ThreadProc, NULL, 0, nullptr);
CreateThread(NULL, 0, ThreadProc, NULL, 0, nullptr);
system("pause");
return 0;
}
4.互斥物件
互斥物件是一個非常簡單的多執行緒同步核心物件,如果一個訊號量未被執行緒所擁有(被等待函式獲取),那麼它是”有訊號狀態(非阻塞)”,只要它被執行緒獲取,那麼它就會變成”無訊號狀態(阻塞)”,需要注意的是,單一互斥物件只對同一執行緒有效,以下是互斥物件的一些常用API
//建立互斥物件
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTES lpMutexAttributes, //屬性
BOOL bInitialOwner, //初始狀態
LPCTSTR lpName //互斥物件名稱
);
//釋放互斥物件
BOOL ReleaseMutex(HANDLE hMutex); //互斥物件控制代碼
//開啟互斥物件
HANDLE OpenMutex(
DWORD dwDesiredAccess, //對互斥物件的請求訪問許可權
BOOL bInheritHandle,//是否希望子程序能夠繼承控制代碼
LPCTSTR lpName //互斥物件名稱
);
如果執行緒成功地等待了互斥量物件不止一次,那麼執行緒必須呼叫 ReleaseMutex 相同的次數才能使物件遞迴計數變成0.當遞迴計數變成0的時候,函式還會將執行緒ID設為0,這樣就觸發了物件。
互斥物件—示例
int g_nNum = 0;
HANDLE g_hMutex = nullptr;
DWORD WINAPI ThreadProc(LPVOID lpParam)
{
for (int i = 0; i < 5; i++)
{
WaitForSingleObject(g_hMutex,INFINITE);
printf("%d",g_nNum++);
ReleaseMutex(g_hMutex);
}
return 0;
}
int _tmain(int argc, _TCHAR* argv[])
{
if (!(g_hMutex=CreateMutex(NULL, FALSE,NULL)))
return 0;
CreateThread(NULL, 0, ThreadProc, NULL, 0, nullptr);
CreateThread(NULL, 0, ThreadProc,NULL, 0, nullptr);
system("pause");
return 0;
}
互斥與臨界區的比較:
特徵 | 互斥量 | 臨界區 |
---|---|---|
效能 | 慢 | 快 |
是否能跨程序使用 | 是 | 否 |
宣告 | HANDLE hmtx; | CRITICAL_SECTION cs; |
初始化 | hmtx = CreateMutex(NULL,FALSE,NULL); | InitializeCriticalSection(&cs); |
清理 | CloseHandle(hmtx); | DeleteCriticalSection(&cs) |
無限等待 | WaitForSingleObject(hmtx,INFINITE); | EnterCriticalSection(&cs); |
0等待 | WaitForSingleObject(hmtx,0); | TryEnterCriticalSection(&cs); |
任意時間長度等待 | WaitForSingleObject(hmtx, dwMilliseconds); | 不支援 |
釋放 | ReleaseMutex(hmtx); | LeaveCriticalSection(&cs); |
是否能同時等待其他核心物件 | WaitForMultipleObjects | 否 |
總結
執行緒同步物件速查:
物件 | 何時處於未觸發狀態 | 何時處於觸發狀態 | 成功等待的副作用 |
---|---|---|---|
程序 | 程序仍在執行 | 程序終止時(ExitProcess,TerminateProcess) | 無 |
執行緒 | 執行緒仍在執行 | 執行緒終止時(ExitThread,TerminateThread) | 無 |
作業 | 作業尚未超時的 | 作業超時時 | 無 |
檔案 | 有待處理的IO請求時 | IO請求完成時 | 無 |
控制檯輸入 | 沒有輸入時 | 有輸入時 | 無 |
檔案變更通知 | 檔案沒有變更時 | 檔案系統檢測到變更的時候 | 重置通知 |
自動重置事件 | ResetEvent,PulseEvent或等待成功的時候 | SetEvent/PulseEvent被呼叫的時候 | 重置事件 |
手動重置事件 | ResetEvent,PulseEvent | SetEvent/PulseEvent被呼叫的時候 | 沒有 |
自動重置可等待計時器 | CancelWaitableTimer或等待成功時 | 時間到的時候(SetWaitableTimer) | 重置計時器 |
手動重置可等待計時器 | CancelWaitableTimer | 時間到的時候(SetWaitableTimer) | 沒有 |
訊號量 | 等待成功的時候 | 計數大於0的時候(ReleaseSemaphore) | 計數減1 |
互斥量 | 等待成功的時候 | 不為執行緒佔用的時候(ReleaseMutex) | 把所有權交給執行緒 |
臨界區(使用者模式) | 等待成功的時候((Try)EnterCriticalSection) | 不為執行緒佔用的時候(LeaveCriticalSection) | 把所有權交給執行緒 |
SRWLock(使用者模式) | 等待成功的時候(AcquireSRWLock(Exclusive)) | 不為執行緒佔用的時候(ReleaseSRWLock(Exclusive)) | 把所有權交給執行緒 |
條件變數(使用者模式) | 等待成功的時候(SleepConditionVariable*) | 被喚醒的時候(Wake(All)ConditionVariable) | 沒有 |
其他的執行緒同步函式
- 非同步裝置I/O
非同步裝置IO(asynchronous device I/O)允許執行緒開始讀取操作或寫入操作,但不必等待讀取操作或寫入操作完成。裝置物件是可同步的核心物件。 - WaitForInputIdle函式(將自己掛起)
DWORD WaitForInputIdle(
HANDLE hProcess,
DWORD dwMilliseconds);
- MsgWaitForMultipleObjects(Ex)既可以等待核心觸發狀態,也可以處理訊息
- WaitForDebugEvent 函式等待除錯訊息
- SignalObjectAndWait 函式 通過一個原子操作觸發一個核心物件並等待另一個核心物件
呼叫這個函式時,引數hObjectToSignal標識的必須是一個互斥量、訊號量或事件。任何其他型別的物件將導致函式返回 WAIT_FAILED,這時呼叫GetLastError會返回ERROR_INVALID_HANDLE。該函式內部會檢查物件的型別並分別執行與ReleaseMutex、ReleaseSemaphore、SetEvent 等價的操作。 - 使用等待鏈遍歷API檢測死鎖
可能的鎖 | 描述 |
---|---|
臨界區 | Windows會記錄哪個執行緒正在佔用哪個臨界區 |
互斥量 | Windows會記錄哪個執行緒正在佔用哪個互斥量。即便已經被遺棄的互斥量 |
程序和執行緒 | Windows會記錄哪個執行緒正在等待程序終止或執行緒終止 |
SendMessage呼叫 | 知道哪個執行緒正在等待SendMessage呼叫返回 |
COM初始化和呼叫 | Windows會記錄對CoCreateInstance的呼叫以及對COM物件的方法的呼叫 |
高階本地過程呼叫(Advanced Local Procedure Call,ALPC) | 本地過程呼叫 |