本文探讨基本的同步概念,并实际动手帮助新手掌握多线程编程。本文的重点在各种同步技巧。主要介绍了下述同步对象的使用:1)信号量 2)互斥锁 3)关键区域 4)事件
原文链接:Synchronization in Multithreaded Applications with MFC
简介
本文探讨基本的同步概念,并实际动手帮助新手掌握多线程编程。本文的重点在各种同步技巧。
基本概念
在线程执行过程中,或多或少都需要彼此交互,这种交互行为有多种形式和类型。例如,一个线程在执行完它被赋予的任务后,通知另一个线程任务已经完成。然后第二个线程做开始剩下的工作。
下述对象是用来支持同步的:
1)信号量
2)互斥锁
3)关键区域
4)事件
每个对象都有不同的目的和用途,但基本目的都是支持同步。当然还有其他可以用来同步的对象,比如进程和线程对象。后两者的使用由程序员决定,比如说判断一个给定进程或线程是否执行完毕为了使用进程和线程对象来进行同步,我们一般使用Wait*函数,在使用这些函数时,你应当知道一个概念,任何被作为同步对象的内核对象(关键区域除外)都处于两种状态之一:通知状态和未通知状态。例如,进程和线程对象,当他们开始执行时处于未通知状态,而当他们执行完毕时处于通知状态,
为了判断一个给定进程或线程是否已经结束,我们必须判断表示其的对象是否处于通知状态,而要达到这样的目的,我们需要使用Wait*函数。
Wait*函数
下面是最简单的Wait*函数:
DWORD WaitForSingleObject
(
HANDLE hHandle,
DWORD dwMilliseconds
);

参数hHandle表示待检查其状态(通知或者未通知)的对象,dwMilliseconds表示调用线程在被检查对象进入其通知状态前应该等待的时间。若对象处于通知状态或指定时间过去了,这个函数返回控制权给调用线程。若dwMilliseconds设置为INIFINITE(值为-1),则调用线程会一直等待直到对象状态变为通知,这有可能使得调用线程永远等待下去,导致“饿死”。
例如,检查指定线程是否正在执行, dwMilliseconds设置为0,是为了让调用线程马上返回。
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;
}

下一个Wait类函数类似上面的,但它带的是一系列句柄,并且等待其中之一或全部进入已通知状态。
DWORD WaitForMultipleObjects
(
DWORD nCount,
CONST HANDLE *lpHandles,
BOOL fWaitAll,
DWORD dwMilliseconds
);

参数nCount表示待检查的句柄个数,lpHandles指向句柄数组,若fWaitAll为TRUE,则等待所有的对象进入已通知状态,若为FALSE,则当任何一个对象进入已通知状态时,函数返回。dwMilliseconds意义同上。
例如,下面代码判断哪个进程会先结束:
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;
}

