简介:一份演示在MFC中为线程自定义消息循环的完整示例工程,适合具备基础MFC使用经验、希望深入理解Windows消息机制与多线程开发的读者。工程围绕CWinThread派生线程类展开,覆盖InitInstance初始化、Run方法内GetMessage/TranslateMessage/DispatchMessage消息循环编写、线程退出清理以及PostThreadMessage线程间通信等关键点,可帮助开发者快速掌握让工作线程响应UI或自定义消息的实现路径。包内共19个文件,以.h头文件、.cpp源文件、.rc资源定义、.vcxproj工程文件及.ico图标等为主,压缩包仅154KB,结构简洁,便于直接打开工程对照学习。已有874人学习下载,内含可直接编译运行的MFC程序框架,代码注释与工程配置完整,适合作为编写线程消息循环的参考模板,也可在此基础上扩展消息类型,加深对MFC消息循环机制的理解。 做MFC界面开发,绕不开两个东西:消息循环和线程。标题里这个“MFC线程自定义消息循环”,说白了就是解决一个很现实的问题:当你的工作线程需要接收外部指令、需要把处理进度回传给界面、或者线程内部根本就是一个需要事件驱动的常驻任务时,光靠全局标志加Sleep轮询是行不通的。程序照样能跑,但后面你会慢慢被各种关窗崩溃、状态刷新不及时、退出卡死折磨到怀疑人生。
这篇文章我会从原理讲到代码,再讲到我实际排过的坑。适合正在做MFC开发、被后台线程和UI通信搞到头大的人,也适合刚接触MFC消息机制、想系统理清这套东西的C++开发者。看完你可以直接照着写一个能用的线程消息循环出来。
1. MFC消息循环与线程的关系
1.1 消息循环到底在转什么
消息循环本质上就是一个取消息、翻译消息、分发消息的while循环。你可以把它理解成一个前台接待员:窗口是工位,消息是来访客户,客户到了前台,接待员按顺序喊号,喊到谁谁就处理自己的事。
MFC的主线程不用你操心,CWinApp::Run()在程序启动后就开始维护主消息循环。主窗口上的鼠标点击、键盘输入、WM_TIMER、界面重绘,全部都是通过这个循环一条条分发出去的。这也是为什么你写MFC的时候基本不需要手动管消息,框架帮你转完了。
但工作线程不一样。你用AfxBeginThread创建出来的线程,默认就是一条纯执行代码的裸线程,跑完函数就结束,中间没有消息队列、没有消息循环,外部也没办法通过PostMessage给它发指令。这时候你如果想让线程在跑任务的同时还能响应“暂停”“停止”“改参数”之类的指令,就需要手动给它造一个消息循环。
1.2 哪些场景必须给线程配消息循环
不是所有线程都需要消息循环。如果线程只是闷头算数据、写文件、查数据库,做完了就退出,那不需要;但下面这几类情况,你最好认真考虑给它配一个:
- 常驻后台线程,需要接收主界面随时下发的控制指令,比如开始、停止、切换模式;
- 线程内部创建了窗口或控件,这些窗口必须维护自己的消息循环才能正常显示和响应消息;
- 线程间通信希望走消息机制,比裸共享变量更安全有序;
- 线程要驱动定时刷新,比如MFC里做OpenGL渲染循环、基于绘制的动画,需要在WM_TIMER或自定义刷新消息里不断重绘。
我在实际项目里见过不少人为了省事,在线程里写一个while(!stopFlag) { Sleep(200); ... },然后主线程处理关窗时去置标志位。表面看工作正常,但线程可能正阻塞在某个耗时的调用上,标志位没机会被检查到,最后导致窗口关了线程还在跑,程序退出时偶发崩溃。消息循环的好处是,它给了外部一个标准化的指令入口,而且Windows的消息队列天然带排队和等待能力,你不需要自己拿锁去保护指令状态。
1.3 UI线程与工作线程的边界
MFC里有一个很关键的概念:谁创建了窗口,谁就是UI线程。UI线程必须要有消息循环,否则窗口不会响应任何操作。反过来,工作线程如果没创建窗口,正常情况下系统不会给它安排消息队列,但这不代表它永远不能有。
Windows的实现是:线程的“消息队列”是按需创建的。一个普通线程第一次调用GetMessage、PeekMessage这类user32消息函数时,系统才为它创建队列。所以在线程里搞自定义消息循环,第一件事就是确保队列存在,后面我会具体讲代码写法。
还要注意,MFC界面对象基本都不是线程安全的。工作线程里不要直接操作CWnd派生对象的方法,更不要跨线程操作CListCtrl、CEdit之类的控件。正确的姿势是把数据封装好,通过消息传给主线程,让主线程的界面代码去更新控件。这也是线程消息循环最常见的用途之一。
2. 自定义消息的设计:编号、处理函数与映射
2.1 消息编号别乱用:WM_USER、WM_APP和安全区
在MFC里定义自定义消息,第一个动作就是选编号。很多人直接写#define WM_MY_THREAD_MSG WM_USER + 100,这在早期控件自定义消息里很常见,但不代表所有场景都合适。
WM_USER是0x0400,它是微软给控件类预留的私有消息区间。你随便用一个控件,控件内部就有一大堆WM_USER + N的消息和通知。如果你的自定义消息编号恰好和某个控件的内部消息撞了,轻则消息行为怪异,重则整个控件状态错乱。所以除非消息只在你自己的类内部使用,且你能保证不冲突,否则尽量别用它。
更保险的区间是WM_APP(0x8000到0xBFFF),这是微软明确留给应用程序自定义消息使用的。线程之间通信、程序内部各模块之间通信的消息号,统一从这个区间起,撞车概率小很多。习惯上我喜欢这样定义:
#define WM_MY_THREAD_MSG (WM_APP + 100) // 线程命令消息 #define WM_MY_THREAD_PROGRESS (WM_APP + 101) // 线程进度回传 #define WM_MY_THREAD_QUIT (WM_APP + 102) // 线程退出消息2.2 处理函数与ON_MESSAGE映射
在MFC类中使用ON_MESSAGE消息映射,处理函数的签名必须是固定的格式:
afx_msg LRESULT OnThreadMsg(WPARAM wParam, LPARAM lParam);wParam和lParam是两个通用参数,具体含义由你自己定。我通常用wParam传递指令编号或进度百分比,用lParam传递指针或者自定义结构体地址。映射宏写进消息映射表里就行:
BEGIN_MESSAGE_MAP(CMyThread, CWinThread) ON_MESSAGE(WM_MY_THREAD_MSG, &CMyThread::OnThreadMsg) END_MESSAGE_MAP()需要提醒的是,CWinThread的消息映射处理的是“线程消息”,也就是通过PostThreadMessage发过来、hwnd为NULL的消息。这一点和窗口消息的分发路径不一样,细节我在第3章代码里会展开。
2.3 线程消息队列是“用的时候才创建”
这个我前面提了一句,这里必须展开。Windows给线程创建消息队列是懒加载机制:第一次调用GetMessage、PeekMessage等函数时才创建。如果目标线程还没进入消息循环,你就从外部调用PostThreadMessage去投递,可能得到的是失败返回码,消息压根送不进去。
所以一个标准动作是在线程入口函数最开始强制创建队列:
MSG msg; PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE);这行代码不取出任何消息,只负责触发系统为当前线程创建消息队列。这之后你再往里PostThreadMessage,就能稳定投递。我见过有人不写这句,然后偶尔复现“消息丢失”“第一次发不进去”的诡异问题,基本都是这个原因。
3. 线程自定义消息循环的两种实现
3.1 方案一:纯GetMessage循环,自己分发
这种最贴合“自定义消息循环”的字面意思,线程函数里自己写循环,自己决定怎么处理消息。伪代码结构如下:
UINT WorkThreadProc(LPVOID pParam) { // 强制创建消息队列 MSG msg; PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE); // 进入消息循环 while (GetMessage(&msg, NULL, 0, 0)) { // 先判断是不是自定义指令 if (msg.message == WM_MY_THREAD_MSG) { // 按需处理 OnThreadCommand(msg.wParam, msg.lParam); } else if (msg.message == WM_MY_THREAD_QUIT) { // 做清理工作,然后退出 DoCleanup(); break; } else { TranslateMessage(&msg); DispatchMessage(&msg); } } return 0; }虽然是连窗口句柄都没有的线程,DispatchMessage不会分发到任何窗口过程,但保留标准的TranslateMessage和DispatchMessage调用并没有坏处,因为这类线程里如果谁又创建了隐藏窗口,这套调用就能无缝衔接。
这里再强调一个关键点:如果你用GetMessage循环,WM_QUIT消息会让GetMessage返回0,从而退出循环。所以在线程里想优雅退出,最标准的做法是先发自定义退出消息做清理,清理完再PostThreadMessage(GetCurrentThreadId(), WM_QUIT, 0, 0)。WM_QUIT本身不会出现在上面分支里,它直接让GetMessage返回0。
3.2 方案二:CWinThread派生类,收编进MFC消息映射
如果项目本身就重度使用MFC,我更推荐派生一个CWinThread子类,把线程消息循环收编进MFC自己的消息泵机制。
// MyThread.h class CMyThread : public CWinThread { DECLARE_DYNCREATE(CMyThread) public: BOOL InitInstance() override; int ExitInstance() override; afx_msg LRESULT OnThreadCommand(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnThreadQuit(WPARAM wParam, LPARAM lParam); DECLARE_MESSAGE_MAP() HWND m_hMainWnd; // 主窗口句柄,用于回传 BOOL m_bRunning; }; // MyThread.cpp IMPLEMENT_DYNCREATE(CMyThread, CWinThread) BEGIN_MESSAGE_MAP(CMyThread, CWinThread) ON_MESSAGE(WM_MY_THREAD_MSG, &CMyThread::OnThreadCommand) ON_MESSAGE(WM_MY_THREAD_QUIT, &CMyThread::OnThreadQuit) END_MESSAGE_MAP() BOOL CMyThread::InitInstance() { // 和线程函数一样,先强制创建消息队列 MSG msg; PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE); m_bRunning = TRUE; return TRUE; // 返回TRUE进入消息循环 } int CMyThread::ExitInstance() { m_bRunning = FALSE; return CWinThread::ExitInstance(); } LRESULT CMyThread::OnThreadCommand(WPARAM wParam, LPARAM lParam) { // 收到主线程的消息后,在这里干活 // 比如 wParam == 1 表示开始,2 表示停止 return 0; } LRESULT CMyThread::OnThreadQuit(WPARAM, LPARAM) { // 清理资源 m_bRunning = FALSE; // 发WM_QUIT退出Run()内部的消息循环 PostThreadMessage(GetCurrentThreadId(), WM_QUIT, 0, 0); return 0; }在业务侧,创建线程要用AfxBeginThread的RUNTIME_CLASS版本:
CMyThread* pThread = (CMyThread*)AfxBeginThread(RUNTIME_CLASS(CMyThread)); pThread->m_hMainWnd = GetSafeHwnd(); DWORD dwThreadId = pThread->m_nThreadID;这里有个细节:不是让你重写Run()。CWinThread::Run()内部已经实现了消息泵,并且在出消息时会检查hwnd是否为NULL。对于PostThreadMessage发来的、没有窗口句柄的消息,MFC会把它们路由到CWinThread的消息映射表,所以ON_MESSAGE才能处理到。如果你自己重写Run(),但又想继续用ON_MESSAGE,就得自己处理这条路由逻辑,等于把MFC内部机制重造一遍,没必要。
3.3 两种方案怎么选
简单总结一下:
- 纯
GetMessage循环写法直观、可控性强、不依赖MFC类体系,适合要在线程里塞大量自定义逻辑,或者不想引入CWinThread派生类的场景; CWinThread派生类代码干净、有标准消息映射,适合线程本身就像一个小型对象、需要封装成员变量和业务方法的场景。
我自己的判断标准是:线程逻辑超过50行,我就用CWinThread派生类;只是临时后台干个活,需要接收两三条指令,用线程函数加GetMessage循环就够了。
4. 线程通信、回传与退出收尾
4.1 主线程往工作线程发指令
消息循环跑起来之后,主线程往工作线程发指令用PostThreadMessage:
::PostThreadMessage(dwThreadId, WM_MY_THREAD_MSG, (WPARAM)CMD_START, 0); ::PostThreadMessage(dwThreadId, WM_MY_THREAD_MSG, (WPARAM)CMD_STOP, 0); ::PostThreadMessage(dwThreadId, WM_MY_THREAD_QUIT, 0, 0);使用PostThreadMessage时注意目标线程ID要用正确的dwThreadId。如果你保存的是CWinThread*指针,记得这个指针在线程退出后不一定还有效,但m_nThreadID这个DWORD值是安全的,你可以在线程创建后立刻保存下来。
另外一个常被忽略的点是消息投递顺序。PostThreadMessage的数据进入目标线程的消息队列后,是按FIFO顺序处理的。所以即使主线程连发多条指令,目标线程也会依次处理,不会出现“还没初始化完就收到停止指令”这种乱序。这一点比共享变量加标志位的方案靠谱得多。
4.2 工作线程给主窗口回传进度与结果
工作线程有一个m_hMainWnd句柄,回传进度时直接构造一个用户消息,PostMessage到主窗口:
// 工作线程内 ::PostMessage(m_hMainWnd, WM_MY_THREAD_PROGRESS, (WPARAM)percent, 0);主窗口那边照常加ON_MESSAGE映射。这样做的最大好处是:更新控件的代码始终在主线程执行,完全规避了跨线程操作MFC控件的问题。
这里我建议一个克制原则:工作线程里不要高频往主窗口发消息。比如你在一个循环里每1毫秒发一次进度,主窗口的消息队列会被刷爆,界面卡顿、刷新闪烁都来了。更稳的写法是在线程内部做个限频,比如每100毫秒或者进度变化超过1%才发一次。UI上100毫秒一次的进度刷新,用户看着已经很流畅了。
4.3 线程退出:WM_QUIT、自定义退出和等待句柄
线程退出有几种处理情况:
如果是业务任务自然结束,你希望线程退出去,那在线程内部发一个WM_QUIT就行。WM_QUIT会让GetMessage返回0,消息循环自然结束,线程函数返回。
但更常见的是主线程主动关停子线程。我的做法是先发WM_MY_THREAD_QUIT,让线程有机会把自己手里的资源清掉,然后在处理函数末尾再发一个WM_QUIT。这样线程能确定性地退出,而不是被一刀砍死。
等待子线程退出时,很多人直接写:
WaitForSingleObject(pThread->m_hThread, INFINITE);这条代码在大多数场景下没问题,但如果子线程在退出前要向主窗口发消息,而主线程此时正在卡着等待,就会出问题。后面第5章我会专门讲这个死锁场景,这里先说结论:如果子线程可能往主线程窗口发送消息,就不要用无限等待来阻塞主线程,要么等一个有限超时,要么用MsgWaitForMultipleObjects在等待期间继续泵消息。
另外在主窗口关闭时一定要先停掉所有后台线程,再让主线程退出。我在项目中见过太多次因为主窗口销毁、子线程还拿着主窗口句柄发消息导致的崩溃,都是收尾顺序没控制好。
5. 常见问题与排查技巧
5.1 消息不响应,先查这三个地方
遇到消息发过去石沉大海,我最先检查这三处:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
PostThreadMessage返回0,GetLastError显示无效线程ID | 线程还没创建完成,或线程已经退出 | 先确保线程启动成功并保存了正确的m_nThreadID;线程退出后就别再投递 |
| 消息发成功了,但线程没反应 | 目标线程没有消息循环,或还没进入循环 | 线程入口首先PeekMessage强制创建队列,等线程进入循环后再发指令 |
CWinThread的ON_MESSAGE没触发 | 重写了Run(),丢掉了MFC对NULL窗口消息的特殊路由 | 别重写Run(),用基类默认消息泵;或者换用纯GetMessage方案手动分发 |
尤其是最后一种,我见过有人把CWinThread::Run()整个重写成一个空循环,然后到处找为什么ON_MESSAGE收不到消息。其实MFC里线程消息能走消息映射,靠的正是基类Run()内部对hwnd == NULL消息做的特殊处理。你一旦绕过基类,这个机制自然就失效了。
5.2 消息循环被长任务卡住怎么办
这个问题很隐蔽。你以为是消息循环在正常工作,实际上某一次收到指令后,消息处理函数里写了一个耗时的for循环,结果整个线程的消息泵被堵住了,后续指令全部排队等待。
想避免这种情况,有三种思路:
第一种是让业务任务分片执行。每次消息处理只做一小步,做完后发一条同样的消息给自己,相当于用消息循环驱动一个状态机。这样消息泵永远有空闲时间处理新指令。
第二种是把业务循环里插入消息泵检出的动作:
while (m_bRunning) { // 非阻塞地处理当前队列里的消息 while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } // 做一小块实际工作 DoSingleStep(); }第三种是干脆把耗时工作拆到另一个更底层的线程队列里,不要让耗时操作占用带消息循环的线程。这个属于架构层面的取舍,看项目复杂度决定。
5.3 死锁:等待子线程时的经典陷阱
这是线程消息循环项目里最经典的死锁场景:
主线程在某按钮响应里执行WaitForSingleObject(hThread, INFINITE),等待子线程结束。子线程在退出前调用SendMessage(hMainWnd, WM_MY_THREAD_PROGRESS, ...),打算把最后结果同步交给主窗口。结果主窗口压根处理不了这个消息,因为主线程已经阻塞在等待函数里了。SendMessage又是个同步调用,会一直等到目标窗口处理完才返回。两边互相等,程序卡死。
解决办法有几个:
- 把子线程的
SendMessage改成PostMessage,发完就返回,不等待主线程处理; - 在等待子线程退出期间,用
MsgWaitForMultipleObjects替代WaitForSingleObject,同时泵主线程的消息队列; - 调整设计,让主线程先发退出指令给子线程,然后立刻返回,等
WM_DESTROY或自定义通知消息再统一收尾。
我个人的建议是:线程间通信能走PostMessage就不走SendMessage。PostMessage投递后立刻返回,不会有持有锁等待的问题。真正需要等待结果的场景,用一个一次性事件对象CEvent来控制更清晰。
5.4 消息处理函数里的线程安全
还有一个容易忽略的点是:消息处理函数在子线程上下文执行,里面访问的成员变量如果同时被主线程或别的线程读写,仍然需要加锁。
比如前面CMyThread里的m_bRunning,如果只是消息处理函数自己写、自己读,问题不大;但如果你在主线程里也直接读取这个变量判断线程是否活着,那就是跨线程访问了。稳妥做法是用volatile加原子操作,或者干脆用CWinThread的m_bRunning辅助判定,再配合线程句柄的等待结果来判断,不要裸读成员变量。
共享数据量大的话,用CCriticalSection或者CSingleLock保护临界区。注意临界区锁的范围越小越好,千万不能在持锁状态下调用PostMessage去等对方处理,否则又绕回死锁。
最后再分享一点个人体会。我早期做MFC后台线程时,习惯用全局标志加Sleep轮询,代码写起来似乎简单,但一旦涉及界面退出、任务取消、异常中断,标志位方案就开始漏洞百出。后来全线切到线程自定义消息循环,把一切外部指令都消息化,代码结构反而更清爽,指令是有序的、可追溯的。如果你正在为MFC线程间通信发愁,建议直接按这篇文章的思路搭一套消息循环的骨架,后面再往里填业务,踩坑概率会小很多。
本文还有配套的精品资源,点击获取