原文链接:Synchronization in Multithreaded Applications with MFC
简介
本文探讨基本的同步概念,并实际动手帮助新手掌握多线程编程。本文的重点在各种同步技巧。
基本概念
在线程执行过程中,或多或少都需要彼此交互,这种交互行为有多种形式和类型。例如,一个线程在执行完它被赋予的任务后,通知另一个线程任务已经完成。然后第二个线程做开始剩下的工作。
下述对象是用来支持同步的:
1)信号量
2)互斥锁
3)关键区域
4)事件
每个对象都有不同的目的和用途,但基本目的都是支持同步。当然还有其他可以用来同步的对象,比如进程和线程对象。后两者的使用由程序员决定,比如说判断一个给定进程或线程是否执行完毕为了使用进程和线程对象来进行同步,我们一般使用Wait*函数,在使用这些函数时,你应当知道一个概念,任何被作为同步对象的内核对象(关键区域除外)都处于两种状态之一:通知状态和未通知状态。例如,进程和线程对象,当他们开始执行时处于未通知状态,而当他们执行完毕时处于通知状态,
为了判断一个给定进程或线程是否已经结束,我们必须判断表示其的对象是否处于通知状态,而要达到这样的目的,我们需要使用Wait*函数。
Wait*函数
下面是最简单的Wait*函数:
参数hHandle表示待检查其状态(通知或者未通知)的对象,dwMilliseconds表示调用线程在被检查对象进入其通知状态前应该等待的时间。若对象处于通知状态或指定时间过去了,这个函数返回控制权给调用线程。若dwMilliseconds设置为INIFINITE(值为-1),则调用线程会一直等待直到对象状态变为通知,这有可能使得调用线程永远等待下去,导致“饿死”。
例如,检查指定线程是否正在执行, dwMilliseconds设置为0,是为了让调用线程马上返回。
下一个Wait类函数类似上面的,但它带的是一系列句柄,并且等待其中之一或全部进入已通知状态。
参数nCount表示待检查的句柄个数,lpHandles指向句柄数组,若fWaitAll为TRUE,则等待所有的对象进入已通知状态,若为FALSE,则当任何一个对象进入已通知状态时,函数返回。dwMilliseconds意义同上。
例如,下面代码判断哪个进程会先结束:
句柄数组中索引号为index的对象进入已通知状态时,函数返回WAIT_OBJECT_0 + 索引号。若fWaitAll为TRUE,则当所有对象进入已通知状态时,函数返回WAIT_OBJECT_0。
一个线程若调用一个Wait*函数,则它从用户模式切换为内核模式。这带来的后果有好有坏。不好的是切换进入内核模式大概需要1000个时钟周期,这消耗不算小。好的是当进入内核模式后,就不需要使用处理器,而是进入休眠态,不参与处理器的调度了。
现在让我们进入MFC,并看看它能为我们做些什么。这里有两个类封装了对Wait*函数的调用: CSingleLock和CMultiLock。
同步对象 | 等价的C++类 |
|
|
|
|
|
|
|
|
每个类都从一个类--CSyncObject继承下来,此类最有用的成员是重载的HANDLE运算符,它返回指定同步对象的内在句柄。所有这些类都定义在<AfxMt.h>头文件中。
事件
一般来说,事件用于这样的情形下:当指定的动作发生后,一个线程(或多个线程)才开始执行其任务。例如,一个线程可能等待必需的数据收集完后才开始将其保存到硬盘上。有两种事件:手动重置型和自动重置型。通过使用事件,我们可以轻松地通知另一个线程特定的动作已经发生了。对于手动重置型事件,线程使用它通知多个线程特定动作已经发生,而对于自动重置型事件,线程使用它只可以通知一个线程。在MFC中,CEvent类封装了事件对象(若在win32中,它是用一个HANDLE来表示的)。CEvent的构造函数运行我们选择创建手动重置型和自动重置型事件。默认的创建类型是自动重置型事件。为了通知正在等待的线程,我们可以调用CEvent::SetEvent方法,这个方法将会让事件进入已通知状态。若事件是手动重置型,则事件会保持已通知状态,直到对应的CEvent::ResetEvent被调用,这个方法将使得事件进入未通知状态。这个特性使得一个线程可以通过一个SetEvent调用去通知多个线程。若事件是自动重置型,则所有正在等待的线程中只有一个线程会接收到通知。当那个线程接收到通知后,事件会自动进入未通知状态。
下面两个例子将展示上述特性:
在这个例子中,一个全局的CEvent对象被创建,当然它是自动重置型的。除此以外,有两个工作线程在等待这个事件对象以便开始其工作。只要第三个线程调用那个事件对象的SetEvent方法,则两个线程中之一(当然没人知道会是哪个)会接收到通知,然后事件会进入未通知状态,这就防止了第二个线程也得到事件的通知。
下面来看第二个例子:
这段代码和上面的稍有不同,CEvent对象构造函数的参数不一样了,但意义上就大不同了,这是一个手动重置型事件对象。若第三个线程调用事件对象的SetEvent方法,则可以确保两个工作线程都会同时(几乎是同时)开始工作。这是因为手动重置型事件在进入已通知状态后,会保持此状态直到对应的ResetEvent被调用。
除此以外事件对象还有一个方法:CEvent::PulseEvent。这个方法首先使得事件对象进入已通知状态,然后使其退回到未通知状态。若事件是手动重置型,事件进入已通知状态会让所有正在等待的线程得到通知,然后事件进入未通知状态。若事件是自动重置型,事件进入已通知状态时只会让所有等待的线程之一得到通知。若没有线程在等待,则调用ResetEvent什么也不干。
实例---工作者线程
本文所带的例子中,作者将展示如何创建工作者线程以及如何合理地销毁它们。作者定义了一个被所有线程使用的控制函数。当点击视图区域时,就创建一个线程。所有被创建的线程使用上述控制函数在视图客户区绘制一个运动的圆形。这里作者使用了一个手动重置型事件,它被用来通知所有工作线程其“死讯”。除此以外,我们将看到如何使得主线程等待直到所有工作者线程销毁掉。
作者将线程函数定义为全局的:
注意作者传入的是一个安全句柄,而不是一个CWnd指针,并且在线程函数中通过传入的句柄创建一个临时的C++对象并使用。这样就避免了在多线程编程中多个对象引用单个C++对象的危险。
为了合理地销毁所有线程,首先使得事件进入已通知状态,这会通知工作线程“死期已至”,然后调用WaitForSingleObject让主线程等待所有的工作者线程完全销毁掉。注意每次迭代时调用WaitForSingleObject会导致从用户模式进入内核模式。例如,10此迭代会浪费掉大约10000次时钟周期。为了避免这个问题,我们可以使用WaitForMultipleObjects。这就是第二种方法。
关键区域
和其他同步对象不同,除非有需要以外,关键区域工作在用户模式下。若一个线程想运行一个封装在关键区域中的代码,它首先做一个旋转封锁,然后等待特定的时间,它进入内核模式去等待关键区域。实际上,关键区域持有一个旋转计数器和一个信号量,前者用于用户模式的等待,后者用于内核模式的等待(休眠态)。在Win32API中,有一个CRITICAL_SECTION结构体表示关键区域对象。在MFC中,有一个类CCriticalSection。关键区域是这样一段代码,当它被一个线程执行时,必须确保不会被另一个线程中断。
一个简单的例子是多个线程共用一个全局变量:
这段代码不是线程安全的,因为没有线程对变量g_nVariable是独占使用的。为了解决这个问题,可以如下使用:
这里使用了CCriticalSection类的两个方法,调用Lock函数通知系统下面代码的执行不能被中断,直到相同的线程调用Unlock方法。系统会首先检查被系统关键区域封锁的代码是否被另一个线程捕获。若是,则线程等待直到捕获线程释放掉关键区域。
若有多个共享资源需要保护,则最好为每个资源使用一个单独的关键区域。记得要配对使用UnLock和Lock。还有一点是需要防止“死锁”。
互斥锁
和关键区域类似,互斥锁设计为对同步访问共享资源进行保护。互斥锁在内核中实现,因此需要进入内核模式操纵它们。互斥锁不仅能在不同线程之间,也可以在不同进程之间进程同步。要跨进程使用,则互斥锁应该是有名的。MFC中使用CMutex类来操纵互斥锁。可以如下方式使用:
我们可以使用互斥锁来限制应用程序的运行实例为一个。可以将如下代码放置到InitInstance函数(或WinMain)中:
信号量
为了限制使用共享资源的线程数目,我们应该使用信号量。信号量是一个内核对象。它存储了一个计数器变量来跟踪使用共享资源的线程数目。例如,下面代码使用CSemaphore类创建了一个信号量对象,它确保在给定的时间间隔内(由构造函数第一个参数指定)最多只有5个线程能使用共享资源。还假定初始时没有线程获得资源:
一旦线程访问共享资源,信号量的计数器就减1.若变为0,则接下来对资源的访问会被拒绝,直到有一个持有资源的线程离开(也就是说释放了信号量)。我们可以如下使用:
主从线程之间的通信
若主线程想通知从线程一些动作的发生,使用事件对象是很方便的。但反过来却是低效,不方便的。因为这会让主线程停下来等待事件,进而降低了应用程序的响应速度。作者提出的方法是让从线程发自定义消息给父线程。
这只能保证窗口类中唯一,但为了确保整个应用程序中唯一,更为安全的方式是:
但这个方法有个很大的缺陷--内存泄露,作者没有深入研究,可以参考我这篇文章《浅谈一个线程通信代码的内存泄露及解决方案 》