ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MFC Socket 多客户端即时通讯:阻塞多线程与广播队列实战

MFC Socket 多客户端即时通讯:阻塞多线程与广播队列实战 简介这是一份面向具备 Java Socket 编程经验、希望转向 C 网络开发的程序员所准备的 MFC 实战示例通过一个服务器对多个客户端的即时通讯场景演示 CSocket 异步通信、CSocketFile 与 CArchive 的配合用法。服务端借助 CPtrList 集合保存各客户端 socket 对象思路与 Java 中用 Vector 加多线程的方案相通但 MFC 的异步机制让代码更简洁。压缩包共 75 个文件约 3.44MB包含 15 个 h 头文件、13 个 cpp 源文件及配套 obj、exe、dsp、dsw 等工程与编译产物辅助类统一收纳在 util 目录中结构清晰。资源注释详尽作者手写部分遵循 Java 命名规范便于快速定位功能代码。阅读时服务端可从 onAccept 回调入手客户端则从 OnSendButton 方法切入即可把握整体通信流程。目前已有 755 人学习下载适合想对比 Java 与 MFC 网络编程差异、提升即时通讯程序编写效率的开发者参考。1. 一个服务器对多个客户端的 MFC Socket 编程从单连接到多路复用的关键一跃很多做 Windows 桌面工具的开发者第一次接触网络编程时写的都是「一对一」的 TCP 小 demo客户端连上服务器回一句然后断开。可一旦需求变成「一个服务端同时接多个客户端任意一端发消息其他端都能收到」代码立刻从几十行膨胀到几百行而且开始出现各种玄学问题——有的客户端连不上有的消息发出去对方收不到有的关掉一个客户端整个服务端就卡死。这个标题讲的就是用 MFC 这套框架把「一个服务器对多个客户端」的即时通讯骨架搭起来。它解决的核心不是协议本身而是并发连接的管理和消息的广播分发。适合已经会写单连接 Socket、想往多客户端方向走一步的 Windows C 开发者也适合需要给内部工具加一个轻量聊天/通知通道的工程师。下面我按自己实际落地的顺序把选型、代码、参数和踩过的坑一次讲清楚。2. 选型先定死为什么是 MFC 阻塞 Socket 多线程而不是别的组合2.1 三种常见技术路线的取舍在 Windows 上做多客户端服务端绕不开三个选择用阻塞 Socket 配多线程、用 select 做 IO 多路复用、或者用 IOCP 做异步。MFC 本身对 Socket 的封装有两层一层是 CAsyncSocket一层是 CSocket。CAsyncSocket 基于窗口消息通知适合单线程事件驱动CSocket 在它之上加了阻塞语义配合线程用起来更接近传统写法。我一般会选「阻塞 Socket 每客户端一线程」的方案原因很实际即时通讯这种场景连接数通常在几十到几百不是上万长连接线程开销可以接受而且阻塞写法逻辑线性调试时断点能直接停在 recv 上不用去追消息循环。IOCP 性能最好但代码复杂度高一个量级对「简单即时通讯」来说是过度设计。select 方案单线程能扛但一旦某个客户端发大包整个循环都会被拖住广播延迟会抖。方案连接数上限代码复杂度调试难度适用场景阻塞 Socket 多线程数百低低内部工具、简单 IMselect 多路复用上千中中中等并发、单线程IOCP 异步上万高高高并发服务器提示如果连接数预期超过 500或者消息频率很高建议直接上 IOCP不要用线程硬扛否则上下文切换会把 CPU 吃满。2.2 MFC 工程里 Socket 的初始化位置MFC 的 Socket 功能需要先调用 AfxSocketInit这个调用必须发生在使用任何 Socket 之前。常见做法是在 CWinApp 派生类的 InitInstance 里调用而不是在对话框的 OnInitDialog 里。原因是如果放在对话框里某些情况下对话框还没创建完就开始监听会出现资源未初始化的报错。// 在 CWinApp 派生类的 InitInstance 中初始化 BOOL CMyApp::InitInstance() { // 初始化 MFC Socket 库失败直接返回 if (!AfxSocketInit()) { AfxMessageBox(_T(Socket 库初始化失败)); return FALSE; } CMyDlg dlg; m_pMainWnd dlg; dlg.DoModal(); return FALSE; }这段代码的关键点是 AfxSocketInit 的返回值必须检查。它内部会加载 ws2_32.dll 并做 WSAStartup如果系统网络组件异常这里就会失败。参数方面没有额外配置但要注意它只能调用一次重复调用不会报错但也没有意义。2.3 服务端与客户端的职责划分服务端要做三件事监听端口、接受连接、维护客户端列表。客户端要做两件事连接服务端、收发消息。广播逻辑放在服务端因为只有服务端知道当前有哪些客户端在线。客户端不需要知道其他客户端的地址所有消息都经过服务端转发这就是典型的星型拓扑。这种划分的好处是客户端逻辑极简坏处是服务端成为单点。对于「简单即时通讯」这个目标星型拓扑完全够用不要一上来就搞 P2P 或者去中心化那会把问题复杂度拉高好几倍。3. 服务端落地监听线程、客户端线程与广播队列怎么写3.1 监听线程的创建与 accept 循环服务端主线程不能直接跑 accept 循环否则界面会卡死。常见做法是开一个独立的监听线程在里面死循环 accept每接受一个连接就为它创建一个客户端线程。// 监听线程函数 UINT CServerDlg::ListenThread(LPVOID pParam) { CServerDlg* pDlg (CServerDlg*)pParam; while (pDlg-m_bRunning) { // 阻塞等待客户端连接 SOCKET clientSock accept(pDlg-m_listenSock, NULL, NULL); if (clientSock INVALID_SOCKET) { // 如果是因为退出标志导致的失败直接跳出 if (!pDlg-m_bRunning) break; continue; } // 为新客户端创建线程 ClientContext* ctx new ClientContext(); ctx-sock clientSock; ctx-pDlg pDlg; AfxBeginThread(ClientThread, ctx); } return 0; }accept 的第二个和第三个参数传 NULL 表示不关心客户端地址如果需要记录 IP 就传 sockaddr_in 结构。这里有个细节accept 返回 INVALID_SOCKET 不一定是错误也可能是监听套接字被关闭导致的所以要先判断退出标志再决定是否 continue。ClientContext 是一个自定义结构用来把套接字和对话框指针打包传给线程避免用全局变量。3.2 客户端线程的 recv 循环与消息边界TCP 是字节流没有消息边界。很多新手直接假设一次 recv 就是一条完整消息结果消息一长就被截断或者两条消息粘在一起。解决办法是自定义一个简单的包头前 4 个字节存消息长度后面跟实际内容。// 客户端线程接收消息并广播 UINT CServerDlg::ClientThread(LPVOID pParam) { ClientContext* ctx (ClientContext*)pParam; SOCKET sock ctx-sock; char header[4]; while (ctx-pDlg-m_bRunning) { // 先收 4 字节包头 int ret recv(sock, header, 4, 0); if (ret 0) break; // 连接断开或出错 int msgLen *(int*)header; if (msgLen 0 || msgLen 4096) break; // 长度非法防攻击 char* buffer new char[msgLen 1]; int received 0; // 循环收满整个消息体 while (received msgLen) { ret recv(sock, buffer received, msgLen - received, 0); if (ret 0) break; received ret; } buffer[msgLen] \0; // 广播给所有其他客户端 ctx-pDlg-Broadcast(sock, buffer, msgLen); delete[] buffer; } // 清理从列表移除、关闭套接字 ctx-pDlg-RemoveClient(sock); closesocket(sock); delete ctx; return 0; }这段代码有两个关键参数msgLen 上限设 4096是为了防止恶意客户端发一个超大长度导致服务端分配巨量内存received 循环是为了处理 TCP 半包recv 不保证一次收满。广播时传入发送者的 sock是为了不把消息发回给发送者自己这个逻辑在 Broadcast 里实现。3.3 广播时的线程安全与发送锁多个客户端线程可能同时调用 Broadcast而 Broadcast 要遍历客户端列表并逐个 send。如果两个线程同时遍历同一个列表或者一个在遍历一个在增删就会崩溃。所以必须加锁。// 广播函数带临界区保护 void CServerDlg::Broadcast(SOCKET sender, const char* data, int len) { CSingleLock lock(m_csClientList, TRUE); // 进入临界区 for (auto it m_clientList.begin(); it ! m_clientList.end(); it) { if (*it sender) continue; // 不回发给发送者 send(*it, (const char*)len, 4, 0); // 先发长度 send(*it, data, len, 0); // 再发内容 } }CSingleLock 是 MFC 对临界区的封装构造时传 TRUE 表示立即加锁。这里要注意 send 也可能阻塞如果某个客户端网络很慢send 会卡住导致锁一直被持有其他线程全部等待。更稳妥的做法是给每个客户端加一个发送队列由独立线程发送但那是进阶优化简单场景下先接受这个限制。注意临界区不要嵌套加锁MFC 的临界区不是可重入的同一个线程重复进入会死锁。4. 客户端落地连接、收消息线程与界面更新4.1 连接服务端的时机与超时处理客户端连接用 connect但 connect 默认是阻塞的如果服务端没开会卡住一段时间。常见做法是把 connect 放在一个工作线程里或者用非阻塞 connect 加 select 做超时。// 客户端连接函数 bool CClientDlg::ConnectToServer(CString ip, int port) { m_sock socket(AF_INET, SOCK_STREAM, 0); if (m_sock INVALID_SOCKET) return false; sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.S_un.S_addr inet_addr(CT2A(ip)); // 设置发送和接收超时避免永久阻塞 int timeout 5000; setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, (char*)timeout, sizeof(timeout)); setsockopt(m_sock, SOL_SOCKET, SO_SNDTIMEO, (char*)timeout, sizeof(timeout)); if (connect(m_sock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(m_sock); m_sock INVALID_SOCKET; return false; } // 连接成功后启动接收线程 AfxBeginThread(RecvThread, this); return true; }inet_addr 要求传入的是窄字符所以用 CT2A 做转换。SO_RCVTIMEO 和 SO_SNDTIMEO 设 5 秒是为了防止服务端异常时客户端一直卡在 recv 上。这两个参数在调试阶段可以设长一点正式使用时建议缩短。4.2 接收线程与自定义消息更新 UIMFC 的界面控件只能在主线程操作接收线程收到消息后不能直接 SetWindowText必须通过自定义消息 PostMessage 到主线程。// 自定义消息号 #define WM_USER_RECV_MSG (WM_USER 100) // 接收线程 UINT CClientDlg::RecvThread(LPVOID pParam) { CClientDlg* pDlg (CClientDlg*)pParam; char header[4]; while (pDlg-m_bConnected) { int ret recv(pDlg-m_sock, header, 4, 0); if (ret 0) break; int msgLen *(int*)header; if (msgLen 0 || msgLen 4096) break; char* buffer new char[msgLen 1]; int received 0; while (received msgLen) { ret recv(pDlg-m_sock, buffer received, msgLen - received, 0); if (ret 0) break; received ret; } buffer[msgLen] \0; // 通过 PostMessage 把消息传给主线程 pDlg-PostMessage(WM_USER_RECV_MSG, (WPARAM)buffer, 0); } return 0; }PostMessage 的 WPARAM 传的是 new 出来的 buffer 指针主线程处理完必须 delete否则内存泄漏。这个约定要在消息处理函数里写清楚。4.3 消息处理函数与内存释放// 消息映射 ON_MESSAGE(WM_USER_RECV_MSG, CClientDlg::OnRecvMsg) // 处理函数 LRESULT CClientDlg::OnRecvMsg(WPARAM wParam, LPARAM lParam) { char* buffer (char*)wParam; CString msg(buffer); // 追加到聊天记录框 m_chatList.AddString(msg); m_chatList.SetTopIndex(m_chatList.GetCount() - 1); delete[] buffer; // 必须释放 return 0; }这里用 AddString 追加到列表框SetTopIndex 让滚动条自动到底部。如果消息量很大列表框会越来越慢可以考虑用 RichEdit 或者限制显示条数。5. 避坑与排查多客户端场景下最容易翻车的五个点5.1 客户端列表遍历时崩溃现象服务端运行一段时间后某个客户端断开服务端直接崩溃。原因客户端线程在 RemoveClient 里删除了列表元素而另一个线程正在 Broadcast 里遍历同一个列表迭代器失效。解决所有对 m_clientList 的读写都必须放在同一个临界区里RemoveClient 也要加锁并且删除后立即 break 遍历。5.2 消息粘包导致内容错乱现象发送「你好」和「在吗」两条消息接收端显示成「你好在吗」或者「你好在」。原因TCP 是流式协议两次 send 可能被合并成一次 recv。解决严格按「4 字节长度 内容」的格式收发接收端循环收满指定长度再处理不要假设一次 recv 对应一条消息。5.3 关闭客户端时服务端卡死现象点关闭按钮后客户端界面卡住几秒才退出。原因接收线程还在阻塞 recv主线程等待线程结束。解决关闭时先调用 shutdown(sock, SD_BOTH)这会唤醒阻塞的 recv 并返回 0然后 closesocket最后等待线程退出。不要直接 closesocket那样 recv 的行为在不同系统上不一致。5.4 端口被占用导致监听失败现象服务端启动时报绑定失败。原因上一次程序没有正常退出端口还处于 TIME_WAIT 状态。解决bind 之前设置 SO_REUSEADDR允许重用本地地址。int opt 1; setsockopt(m_listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)opt, sizeof(opt));5.5 中文乱码现象发送中文接收端显示乱码。原因发送端用 CString 的 Unicode 版本接收端按 char 处理。解决统一用 UTF-8 编码发送发送前用 WideCharToMultiByte 转换接收端按 UTF-8 解析。或者整个工程都用多字节字符集但这不是长久之计。提示调试网络问题时先用网络调试工具确认数据是否真的发出去了再排查代码能省很多时间。6. 进阶技巧把广播延迟压下来并验证连接稳定性6.1 用发送队列替代直接 send前面提到 Broadcast 里直接 send 会持锁阻塞。改进方法是给每个客户端维护一个发送队列Broadcast 只负责把数据塞进队列并唤醒发送线程实际 send 由各客户端的发送线程完成。这样即使某个客户端网络慢也不会影响其他人。// 发送队列结构 struct SendPacket { char* data; int len; }; // 发送线程 UINT CServerDlg::SendThread(LPVOID pParam) { ClientContext* ctx (ClientContext*)pParam; while (ctx-pDlg-m_bRunning) { SendPacket* pkt nullptr; { CSingleLock lock(ctx-csSendQueue, TRUE); if (ctx-sendQueue.empty()) { // 队列空则等待用事件对象唤醒 lock.Unlock(); WaitForSingleObject(ctx-hSendEvent, 100); continue; } pkt ctx-sendQueue.front(); ctx-sendQueue.pop(); } send(ctx-sock, (const char*)pkt-len, 4, 0); send(ctx-sock, pkt-data, pkt-len, 0); delete[] pkt-data; delete pkt; } return 0; }这个改动的核心是把「锁内 send」变成「锁内入队、锁外 send」。hSendEvent 是一个手动重置事件Broadcast 入队后 SetEvent发送线程 WaitForSingleObject 到事件后继续处理。参数上WaitForSingleObject 的超时设 100 毫秒是为了即使事件丢失也能周期性检查退出标志。6.2 用心跳检测死连接TCP 连接在没有数据往来时对端异常断电本端可能长时间不知道。解决办法是客户端每隔 30 秒发一个心跳包服务端超过 90 秒没收到任何数据就判定连接失效并清理。// 服务端在 ClientThread 里记录最后活跃时间 ctx-lastActive GetTickCount(); // 另开一个检测线程每 30 秒扫描一次 void CServerDlg::CheckTimeout() { DWORD now GetTickCount(); CSingleLock lock(m_csClientList, TRUE); for (auto it m_clientList.begin(); it ! m_clientList.end(); ) { ClientContext* ctx FindContext(*it); if (ctx (now - ctx-lastActive 90000)) { shutdown(*it, SD_BOTH); it m_clientList.erase(it); } else { it; } } }GetTickCount 返回的是毫秒数90 秒就是 90000。这个值不要设太短否则网络抖动会误杀正常连接也不要太长否则死连接占着资源。6.3 验证方法用脚本模拟多客户端手工开多个客户端窗口测试效率太低。我一般写一个简单的 Python 脚本模拟 50 个客户端同时连接并互发消息观察服务端 CPU 和内存。import socket import threading import time def client_task(cid): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 8888)) for i in range(10): msg fclient {cid} msg {i}.encode(utf-8) s.send(len(msg).to_bytes(4, little) msg) time.sleep(0.1) s.close() threads [] for i in range(50): t threading.Thread(targetclient_task, args(i,)) t.start() threads.append(t) for t in threads: t.join() print(done)这个脚本用 4 字节小端长度做包头和服务端的格式一致。跑起来后看服务端是否能正常广播、是否有崩溃、内存是否持续增长。如果内存一直涨多半是 PostMessage 的 buffer 没释放或者客户端列表没清理。6.4 一个我踩过的坑早期版本里我在 Broadcast 里直接 send结果一个客户端用手机热点网络极慢send 阻塞了十几秒整个服务端所有客户端都收不到消息。后来改成发送队列问题立刻消失。这个教训是任何在锁内做的 IO 操作都要假设它会阻塞然后想办法把它挪出去。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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