Windows提供了多組支持多線程的應用程序接口(API)函數。許多讀者已經對Windows提供的多線程函數有一定程度的了解,但是對于那些不熟悉這些的讀者,本章提供了這些函數的概述。記住,Windows提供了許多其他的基于多線程的函數,這些函數需要您自己去探索。
為了使用Windows的多線程函數,必須在程序中包含<Windows.h>。
3.4.1 線程的創建和終止
Windows API提供了CreateThread()函數來創建一個線程。其原型如下所示:
HANDLE CreateThread(LPSECURITY_ATTRIBUTES secAttr,
SIZE_T stackSize,
LPTHREAD_START_ROUTINE threadFunc,
LPVOID param,
DWORD flags,
LPDWORD threadID);
在此,secAttr是一個用來描述線程的安全屬性的指針。如果secAttr是NULL,就會使用默認的安全描述符。
每個線程都具有自己的堆棧。可以使用stackSize參數來按字節指定新線程堆棧的大小。如果這個整數值為0,那么這個線程堆棧的大小與創建它的線程相同。如果需要的話,這個堆??梢詳U展。(通常使用0來指定線程堆棧的大小)。
每個線程都在創建它的進程中通過調用線程函數來開始執行。線程的執行一直持續到線程函數返回。這個函數的地址(也就是線程的入口點)在threadFunc中指定。每個線程函數都必須具有這樣的原型:
DWORD WINAPI threadfunc(LPVOID param);
需要傳遞給新線程的任何參數都在CreateThread()的param中指定。線程函數在它的參數中接收這個32位的值。這個參數可以用作任何目的。函數返回它的退出狀態。
參數flags確定了線程的執行狀態。如果它是0,線程會立即執行。如果是CREATE_SUSPEND,線程則以掛起狀態創建并等待執行。(可以通過調用ResumeThread()來開始執行,稍后討論)。
與線程相關的標識符以threadID所指向的長整型返回。
如果成功,函數則向線程返回一個句柄。如果失敗,則返回NULL。可以通過調用CloseHandle()來顯式銷毀這個線程。否則,會在父進程結束時自動銷毀它。
如前所述,當線程的入口函數返回時終止執行線程。進程也可以使用TerminateThread()或者ExitThread()來手動終止線程,這兩個函數的原型如下:
BOOL TerminateThread(HANDLE thread, DWORD status);
VOID ExitThread(DWORD status);
對于TerminateThread(),thread是將要終止的線程的句柄。ExitThread()只能用來終止調用了ExitThread()的線程。對于兩個函數而言,status是終止狀態。TerminateThread()如果成功,則會返回非0值,否則返回0。
調用ExitThread()在功能上等價于允許線程函數正常返回。這意味著堆棧會正確地重新設置。當使用TerminateThread()結束線程時,線程會立刻終止,而會執行任何特定的清理任務。另外,TerminateThread()可能會停止正在執行重要操作的線程。為此,當入口函數返回時,通常最好(也是最容易的)讓線程正常終止。
3.4.2 Visual C++對CreateThread()和ExitThread()的替換
盡管CreateThread()和ExitThread()是用來創建并終止線程的Windows API函數,我們在本章并不會使用它們。原因是在Visual C++中(其他的Windows兼容的編譯器也可能有這個問題)使用這兩個函數時,會導致內存泄漏,丟失少量的內存。對于Visual C++,如果多線程程序利用了C/C++標準庫函數并使用了CreateThread()和ExitThread(),就會丟失少量的內存。(如果您的程序沒有使用C/C++的標準庫,就不會發生這樣的內存丟失)。為了避免這種情況,必須使用Visual C++運行庫中定義的函數來開始和終止線程,而不是使用由Win32 API指定的函數。這些函數類似于CreateThread()和ExitThread(),但是不會產生內存泄漏。
提示:
如果使用非Visual C++的編譯器,如果需要的話,檢查它的文檔來確定是否需要忽略CreateThread()和ExitThread(),以及如何做到這一點。
Visual C++用_beginthreadex()和_endthreadex()來取代CreateThread()和ExitThread()。這兩個函數都需要頭文件<process.h>。下面是_beginthreadex()函數的原型:
uintptr_t _beginthreadex(void *secAttr, unsigned stackSize,
unsigned (__stdcall *threadFunc)(void *),
void *param, unsigned flags,
unsigned *threadID);
正如您看到的那樣,_beginthreadex()的參數類似于CreateThread()的參數。另外,這些參數與CreateThread()指定的參數有相同的含義。secAttr是一個用來描述線程安全性屬性的指針。然而,如果secAttr為NULL,則會使用默認的安全描述符。新線程堆棧的大小由stackSize參數按字節數傳遞。如果這個值為0,那么這個線程堆棧的大小與進程中創建它的主線程的大小相同。
線程函數的地址(也就是線程的入口點)在threadFunc中指定。對于_beginthreadex(),線程函數的原型如下:
unsigned_stdcall threadfunc(void *param)
這個原型在功能上等價于CreateThread()的線程函數的原型,但是它使用了不同的類型名稱。想要傳遞給新線程的任何參數都在param參數中指定。
flags參數確定線程的執行狀態。如果flags參數為0,線程會立即開始執行。如果flags參數為CREATE_SUSPEND,則以掛起狀態創建線程。(可以調用ResumeThread()來開始它)。與線程相關的標識符以threadID指向的double word返回。
如果成功,則這個函數返回一個線程的句柄;如果失敗,則返回0。類型uintptr_t指定了可以擁有指針或者句柄的Visual C++類型。
_endthreadex()的原型如下:
void _endthreadex(unsigned status);
它的功能就像ExitThread()那樣,停止線程并返回status中指定的退出代碼(exit code)。
由于Windows下使用最廣泛的編譯器是Visual C++,因此本章示例將使用_beginthreadex()和_endthreadex()而不是使用它們的等價的API函數。如果您使用了非Visual C++的編譯器,那么只需要用CreateThread()和EndThread()來替代這兩個函數。
當使用_beginthreadex()和_endthreadex()時,必須記住鏈接多線程庫。這隨編譯器的不同而不同。在此有一些示例。當使用Visual C++的命令行編譯器時,包括-MT選項。為了在Visual C++ 6 IDE中使用多線程庫,首先要激活“Project | Settings”屬性頁。然后選擇“C/C++”選項卡。接著在“Category”下拉列表框中選擇“Code Generation”,然后在“Use Runtime Library ”下拉列表框中選擇“Multithreaded”。對于Visual C++ 7 .NET IDE,選擇“Project |Properties”。然后選擇“C/C++”條目,并加亮顯示“Code Generation”。最后,將“Multithreaded”選擇為運行庫。
3.4.3 線程的掛起和恢復
線程的執行可以通過調用SuspendThread()來掛起??梢酝ㄟ^調用ResumeThread()來恢復它。這兩個函數的原型如下:
DWORD SuspendThread(HANDLE hThread);
DWORD ResumeThread(HANDLE hThread);
兩個函數都通過hThread來傳遞線程的句柄。
每個執行的線程都有與其相關的掛起計數。如果這個計數為0,那么不會掛起線程。如果為非0值,則線程就會處于掛起狀態。每次調用SuspendThread()都會增加這個計數。每次調用ResumeThread()都會減小這個掛起計數。掛起的線程只有在它的掛起計數達到0時才會恢復。因此,為了恢復一個掛起的線程,對ResumeThread()的調用次數必須與對SuspendThread()的調用次數相等。
這兩個函數都返回線程先前的掛起計數,如果發生錯誤,返回值為–1。
3.4.4 改變線程的優先級
在Windows中,每個線程都與一個優先級設置相關。線程的優先級決定了線程接收的CPU時間的多少。低優先級的線程接收比較少的時間,高優先級的線程接收比較多的時間。當然,線程接收的CPU時間的多少對于它的執行性能以及它與系統中當前執行的其他線程之間的交互有著深遠的影響。
在Windows中,線程優先級的設置是兩個值的組合:進程總體的優先級類別以及相對于這個優先級類別的各個線程的優先級設置。也就是說,線程實際的優先級由進程的優先級類別和各個線程的優先級的組合來確定。后面會逐一講述。
1. 優先級類別
在默認情況下,進程具有普通的優先級類別,大多數程序在其執行的聲明周期內保持這個普通的優先級類別。盡管在本章的示例中沒有改變優先級類別,但是為了完整起見,在此給出了線程優先級類別的簡單概況。
Windows定義了6個優先級類別,相應的值以從高到低的順序顯示如下:
REALTIME_PRIORITY_CLASS
HIGH_PRIORITY_CLASS
ABOVE_NORMAL_PRIORITY_CLASS
NORMAL_PRIORITY_CLASS
BELOW_NORMAL_PRIORITY_CLASS
IDLE_PRIORITY_CLASS
在默認情況下,程序的優先級類別為NORMAL_PRIORITY_CLASS。通常,您不需要改變程序的優先級類別。事實上,改變進程的優先級類別對于整個計算機系統的性能會有負面的影響。例如,如果您將一個程序的優先級類別增加到REALTIME_PRIORITY_CLASS,它就會支配CPU。對于某些特殊的應用程序,可能需要增加應用程序的優先級類別,但通常并不需要。如前所述,本章的應用程序沒有改變優先級類別。
當確實需要改變程序的優先級類別時,可以調用SetPriorityClass()??梢哉{用GetPriorityClass()來獲取當前的優先級類別。這兩個函數的原型如下:
DWORD GetPriorityClass(HANDLE hApp);
BOOL SetPriorityClass(HANDLE hApp, DWORD priority);
在此,hApp是進程的句柄。GetPriorityClass()返回應用程序的優先級類別,如果失敗的話,返回0。對于SetPriorityClass(),priority指定了進程的新優先級類別。
2. 線程優先級
對于給定的優先級類別,各個線程的優先級確定了它在進程內接收的CPU時間的多少。當線程第一次創建時,它具有普通的優先級,但是您可以改變線程的優先級—— 即使在它執 行時。
可以通過調用GetThreadPriority()來獲取線程的優先級設置??梢允褂肧etThreadPriority()來增加或者減小線程的優先級。這兩個函數的原型如下:
BOOL SetThreadPriority(HANDLE hThread, int priority);
int GetThreadPriority(HANDLE hThread);
對于這兩個函數而言,hThread是線程的句柄。對于SetThreadPriority(),priority是新的優先級設置。如果發生錯誤,則返回值為0;否則,返回非0值。GetThreadPriority()會返回當前的優先級設置。優先級設置按照從高到低的順序如表3-1所示。
表3-1 優先級設置
線程優先級
|
值
|
THREAD_PRIORITY_TIME_CRITICAL
|
15
|
THREAD_PRIORITY_HIGHEST
|
2
|
THREAD_PRIORITY_ABOVE_NORMAL
|
1
|
THREAD_PRIORITY_NORMAL
|
0
|
THREAD_PRIORITY_BELOW_NORMAL
|
-1
|
THREAD_PRIORITY_LOWEST
|
-2
|
THREAD_PRIORITY_IDLE
|
-15
|
這些值相對于進程的優先級類別或增或減。通過組合進程的優先級類別和線程的優先級,Windows向應用程序提供了31個不同的優先級設置的支持。
如果有錯誤發生,則GetThreadPriority()返回THREAD_PRIORITY_ERROR_RETURN。
在大多數情況下,如果線程具有普通的優先級類別,那么可以隨意地改變它的優先級設置,而不必擔心會給整個系統的性能帶來災難性的影響。您將會看到,在下面部分開發的線程控制面板中,可以改變進程內線程的優先級設置(但是不能改變優先級類別)。
3.4.5 獲取主線程的句柄
主線程的執行是可以控制的。為此,需要獲取它的句柄。做到這一點最簡單的方法是調用GetCurrentThread(),其原型如下:
HANDLE GetCurrentThread(void);
這個函數返回當前線程的偽句柄(pseudohandle)。之所以稱之為偽句柄,是因為它是一個預定義的值,總是引用當前的線程,而不是引用指定的調用線程。然而,它能夠用在任何可以使用普通線程處理的地方。
3.4.6 同步
在使用多線程或多進程時,有時需要調整兩個或者多個線程(或者進程)之間的活動。這個過程稱為同步。當兩個或者多個線程需要訪問共享資源,而這個共享資源在同一時刻只能由一個線程使用時,就需要使用同步。例如,當一個線程在寫文件時,在此時必須阻止另一個線程也這么做。同步的另一個原因是有時線程需要等待由另一個線程引發的事件。在此情況下,必須采取某種措施將第一個線程保持掛起狀態,直到這個事件發生。隨后等待的線程必須恢復 執行。
通常某個任務會處于兩種狀態。首先,它可能正在執行(或者在獲得它的時間段時就開始執行)。另外,任務可能被阻塞,等待某個資源或者事件。在此情況下其執行被掛起,直到所需的資源可以使用或者所等待的事件發生。
如果您對于同步問題或者它的常用解決方案(信號量)不熟悉,下面的部分將對此進行討論。
1. 理解同步問題
Windows必須提供某種特殊的服務來允許對共享資源的訪問同步,因為如果沒有操作系統的協助,進程或者線程就沒有辦法得知它是否在單獨訪問某個資源。為了理解這個問題,假定您在為一個沒有提供任何同步支持的多任務操作系統編寫程序,并且假定您具有兩個并發執行的線程A和B,它們都不時地訪問某個資源R(如磁盤文件),這個資源在某個時刻只能被一個線程訪問。為了在一個線程使用這個資源時阻止另一個線程訪問它,您嘗試了下面的解決方案。首先,創建了一個初始化值為0并且兩個線程都可以訪問的變量,名為flag。然后,在使用訪問R的每段代碼之前,等待flag被清0,然后設置flag,訪問R,最后將flag清0。也就是說,在每個線程訪問R之前,執行如下的代碼:
while(flag) ; // wait for flag to be cleared
flag = 1; // set flag
// ... access resource R ...
flag = 0; // clear the flag
這段代碼隱含的概念是,如果設置了flag,則兩個線程都不能夠訪問R。從概念上講,這種方法是正確的解決方案。然而,實際上它遠遠沒有達到要求,原因很簡單:它并非總是有效!讓我們看一下原因。
使用剛才給定的代碼,有可能兩個進程同時訪問R。while循環在本質上執行重復的加載和比較flag上的指令。換句話說,它一直在測試flag的值。當flag被清0的時候,代碼的下一行將設置flag的值。問題在于,這兩個操作有可能在兩個不同的時間段執行。在兩個時間段之間,flag的值有可能被另一個線程訪問,從而R被兩個線程同時訪問。為了理解這一點,假定線程A進入while循環,發現flag為0,這是訪問R的綠燈。然而,在將flag設置為1之前,其時間段用盡,線程B恢復執行。如果B執行了它的while,它也發現flag沒有被設置,并且認為它可以安全地訪問R。然而,當A重新開始時,它也會訪問R。問題的關鍵在于對flag的測試和設置沒有包含在一個連續的操作中,而是可以被分為兩個部分,正如剛才說明的那樣。無論您如何努力,都沒有辦法只使用應用層的代碼以絕對保證在同一時刻只有一個線程訪問R。
對同步問題的解決方案簡單而優雅。操作系統(在Windows中)提供了一個例程,在一個連續的操作中完成對flag的測試和設置(如果可能的話)。用操作系統工程師的話來說,這就是所謂的測試和置位(test and set)操作。由于歷史的原因,用來控制對共享資源的訪問并提供線程(以及進程)間同步的標記被稱為信號量。信號量是Windows同步系統的核心。
2. Windows的同步對象
Windows支持幾種類型的同步對象。第一種類型是經典的信號量。當使用信號量時,可以完全同步資源,在此情況下只有一個進程或者線程在同一時刻可以訪問這個資源,或者信號量允許不超過一定數量的進程或者線程在同一時刻訪問資源。信號量使用計數器來實現,當某個任務使用信號量時,計數器減?。划斶@個任務釋放信號量時,計數器增加。
第二個同步對象是互斥體信號量,或者簡稱為互斥體。互斥體將一個資源同步,保證在任何時候都只有一個線程或者進程來訪問它。在本質上,互斥體是標準信號量的一個特殊版本。
第三個同步對象是事件對象。它可以用來阻塞對某個資源的訪問,直到某個其他的進程或者線程發送信號,通知可以使用資源(也就是一個事件對象發送某個指定的事件發生的信號)。
第四個同步對象是可等待計時器??傻却嫊r器阻塞線程的執行,直到指定的時間。也可以創建計時器序列,這是一個計時器的列表。
可以使用臨界區對象將一個代碼段放入臨界區,從而阻止在同一時刻一個以上的線程使用這段代碼。當一個線程進入臨界區時,其他線程只有在第一個線程離開整個臨界區時才可以使用它。
本章使用的惟一的同步對象是互斥體,下面的部分將對其進行描述。然而,C++程序員可以使用所有的Windows定義的同步對象。如前所述,這是使得C++依賴于操作系統處理多線程的主要優點之一:所有的多線程特性都在您的控制之中。
3. 使用互斥體同步線程
如前所述,互斥體是一種特殊的信號量,在給定的時間內,只允許一個線程訪問某個資源。在使用互斥體之前,必須使用CreatMutex()創建一個互斥體,函數原型如下:
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES secAttr,
BOOL acquire,
LPCSTR name);
在此,secAttr是用來描述安全屬性的指針。如果secAttr為NULL,則使用默認的安全描 述符。
如果創建的線程需要互斥體的控制,則acquire為true,否則為false。
name參數指向一個字符串,這個字符串是互斥體對象的名稱?;コ怏w是一個全局對象,它可能被其他進程使用。為此,當兩個進程都打開了使用相同名稱的互斥體時,二者引用了相同的互斥體。使用這種方法可以將兩個進程同步。這個名稱也可以為NULL,在此情況下這個信號量被限制在一個進程之內。
如果成功,則CreatMutex()函數返回信號量的句柄,否則,返回NULL。當主進程結束時,互斥體的句柄則自動關閉。當不再需要時,可以調用CloseHandle()來顯式地關閉互斥體的句柄。
當創建信號量時,可以調用兩個相關的函數來使用它:WaitForSingleObject()和ReleaseMutex()。這兩個函數的原型如下:
DWORD WaitForSingleObject(HANDLE hObject, DWORD howLong);
BOOL ReleaseMutex(HANDLE hMutex);
WaitForSingleObject()等待一個同步對象,直到這個對象可以使用或者超時之后才會返回。在使用互斥體時,hObject是互斥體的句柄。howLong參數以毫秒為單位指定調用例程的等待時間。當這個時間用盡時,會返回超時錯誤。為了無限期地等待,可以使用值INFINITE。當成功時(也就是訪問被準許),這個函數返回WAIT_OBJECT_0。當發生超時時,返回WAIT_TIMEOUT。
ReleaseMutex()釋放互斥體,并允許其他線程獲取它。在此,hMutex是互斥體的句柄。如果成功,則函數返回非0值;如果失敗,則返回0。
為了使用互斥體控制對共享資源的訪問,封裝了訪問在調用WaitForSingleObject()和ReleaseMutex()之間的資源的代碼,如下面的代碼所示(當然,超時期限隨應用程序的不同而 不同)。
if(WaitForSingleObject(hMutex, 10000)==WAIT_TIMEOUT) {
// handle time-out error
}
// access the resource
ReleaseMutex(hMutex);
通常,您會選擇足夠長的超時期限來適應程序的操作。如果在開發多線程應用程序時重復出現超時錯誤,那么通常意味著您創建了死鎖條件。當一個線程等待另一個線程永遠都不會釋放的互斥體時,就會發生死鎖。