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,那么这个线程堆栈的大小与创建它的线程相同。如果需要的话,这个堆栈可以扩展。(通常使用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()来获取线程的优先级设置。可以使用SetThreadPriority()来增加或者减小线程的优先级。这两个函数的原型如下:
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支持几种类型的同步对象。第一种类型是经典的信号量。当使用信号量时,可以完全同步资源,在此情况下只有一个进程或者线程在同一时刻可以访问这个资源,或者信号量允许不超过一定数量的进程或者线程在同一时刻访问资源。信号量使用计数器来实现,当某个任务使用信号量时,计数器减小;当这个任务释放信号量时,计数器增加。
第二个同步对象是互斥体信号量,或者简称为互斥体。互斥体将一个资源同步,保证在任何时候都只有一个线程或者进程来访问它。在本质上,互斥体是标准信号量的一个特殊版本。
第三个同步对象是事件对象。它可以用来阻塞对某个资源的访问,直到某个其他的进程或者线程发送信号,通知可以使用资源(也就是一个事件对象发送某个指定的事件发生的信号)。
第四个同步对象是可等待计时器。可等待计时器阻塞线程的执行,直到指定的时间。也可以创建计时器序列,这是一个计时器的列表。
可以使用临界区对象将一个代码段放入临界区,从而阻止在同一时刻一个以上的线程使用这段代码。当一个线程进入临界区时,其他线程只有在第一个线程离开整个临界区时才可以使用它。
本章使用的惟一的同步对象是互斥体,下面的部分将对其进行描述。然而,C++程序员可以使用所有的Windows定义的同步对象。如前所述,这是使得C++依赖于操作系统处理多线程的主要优点之一:所有的多线程特性都在您的控制之中。
3. 使用互斥体同步线程
如前所述,互斥体是一种特殊的信号量,在给定的时间内,只允许一个线程访问某个资源。在使用互斥体之前,必须使用CreatMutex()创建一个互斥体,函数原型如下:
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES secAttr,
BOOL acquire,
LPCSTR name);
在此,secAttr是用来描述安全属性的指针。如果secAttr为NULL,则使用默认的安全描 述符。
如果创建的线程需要互斥体的控制,则acquire为true,否则为false。
name参数指向一个字符串,这个字符串是互斥体对象的名称。互斥体是一个全局对象,它可能被其他进程使用。为此,当两个进程都打开了使用相同名称的互斥体时,二者引用了相同的互斥体。使用这种方法可以将两个进程同步。这个名称也可以为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);
通常,您会选择足够长的超时期限来适应程序的操作。如果在开发多线程应用程序时重复出现超时错误,那么通常意味着您创建了死锁条件。当一个线程等待另一个线程永远都不会释放的互斥体时,就会发生死锁。