ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC多客户端Socket服务器:线程安全即时通讯实现

MFC多客户端Socket服务器:线程安全即时通讯实现

简介:本资源是一个基于MFC框架的Windows平台Socket即时通讯编程示例,面向具备Java Socket开发经验、希望转向更高性能C++网络编程的中高级开发者。项目完整实现单服务器与多个客户端间的异步消息广播,核心在于服务端使用CPtrList管理客户端socket连接,并依托CSocket/CSocketFile/CArchive类体系构建轻量级通信模型,代码结构清晰、注释详尽,特别适合理解MFC网络编程与Java多线程模型的对应设计思想。压缩包共75个文件,含15个头文件(h)、13个源码文件(cpp)、2个可执行程序(exe)及配套资源文件(ico、rc、res等),总大小3.44MB;其中util目录封装了ChatSocket、Message等关键辅助类,工程按功能分层组织,便于快速定位服务端onAccept回调与客户端OnSendButton入口逻辑。目前已有756人学习下载,是深入掌握MFC异步Socket编程、构建简易IM底层通信模块的优质实践范例。

1. 一个服务器对多个客户端的MFC Socket编程示例(实现简单的即时通讯功能):不是写个accept()就算多线程,真跑起来卡死、消息乱序、客户端断连不通知——这套方案专治 MFC 网络编程里的“玄学崩溃”

你是不是也试过:用 MFC 写了个 Server,WSAStartup成功、socket()成功、bind()成功、listen()成功……一调accept(),界面就卡住?加个AfxBeginThread,结果发出去的消息在客户端显示成乱码,或者 A 发的“你好”被 B 收到,C 却收不到?更糟的是,某个客户端手滑关了程序,服务器端毫无反应,recv()一直阻塞,整个服务线程挂死,其他用户全掉线?这不是代码没写完,是架构没立住。这套「一个服务器对多个客户端的MFC Socket编程示例」,就是为解决这些真实翻车现场而生:它不堆砌 Win32 API 调用,而是以可交付、可调试、可答辩为目标,完整实现带连接管理、消息广播、异常断连检测、UI 线程安全更新的即时通讯最小闭环。适合正在做课程设计、毕业设计或需要快速验证网络通信逻辑的开发者——它不追求高并发百万级,但保证 50 个客户端稳定在线时,每条消息从发送到所有在线用户 UI 刷新,延迟可控、日志可查、断连可感知。核心不在“能连”,而在“连得稳、断得清、发得准”。

2. 总体架构设计:单服务进程 + 多工作线程 + 主UI线程三线协同模型与四类核心对象职责划分

这套方案没有采用 IOCP 或完成端口这类重型机制,而是基于 MFC 框架特性,构建了一个轻量但健壮的三层协作模型:主线程(UI线程)专注界面响应与用户操作;服务监听线程专职accept()新连接;每个客户端连接由独立的工作线程承载,负责该连接的recv/send循环与协议解析;所有跨线程数据交互通过线程安全队列与 Windows 消息机制完成。这种结构既规避了 MFC 窗口句柄跨线程调用的致命陷阱,又避免了单线程轮询带来的高 CPU 占用和响应延迟。

整个系统围绕四个核心对象展开职责划分:

  • CServerSocket:继承自CSocket,仅封装监听套接字的创建、绑定、监听逻辑,不处理任何客户端通信,纯粹作为“守门人”。
  • CClientSession:每个已连接客户端对应一个实例,持有该连接的SOCKET句柄、客户端 IP/端口、连接时间、最后心跳时间,并提供SendMsg()和Close()接口。它是数据收发的最小单元。
  • CSessionManager:全局单例,管理所有活跃的CClientSession*指针容器(std::vector<std::shared_ptr<CClientSession>>),提供线程安全的Add(),Remove(),Broadcast()方法。所有广播逻辑、在线数统计、遍历操作均由此统一调度。
  • CMainFrm / CChatDlg:UI 层,通过自定义 Windows 消息(如WM_CLIENT_CONNECTED,WM_MSG_RECEIVED,WM_CLIENT_DISCONNECTED)接收后台线程通知,更新列表控件、聊天框、状态栏,绝不直接调用CClientSession的send()或recv()。

