再次學習MFC多執行緒及同步
一、MFC對多執行緒程式設計的支援
MFC中有兩類執行緒,分別稱之為工作者執行緒和使用者介面執行緒。二者的主要區別在於工作者執行緒沒有訊息迴圈,而使用者介面執行緒有自己的訊息佇列和訊息迴圈。
工作者執行緒沒有訊息機制,通常用來執行後臺計算和維護任務,如冗長的計算過程,印表機的後臺列印等。使用者介面執行緒一般用於處理獨立於其他執行緒執行之外的使用者輸入,響應使用者及系統所產生的事件和訊息等。但對於Win32的API程式設計而言,這兩種執行緒是沒有區別的,它們都只需執行緒的啟動地址即可啟動執行緒來執行任務。
在MFC中,一般用全域性函式AfxBeginThread()來建立並初始化一個執行緒的執行,該函式有兩種過載形式,分別用於建立工作者執行緒和使用者介面執行緒。兩種過載函式原型和引數分別說明如下:
(1) CWinThread* AfxBeginThread(
AFX_THREADPROC pfnThreadProc,
LPVOID pParam,
int nPriority = THREAD_PRIORITY_NORMAL,
UNT nStackSize = 0,
DWORD dwCreateFlags = 0,
LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL
);//用於建立工作者執行緒
PfnThreadProc:指向工作者執行緒的執行函式的指標,執行緒函式原型必須宣告如下:
UINT ExecutingFunction(LPVOID pParam);
請注意,ExecutingFunction()應返回一個UINT型別的值,用以指明該函式結束的原因。一般情況下,返回0表明執行成功。
- pParam: 一個32位引數,執行函式將用某種方式解釋該值。它可以是數值,或是指向一個結構的指標,甚至可以被忽略;
- nPriority: 執行緒的優先順序。如果為0,則執行緒與其父執行緒具有相同的優先順序;
- nStackSize: 執行緒為自己分配堆疊的大小,其單位為位元組。如果nStackSize被設為0,則執行緒的堆疊被設定成與父執行緒堆疊相同大小;
- dwCreateFlags:如果為0,則執行緒在建立後立刻開始執行。如果為CREATE_SUSPEND,則執行緒在建立後立刻被掛起;
- lpSecurityAttrs:執行緒的安全屬性指標,一般為NULL;
(2) CWinThread* AfxBeginThread(
CRuntimeClass* pThreadClass,
int nPriority = THREAD_PRIORITY_NORMAL,
UNT nStackSize = 0,
DWORD dwCreateFlags = 0,
LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL
);
pThreadClass 是指向 CWinThread 的一個匯出類的執行時類物件的指標,該匯出類定義了被建立的使用者介面執行緒的啟動、退出等;其它引數的意義同形式1。使用函式的這個原型生成的執行緒也有訊息機制,在以後的例子中我們將發現同主執行緒的機制幾乎一樣。
下面我們對CWinThread類的資料成員及常用函式進行簡要說明。
- m_hThread: 當前執行緒的控制代碼;
- m_nThreadID: 當前執行緒的ID;
- m_pMainWnd: 指向應用程式主視窗的指標
BOOL CWinThread::CreateThread(DWORD dwCreateFlags=0,UINT nStackSize=0,LPSECURITY_ATTRIBUTES lpSecurityAttrs=NULL);
該函式中的dwCreateFlags、nStackSize、lpSecurityAttrs引數和API函式CreateThread中的對應引數有相同含義,該函式執行成功,返回非0值,否則返回0。
一般情況下,呼叫AfxBeginThread()來一次性地建立並啟動一個執行緒,但是也可以通過兩步法來建立執行緒:首先建立CWinThread類的一個物件,然後呼叫該物件的成員函式CreateThread()來啟動該執行緒。
virtual BOOL CWinThread::InitInstance();
過載該函式以控制使用者介面執行緒例項的初始化。初始化成功則返回非0值,否則返回0。使用者介面執行緒經常過載該函式,工作者執行緒一般不使用InitInstance()。
virtual int CWinThread::ExitInstance();
線上程終結前過載該函式進行一些必要的清理工作。該函式返回執行緒的退出碼,0表示執行成功,非0值用來標識各種錯誤。同InitInstance()成員函式一樣,該函式也只適用於使用者介面執行緒。
二、MFC中執行緒同步
在程式中使用多執行緒時,一般很少有多個執行緒能在其生命期內進行完全獨立的操作。更多的情況是一些執行緒進行某些處理操作,而其他的執行緒必須對其處理結果進行了解。正常情況下對這種處理結果的瞭解應當在其處理任務完成後進行。
如果不採取適當的措施,其他執行緒往往會線上程處理任務結束前就去訪問處理結果,這就很有可能得到有關處理結果的錯誤瞭解。例如,多個執行緒同時訪問同一個全域性變數,如果都是讀取操作,則不會出現問題。如果一個執行緒負責改變此變數的值,而其他執行緒負責同時讀取變數內容,則不能保證讀取到的資料是經過寫執行緒修改後的。
為了確保讀執行緒讀取到的是經過修改的變數,就必須在向變數寫入資料時禁止其他執行緒對其的任何訪問,直至賦值過程結束後再解除對其他執行緒的訪問限制。象這種保證執行緒能瞭解其他執行緒任務處理結束後的處理結果而採取的保護措施即為執行緒同步。
執行緒的同步可分使用者模式的執行緒同步和核心物件的執行緒同步兩大類。使用者模式中執行緒的同步方法主要有原子訪問和臨界區等方法。其特點是同步速度特別快,適合於對執行緒執行速度有嚴格要求的場合。
核心物件的執行緒同步則主要由事件、等待定時器、訊號量以及訊號燈等核心物件構成。由於這種同步機制使用了核心物件,使用時必須將執行緒從使用者模式切換到核心模式,而這種轉換一般要耗費近千個CPU週期,因此同步速度較慢,但在適用性上卻要遠優於使用者模式的執行緒同步方式。
1.臨界區
臨界區(Critical Section)是一段獨佔對某些共享資源訪問的程式碼,在任意時刻只允許一個執行緒對共享資源進行訪問。如果有多個執行緒試圖同時訪問臨界區,那麼在有一個執行緒進入後其他所有試圖訪問此臨界區的執行緒將被掛起,並一直持續到進入臨界區的執行緒離開。臨界區在被釋放後,其他執行緒可以繼續搶佔,並以此達到用原子方式操作共享資源的目的。
臨界區在使用時以CRITICAL_SECTION結構物件保護共享資源,並分別用EnterCriticalSection()和LeaveCriticalSection()函式去標識和釋放一個臨界區。所用到的CRITICAL_SECTION結構物件必須經過InitializeCriticalSection()的初始化後才能使用,而且必須確保所有執行緒中的任何試圖訪問此共享資源的程式碼都處在此臨界區的保護之下。否則臨界區將不會起到應有的作用,共享資源依然有被破壞的可能。
UINT ThreadProc10(LPVOID pParam)
{
EnterCriticalSection(&g_cs); // 進入臨界區for (int i =0; i <10; i++) // 對共享資源進行寫入操作 {
g_cArray[i] ='a';
Sleep(1);
}
LeaveCriticalSection(&g_cs); // 離開臨界區return0;
}
UINT ThreadProc11(LPVOID pParam)
{
EnterCriticalSection(&g_cs);
for (int i =0; i <10; i++)
{
g_cArray[10- i -1] ='b';
Sleep(1);
}
LeaveCriticalSection(&g_cs);
return0;
}
……
void CSample08View::OnCriticalSection()
{
InitializeCriticalSection(&g_cs); // 初始化臨界區
AfxBeginThread(ThreadProc10, NULL); // 啟動執行緒 AfxBeginThread(ThreadProc11, NULL);
Sleep(300);
CString sResult = CString(g_cArray);
AfxMessageBox(sResult);
} 在使用臨界區時,一般不允許其執行時間過長,只要進入臨界區的執行緒還沒有離開,其他所有試圖進入此臨界區的執行緒都會被掛起而進入到等待狀態,並會在一定程度上影響。程式的執行效能。尤其需要注意的是不要將等待使用者輸入或是其他一些外界干預的操作包含到臨界區。如果進入了臨界區卻一直沒有釋放,同樣也會引起其他執行緒的長時間等待。換句話說,在執行了EnterCriticalSection()語句進入臨界區後無論發生什麼,必須確保與之匹配的LeaveCriticalSection()都能夠被執行到。可以通過新增結構化異常處理程式碼來確保LeaveCriticalSection()語句的執行。雖然臨界區同步速度很快,但卻只能用來同步本程序內的執行緒,而不可用來同步多個程序中的執行緒。
MFC為臨界區提供有一個CCriticalSection類,使用該類進行執行緒同步處理是非常簡單的,只需線上程函式中用CCriticalSection類成員函式Lock()和UnLock()標定出被保護程式碼片段即可。對於上述程式碼,可通過CCriticalSection類將其改寫如下: CCriticalSection g_clsCriticalSection; // MFC臨界區類物件char g_cArray[10]; // 共享資源
UINT ThreadProc20(LPVOID pParam)
{
g_clsCriticalSection.Lock(); // 進入臨界區for (int i =0; i <10; i++) // 對共享資源進行寫入操作 {
g_cArray[i] ='a';
Sleep(1);
}
g_clsCriticalSection.Unlock(); // 離開臨界區return0;
}
UINT ThreadProc21(LPVOID pParam)
{
g_clsCriticalSection.Lock();
for (int i =0; i <10; i++)
{
g_cArray[10- i -1] ='b';
Sleep(1);
}
g_clsCriticalSection.Unlock();
return0;
}
……
void CSample08View::OnCriticalSectionMfc()
{
AfxBeginThread(ThreadProc20, NULL);
AfxBeginThread(ThreadProc21, NULL);
Sleep(300);
CString sResult = CString(g_cArray);
AfxMessageBox(sResult);
}
2.事件核心物件
在前面講述執行緒通訊時曾使用過事件核心物件來進行執行緒間的通訊,除此之外,事件核心物件也可以通過通知操作的方式來保持執行緒的同步。對於前面那段使用臨界區保持執行緒同步的程式碼可用事件物件的執行緒同步方法改寫如下:
UINT ThreadProc12(LPVOID pParam)
{
WaitForSingleObject(hEvent, INFINITE); // 等待事件置位for (int i =0; i <10; i++)
{
g_cArray[i] ='a';
Sleep(1);
}
SetEvent(hEvent); // 處理完成後即將事件物件置位return0;
}
UINT ThreadProc13(LPVOID pParam)
{
WaitForSingleObject(hEvent, INFINITE);
for (int i =0; i <10; i++)
{
g_cArray[10- i -1] ='b';
Sleep(1);
}
SetEvent(hEvent);
return0;
}
……
void CSample08View::OnEvent()
{
hEvent = CreateEvent(NULL, FALSE, FALSE, NULL); // 建立事件
SetEvent(hEvent); // 事件置位
AfxBeginThread(ThreadProc12, NULL); // 啟動執行緒 AfxBeginThread(ThreadProc13, NULL);
Sleep(300);
CString sResult = CString(g_cArray);
AfxMessageBox(sResult);
}
在建立執行緒前,首先建立一個可以自動復位的事件核心物件hEvent,而執行緒函式則通過WaitForSingleObject()等待函式無限等待hEvent的置位,只有在事件置位時WaitForSingleObject()才會返回,被保護的程式碼將得以執行。對於以自動復位方式建立的事件物件,在其置位後一被WaitForSingleObject()等待到就會立即復位,也就是說在執行ThreadProc12()中的受保護程式碼時,事件物件已經是復位狀態的,這時即使有ThreadProc13()對CPU的搶佔,也會由於WaitForSingleObject()沒有hEvent的置位而不能繼續執行,也就沒有可能破壞受保護的共享資源。在ThreadProc12()中的處理完成後可以通過SetEvent()對hEvent的置位而允許ThreadProc13()對共享資源g_cArray的處理。這裡SetEvent()所起的作用可以看作是對某項特定任務完成的通知。
使用臨界區只能同步同一程序中的執行緒,而使用事件核心物件則可以對程序外的執行緒進行同步,其前提是得到對此事件物件的訪問權。可以通過OpenEvent()函式獲取得到,其函式原型為:
DWORD dwDesiredAccess, // 訪問標誌 BOOL bInheritHandle, // 繼承標誌 LPCTSTR lpName // 指向事件物件名的指標);
如果事件物件已建立(在建立事件時需要指定事件名),函式將返回指定事件的控制代碼。對於那些在建立事件時沒有指定事件名的事件核心物件,可以通過使用核心物件的繼承性或是呼叫DuplicateHandle()函式來呼叫CreateEvent()以獲得對指定事件物件的訪問權。在獲取到訪問權後所進行的同步操作與在同一個程序中所進行的執行緒同步操作是一樣的。
如果需要在一個執行緒中等待多個事件,則用WaitForMultipleObjects()來等待。WaitForMultipleObjects()與WaitForSingleObject()類似,同時監視位於控制代碼陣列中的所有控制代碼。這些被監視物件的控制代碼享有平等的優先權,任何一個控制代碼都不可能比其他控制代碼具有更高的優先權。WaitForMultipleObjects()的函式原型為:
DWORD WaitForMultipleObjects(
DWORD nCount, // 等待控制代碼數 CONST HANDLE *lpHandles, // 控制代碼陣列首地址 BOOL fWaitAll, // 等待標誌 DWORD dwMilliseconds // 等待時間間隔);
引數nCount指定了要等待的核心物件的數目,存放這些核心物件的陣列由lpHandles來指向。fWaitAll對指定的這nCount個核心物件的兩種等待方式進行了指定,為TRUE時當所有物件都被通知時函式才會返回,為FALSE則只要其中任何一個得到通知就可以返回。dwMilliseconds在這裡的作用與在WaitForSingleObject()中的作用是完全一致的。如果等待超時,函式將返回WAIT_TIMEOUT。如果返回WAIT_OBJECT_0到WAIT_OBJECT_0+nCount-1中的某個值,則說明所有指定物件的狀態均為已通知狀態(當fWaitAll為TRUE時)或是用以減去WAIT_OBJECT_0而得到發生通知的物件的索引(當fWaitAll為FALSE時)。如果返回值在WAIT_ABANDONED_0與WAIT_ABANDONED_0+nCount-1之間,則表示所有指定物件的狀態均為已通知,且其中至少有一個物件是被丟棄的互斥物件(當fWaitAll為TRUE時),或是用以減去WAIT_OBJECT_0表示一個等待正常結束的互斥物件的索引(當fWaitAll為FALSE時)。
下面給出的程式碼主要展示了對WaitForMultipleObjects()函式的使用。通過對兩個事件核心物件的等待來控制執行緒任務的執行與中途退出:
UINT ThreadProc14(LPVOID pParam)
{
DWORD dwRet1 = WaitForMultipleObjects(2, hEvents, FALSE, INFINITE); // 等待開啟事件if (dwRet1 == WAIT_OBJECT_0) // 如果開啟事件到達則執行緒開始執行任務 {
AfxMessageBox("執行緒開始工作!");
while (true)
{
for (int i =0; i <10000; i++);
DWORD dwRet2 = WaitForMultipleObjects(2, hEvents, FALSE, 0); // 在任務處理過程中等待結束事件 if (dwRet2 == WAIT_OBJECT_0 +1) // 如果結束事件置位則立即終止任務的執行break;
}
}
AfxMessageBox("執行緒退出!");
return0;
}
……
void CSample08View::OnStartEvent()
{
for (int i =0; i <2; i++) // 建立執行緒 hEvents[i] = CreateEvent(NULL, FALSE, FALSE, NULL);
AfxBeginThread(ThreadProc14, NULL); // 開啟執行緒 SetEvent(hEvents[0]); // 設定事件0(開啟事件)
}
void CSample08View::OnEndevent()
{
SetEvent(hEvents[1]); // 設定事件1(結束事件)
}
MFC為事件相關處理也提供了一個CEvent類,共包含有除建構函式外的4個成員函式PulseEvent()、ResetEvent()、SetEvent()和UnLock()。在功能上分別相當與Win32 API的PulseEvent()、ResetEvent()、SetEvent()和CloseHandle()等函式。而建構函式則履行了原CreateEvent()函式建立事件物件的職責,其函式原型為:
CEvent(BOOL bInitiallyOwn = FALSE, BOOL bManualReset = FALSE, LPCTSTR lpszName = NULL, LPSECURITY_ATTRIBUTES lpsaAttribute = NULL );
3.訊號量核心物件
訊號量(Semaphore)核心物件對執行緒的同步方式與前面幾種方法不同,它允許多個執行緒在同一時刻訪問同一資源,但是需要限制在同一時刻訪問此資源的最大執行緒數目。在用CreateSemaphore()建立訊號量時即要同時指出允許的最大資源計數和當前可用資源計數。一般是將當前可用資源計數設定為最大資源計數,每增加一個執行緒對共享資源的訪問,當前可用資源計數就會減1,只要當前可用資源計數是大於0的,就可以發出訊號量訊號。但是當前可用計數減小到0時則說明當前佔用資源的執行緒數已經達到了所允許的最大數目,不能在允許其他執行緒的進入,此時的訊號量訊號將無法發出。執行緒在處理完共享資源後,應在離開的同時通過ReleaseSemaphore()函式將當前可用資源計數加1。在任何時候當前可用資源計數決不可能大於最大資源計數。
使用訊號量核心物件進行執行緒同步主要會用到CreateSemaphore()、OpenSemaphore()、ReleaseSemaphore()、WaitForSingleObject()和WaitForMultipleObjects()等函式。其中,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伺服器要對同一時間內訪問同一頁面的使用者數加以限制,這時可以為沒一個使用者對伺服器的頁面請求設定一個執行緒,而頁面則是待保護的共享資源,通過使用訊號量對執行緒的同步作用可以確保在任一時刻無論有多少使用者對某一頁面進行訪問,只有不大於設定的最大使用者數目的執行緒能夠進行訪問,而其他的訪問企圖則被掛起,只有在有使用者退出對此頁面的訪問後才有可能進入。下面給出的示例程式碼即展示了類似的處理過程:
UINT ThreadProc15(LPVOID pParam)
{
WaitForSingleObject(hSemaphore, INFINITE); // 試圖進入訊號量關口
AfxMessageBox("執行緒一正在執行!"); // 執行緒任務處理
ReleaseSemaphore(hSemaphore, 1, NULL); // 釋放訊號量計數return0;
}
UINT ThreadProc16(LPVOID pParam)
{
WaitForSingleObject(hSemaphore, INFINITE);
AfxMessageBox("執行緒二正在執行!");
ReleaseSemaphore(hSemaphore, 1, NULL);
return0;
}
UINT ThreadProc17(LPVOID pParam)
{
WaitForSingleObject(hSemaphore, INFINITE);
AfxMessageBox("執行緒三正在執行!");
ReleaseSemaphore(hSemaphore, 1, NULL);
return0;
}
……
void CSample08View::OnSemaphore()
{
hSemaphore = CreateSemaphore(NULL, 2, 2, NULL); // 建立訊號量物件
AfxBeginThread(ThreadProc15, NULL); // 開啟執行緒 AfxBeginThread(ThreadProc16, NULL);
AfxBeginThread(ThreadProc17, NULL);
}
在MFC中,通過CSemaphore類對訊號量作了表述。該類只具有一個建構函式,可以構造一個訊號量物件,並對初始資源計數、最大資源計數、物件名和安全屬性等進行初始化,其原型如下:
CSemaphore( LONG lInitialCount = 1, LONG lMaxCount = 1, LPCTSTR pstrName = NULL, LPSECURITY_ATTRIBUTES lpsaAttributes = NULL );
在構造了CSemaphore類物件後,任何一個訪問受保護共享資源的執行緒都必須通過CSemaphore從父類CSyncObject類繼承得到的Lock()和UnLock()成員函式來訪問或釋放CSemaphore物件。與前面介紹的幾種通過MFC類保持執行緒同步的方法類似,通過CSemaphore類也可以將前面的執行緒同步程式碼進行改寫,這兩種使用訊號量的執行緒同步方法無論是在實現原理上還是從實現結果上都是完全一致的。下面給出經MFC改寫後的訊號量執行緒同步程式碼:
UINT ThreadProc24(LPVOID pParam)
{
// 試圖進入訊號量關口