MFC 中的多线程应用程序同步
介绍多线程应用程序中的同步概念和实践。

引言
本文讨论了基本的同步概念和实践,旨在帮助多线程编程的初学者。我所说的初学者,并非指学习 C++ 语言的初学者,而是指那些对多线程编程还比较陌生的人。本文主要关注同步技术。因此,本文更像是一篇关于同步的教程。
概述
在执行过程中,线程之间或多或少会相互协作。这种协作可能有多种形式和类型。例如,一个线程在完成其分配的任务后,会通知另一个线程。然后,第二个线程的工作作为第一个线程的逻辑延续,便开始运行。
所有形式的协作都可以用“同步”一词来描述,它可以通过多种方式实现。最常用的是那些主要目的就是支持同步本身的方式。以下对象旨在支持同步(此列表不完整):
- 信号量
- 互斥锁
- 临界区
- 事件
这些对象各有其特殊的用途和用法,但总的目标是支持同步。我将在本文后面向您介绍它们。还有一些其他对象也可以用作同步媒介,例如 Process
和 Thread
对象。使用它们可以让程序员决定,例如,一个给定的进程或线程是否已完成其执行。
要使用 Process
和 Thread
对象进行同步,我们需要使用等待函数(wait-functions)。在学习这些函数之前,您应该先了解一个关键概念,即任何可用作同步对象的内核对象都可以处于两种状态之一:signaled state
(有信号状态)和 nonsignaled state
(无信号状态)。除了临界区(critical sections)之外,所有同步对象都可以处于这两种状态之一。例如,对于 Process
和 Thread
对象,当它们开始执行时处于无信号状态,当它们执行结束时则进入有信号状态。要判断一个给定的进程或线程是否已经结束,我们应该查明其代表对象是否处于有信号状态;为此,我们需要借助等待函数。
等待函数
以下函数是所有等待函数中最简单的一个。其声明格式如下:
DWORD WaitForSingleObject
(
HANDLE hHandle,
DWORD dwMilliseconds
);
参数 hHandle
接收一个对象的描述符,该对象将被检查其是有信号状态还是无信号状态。参数 dwMilliseconds
指定了调用线程应该等待该对象进入有信号状态的时间。一旦对象被置为有信号状态或给定的时间间隔到期,函数就会将控制权返回给调用线程。如果 dwMilliseconds
的值为 INFINITE
(-1),则线程将一直等待直到该对象变为有信号状态。如果该对象一直没有变为有信号状态,线程将永远等待下去。
例如,下面的调用检查一个进程 [由 hProcess
描述符标识] 是否正在执行:
DWORD dw = WaitForSingleObject(hProcess, 0);
switch (dw)
{
case WAIT_OBJECT_0:
// the process has exited
break;
case WAIT_TIMEOUT:
// the process is still executing
break;
case WAIT_FAILED:
// failure
break;
}
如您所见,我们向函数的 dwMilliseconds
参数传递了 0,这种情况下函数会立即检查对象的状态 [有信号或无信号] 并立即返回控制权。如果对象处于有信号状态,函数返回 WAIT_OBJECT_0
。如果处于无信号状态,则返回 WAIT_TIMEOUT
。如果失败,则返回 WAIT_FAILED
(当传递了无效的描述符时可能会发生失败)。
下一个等待函数与前一个类似,不同之处在于它接受一个描述符列表,并等待其中一个或全部变为有信号状态:
DWORD WaitForMultipleObjects
(
DWORD nCount,
CONST HANDLE *lpHandles,
BOOL fWaitAll,
DWORD dwMilliseconds
);
参数 nCount
指定了要检查的描述符数量。参数 lpHandles
应指向一个描述符数组。如果参数 fWaitAll
为 TRUE
,函数将等待所有对象都变为有信号状态。如果为 FALSE
,只要有一个对象变为有信号状态,函数就会返回 [无论其他对象的状态如何]。dwMilliseconds
的作用与前一个函数中的相同。
例如,以下代码判断给定 HANDLE
列表中的哪个进程会首先退出:
HANDLE h[3];
h[0] = hThread1;
h[1] = hThread2;
h[2] = hThread3;
DWORD dw = WaitForMultipleObjects(3, h, FALSE, 5000);
switch (dw)
{
case WAIT_FAILED:
// failure
break;
case WAIT_TIMEOUT:
// no processes exited during 5000ms
break;
case WAIT_OBJECT_0 + 0:
// a process with h[0] descriptor has exited
break;
case WAIT_OBJECT_0 + 1:
// a process with h[1] descriptor has exited
break;
case WAIT_OBJECT_0 + 2:
// a process with h[2] descriptor has exited
break;
}
正如我们所见,该函数可以返回不同的值,以表明函数返回的原因。您已经知道前两个值的含义。接下来的值是根据以下逻辑返回的:返回 WAIT_OBJECT_0
+ 索引,表示 HANDLE
数组中索引为 index
的对象已进入有信号状态。如果 fWaitAll
参数为 TRUE
,则会返回 WAIT_OBJECT_0
[如果所有对象都进入有信号状态]。
一个线程如果调用了等待函数,就会从用户模式进入内核模式。这一事实有好有坏。坏处是进入内核模式大约需要 1000 个处理器周期,这在具体情况下可能代价太高。好处是进入内核模式后,不需要再占用处理器时间;线程处于休眠状态。
让我们来看看 MFC,看看它能为我们做些什么。有两个类封装了对等待函数的调用:CSingleLock
和 CMultiLock
。我们将在本文后面看到它们的用法。
同步对象 | 等效的 C++ 类 |
事件 |
CEvent |
临界区 |
CCriticalSection |
互斥锁 |
CMutex |
信号量 |
CSemaphore |
这些类都继承自一个共同的基类——CSyncObject
,其最常用的成员是重载的 HANDLE
运算符,该运算符返回给定同步对象的底层描述符。所有这些类都在 <AfxMt.h> 头文件中声明。
事件
通常,事件(events)用于某个线程(或多个线程)需要在特定操作发生后才开始工作的场景。例如,一个线程可能需要等待必要的数据收集完毕,然后才开始将其保存到硬盘。事件有两种类型:手动重置(manual-reset)和自动重置(auto-reset)。通过使用事件,我们可以简单地通知另一个线程某个指定的动作已经发生。对于第一种事件,即手动重置事件,一个线程可以通知多个线程某个指定动作的发生。但对于第二种事件,即自动重置事件,只有一个线程能被通知。在 MFC 中,CEvent
类封装了事件对象(在 Windows 术语中,它由一个 HANDLE
值表示)。CEvent
的构造函数允许我们创建手动重置和自动重置两种事件。默认情况下,创建的是第二种事件。要通知等待的线程,我们应该使用 CEvent::SetEvent
方法,这意味着这种调用会使事件进入有信号状态。如果事件是手动重置的,那么它将保持在有信号状态,直到调用相应的 CEvent::ResetEvent
,这会使事件进入无信号状态。正是这个特性,允许一个线程通过一次 SetEvent
调用通知多个线程。如果事件是自动重置的,那么在所有等待的线程中,只有一个线程能够接收到通知。在该线程接收到通知后,事件将自动进入无信号状态。以下两个例子将说明这些思想。第一个例子:// create an auto-reset event
CEvent g_eventStart;
UINT ThreadProc1(LPVOID pParam)
{
::WaitForSingleObject(g_eventStart, INFINITE);
...
return 0;
}
UINT ThreadProc2(LPVOID pParam)
{
::WaitForSingleObject(g_eventStart, INFINITE);
...
return 0;
}
在这段代码中,创建了一个自动重置类型的全局 CEvent
对象。此外,还有两个工作线程正在等待该事件以便开始工作。一旦第三个线程为该对象调用 SetEvent
,这两个线程中只有一个(注意,没人能确切地说出是哪一个)会收到通知,之后该事件将进入无信号状态,从而阻止第二个线程捕获该事件。这段代码虽然不是很实用,但它展示了自动重置事件的工作原理。让我们看第二个例子:
// create a manual-reset event
CEvent g_eventStart(FALSE, TRUE);
UINT ThreadProc1(LPVOID pParam)
{
::WaitForSingleObject(g_eventStart, INFINITE);
...
return 0;
}
UINT ThreadProc2(LPVOID pParam)
{
::WaitForSingleObject(g_eventStart, INFINITE);
...
return 0;
}
这段代码与前一个仅在 CEvent
构造函数的参数上有所不同。但在功能意义上,两个线程的工作方式存在着根本性的差异。如果第三个线程为该对象调用 SetEvent
方法,那么就可以保证这两个线程将(几乎)同时开始工作。这是因为手动重置事件在进入有信号状态后,在相应的 ResetEvent
调用完成之前,不会进入无信号状态。
另一个用于操作事件的方法是 CEvent::PulseEvent
。此方法首先使事件进入有信号状态,然后又使其返回到无信号状态。如果事件是手动重置类型,事件会进入有信号状态,然后所有等待的线程都会被通知,接着事件进入无信号状态。如果事件是自动重置类型,那么即使有多个线程在等待,也只有一个线程会得到通知。如果没有线程在等待,调用 ResetEvent
将不会有任何效果。
示例 - WorkerThreads
在这个示例中,我将展示如何创建工作线程以及如何正确地销毁它们。这里我们定义一个所有线程都使用的控制函数。每次我们点击视图,就会创建一个线程。所有创建的线程都使用前面提到的控制函数,该函数将在视图的客户区绘制一个移动的椭圆。这里使用了一个手动重置事件,它会通知所有工作线程它们需要结束。此外,我们还将看到如何让主线程等待所有工作线程退出。
所有的椭圆都在客户区内移动,并且不会超出其边界。
- 您应该打开一个 SDI 应用程序。假设项目名称为
WorkerThreads
。 - 让我们为启动线程准备一个
WM_LBUTTONDOWN
消息处理程序。 - 声明控制函数。控制函数可以在任何文件中声明;关键是它应该具有全局访问权限。假设我们有一个 Threads.h/Threads.cpp 文件,其中控制函数的声明/定义如下:
// Threads.h #pragma once struct THREADINFO { HWND hWnd; POINT point; }; UINT ThreadDraw(PVOID pParam);
// Threads.cpp extern CEvent g_eventEnd; UINT ThreadDraw(PVOID pParam) { static int snCount = 0; snCount ++; TRACE("- ThreadDraw %d: started...\n", snCount); THREADINFO *pInfo = reinterpret_cast<threadinfo /> (pParam); CWnd *pWnd = CWnd::FromHandle(pInfo->hWnd); CClientDC dc(pWnd); int x = pInfo->point.x; int y = pInfo->point.y; srand((UINT)time(NULL)); CRect rectEllipse(x - 25, y - 25, x + 25, y + 25); CSize sizeOffset(1, 1); CBrush brush(RGB(rand()% 256, rand()% 256, rand()% 256)); CBrush *pOld = dc.SelectObject(&brush); while (WAIT_TIMEOUT == ::WaitForSingleObject(g_eventEnd, 0)) { CRect rectClient; pWnd->GetClientRect(rectClient); if (rectEllipse.left < rectClient.left || rectEllipse.right > rectClient.right) sizeOffset.cx *= -1; if (rectEllipse.top < rectClient.top || rectEllipse.bottom > rectClient.bottom) sizeOffset.cy *= -1; dc.FillRect(rectEllipse, CBrush::FromHandle ((HBRUSH)GetStockObject(WHITE_BRUSH))); rectEllipse.OffsetRect(sizeOffset); dc.Ellipse(rectEllipse); Sleep(25); } dc.SelectObject(pOld); delete pInfo; TRACE("- ThreadDraw %d: exiting.\n", snCount --); return 0; }
此函数通过其
PVOID
参数接收一个对象,即一个结构体,其字段包括视图的句柄(以便能够在视图的客户区绘图)和循环开始的坐标点。注意,我们应该传递句柄本身,而不是CWnd
指针,这样每个线程都可以基于该句柄创建一个临时的 C++ 对象来使用。否则,所有线程将共享同一个 C++ 对象,这在安全的多线程编程中是潜在的危险。其核心是,该控制函数在视图的客户区渲染一个移动的圆。此外,请在 "StdAfx.h" 文件中包含 <Afxmt.h> 文件,以使CEvent
可见。另一个关键点是,我们准备了一个
THREADINFO
结构体来传递给线程。当需要向线程传递多个值(或从线程获取多个值)时,通常会使用这种技术。我们需要传递视图的窗口句柄和即将创建的圆的初始点。每个线程都会删除传递给自身的THREADINFO
对象。请注意,这种删除是根据我们的约定进行的;即,主线程应为THREADINFO
对象预留堆内存,而目标线程应负责删除它。这样做的原因是主线程不知道何时进行删除,因为该对象将由次线程自己拥有。 - 在
CWorkerThreadView
类中声明一个数组成员变量。我们应该存储指向CWinThread
对象的指针,以便以后使用它们。private: CArray<CWinThread *, CWinThread *> m_ThreadArray;
此外,在 "StdAfx.h" 文件中包含 <AfxTempl.h> 文件,以使
CArray
可见。 - 修改 WorkerThreadsView.cpp 文件。首先在该文件开头的某个位置定义一个全局的
CEvent
手动重置变量:// manual-reset event CEvent g_eventEnd(FALSE, TRUE);
现在向
WM_LBUTTONDOWN
消息处理程序添加代码:void CWorkerThreadsView::OnLButtonDown() { THREADINFO *pInfo = new THREADINFO; pInfo->hWnd = GetSafeHwnd(); pInfo->point = point; CWinThread *pThread = AfxBeginThread(ThreadDraw, (PVOID) pInfo, THREAD_PRIORITY_NORMAL, 0, CREATE_SUSPENDED); pThread->m_bAutoDelete = FALSE; pThread->ResumeThread(); m_ThreadArray.Add(pThread); }
请注意,我们排除了新创建线程的自动删除属性,而是将指向该
CWinThread
对象的指针存储在我们的数组中。注意,我们在堆上创建了一个THREADINFO
实例,并让线程在完成使用该结构后将其删除。为了使ThreadDraw
和THREADINFO
在 WorkerThreadsView.cpp 文件中可见,请包含 "Threads.h" 文件。 - 注意要正确销毁线程。由于所有线程都与视图对象相关联(它们正在使用它),因此在视图的
WM_DESTROY
消息处理程序中销毁它们是合理的。void CWorkerThreadsView::OnDestroy() { CView::OnDestroy(); // TODO: Add your message handler code here g_eventEnd.SetEvent(); for (int j = 0; j < m_ThreadArray.GetSize(); j ++) { ::WaitForSingleObject(m_ThreadArray[j]->m_hThread, INFINITE); delete m_ThreadArray[j]; } }
此函数首先使事件变为有信号状态,以通知工作线程它们需要结束,然后它使用
WaitForSingleObject
使主线程为每个工作线程等待,直到后者完全销毁。为此,即使相应的线程被销毁,我们也需要有一个有效的CWinThread
指针;这就是为什么我们在上一步中移除了CWinThread
对象的自动删除属性。一旦一个工作线程退出,for 循环的第二行就会销毁相应的 C++ 对象。请注意,在每次迭代中都会调用WaitForSingleObject
,这仅仅是导致从用户模式进入内核模式。例如,10 次迭代将浪费约 10000 个处理器周期。为了克服这一点,我们可以使用WaitForMultipleObjects
。在这种情况下,我们将需要一个 C 风格的线程描述符数组。因此,上面的 for 循环可以替换为以下代码://second way (comment in 'for' loop above) int nSize = m_ThreadArray.GetSize(); HANDLE *p = new HANDLE[nSize]; for (int j = 0; j < nSize; j ++) { p[j] = m_ThreadArray[j]->m_hThread; } ::WaitForMultipleObjects(nSize, p, TRUE, INFINITE); for (j = 0; j < nSize; j ++) { delete m_ThreadArray[j]; } delete [] p;
由于之前的代码只执行一次,而且是在应用程序结束时执行,所以这样的改进价值不大。
- 就这些了。你可以测试一下。
临界区
与其他同步对象不同,临界区(critical sections)在用户模式下工作,除非需要进入内核模式。如果一个线程试图运行被临界区保护的代码,它会首先进行自旋阻塞(spin blocking),并在指定时间后进入内核模式等待临界区。实际上,一个临界区由一个自旋计数器和一个信号量组成;前者用于用户模式等待,后者用于内核模式等待(休眠)。在 Win32 API 中,有一个 CRITICAL_SECTION
结构体来表示临界区对象。在 MFC 中,有一个名为 CCriticalSection
的类。从概念上讲,临界区是源代码中需要整体执行的一个区段,也就是说,在执行该部分代码期间,必须保证执行不会被另一个线程中断。当需要授予单个线程独占使用共享资源的权限时,就可能需要这样的代码区段。一个简单的例子是多个线程使用全局变量。例如:
int g_nVariable = 0;
UINT Thread_First(LPVOID pParam)
{
if (g_nVariable < 100)
{
...
}
return 0;
}
UINT Thread_Second(LPVOID pParam)
{
g_nVariable += 50;
...
return 0;
}
这段代码不安全,因为没有线程对 g_nVariable
变量有独占访问权。考虑以下场景:假设 g_nVariable
的初始值为 80,控制权传递给第一个线程,它看到 g_nVariable
的值小于 100,因此它尝试执行条件下的代码块。但此时处理器切换到第二个线程,该线程给变量加上 50,使其值大于 100。之后,处理器切换回第一个线程并继续执行 if 块。猜猜会发生什么?在 if 块内部,g_nVariable
的值大于 100,尽管它本应小于 100。为了弥补这个漏洞,我们可以使用临界区,如下所示:
CCriticalSection g_cs;
int g_nVariable = 0;
UINT Thread_First(LPVOID pParam)
{
g_cs.Lock();
if (g_nVariable < 100)
{
...
}
g_cs.Unlock();
return 0;
}
UINT Thread_Second(LPVOID pParam)
{
g_cs.Lock();
g_nVariable += 20;
g_cs.Unlock();
...
return 0;
}
这里使用了 CCriticalSection
类的两个方法。调用 Lock
函数通知系统,底层代码的执行不应被中断,直到同一个线程调用 Unlock
函数为止。作为对这个调用的响应,系统首先检查该代码是否没有被另一个线程用同一个临界区对象捕获。如果是,则该线程等待,直到捕获该临界区的线程释放它,然后自己捕获它。
如果有两个以上的共享资源需要保护,最好为每个资源使用一个单独的临界区。不要忘记为每个 Lock
配对一个 Unlock
。使用临界区时,应小心不要为协作的线程制造相互阻塞的情况。这意味着一个线程可能在等待一个临界区被另一个线程释放,而后者又在等待一个被前者捕获的临界区。很明显,在这种情况下,两个线程将永远等待下去。
有一种做法是将临界区嵌入到 C++ 类中,从而使它们成为线程安全的。当一个特定类的对象需要被多个线程同时使用时,可能需要这种技巧。大致情况如下:
class CSomeClass
{
CCriticalSection m_cs;
int m_nData1;
int m_nData2;
public:
void SetData(int nData1, int nData2)
{
m_cs.Lock();
m_nData1 = Function(nData1);
m_nData2 = Function(nData2);
m_cs.Unlock();
}
int GetResult()
{
m_cs.Lock();
int nResult = Function(m_nData1, m_nData2);
m_cs.Unlock();
return nResult;
}
};
有可能在同一时间,两个或多个线程对同一个 CSomeClass
类型的对象调用 SetData
和/或 GetData
方法。因此,通过包装这些方法的内容,我们将防止数据在这些调用期间被破坏。
互斥锁
互斥体(Mutexes),像临界区一样,旨在保护共享资源免受并发访问。互斥体在内核内部实现,因此它们需要进入内核模式才能操作。互斥体不仅可以在不同线程之间进行同步,还可以在不同进程之间进行同步。这样的互斥体应该有一个唯一的名称,以便被其他进程识别(这种互斥体称为命名互斥体)。MFC 提供了 CMutex
类来处理互斥体。互斥体可以这样使用:
CSingleLock singleLock(&m_Mutex);
singleLock.Lock(); // try to capture the shared resource
if (singleLock.IsLocked()) // we did it
{
// use the shared resource ...
// After we done, let other threads use the resource
singleLock.Unlock();
}
或者用 Win32 API 函数实现相同的功能:
// try to capture the shared resource
::WaitForSingleObject(m_Mutex, INFINITE);
// use the shared resource ...
// After we done, let other threads use the resource
::ReleaseMutex(m_Mutex);
互斥体也可以用来将正在运行的实例数量限制为一个。以下代码可以放在 InitInstance
方法(或 WinMain
)的开头:
HANDLE h = CreateMutex(NULL, FALSE, "MutexUniqueName");
if (GetLastError() == ERROR_ALREADY_EXISTS)
{
AfxMessageBox("An instance is already running.");
return(0);
}
为了保证全局唯一的名称,请使用 GUID。
信号量
为了限制使用共享资源的线程数量,我们应该使用信号量(semaphores)。信号量是一个内核对象。它存储一个计数器变量,以跟踪正在使用共享资源的线程数量。例如,以下代码使用 MFC 的 CSemaphore
类创建一个信号量,它可以保证在给定的时间段内最多只有 5 个线程能够使用共享资源(这一点由构造函数的第一个参数指明)。假设最初没有线程捕获该资源(第二个参数):
CSemaphore g_Sem(5, 5);
一旦一个线程获得了对共享资源的访问权限,信号量的计数器变量就会减一。如果它变为零,那么任何进一步使用该资源的尝试都将被拒绝,直到至少有一个已经捕获该资源的线程离开它(换句话说,释放信号量)。我们可以使用 CSingleLock
和/或 CMultiLock
类来等待/捕获/释放一个信号量。我们也可以使用 API 函数,如下所示:
// Try to use the shared resource
::WaitForSingleObject(g_Sem, INFINITE);
// Now the user's counter of the semaphore has decremented by one
//... Use the shared resource ...
// After we done, let other threads use the resource
::ReleaseSemaphore(g_Sem, 1, NULL);
// Now the user's counter of the semaphore has incremented by one
次线程与主线程之间的通信
如果主线程想要通知次线程某个动作,使用事件对象是很方便的。但反过来做效率不高,对用户也不方便,因为停止主线程来等待一个事件可能会(而且通常会)减慢应用程序的速度。在这种情况下,使用用户自定义消息与主线程交互是正确的做法。这样的消息应该被发送到一个特定的窗口,这意味着该窗口的描述符应该对调用者(次线程)可见。
要创建一个用户自定义消息,我们首先应该为该消息定义一个标识符(更确切地说——定义消息本身)。这个标识符应该对主线程和次线程都可见:
#define WM_MYMSG WM_USER + 1
WM_USER+n
消息在一个窗口类中应是唯一的,但在整个应用程序中不一定。一个更安全(在唯一性方面)的方法是使用 WM_APP+n
消息,像这样:
#define WM_MYMSG WM_APP + 1
接下来,应该在该消息将要发送到的窗口的类声明中,为该消息声明一个处理方法:
afx_msg LRESULT OnMyMessage(WPARAM , LPARAM );
当然,也应该有该方法的定义:
LRESULT CMyWnd::OnMyMessage(WPARAM wParam, LPARAM lParam)
{
// A notification got
// Do something ...
return 0;
}
最后,为了将处理程序分配给消息标识符,应该在 BEGIN_MESSAGE_MAP
和 END_MESSAGE_MAP
对之间使用 ON_MESSAGE
宏:
BEGIN_MESSAGE_MAP(CMyWnd, CWnd)
...
ON_MESSAGE(WM_MYMSG, OnMyMessage)
END_MESSAGE_MAP()
现在,一个拥有(存在于主线程中的)窗口句柄的次线程,可以通过用户自定义消息来通知它,如下所示:
UINT ThreadProc(LPVOID pParam)
{
HWND hWnd = (HWND) pParam;
...
// notify the primary thread's window
::PostMessage(hWnd, WM_MYMSG, 0, 0);
return 0;
}
历史
这篇文章初稿写于三年多以前。那时我还是一个只有两年经验的程序员。我的意图是写一本关于 MFC 的书。好笑吧?但我太年轻了,写不出一本书,所以我的章节只留在了我的电脑里。现在我重写了其中一段文字并提交给你们。当然,如果您认为对这篇文章有任何值得提出的建议,我将不胜感激。