提示:MFC 中CSocket的OnReceive()是异步回调,但其执行上下文仍在 UI 线程。若在此回调中做耗时解析或直接调用Send()给其他客户端,极易导致 UI 卡顿甚至死锁。本方案彻底弃用CSocket的事件回调机制,改用WSAEventSelect+WaitForMultipleObjects配合工作线程轮询,将网络 I/O 完全剥离出 UI 线程。

2.1 监听线程启动与连接接纳:AfxBeginThread启动ListenThreadProc并传递CServerSocket*

监听线程是整个服务的入口,必须确保其永不退出且能及时响应新连接。我们不使用CSocket::AsyncSelect(),而是手动创建监听套接字并启用事件驱动:

// ListenThreadProc.cpp UINT ListenThreadProc(LPVOID pParam) { CServerSocket* pServer = (CServerSocket*)pParam; if (!pServer || !pServer->m_hAcceptEvent) return 1; // 设置监听套接字为非阻塞,并关联事件对象 WSAEventSelect(pServer->m_hSocket, pServer->m_hAcceptEvent, FD_ACCEPT); while (pServer->m_bRunning) { DWORD dwRet = WaitForSingleObject(pServer->m_hAcceptEvent, 1000); if (dwRet == WAIT_OBJECT_0) { // 有新连接到达 sockaddr_in addrClient; int nAddrLen = sizeof(addrClient); SOCKET hClientSock = accept(pServer->m_hSocket, (sockaddr*)&addrClient, &nAddrLen); if (hClientSock != INVALID_SOCKET) { // 创建会话对象并加入管理器 auto pSession = std::make_shared<CClientSession>(hClientSock, addrClient); CSessionManager::GetInstance()->Add(pSession); // 通知UI:新客户端接入 ::PostMessage(AfxGetMainWnd()->m_hWnd, WM_CLIENT_CONNECTED, (WPARAM)pSession.get(), 0); } // 重置事件对象,等待下一次FD_ACCEPT WSAResetEvent(pServer->m_hAcceptEvent); } else if (dwRet == WAIT_TIMEOUT) { // 定期检查服务运行状态,避免假死 continue; } else { break; // 其他错误,退出线程 } } return 0; }

这段代码的关键在于:WSAEventSelect将套接字事件与内核事件对象绑定,WaitForSingleObject实现低开销等待,避免accept()阻塞主线程。PostMessage是跨线程通知 UI 的唯一安全方式,参数WPARAM传入CClientSession*地址(注意:仅作标识,UI 层不可解引用操作,因会话对象生命周期由CSessionManager管理)。

2.2 客户端工作线程:每个CClientSession独立RecvSendLoop

每个客户端连接启动一个专属工作线程,负责该连接的全部 I/O。这是实现“一个服务器对多个客户端”的核心载体。线程函数需处理三类事件:接收数据、发送队列消费、连接异常检测。

// CClientSession.cpp UINT CClientSession::WorkThreadProc(LPVOID pParam) { CClientSession* pThis = (CClientSession*)pParam; if (!pThis) return 1; // 设置套接字为非阻塞 u_long nonBlocking = 1; ioctlsocket(pThis->m_hSocket, FIONBIO, &nonBlocking); char szBuffer[4096] = {0}; int nBytes = 0; DWORD dwLastActive = GetTickCount(); while (pThis->m_bConnected) { // 1. 尝试接收数据 nBytes = recv(pThis->m_hSocket, szBuffer, sizeof(szBuffer)-1, 0); if (nBytes > 0) { szBuffer[nBytes] = '\0'; // 解析协议:假设为 "MSG:用户名|内容" 格式 pThis->ParseAndDispatch(szBuffer); dwLastActive = GetTickCount(); } else if (nBytes == 0) { // 对端正常关闭连接 pThis->m_bConnected = FALSE; break; } else { int nError = WSAGetLastError(); if (nError == WSAEWOULDBLOCK) { // 无数据,继续循环 Sleep(10); } else if (nError == WSAECONNRESET || nError == WSAENETDOWN) { // 连接被重置或网络中断 pThis->m_bConnected = FALSE; break; } else { // 其他错误,记录日志 LogError(_T("recv error %d on client %s"), nError, pThis->m_strIP); pThis->m_bConnected = FALSE; break; } } // 2. 检查是否超时(心跳机制) if (GetTickCount() - dwLastActive > 60000) { // 60秒无活动 pThis->m_bConnected = FALSE; break; } // 3. 尝试发送队列中的消息(线程安全队列 pop) pThis->TrySendFromQueue(); } // 连接终止,清理资源并通知管理器 CSessionManager::GetInstance()->Remove(pThis); ::PostMessage(AfxGetMainWnd()->m_hWnd, WM_CLIENT_DISCONNECTED, (WPARAM)pThis, 0); return 0; }

