3.2 線程同步
同步可以保證在一個時間內只有一個線程對某個共享資源有控制權。共享資源包括全局變量、公共數據成員或者句柄等。臨界區內核對象和事件內核對象可以很好地用於多線程同步和它們之間的通信。本節將結合各種簡單的例子來討論產生同步問題的根本原因,進而提出相應的解決方案。
3.2.1 臨界區對象
1.爲什麼要線程同步
當多個線程在同一個進程中執行時,可能有不止一個線程同時執行同一段代碼,訪問同一段內存中的數據。多個線程同時讀共享數據沒有問題,但如果同時讀和寫,情況就不同了。下面是一個有問題的程序,該程序用兩個線程來同時增加全局變量g_nCount1和g_nCount2的計數,運行1秒之後打印出計數結果。
#include <stdio.h> // 03CountErr工程下
#include <windows.h>
#include <process.h>
int g_nCount1 = 0;
int g_nCount2 = 0;
BOOL g_bContinue = TRUE;
UINT __stdcall ThreadFunc(LPVOID);
int main(int argc, char* argv[])
{ UINT uId;
HANDLE h[2];
h[0] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
h[1] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
// 等待1秒後通知兩個計數線程結束,關閉句柄
Sleep(1000);
g_bContinue = FALSE;
::WaitForMultipleObjects(2, h, TRUE, INFINITE);
::CloseHandle(h[0]);
::CloseHandle(h[1]);
printf("g_nCount1 = %d \n", g_nCount1);
printf("g_nCount2 = %d \n", g_nCount2);
return 0;
}
UINT __stdcall ThreadFunc(LPVOID)
{ while(g_bContinue)
{ g_nCount1++;
g_nCount2++; }
return 0;
}
線程函數ThreadFunc同時增加全局變量g_nCount1和g_nCount2的計數。按道理來說最終在主線程中輸出的它們的值應該是相同的,可是結果並不盡如人意,圖3.4所示是運行上面的代碼,並等待1秒後程序的輸出。
圖3.4 程序錯誤的輸出
g_nCount1和g_nCount2的值並不相同。出現這種結果主要是因爲同時訪問g_nCount1和g_nCount2的兩個線程具有相同的優先級。在執行過程中如果第一個線程取走g_nCount1的值準備進行自加操作的時候,它的時間片恰好用完,系統切換到第二個線程去對g_nCount1進行自加操作;一個時間片過後,第一個線程再次被調度,此時它會將上次取出的值自加,並放入g_nCount1所在的內存裏,這就會覆蓋掉第二個線程對g_nCount1的自加操作。變量g_nCount2也存在相同的問題。由於這樣的事情的發生次數是不可預知的,所以最終的值就不相同了。
例子中,g_nCount1和g_nCount2是全局變量,屬於該進程內所有線程共有的資源。多線程同步就要保證在一個線程佔有公共資源的時候,其他線程不會再次佔有這個資源。所以,解決同步問題,就是保證整個存取過程的獨佔性。在一個線程對某個對象進行操作的過程中,需要有某種機制阻止其他線程的操作,這就用到了臨界區對象。
2.使用臨界區對象
臨界區對象是定義在數據段中的一個CRITICAL_SECTION結構,Windows內部使用這個結構記錄一些信息,確保在同一時間只有一個線程訪問該數據段中的數據。
編程的時候,要把臨界區對象定義在想保護的數據段中,然後在任何線程使用此臨界區對象之前對它進行初始化。
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection );
// 指向數據段中定義的CRITICAL_SECTION結構
之後,線程訪問臨界區中數據的時候,必須首先調用EnterCriticalSection函數,申請進入臨界區(又叫關鍵代碼段)。在同一時間內,Windows只允許一個線程進入臨界區。所以在申請的時候,如果有另一個線程在臨界區的話,EnterCriticalSection函數會一直等待下去,直到其他線程離開臨界區才返回。EnterCriticalSection函數用法如下:
void EnterCriticalSection( LPCRITICAL_SECTION lpCriticalSection);
當操作完成的時候,還要將臨界區交還給Windows,以便其他線程可以申請使用。這個工作由LeaveCriticalSection函數來完成。
void LeaveCriticalSection( LPCRITICAL_SECTION lpCriticalSection);
當程序不再使用臨界區對象的時候,必須使用DeleteCriticalSection函數將它刪除。
void DeleteCriticalSection( LPCRITICAL_SECTION lpCriticalSection);
現在使用臨界區對象來改寫上面有同步問題的計數程序。
BOOL g_bContinue = TRUE; // 03CriticalSection工程下
int g_nCount1 = 0;
int g_nCount2 = 0;
CRITICAL_SECTION g_cs; // 對存在同步問題的代碼段使用臨界區對象
UINT __stdcall ThreadFunc(LPVOID);
int main(int argc, char* argv[])
{ UINT uId;
HANDLE h[2];
// 初始化臨界區對象
::InitializeCriticalSection(&g_cs);
h[0] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
h[1] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
// 等待1秒後通知兩個計數線程結束,關閉句柄
Sleep(1000);
g_bContinue = FALSE;
::WaitForMultipleObjects(2, h, TRUE, INFINITE);
::CloseHandle(h[0]);
::CloseHandle(h[1]);
// 刪除臨界區對象
::DeleteCriticalSection(&g_cs);
printf("g_nCount1 = %d \n", g_nCount1);
printf("g_nCount2 = %d \n", g_nCount2);
return 0;
}
UINT __stdcall ThreadFunc(LPVOID)
{ while(g_bContinue)
{ ::EnterCriticalSection(&g_cs);
g_nCount1++;
g_nCount2++;
::LeaveCriticalSection(&g_cs);
}
return 0;
}
運行這段代碼,兩個值的最終結果是相同的,如圖3.5所示。
圖3.5 程序正確的輸出
臨界區對象能夠很好地保護共享數據,但是它不能夠用於進程之間資源的鎖定,因爲它不是內核對象。如果要在進程間維持線程的同步,可以使用事件內核對象。
3.2.2 互鎖函數
互鎖函數爲同步訪問多線程共享變量提供了一個簡單的機制。如果變量在共享內存,不同進程的線程也可以使用此機制。用於互鎖的函數有InterlockedIncrement、InterlockedDecrement、InterlockedExchangeAdd、InterlockedExchangePointer等,這裏僅介紹前兩個。
InterlockedIncrement函數遞增(加1)指定的32位變量。這個函數可以阻止其他線程同時使用此變量,函數原型如下:
LONG InterlockedIncrement( LONG volatile* Addend); // 指向要遞增的變量
InterlockedDecrement函數同步遞減(減1)指定的32位變量,原型如下:
LONG InterlockedDecrement( LONG volatile* Addend); // 指向要遞減的變量
函數用法相當簡單,例如在03CountErr實例中,爲了同步對全局變量g_nCount1、g_nCount2的訪問,可以按如下所示修改線程函數:
UINT __stdcall ThreadFunc(LPVOID) // 03InterlockDemo工程下
{ while(g_bContinue)
{ ::InterlockedIncrement((long*)&g_nCount1);
::InterlockedIncrement((long*)&g_nCount2);
}
return 0;
}
3.2.3 事件內核對象
多線程程序設計大多會涉及線程間相互通信。使用編程就要涉及到線程的問題。主線程在創建工作線程的時候,可以通過參數給工作線程傳遞初始化數據,當工作線程開始運行後,還需要通過通信機制來控制工作線程。同樣,工作線程有時候也需要將一些情況主動通知主線程。一種比較好的通信方法是使用事件內核對象。
事件對象(event)是一種抽象的對象,它也有未受信(nonsignaled)和受信(signaled)兩種狀態,編程人員也可以使用WaitForSingleObject函數等待其變成受信狀態。不同於其他內核對象的是,一些函數可以使事件對象在這兩種狀態之間轉化。可以把事件對象看成是一個設置在Windows內部的標誌,它的狀態設置和測試工作由Windows來完成。
事件對象包含3個成員:nUsageCount (使用計數)、bManualReset(是否人工重置)和bSignaled(是否受信)。成員nUsageCount記錄當前的使用計數,當使用計數爲0的時候,Windows就會銷燬此內核對象佔用的資源;成員bManualReset指定在一個事件內核對象上等待的函數返回之後,Windows是否重置這個對象爲未受信狀態;成員bSignaled指定當前事件內核對象是否受信。下面要介紹的操作事件內核對象的函數會影響這些成員的值。
1.基本函數
如果想使用事件對象,需要首先用CreateEvent 函數去創建它,初始狀態下,nUsageCount的值爲1。
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, // 用來定義事件對象的安全屬性
BOOL bManualReset, // 指定是否需要手動重置事件對象爲未受信狀態。
BOOL bInitialState, // 指定事件對象創建時的初始狀態
LPCWSTR lpName); // 事件對象的名稱
參數bManualReset對應着內核對象中的bManualReset成員。自動重置(auto-reset)和人工重置(manual-reset)是事件內核對象兩種不同的類型。當一個人工重置的事件對象受信以後,所有等待在這個事件上的線程都會變爲可調度狀態;可是當一個自動重置的事件對象受信以後,Windows僅允許一個等待在該事件上的線程變成可調度狀態,然後就自動重置此事件對象爲未受信狀態。
bInitialState參數對應着bSignaled成員。將它設爲TRUE,則表示事件對象創建時的初始化狀態爲受信(bSignaled = TRUE);設爲FALSE時,狀態爲未受信(bSignaled = FALSE)。
lpName參數用來指定事件對象的名稱。爲事件對象命名是爲了在其他地方(比如,其他進程的線程中)使用OpenEvent或CreateEvent函數獲取此內核對象的句柄。
HANDLE OpenEvent (
DWORD dwDesiredAccess, // 指定想要的訪問權限
BOOL bInheritHandle, // 指定返回句柄是否可被繼承
LPCWSTR lpName); // 要打開的事件對象的名稱
系統創建或打開一個事件內核對象後,會返回事件的句柄。當編程人員不使用此內核對象的時候,應該調用CloseHandle函數釋放它佔用的資源。
事件對象被建立後,程序可以通過SetEvent和ResetEvent函數來設置它的狀態。
BOOL SetEvent( HANDLE hEvent ); // 將事件狀態設爲 “受信(sigaled)”;
BOOL ResetEvent(HANDLE hEvent ); // 將事件狀態設爲 “未受信(nonsigaled)”;
hEvent參數是事件對象的句柄,這個句柄可以通過CreateEvent或OpenEvent函數獲得。
對於一個自動重置類型的事件對象,Microsoft定義了一套比較實用的規則:當在這樣的事件對象上等待的函數(比如,WaitForSingleObject函數)返回時,Windows會自動重置事件對象爲未受信狀態。通常情況下,爲一個自動重置類型的事件對象調用ResetEvent函數是不必要的,因爲Windows會自動重置此事件對象。
2.應用舉例
下面例子中,主線程通過將事件狀態設爲“受信”來通知子線程開始工作。這是事件內核對象一個很重要的用途,示例代碼如下:
#include <stdio.h> // 03EventDemo工程下
#include <windows.h>
#include <process.h>
HANDLE g_hEvent;
UINT __stdcall ChildFunc(LPVOID);
int main(int argc, char* argv[])
{ HANDLE hChildThread;
UINT uId;
// 創建一個自動重置的(auto-reset events),未受信的(nonsignaled)事件內核對象
g_hEvent = ::CreateEvent(NULL, FALSE, FALSE, NULL);
hChildThread = (HANDLE)::_beginthreadex(NULL, 0, ChildFunc, NULL, 0, &uId);
// 通知子線程開始工作
printf("Please input a char to tell the Child Thread to work: \n");
getchar();
::SetEvent(g_hEvent);
// 等待子線程完成工作,釋放資源
::WaitForSingleObject(hChildThread, INFINITE);
printf("All the work has been finished. \n");
::CloseHandle(hChildThread);
::CloseHandle(g_hEvent);
return 0;
}
UINT __stdcall ChildFunc(LPVOID)
{ ::WaitForSingleObject(g_hEvent, INFINITE);
printf(" Child thread is working...... \n");
::Sleep(5*1000); // 暫停5秒,模擬真正的工作
return 0;
}
運行程序,輸入一個字符通知子線程開始工作,結果如圖3.6所示。
圖3.6 使用事件內核對象通信
主線程一開始,就創建了一個自動重置的(auto-reset),未受信的(nonsignaled)事件內核對象,並用全局變量g_hEvent保存對象的句柄。這樣做會使本進程的其他線程訪問此內核對象更加容易。接着子線程被創建,並等待主線程的通知來開始真正的工作。最後,子線程工作結束,主線程退出。
事件對象主要用於線程間通信,因爲它是一個內核對象,所以也可以跨進程使用。依靠在線程間通信就可以使各線程的工作協調進行,達到同步的目的。
3.2.4 信號量內核對象
信號量(Semaphore)內核對象對線程的同步方式與前面幾種方法不同,它允許多個線程在同一時刻訪問同一資源,但是需要限制在同一時刻訪問此資源的最大線程數目。在用CreateSemaphore函數創建信號量時,即要同時指出允許的最大資源計數和當前可用資源計數。一般是將當前可用資源計數設置爲最大資源計數,每增加一個線程對共享資源的訪問,當前可用資源計數就會減1,只要當前可用資源計數是大於0的,就可以發出信號量信號。但是當前可用計數減小到0時則說明當前佔用資源的線程數已經達到了所允許的最大數目,不能再允許其他線程的進入,此時的信號量信號將無法發出。線程在處理完共享資源後,應在離開的同時通過ReleaseSemaphore函數將當前可用資源計數加1。在任何時候當前可用資源計數決不可能大於最大資源計數。圖3.7顯示了信號量對象對資源的控制。
圖3.7 使用信號量對象控制資源
在圖3.7中,以箭頭和白色箭頭表示共享資源所允許的最大資源計數和當前可用資源計數。初始如圖(a)所示,最大資源計數和當前可用資源計數均爲4,此後每增加一個對資源進行訪問的線程(用黑色箭頭表示)當前資源計數就會相應減1,圖(b)即表示的在3個線程對共享資源進行訪問時的狀態。當進入線程數達到4個時,將如圖(c)所示,此時已達到最大資源計數,而當前可用資源計數也已減到0,其他線程無法對共享資源進行訪問。在當前佔有資源的線程處理完畢而退出後,將會釋放出空間,圖(d)已有兩個線程退出對資源的佔有,當前可用計數爲2,可以再允許2個線程進入到對資源的處理。可以看出,信號量是通過計數來對線程訪問資源進行控制的,而實際上信號量確實也被稱作Dijkstra計數器。
使用信號量內核對象進行線程同步主要會用到CreateSemaphore、OpenSemaphore、ReleaseSemaphore、WaitForSingleObject和WaitForMultipleObjects等函數。其中,CreateSemaphore用來創建一個信號量內核對象,其函數原型爲:
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // 安全屬性指針
LONG lInitialCount, // 初始計數
LONG lMaximumCount, // 最大計數
LPCTSTR lpName // 對象名指針
);
參數lMaximumCount是一個有符號32位值,定義了允許的最大資源計數,最大取值不能超過4294967295。lpName參數可以爲創建的信號量定義一個名字,由於其創建的是一個內核對象,因此在其他進程中可以通過該名字而得到此信號量。OpenSemaphore()函數即可用來根據信號量名打開在其他進程中創建的信號量,函數原型如下:
HANDLE OpenSemaphore(
DWORD dwDesiredAccess, // 訪問標誌
BOOL bInheritHandle, // 繼承標誌
LPCTSTR lpName // 信號量名
);
在線程離開對共享資源的處理時,必須通過ReleaseSemaphore來增加當前可用資源計數。否則將會出現當前正在處理共享資源的實際線程數並沒有達到要限制的數值,而其他線程卻因爲當前可用資源計數爲0而仍無法進入的情況。ReleaseSemaphore的函數原型爲:
BOOL ReleaseSemaphore(
HANDLE hSemaphore, // 信號量句柄
LONG lReleaseCount, // 計數遞增數量
LPLONG lpPreviousCount // 先前計數
);
該函數將lReleaseCount中的值添加給信號量的當前資源計數,一般將lReleaseCount設置爲1,如果需要也可以設置其他的值。WaitForSingleObject和WaitForMultipleObjects主要用在試圖進入共享資源的線程函數入口處,主要用來判斷信號量的當前可用資源計數是否允許本線程的進入。只有在當前可用資源計數值大於0時,被監視的信號量內核對象纔會得到通知。
信號量的使用特點使其更適用於對Socket(套接字)程序中線程的同步。例如,網絡上的HTTP服務器要對同一時間內訪問同一頁面的用戶數加以限制,這時可以爲沒一個用戶對服務器的頁面請求設置一個線程,而頁面則是待保護的共享資源,通過使用信號量對線程的同步作用可以確保在任一時刻無論有多少用戶對某一頁面進行訪問,只有不大於設定的最大用戶數目的線程能夠進行訪問,而其他的訪問企圖則被掛起,只有在有用戶退出對此頁面的訪問後纔有可能進入。下面給出的示例代碼即展示了類似的處理過程:
// 信號量對象句柄
HANDLE hSemaphore;
UINT ThreadProc15(LPVOID pParam)
{
// 試圖進入信號量關口
WaitForSingleObject(hSemaphore, INFINITE);
// 線程任務處理
AfxMessageBox("線程一正在執行!");
// 釋放信號量計數
ReleaseSemaphore(hSemaphore, 1, NULL);
return 0;
}
UINT ThreadProc16(LPVOID pParam)
{
// 試圖進入信號量關口
WaitForSingleObject(hSemaphore, INFINITE);
// 線程任務處理
AfxMessageBox("線程二正在執行!");
// 釋放信號量計數
ReleaseSemaphore(hSemaphore, 1, NULL);
return 0;
}
UINT ThreadProc17(LPVOID pParam)
{
// 試圖進入信號量關口
WaitForSingleObject(hSemaphore, INFINITE);
// 線程任務處理
AfxMessageBox("線程三正在執行!");
// 釋放信號量計數
ReleaseSemaphore(hSemaphore, 1, NULL);
return 0;
}
……
void CSample08View::OnSemaphore()
{
// 創建信號量對象
hSemaphore = CreateSemaphore(NULL, 2, 2, NULL);
// 開啓線程
AfxBeginThread(ThreadProc15, NULL);
AfxBeginThread(ThreadProc16, NULL);
AfxBeginThread(ThreadProc17, NULL);
}