句柄数组中索引号为index的对象进入已通知状态时,函数返回WAIT_OBJECT_0 + 索引号。若fWaitAll为TRUE,则当所有对象进入已通知状态时,函数返回WAIT_OBJECT_0。
一个线程若调用一个Wait*函数,则它从用户模式切换为内核模式。这带来的后果有好有坏。不好的是切换进入内核模式大概需要1000个时钟周期,这消耗不算小。好的是当进入内核模式后,就不需要使用处理器,而是进入休眠态,不参与处理器的调度了。
现在让我们进入MFC,并看看它能为我们做些什么。这里有两个类封装了对Wait*函数的调用: CSingleLock和CMultiLock。
同步对象
|
等价的C++类
|
Events
|
CEvent
|
Critical sections
|
CCriticalSection
|
Mutexes
|
CMutex
|
Semaphores
|
CSemaphore
|
每个类都从一个类--CSyncObject继承下来,此类最有用的成员是重载的HANDLE运算符,它返回指定同步对象的内在句柄。所有这些类都定义在<AfxMt.h>头文件中。
事件
一般来说,事件用于这样的情形下:当指定的动作发生后,一个线程(或多个线程)才开始执行其任务。例如,一个线程可能等待必需的数据收集完后才开始将其保存到硬盘上。有两种事件:手动重置型和自动重置型。通过使用事件,我们可以轻松地通知另一个线程特定的动作已经发生了。对于手动重置型事件,线程使用它通知多个线程特定动作已经发生,而对于自动重置型事件,线程使用它只可以通知一个线程。在MFC中,CEvent类封装了事件对象(若在win32中,它是用一个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什么也不干。
实例---工作者线程
本文所带的例子中,作者将展示如何创建工作者线程以及如何合理地销毁它们。作者定义了一个被所有线程使用的控制函数。当点击视图区域时,就创建一个线程。所有被创建的线程使用上述控制函数在视图客户区绘制一个运动的圆形。这里作者使用了一个手动重置型事件,它被用来通知所有工作线程其“死讯”。除此以外,我们将看到如何使得主线程等待直到所有工作者线程销毁掉。
作者将线程函数定义为全局的:
struct THREADINFO


{
HWND hWnd;//主视图区
POINT point;//起始点
};

UINT ThreadDraw(PVOID pParam);

extern CEvent g_eventEnd;

UINT ThreadDraw(PVOID pParam)


{
static int snCount = 0;//线程计数器
snCount ++;//计数器递增
TRACE(TEXT("- 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(TEXT("- ThreadDraw %d: exiting.\n"), snCount --);//归还计数器
return 0;
}


注意作者传入的是一个安全句柄,而不是一个CWnd指针,并且在线程函数中通过传入的句柄创建一个临时的C++对象并使用。这样就避免了在多线程编程中多个对象引用单个C++对象的危险。
CArray<CWinThread *, CWinThread *> m_ThreadArray;//保存CWinThread对象指针


// manual-reset event
CEvent g_eventEnd(FALSE, TRUE);

void CWorkerThreadsView::OnLButtonDown(UINT nFlags, CPoint point)


{
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);//保存创建的线程
}

为了合理地销毁所有线程,首先使得事件进入已通知状态,这会通知工作线程“死期已至”,然后调用WaitForSingleObject让主线程等待所有的工作者线程完全销毁掉。注意每次迭代时调用WaitForSingleObject会导致从用户模式进入内核模式。例如,10此迭代会浪费掉大约10000次时钟周期。为了避免这个问题,我们可以使用WaitForMultipleObjects。这就是第二种方法。
void CWorkerThreadsView::OnDestroy()


{
g_eventEnd.SetEvent();


/**//* // 第一种方式
for (int j = 0; j < m_ThreadArray.GetSize(); j ++)
{
::WaitForSingleObject(m_ThreadArray[j]->m_hThread, INFINITE);
delete m_ThreadArray[j];
}
*/
//第二种方式
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 (int j = 0; j < nSize; j ++)

{
delete m_ThreadArray[j];
}
delete [] p;
TRACE("- CWorkerThreadsView::OnDestroy: finished!\n");
}

关键区域
和其他同步对象不同,除非有需要以外,关键区域工作在用户模式下。若一个线程想运行一个封装在关键区域中的代码,它首先做一个旋转封锁,然后等待特定的时间,它进入内核模式去等待关键区域。实际上,关键区域持有一个旋转计数器和一个信号量,前者用于用户模式的等待,后者用于内核模式的等待(休眠态)。在Win32API中,有一个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是独占使用的。为了解决这个问题,可以如下使用:
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方法。系统会首先检查被系统关键区域封锁的代码是否被另一个线程捕获。若是,则线程等待直到捕获线程释放掉关键区域。
若有多个共享资源需要保护,则最好为每个资源使用一个单独的关键区域。记得要配对使用UnLock和Lock。还有一点是需要防止“死锁”。
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;
}
};

互斥锁
和关键区域类似,互斥锁设计为对同步访问共享资源进行保护。互斥锁在内核中实现,因此需要进入内核模式操纵它们。互斥锁不仅能在不同线程之间,也可以在不同进程之间进程同步。要跨进程使用,则互斥锁应该是有名的。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函数:
// 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);
}

信号量
为了限制使用共享资源的线程数目,我们应该使用信号量。信号量是一个内核对象。它存储了一个计数器变量来跟踪使用共享资源的线程数目。例如,下面代码使用CSemaphore类创建了一个信号量对象,它确保在给定的时间间隔内(由构造函数第一个参数指定)最多只有5个线程能使用共享资源。还假定初始时没有线程获得资源:
CSemaphore g_Sem(5, 5);
一旦线程访问共享资源,信号量的计数器就减1.若变为0,则接下来对资源的访问会被拒绝,直到有一个持有资源的线程离开(也就是说释放了信号量)。我们可以如下使用:
// 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
这只能保证窗口类中唯一,但为了确保整个应用程序中唯一,更为安全的方式是:
#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(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;
}

但这个方法有个很大的缺陷--内存泄露,作者没有深入研究,可以参考我这篇文章《浅谈一个线程通信代码的内存泄露及解决方案 》
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· .NET周刊【3月第1期 2025-03-02】
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· [AI/GPT/综述] AI Agent的设计模式综述