TrySendFromQueue()内部使用CCriticalSection保护的std::queue<CString>,UI 线程调用CClientSession::EnqueueSend(CString msg)将待发消息压入队列,工作线程在空闲时pop并send()。这种“生产者-消费者”模式彻底解耦 UI 与网络线程,避免send()阻塞 UI。

2.3 消息广播与会话管理:CSessionManager的线程安全广播实现

广播不是简单遍历send(),必须考虑:目标客户端是否仍在线、send()是否成功、失败后如何清理。CSessionManager::Broadcast()是核心业务逻辑出口:

// CSessionManager.cpp void CSessionManager::Broadcast(const CString& strMsg, CClientSession* pExcept /*= nullptr*/) { std::vector<std::shared_ptr<CClientSession>> vecOnline; // 1. 快照当前在线会话(避免遍历时容器被修改) { CSingleLock lock(&m_csLock, TRUE); vecOnline = m_vecSessions; } // 2. 遍历快照,对每个会话尝试发送 for (auto& pSession : vecOnline) { if (!pSession || pSession.get() == pExcept || !pSession->IsConnected()) { continue; } // 使用会话自身的发送队列,而非直接 send() // 因为 send() 可能阻塞或失败,交由工作线程处理更安全 pSession->EnqueueSend(strMsg); } }

关键点在于“快照”:先加锁拷贝m_vecSessions到局部vecOnline,再释放锁,然后遍历快照。这样即使在遍历过程中有其他线程调用Remove(),也不会导致迭代器失效或访问已释放内存。EnqueueSend()将消息推入该会话的工作线程队列,由其TrySendFromQueue()异步发出,保证广播操作本身毫秒级返回,不拖慢调用方。

2.4 UI 线程消息响应:CChatDlg中处理WM_CLIENT_CONNECTED等自定义消息

MFC 对话框需在ON_MESSAGE宏中注册自定义消息处理器。这是 UI 与后台唯一合法的数据通道:

// CChatDlg.h afx_msg LRESULT OnClientConnected(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnMsgReceived(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnClientDisconnected(WPARAM wParam, LPARAM lParam); // CChatDlg.cpp BEGIN_MESSAGE_MAP(CChatDlg, CDialogEx) ON_MESSAGE(WM_CLIENT_CONNECTED, &CChatDlg::OnClientConnected) ON_MESSAGE(WM_MSG_RECEIVED, &CChatDlg::OnMsgReceived) ON_MESSAGE(WM_CLIENT_DISCONNECTED, &CChatDlg::OnClientDisconnected) END_MESSAGE_MAP() LRESULT CChatDlg::OnClientConnected(WPARAM wParam, LPARAM lParam) { CClientSession* pSession = (CClientSession*)wParam; if (pSession) { // 更新在线用户列表(ListCtrl) int nItem = m_listUsers.InsertItem(m_listUsers.GetItemCount(), pSession->m_strIP); m_listUsers.SetItemText(nItem, 1, pSession->m_strPort); m_listUsers.SetItemText(nItem, 2, _T("在线")); // 更新状态栏 CString strStatus; strStatus.Format(_T("在线用户:%d"), CSessionManager::GetInstance()->GetCount()); m_wndStatusBar.SetPaneText(0, strStatus); } return 0; } LRESULT CChatDlg::OnMsgReceived(WPARAM wParam, LPARAM lParam) { MSG_INFO* pMsg = (MSG_INFO*)wParam; // MSG_INFO 结构体含 senderIP, content 等 if (pMsg) { // 格式化消息并追加到聊天框(RichEditCtrl) CString strLog; strLog.Format(_T("[%s] %s\r\n"), pMsg->strSenderIP, pMsg->strContent); m_richEdit.AppendText(strLog); // 滚动到底部 m_richEdit.PostMessage(EM_SCROLLCARET, 0, 0); delete pMsg; // 由UI线程释放 } return 0; }

