在使用多线程或多进程时,有时需要调整两个或者多个线程(或者进程)之间的活动。这个过程称为同步。当两个或者多个线程需要访问共享资源,而这个共享资源在同一时刻只能由一个线程使用时,就需要使用同步。例如,当一个线程在写文件时,在此时必须阻止另一个线程也这么做。同步的另一个原因是有时线程需要等待由另一个线程引发的事件。在此情况下,必须采取某种措施将第一个线程保持挂起状态,直到这个事件发生。随后等待的线程必须恢复 执行。
通常某个任务会处于两种状态。首先,它可能正在执行(或者在获得它的时间段时就开始执行)。另外,任务可能被阻塞,等待某个资源或者事件。在此情况下其执行被挂起,直到所需的资源可以使用或者所等待的事件发生。
如果您对于同步问题或者它的常用解决方案(信号量)不熟悉,下面的部分将对此进行讨论。
理解同步问题
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同步系统的核心。
Windows的同步对象
Windows支持几种类型的同步对象。
第一种类型是经典的信号量。当使用信号量时,可以完全同步资源,在此情况下只有一个进程或者线程在同一时刻可以访问这个资源,或者信号量允许不超过一定数量的进程或者线程在同一时刻访问资源。信号量使用计数器来实现,当某个任务使用信号量时,计数器减小;当这个任务释放信号量时,计数器增加。
第二个同步对象是互斥体信号量,或者简称为互斥体。互斥体将一个资源同步,保证在任何时候都只有一个线程或者进程来访问它。在本质上,互斥体是标准信号量的一个特殊版本。
第三个同步对象是事件对象。它可以用来阻塞对某个资源的访问,直到某个其他的进程或者线程发送信号,通知可以使用资源(也就是一个事件对象发送某个指定的事件发生的信号)。
第四个同步对象是可等待计时器。可等待计时器阻塞线程的执行,直到指定的时间。也可以创建计时器序列,这是一个计时器的列表。
可以使用临界区对象将一个代码段放入临界区,从而阻止在同一时刻一个以上的线程使用这段代码。当一个线程进入临界区时,其他线程只有在第一个线程离开整个临界区时才可以使用它。
本章使用的惟一的同步对象是互斥体,下面的部分将对其进行描述。然而,C++程序员可以使用所有的Windows定义的同步对象。如前所述,这是使得C++依赖于操作系统处理多线程的主要优点之一:所有的多线程特性都在您的控制之中。
使用互斥体同步线程
如前所述,互斥体是一种特殊的信号量,在给定的时间内,只允许一个线程访问某个资源。在使用互斥体之前,必须使用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);
通常,您会选择足够长的超时期限来适应程序的操作。如果在开发多线程应用程序时重复出现超时错误,那么通常意味着您创建了死锁条件。当一个线程等待另一个线程永远都不会释放的互斥体时,就会发生死锁。