17-2线程同步
Windows支持4中类型的同步对象,可以用过来同步由并发运行的线程执行的操作:
临界区
互斥量
事件
信号量
MFC在名为CCriticalSectionCMutexCEvent和CSemaphore的类中封装了这些对象。MFC还包含了名为CSingleLock和CMultiLock的一对类。
17.2.1临界区
最简单类型的线程同步对象就是临界区。临界区用来串行化对两个或多个线程公用的链表、简单变量、结构和其他资源的访问、这些线程必须属于相同的进程。因为临界区是不能跨越进程的边界工作。
临界区背后的思想就是,每个对战型第访问一个资源的线程可以在访问那个资源之前锁定临界区,网文完成之后接触锁定。如果线程B师徒锁定当前有线程A锁定的临界区,线程B将阻塞知道该临界区空闲。
阻塞是,线程B出在一个十分有效的等待状态等待,他不消耗处理器时间。
CCriticalSection::Lock锁定临界区,而CCriticalSection::Unlock接触对临界区的锁定。
1 //Global data 2 CCriticalSection g_cs; 3 . 4 . 5 . 6 //Thread A 7 g_cs.Lock(); 8 //Write to the linked list. 9 g_cs.Unlock(); 10 . 11 . 12 . 13 //Thread B 14 g_cs.Lock(); 15 //Read from the linked list. 16 g_cs.Unlock();
17.2.2互斥量
与临界区不同的是,互斥量可以用来同步在相同进程或者不同进程上运行的线程。
1 //Global data 2 CMutex g_mutex(FALSE,_T("MyMutex")); 3 . 4 . 5 . 6 g_mutex.Lock(); 7 //Read or write the linked list. 8 g_mutex.Unlock();
传递给CMutex构造函数的第一个参数指定互斥两的初始状态是锁定(TRUE)或者么有所动(FALSE).第二个参数指定互斥量的名称,如果该互斥量是用来同步在两个不同进程上的线程,就需要这个名称。
默认情况下,Lock将永远等待直到互斥量变为没有锁定。您可以指定一个以毫秒为单位的最大等待时间来建立一个安全失败机制。
g_mutex.Lock(60000); //Read or write the linked list g_mutex.Unlock();
Lock的返回值高速您函数调用返回的原因。一个非0的返回值表示互斥量空闲,而0表示超时时间段首先到期了。如果Lock返回0,不访问共享资源通常是比较紧身的,因为访问共享资源可能会导致访问重叠。因此,使用Lock的超时特性的代码一般如下这样的构造:
if(g_mutex.Lock(60000)){ //Read or write the linked list. g_mutex.Unlock(); }
互斥量和临界区之间还有另外一个差别。如果一个线程锁定了临界区而终止时没有解除临界区的锁定,那么等待临界区空闲的其他线程将无限期第阻塞下去。热按而,如果锁定互斥两的线程不能在其终止前解除互斥量的锁定,那么系统将认为互斥量被放弃了
并自动释放互斥两,这样等待进程就可以继续执行。
17.2.3事件
MFC的CEvent类封装了Win32事件对象。在任何特定的时间,事件值能处于两种状态中的一种:引发(设置)或者调低(重置)。设置状态也可以认为是处于信号状态,重置状态时间也可以热内是处于非信号状态。CEvent::SetEvent设置一个事件,而CEvent::RestEvent将事件重置。相关函数CEvent::PulseEvent可以在一次操作中设置和清楚一个事件。
Windows支持两种不同类型的事件:自动重置时间和手动重置事件。它们之间的差别非常细微。当在自动重置时间上阻塞的线程被唤醒时,该事件被自动重置为信号状态。手动重置时间不能自动重置,他必须使用编程方式重置。用于选择自动重置事件还是手动重置事件——以及一旦您做出选择后如何使用它们——规则如下:
1如果时间只触发一个线程,那么使用自动重置时间和使用SetEvent唤醒等待线程。这里不需要调用ResetEvent,因为线程被唤醒的那一刻事件将被自动重置。
2如果时间将触发两个或多个线程,那么使用手动重置线程和使用PulseEvent唤醒所有等待线程。而且,您不需要调用ResetEvent,因为PulseEvent在唤醒线程后将为您重置时间。
使用手动重置时间来触发多个线程是至关重要的。因为自动重置时间将其中一个线程被唤醒的哪一个被重置,因此它只触发一个线程。使用PulseEvent来按下手动重置时间上的触发器也是相当重要的。如果您使用SetEvent和ResetEvent,您就不能保证所有的等待线程将被唤醒。PulseEvent不进能够设置和重置时间,而且还确保了所有在时间上等待的线程重置之前被唤醒。
通过构造一个CEvent对象来创建时间。CEvent::CEvent接收4个参数,它们都是可默认的。其原型如下所示:
1 CEvent(BOOL bInitiallyOwn=FALSE, 2 BOOL bManualReset=FALSE, 3 LPCTSTR lpszName=NULL, 4 LPSECURITY_ATTRIBUTES lpsaAttribute=NULL)
第一个参数bInitiallyOwn指定时间对象被初始化为信号状态(TRUE)还是非信号状态(FALSE)。在大多数情况下去默认值即可。
bManualReset指定时间为手动重置事件(TRUE)还是自动重置时间(FALSE).
lpszName给时间对象指定一个名称。与互斥量相同的事,时间可以i用来协调在不同进程上运行的线程,对于跨越进程边界的事件,必须给它指定一个名称。如果使用时间的多个线程属于同一进程,那么lpszName应当为NULL。
lpsaAttribute是一个纸箱SECURITY_ATTRIBUTES结构的指针,它描述了时间对象的安全属性。NULL表示接受默认的安全属性它适用于大部分的应用程序。
下面的示例包括一个向缓冲区填充数据的线程(线程A)和另一个对数据进行处理的线程(线程B)。假定线程B必须等待来自线程A的一个信号(缓冲区一初始化并准备工作)。自动重置时间是完成这项工作的绝好工具:
//Global data CEvent g_event;//Autoreset, initially nonsignaled . . . //Tread A InitBuffer(&buffer);//Initialize the buffer g_event.SetEvent();//Release thread B. . . . //Thread B g_event.Lock();//Wait for the signal.
线程B调用Lock()来阻塞时间对象。当线程A准备唤醒线程B是调用SetEvent
A---------------------->SetEvent-------------------------->
B--------->Loce- - - - >线程B被释放后系统自动重置时间------>
在线程A设置事件之前线程B被锁定。
传递给Lock的单个参数指定了调用者愿意等待的时间长度,一毫秒为单位。默认值是INFINITE。非0返回值表示Lock成功返回,因为对象变为信号状态;0表示超时时间段到期或发生错误。
手动:
//Global data CEvent g_event(FASLE,TRUE);//Nonsignaled,manual-reset . . . //Thread A InitBuffer(&buffer);//Initialize the buffer g_event.PulseEvent();//Release threads B and C . . . //Thread B g_event.Lock();//Wait for the signal. . . . //Thread C g_event.Lock();//Wait for the signal.
A----------------------------------------->PulseEvent--------------------------------->
B----->Lock- - - - - - - - - - - - - - - - - ->当所有等待线程释放后,---------------------->
PulseEvent重置时间。
C----------->Lock- - - - - - - - - - -- - - - >同上--------------------------------------->
有时不用时间做触发器,而是用它做原始的信号发生机制。例如:或许线程B想知道线程A是否完成任务,但如果答案是否定的,它不希望被阻塞。通过传递给::WaitForSigleObject事件句柄和超时值0,线程B可以避免被阻塞并检查事件的状态。时间句柄可以从CEvent的m_hObject数据成员中提取:
if(::WaitForSingleObject(g_event.m_hObject,0)==WAIT_OBJECT_0){ //The event is signaled. }else{ //The event is not signaled. }
这样使用事件时要注意一个警告:如果在时间变成设置状态之前,线程要反复检查时间,则一定要确信时间是手动重置时间,而不是自动重置事件。否则,检查本身就会使他重置。
17.2.4信号量
如果任何一个线程锁定了时间、临界区和互斥对象,Lock就会阻塞他们,在这个意义上,这三种对象具有这样的特性:“要么有,要么什么都没有“。信号量不同,它始终保持有代表可用资源数量的资源数。锁定信号量会减少资源数,释放信号量则增加资源数。只有在线程适度锁定资源数为0的信号量时,线程才会被阻塞。在这种情况下,知道另一个线程释放信号量,资源数随之增加时,或者直到指定的超时时间其满时,该线程才被释放。信号量可以用来同步化同一进程中的线程,也可以同步话不同进程中的线程。
MFC用CSemaphore的示例代表信号量。
CSemaphore g_semaphore(3,3);
上面语句构造了一个信号量对象,其初始资源数为3(参数1),最大资源数也为3(参数2)。如果信号量用于同步化不同进程中的线程,则要添加第三个参数,并赋予它信号量名。可选的第四个参数指向SECURITY_ATTRIBUTES结构(默认值等于NULL)。每个线程可以这样访问由信号量控制的资源:
CSemaphore g_semaphore(3,3); g_semaphore.Lock(); //Access the shared resource. g_semaphore.Unlock();
A------------->Lock================>Unlock------------------------>
B--------------->Lock================>Unlock---------------------->
C----------------->Lock================>Unlock-------------------->
D------------------->Lock - - - - - - - - - - - - ========>Unlock--------->
CSemphore::Unlock可用大于1的增量增加资源数,并在调用Unlock之前确定资源数的大小。例如:假定同一线程连续两次调用Lock,请求使用由信号量保护的两个资源,则为了解除锁定,线程不必调用Unlock两次,可以这样:
LONG lPrevCount; g_semaphore.Unlock(2,&lPrevCount);
信号量的传统用法是:它允许一组线程(m个)共享n个资源,其中m比n大。例如:假定要启动10个县城,并且每个线程都要手机数据。不管何时线程用数据填充缓冲区,线程都要通过打开的套接字传送数据,清空缓冲区并重新开始手机数据。现在假定在任意时刻只有三个套接字可以使用。如果信号量保护该套接字池,其中资源数为3,然后编写各线程代码,使它们在请求套接字前锁定信号量,则线程在等待套接字被释放的过程中就不会占用CPU时间。
17.2.5 CSingleLock和CMultiLock类
MFC包含了一对类:CSingleLock和CMultiLock,它们具有自己的Lock和Unlock函数。您可以在CSingleLock对象中包含临界区、互斥对象、事件或信号量,并使用CSingleLock::Lock来实现锁定,如下所示;
CCriticalSection g_cs; . . . CSingleLock lock(&g_cs);//Wrap it in a CSingleLock. lock.Lock();//Lock the critical section.
CSingleLock对象时在栈上创建的,如果异常出现就会调用给他的析构函数。CSingleLock的析构函数会调用被包含的同步化对象中的Unlock。CSingleLock是一个便于使用的工具,用来确保面对偶然产生的异常,已锁定的同步化对象也可以得到解锁
CMultiLock则完全不同。通过使用CMultiLock,一个线程可以一次阻塞之多64个同步化对象。并且根据它调用CMultiLock::Lock方式的不同,线程可以处于阻塞状态直到同步化对象之一获得自由或知道所有对象被释放。下列代码说明了一个线程同时阻塞两个时间和一个互斥对象。需要注意事件、互斥对象和信号量可以封装在CMultiLock对象中,而临界区不行。
CMutex g_mutex; CEvent g_event[2]; CSyncObject *g_pObjects[3] = {&g_mutex,&g_event[0],&g_event[1]}; //Block until all three objexts become signaled. CMultiLock multiLock(g_pObjects,3); multiLock.Lock(); //Block until one of the three objects becomes signaled CMultiLock multiLock(g_pObjects,3); multiLock.Lock(INFINITE,FALSE);
CMutiLock::Lock接收3个参数,他们都是可选的。第一个指定等待时间值(默认为INFINITE).第2个参数指定线程应该被唤醒的时间,是在同步化对象之一解锁(FALSE)之后还是在所有对象解锁(默认为TRUE)之后。第3个参数是“唤醒掩码”,指定了唤醒线程其他条件,例如WM_PAINT消息或鼠标键消息。默认唤醒掩码的值为0,它防止因任何原因被唤醒线程,除非是同步化对象被释放或等待时间已经期满。
在调用CMultiLock::Lock使线程处于阻塞状态,直到同步化对象进入信号发出状态,如果线程脱离了阻塞状态一般情况下线程需要知道是哪个对象处于信号发出状态。从Lock的返回值中可以弄清答案:
CMutex g_mutex; CEvent g_event[2]; CSyncObject *g_pObjects[3] = {&g_mutex,&g_event[0],&g_event[1]}; //Block until all three objexts become signaled. CMultiLock multiLock(g_pObjects,3); DWORD dwResult = multiLock.Lock(INFINITE,FALSE); DWORD nIndex = dwResult - WAIT_OBJECT_0; if(nIndex==0){ //The mutex became signaled. } else if(nIndex==1){ //The first event became signaled. } else if(nIndex==2){ //The second event became signaled }
如果Lock传递的等待时间不是INFINITE,就应该减去WAIT_OBJECT_0之前与返回值WAIT_TIMEOUT进行比较,以防Lock在等待期满之后发返回。另外,如果Lock返回时因为已放弃的互斥对象进入了信号已发出状态,那么您必须从返回值中减去WAIT_ABANDONED_0而不是WAIT_OBJECT_0。
下面给出的例子说明了可以使用CMultiLock的情况。假设3个线程,线程A,B,C共同准备缓冲区中的数据。一旦数据准备好,线程D就会通过套接字来传送数据结构或将它写入文件。但是在线程A,B,C全部完成它们的工作前不能调用线程D。
怎样解决此问题呢?创建独立的时间对象代表线程A,B,C让线程D使用CMultiLock对象儿进入阻塞状态,直到所有3个事件都处于信号发出状态。当每个线程都完成它们的工作时,它就会将相应的时间对象设置为信号发出状态。所以线程D会持续阻塞状态,直到3个县城中的最后一个被设置为信号发出状态。
17.2.6编写线程安全类
MFC类在类层次是线程安全的而在对象层次上却不是。这意味着两个线程可以安全的访问同一个类的两个独立的实例,但是如果允许两个线程同时访问同一个实例就会产生问题。
一种方式是派生一个类,通过将其声称与临界区内实现派生类的线程安全性,该临界区要杂i任何访问发生的时刻被自动锁定。这样对象自身就确保了访问时在线程安全的状态下进行的,并且在也不用将同步化线程的责任依赖于使用对象的应用程序了。
派生一个类并使它具有线程安全性基本上就是i要覆盖所有读取或写入对象数据的成员函数,并用锁定和解锁作为派生类成员的同步化对象的函数调用来封装基类中成员函数的调用。同样,对于那些不是通过派生而是通通过组合的到的线程安全类来说,要给类添加CCriticalSection或CMutex数据成员,并在任何访问发生之前锁定和解锁同步化对象。