注意:OnMsgReceived中wParam传入的是new MSG_INFO分配的堆内存地址,UI 线程处理完必须delete,否则内存泄漏。这是跨线程传递复杂数据的常用手法,比CString更灵活(可携带结构化信息)。

3. 关键模块实现:协议设计、心跳保活、UI线程安全更新与异常断连检测

即时通讯的“即时”二字,不仅指发送快,更指状态准、断连清、消息不丢。本节聚焦四个让项目从“能跑”升级到“可靠”的硬核模块。

3.1 文本协议设计:MSG:前缀 +|分隔 + UTF8 编码,兼顾可读性与扩展性

不采用二进制协议,是因为课程设计/毕设阶段首要目标是可调试、可验证、可演示。明文协议让 Wireshark 抓包一目了然,也让测试脚本(如 Python socket 脚本)能轻松模拟客户端。协议格式定义为:

MSG:用户名|消息内容 HEARTBEAT: LIST_USERS:
  • MSG:开头表示普通聊天消息,|左侧为发送者标识(可为 IP 或自定义昵称),右侧为 UTF8 编码的正文。服务端收到后,自动在消息前添加[IP:端口]前缀再广播,确保客户端无需解析即可显示来源。
  • HEARTBEAT:是客户端主动发送的心跳包,服务端收到后刷新该会话的dwLastActive时间戳,用于超时检测。
  • LIST_USERS:是客户端请求在线用户列表,服务端回复USERS:IP1:PORT1|IP2:PORT2|...格式字符串。

所有字符串均以\r\n结尾,recv()时按行解析(find("\r\n")),避免粘包。UTF8 编码通过CT2CA转换实现:

// 发送前编码 CString strToSend = _T("MSG:张三|你好,世界!\r\n"); CT2CA pszConverted(strToSend); // CT2CA 自动转为 ANSI/UTF8 send(m_hSocket, pszConverted, strlen(pszConverted), 0);

注意:MFC 默认使用 ANSI 编码,若需支持中文,项目属性中必须设置“字符集”为“使用 Unicode 字符集”,否则CT2CA转换会丢失中文。这是学生项目里最高频的“消息变问号”原因。

3.2 心跳保活与超时检测:双机制兜底,拒绝“僵尸连接”

网络不稳定时,TCP 连接可能处于半开状态(recv()不返回 0,但send()会失败)。仅靠recv()返回值无法及时发现。本方案采用“应用层心跳 + TCP KeepAlive”双保险:

  • 应用层心跳:客户端每 30 秒发送HEARTBEAT:\r\n,服务端工作线程维护dwLastActive时间戳。若GetTickCount() - dwLastActive > 60000(即 60 秒无任何活动),强制断开连接。此机制可捕获网络中断、客户端崩溃等场景。
  • TCP KeepAlive:在accept()后立即启用,作为底层兜底:
    // 启用 TCP KeepAlive DWORD dwKeepAlive = 1; setsockopt(hClientSock, SOL_SOCKET, SO_KEEPALIVE, (char*)&dwKeepAlive, sizeof(dwKeepAlive)); // Windows 下需通过 ioctlsocket 设置间隔(非标准,但有效) tcp_keepalive ka; ka.onoff = 1; ka.keepalivetime = 60000; // 60秒后开始探测 ka.keepaliveinterval = 5000; // 每5秒探测一次 DWORD dwBytes; WSAIoctl(hClientSock, SIO_KEEPALIVE_VALS, &ka, sizeof(ka), NULL, 0, &dwBytes, NULL, NULL);

双机制确保:网络闪断时,应用层心跳在 60 秒内发现;客户端进程僵死但 TCP 连接未关闭时,TCP KeepAlive 在 65 秒内(60+5)触发recv()返回错误,工作线程捕获并清理。

3.3 UI 线程安全更新:PostMessage+CString拷贝 +CMapStringToString缓存

UI 控件(如CListCtrl,CRichEditCtrl)只能由创建它的线程(UI 线程)访问。工作线程若直接调用m_listUsers.InsertItem(),程序大概率崩溃。安全做法是:工作线程PostMessage发送消息,UI 线程在消息处理器中操作控件。

但PostMessage的wParam/lParam只能传 32/64 位整数,无法直接传CString。解决方案是:在工作线程堆上分配CString,PostMessage传地址,UI 线程处理完delete。为防重复释放,使用CMapStringToString缓存待处理消息:

// 工作线程中 CString* pMsg = new CString(_T("[192.168.1.100:5000] 你好")); ::PostMessage(AfxGetMainWnd()->m_hWnd, WM_MSG_RECEIVED, (WPARAM)pMsg, 0); // UI 线程中(CChatDlg.cpp) LRESULT CChatDlg::OnMsgReceived(WPARAM wParam, LPARAM lParam) { CString* pStr = (CString*)wParam; if (pStr) { m_richEdit.AppendText(*pStr + _T("\r\n")); delete pStr; // 必须释放! } return 0; }

提示:“忘记 delete”是导致内存泄漏的头号原因。建议在CChatDlg析构函数中遍历CMapStringToString清理残留,或改用智能指针(如std::shared_ptr<CString>),但需确保 MFC 项目支持 C++11。

3.4 异常断连检测:WSAGetLastError()错误码精准识别与分级处理

recv()返回 -1 时,WSAGetLastError()的值决定后续动作。不能一概closesocket(),必须分级:

错误码含义处理方式
WSAEWOULDBLOCK非阻塞套接字无数据忽略,继续循环
WSAECONNRESET对端强行关闭(RST包)立即标记m_bConnected=FALSE,退出循环
WSAENETDOWN网络接口断开同上,需记录日志
WSAETIMEDOUT连接超时(罕见)同上
WSAENOTCONN套接字未连接理论不应出现,记严重错误

工作线程中必须对每个错误码编写明确分支,而非笼统if (nBytes == SOCKET_ERROR)。例如:

else { int nError = WSAGetLastError(); switch (nError) { case WSAEWOULDBLOCK: Sleep(10); break; case WSAECONNRESET: case WSAENETDOWN: case WSAETIMEDOUT: LogInfo(_T("Client %s disconnected due to network error %d"), m_strIP, nError); m_bConnected = FALSE; break; default: LogError(_T("Unexpected recv error %d from %s"), nError, m_strIP); m_bConnected = FALSE; break; } }

精准识别错误码,是写出健壮网络程序的基本功。很多“随机崩溃”都源于对WSAETIMEDOUT或WSAENOTCONN的忽略。

4. 常见问题排查:MFC Socket编程中5个最易翻车的点与血泪解决方案

写 MFC Socket 最痛苦的不是写不出,而是写出来后“时好时坏”“重启就崩”“消息偶尔乱序”。这些问题往往源于对 MFC 线程模型和 Windows 网络栈的误解。以下是我在带多个模拟项目X 时,学生踩坑频率最高的 5 个点,每一条都附带现象、根因和可立即执行的修复方案。

4.1 现象:UI 界面卡死,鼠标变成沙漏,但accept()之后的代码明明没写死循环

原因:CSocket::Create()或socket()创建后,未调用ioctlsocket(hSocket, FIONBIO, &nonBlocking)设置为非阻塞模式,导致accept()或recv()在 UI 线程中阻塞。
解决:

  • 彻底弃用CSocket的AsyncSelect()机制(它依赖 UI 线程消息泵,易受阻塞影响)。
  • 所有套接字(监听套接字、客户端套接字)创建后,立即设置非阻塞:
    u_long nonBlocking = 1; ioctlsocket(hSocket, FIONBIO, &nonBlocking);
  • 监听逻辑必须放入AfxBeginThread启动的独立线程,accept()调用必须在该线程内。

4.2 现象:客户端发送中文,服务器收到乱码(如“浣犲ソ”),或部分中文显示为方块

原因:项目字符集设置为“多字节字符集(MBCS)”,但网络传输使用 UTF8 编码,CString默认构造函数按 ANSI 解析。
解决:

  • 项目属性 → 常规 → 字符集 →选择“使用 Unicode 字符集”(强制要求)。
  • 发送前,用CT2CA显式转换为 UTF8:
    CString strMsg = _T("你好"); // Unicode 字符串 CT2CA pszUtf8(strMsg); // 转为 const char* send(hSocket, pszUtf8, strlen(pszUtf8), 0);
  • 接收后,用CA2CT将 UTF8 转回 Unicode:
    char szBuf[1024]; int n = recv(hSocket, szBuf, sizeof(szBuf)-1, 0); if (n > 0) { szBuf[n] = '\0'; CA2CT pszUnicode(szBuf); // UTF8 -> Unicode CString strUnicode = pszUnicode; // 此时 strUnicode 可正确显示中文 }

4.3 现象:客户端关闭后,服务器端recv()一直返回 0,但CSessionManager中该会话未被移除,Broadcast()仍向其发送,导致send()失败

原因:recv()返回 0 表示对端正常关闭(FIN 包),但工作线程未及时跳出循环并调用CSessionManager::Remove()。
解决:

  • 工作线程recv()循环中,必须显式检查nBytes == 0:
    if (nBytes == 0) { // 对端优雅关闭,立即终止本线程 m_bConnected = FALSE; break; // 退出 recv 循环 }
  • break后,线程函数末尾必须执行CSessionManager::GetInstance()->Remove(this)和PostMessage(WM_CLIENT_DISCONNECTED)。
  • 严禁在nBytes == 0时只打印日志而不break,这是“僵尸连接”的根源。

4.4 现象:多个客户端同时发送消息,服务器广播时,A 的消息被 C 收到,B 却收不到,或消息顺序错乱

原因:CSessionManager::Broadcast()直接遍历std::vector并调用send(),而send()在非阻塞套接字上可能返回SOCKET_ERROR且WSAGetLastError() == WSAEWOULDBLOCK,此时消息实际未发出,但代码未重试也未入队。
解决:

  • Broadcast()绝不直接调用send(),必须调用CClientSession::EnqueueSend(),将消息压入该会话的线程安全发送队列。
  • 每个CClientSession工作线程在TrySendFromQueue()中循环pop并send(),若send()返回SOCKET_ERROR且WSAGetLastError() == WSAEWOULDBLOCK,则push_back()回队列尾部,稍后重试。
  • 队列使用CCriticalSection保护,确保push/pop原子性。

4.5 现象:程序运行一段时间后,CPU 占用飙升至 100%,任务管理器看到多个AfxWinMain线程

原因:工作线程中Sleep(0)或Sleep(1)时间过短,导致线程频繁唤醒,陷入“忙等”(busy-waiting)。
解决:

  • 工作线程recv()循环中,WSAEWOULDBLOCK分支的Sleep()时间必须 ≥ 10ms:
    if (nError == WSAEWOULDBLOCK) { Sleep(10); // 关键!不能是 Sleep(0) 或 Sleep(1) }
  • 更优方案:使用select()或WSAEventSelect配合WaitForMultipleObjects,让线程真正休眠,直到有 I/O 事件发生。本方案监听线程已采用此法,客户端工作线程亦可升级。

5. 方案落地与验收:从编译通过到答辩演示的6步闭环流程与3个必验场景

一份能交差的课程设计/毕设,不在于代码行数,而在于能否在导师面前稳定、清晰、可解释地跑通核心流程。我带过的某高校课程设计中,80% 的学生倒在“能编译但跑不通”或“跑通了但讲不清为什么”。以下是我提炼的 6 步闭环流程,以及答辩前必须亲自验证的 3 个硬核场景——它们直接对应评分标准里的“功能完整性”、“稳定性”、“可维护性”。

5.1 6步闭环流程:从零开始到可演示的标准化路径

这 6 步不是理想化步骤,而是我每次帮学生搭环境时强制执行的 checklist,确保不遗漏任何隐性依赖:

  1. 环境初始化:安装 Visual Studio 2019(或 VS2022),创建 MFC 应用程序(基于对话框),项目属性 → 常规 → 字符集 → 使用 Unicode 字符集(此步遗漏,后续中文全废)。
  2. 网络库链接:项目属性 → 链接器 → 输入 → 附加依赖项 → 添加ws2_32.lib(Windows Sockets 2 库,无此库socket()等函数链接失败)。
  3. 头文件与宏定义:在stdafx.h(或pch.h)中添加:
    #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") #define WM_CLIENT_CONNECTED (WM_USER + 101) #define WM_MSG_RECEIVED (WM_USER + 102) #define WM_CLIENT_DISCONNECTED (WM_USER + 103)
  4. UI 控件布局:在对话框资源中,拖入ListCtrl(ID:IDC_LIST_USERS,风格:Report)、RichEditCtrl(ID:IDC_RICHEDIT_CHAT)、EditBox(ID:IDC_EDIT_INPUT)、Button(ID:IDC_BTN_SEND)。
  5. 线程安全类实现:新建CSessionManager.h/cpp,实现单例、Add()/Remove()/Broadcast(),内部std::vector加CCriticalSection保护;新建CClientSession.h/cpp,实现工作线程、EnqueueSend()、TrySendFromQueue()。
  6. 消息映射与启动:在CChatDlg中ON_MESSAGE注册三个自定义消息;在OnInitDialog()中调用AfxBeginThread(ListenThreadProc, pServerSocket)启动监听线程;在OnBtnSend()中获取EditBox文本,调用CSessionManager::Broadcast()。

提示:第 3 步的#define WM_XXX必须放在stdafx.h,否则不同.cpp文件中宏值不一致,PostMessage会发错消息。这是“消息收不到”的隐藏元凶。

5.2 3个必验场景:答辩前亲手跑通,覆盖90%扣分点

不要只测“两个客户端聊天”,导师最可能问的问题,都藏在这三个极端场景里。每个场景我都附上验证方法和预期结果:

场景验证步骤预期结果为什么必验
场景1:异常断连韧性1. 启动 Server;2. 启动 ClientA、ClientB;3. ClientA 发送“测试1”;4.手动结束 ClientB 进程(任务管理器 → 结束任务);5. ClientA 再发“测试2”Server 界面“在线用户”数从 2 变为 1;ClientA 收到“测试1”和“测试2”,且“测试2”不显示 ClientB 的头像/名称(因 ClientB 已离线);Server 日志显示 “Client 127.0.0.1:5001 disconnected due to network error 10054”验证WSAECONNRESET捕获、CSessionManager::Remove()执行、广播逻辑自动过滤离线用户。这是“稳定性”核心指标。
场景2:中文与特殊字符1. Server 启动;2. ClientA 发送:`MSG:小明Hello! 你好,世界!@#$%^&*();3. ClientB 发送:MSG:小红测试换行
第二行(注意
` 是纯文本,非 HTML)
场景3:高负载下的消息时序1. 启动 Server;2. 启动 5 个 Client(可用 Python 脚本模拟);3. 所有 Client 同时发送 10 条消息(如 “MSG:A1”, “MSG:A2”, …);4. 观察 Server RichEdit 中消息顺序

5.3 答辩演示技巧:3句话讲清架构,1个截图锁定亮点

导师没时间听你逐行讲代码。我的经验是:用一张图 + 三句话建立技术可信度,再用一个动态截图证明功能真实。

  • 第一句话(定位):“这是一个基于 MFC 框架的 C/S 即时通讯最小系统,核心目标是验证‘一个服务器稳定支撑多个客户端’的工程可行性,而非追求百万并发。”
  • 第二句话(架构):“它采用三线程模型:UI 线程管界面、监听线程管接入、每个客户端一个工作线程管通信,所有跨线程数据通过PostMessage和线程安全队列传递,彻底规避 MFC 跨线程调用陷阱。”
  • 第三句话(亮点):“我们实现了应用层

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进