资讯动态

MFC Socket一对多即时通讯实战:多线程阻塞服务器与客户端框架解析

发布时间:2026/10/18 20:32:42 来源:尧图企业网站定制
简介这是一份面向具备Java Socket编程基础、希望用MFC实现一对多即时通讯的开发者的示例工程。资源演示了单服务器与多个客户端之间的消息互通服务端利用CPtrList集合保存各客户端Socket对象配合CSocket异步通信机制完成消息转发默认端口为1008运行多个客户端进程后任一端发送的消息均可在服务端与其他客户端同步显示。压缩包共75个文件大小3.44MB包含15个h和13个cpp源码、可直接运行的exe程序以及obj、pdb等编译中间文件服务器端与客户端分属两个工程便于对照学习。资源已有755人学习。代码注释十分详细辅助类统一放入util目录手写功能代码使用驼峰命名法标出建议从服务端onAccept回调和客户端OnSendButton方法读起可快速理解CSocketFile与CArchive联合使用的异步收发流程并掌握与Java多线程版不同的C网络编程实现思路。1. 一个服务器对多个客户端的 MFC Socket 即时通讯为什么值得亲手敲一遍刚接触 MFC 网络编程的人十有八九会卡在同一个地方网上找的 Socket 聊天室代码全是单客户端阻塞收发把 listen 和 accept 写在按钮事件里一连接就卡死界面真正想做一个「一个服务器对多个客户端的即时通讯」工具时多客户端管理、消息广播、界面线程同步这些坑一个个冒出来每一个都能让你调一晚上。这个 MFC Socket 编程示例解决的正是这件事它把服务器和客户端拆成两个完整工程用阻塞 Socket 加多线程实现了一对多的即时通讯消息从客户端发出去服务器能转发给所有在线连接。适合刚入门 Windows 网络编程、想搞懂 TCP 服务器基本骨架的开发者也适合课程设计里需要现成通信框架的人——代码能编译能跑原理也能讲清楚。2. 网络通信的地基Winsock 启动、套接字创建与阻塞/非阻塞边界2.1 协议栈选型为什么一对多即时通讯必须用 TCP 流式套接字先说选型。服务器对多客户端做即时通讯核心需求是「每条消息都不能丢、顺序不能乱」。UDP 的 sendto 效率高但无法保证到达顺序和完整性自己做重传和序号管理就偏离了即时通讯的主题。TCP 的流式套接字SOCK_STREAM把数据的可靠性、顺序性全交给系统协议栈你要做的只是 read/write这是性价比最高的选择。至于为什么不用 P2P 直连——因为即时通讯工具十有八九要保留服务器端做消息记录和离线缓存而且多个客户端之间做 NAT 穿透是另一门麻烦手艺不如让服务器做纯转发的中枢这也是这个示例最普适的架构方式。选择AF_INET地址族加SOCK_STREAM类型协议字段填 0 让系统自动匹配 TCP。注意不要用SOCK_DGRAM否则你后面的消息封装要自己处理分片和丢包更不要在 MFC 的 CAsyncSocket 和 WinSock API 之间来回切示例底层用的是 WinSock 2.0 的socket()函数上层用 MFC 封装界面逻辑这样理解起来最顺,把网络层和界面层分开调试也容易。2.2 启动与注册WSAStartup 参数没你想的那么简单WSAStartup 是每个 WinSock 程序的第一步但很多新手栽在版本协商上。代码片段里写的是MAKEWORD(2, 2)请求的是 Winsock 2.2 版本但系统不一定返回你请求的版本。正确做法是启动后用LOBYTE(wsaData.wVersion)判断协商结果如果版本低于 1.1 就说明系统里那套 WinSock 库过于古老继续运行会有兼容怪癖。我一般会直接判断wsaData.wVersion ! 2就报错退出因为 2.0 以上的 API 才是这个示例依赖的稳定基座。// Socket_Init.cpp #include winsock2.h #pragma comment(lib, ws2_32.lib) WSADATA wsaData; int nRet WSAStartup(MAKEWORD(2, 2), wsaData); if (nRet ! 0) { // 返回 SOCKET_ERROR 或非零值版本协商失败直接退出 AfxMessageBox(_T(WSAStartup 初始化失败)); return FALSE; } if (LOBYTE(wsaData.wVersion) 2) { // 拿到的主版本号低于 2说明系统只支持 1.1 WSACleanup(); return FALSE; }这里有个大家经常忽略的点WSAStartup每次成功调用都要对应一次WSACleanup()而且调用次数要严格成对。示例程序在InitInstance里做启动在ExitInstance里做清理——不要把WSACleanup放在OnDestroy里那会导致界面销毁和 Socket 清理的顺序错乱可能出现程序退出时报错或者端口没释放干净。我自己调试时遇到过「下次启动 bind 失败错误码 10048」的情况就是上一次进程没把 Socket 关干净。2.3 套接字模式阻塞模式是新手唯一能驾驭的起点示例里用的是阻塞模式即recv调用在没有数据时会一直等在那里。很多人一听说阻塞就慌觉得界面会卡死——其实不然。阻塞模式本身不会卡界面它会卡调用它的线程只要你为每个连接开一个独立线程让recv在子线程里死等主线程继续跑消息循环界面就完全不受影响。反过来如果用非阻塞模式你要在循环里反复查询recv的返回值处理WSAEWOULDBLOCK再配合select或WSAEventSelect管理一堆套接字的超时和事件这套东西对新手来说就是个黑匣子调试时根本无法从现象判断是网络问题还是事件驱动问题。// ListenThread.cpp SOCKET hListen socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN addrSrv; addrSrv.sin_family AF_INET; addrSrv.sin_port htons(8888); // 端口冲突高发点注意 1024 以下权限 addrSrv.sin_addr.S_un.S_addr htonl(INADDR_ANY); // 强制地址重用TIME_WAIT 状态下 bind 失败时很实用 int nOpt 1; setsockopt(hListen, SOL_SOCKET, SO_REUSEADDR, (const char*)nOpt, sizeof(nOpt)); if (bind(hListen, (SOCKADDR*)addrSrv, sizeof(addrSrv)) SOCKET_ERROR) { closesocket(hListen); return FALSE; } listen(hListen, 10); // 2 表示只允许 2 个连接等待10 是排队上限listen的第二个参数是等待队列的长度示例写的是 10意思是连接请求排队等待accept的最大数量是 10。如果你的客户端数量可能超过 10 个且连接瞬时并发高这个值要改到 50 甚至 100但如果只是做课程设计级别的验证10 完全够了。另外我建议加上SO_REUSEADDR因为服务器崩溃后端口会进入TIME_WAIT状态没有这个选项的话重启服务器时 bind 会报错 10048血泪教训。3. 服务器端骨架从单次 accept 到多客户端消息中转3.1 主线程与 Accept为什么不能把所有逻辑塞进 OnTimer示例里的服务器界面很简单主线程跑的是 MFC 的消息循环。如果直接把accept放进OnTimer那么每个定时器 tick 才会去看一眼有没有新连接客户端发来消息要等到下一个 tick 才收到延时大到无法做即时通讯如果客户端连接后长时间不发消息accept又不会返回OnTimer 就被卡住了界面勾选和按钮全部失效。所以正确做法是把accept循环放到一个独立工作线程里主线程只负责用消息比如PostMessage发一个自定义的WM_UPDATE_LIST去刷新列表控件和聊天框。示例工程里有一个全局CWinThread指针指向监听线程线程函数体里是一个while循环不断accept每接受一个新连接就创建一个客户端结构体然后给主界面发PostMessage通知更新在线列表。这里的关键点是accept返回的SOCKET不能直接扔给界面线程去recv每个客户端连接都要有独立的接收线程。示例代码在AcceptThread里用了一个std::vectorSOCKET来维护所有已连接的套接字然后为每个新连接_beginthreadex一个专门做recv的子线程这样阻塞模式下每个连接的消息互相不干扰。3.2 客户端对象管理链表、数组与状态机一对多服务器最容易翻车的是数据结构。简单的SOCKET hClient[100]会带来三个麻烦一是不知道数组里哪个位置的客户端已经断开了二是删除一个客户端时要把后面的元素整体搬移复杂度高还容易把索引弄乱三是无法携带每个客户端的额外状态比如“这个客户端是否刚登录”或“它的昵称是什么”。示例里定义了一个CClientNode结构体用CListCClientNode, CClientNode双向链表存储每个客户端的 socket、线程句柄和在线状态。枚举在线列表时用迭代器从头走到尾向用断开标记为FALSE的节点逐个发送消息。这里有个小陷阱不要在遍历链表的同时关闭 socket——也就是不要在for循环的内部直接调用closesocket再删除节点因为下一个节点拿到的迭代器可能已经被销毁。示例的解决办法是先把需要断开的 socket 拷贝到一个临时数组里等遍历完链表再把它们从链表中摘除。看起来多写几行代码但能避免那种只在 Release 版才偶现的运行时崩溃。// ClientMap.cpp struct CClientNode { SOCKET hSocket; HANDLE hThread; CString strName; BOOL bOnline; // 标记连接是否处于可用状态 }; // 断开连接的正确姿势 void CloseClient(CListCClientNode, CClientNode lstClient, CClientNode node) { // 1. 先关闭 socket让 recv 立即返回 shutdown(node.hSocket, SD_BOTH); closesocket(node.hSocket); node.bOnline FALSE; // 2. 等接收线程退出避免句柄悬空 WaitForSingleObject(node.hThread, 3000); CloseHandle(node.hThread); }参数说明shutdown在这里比closesocket更体面它先禁止收发让阻塞中的recv马上返回错误然后才真正关闭套接字。如果不做这一步直接closesocket系统会把已经被内核缓存的数据直接丢弃协同方就可能收到半截消息——这种状态不干净很容易让下一步退出逻辑措手不及。WaitForSingleObject的 3000 毫秒超时是防止接住的收包线程因为某些原因没能及时退出导致资源句柄一直挂着。3.3 消息转发从 recv 到重绘的线程安全通道服务器收到消息后要做两件事一是把消息转发给其它客户端二是把消息内容显示到自己的聊天记录框。第二件事不能在子线程里直接调用CEdit::SetWindowText因为 MFC 控件属于界面线程跨线程操作会因为消息循环不同步造成内存访问冲突。示例工程里的做法是定义一个自定义窗口消息WM_SERVER_UPDATE_UI接收数据线程把内容放到一个字符串变量里然后用PostMessage(WM_SERVER_UPDATE_UI, 0, 0)通知主窗口去刷新显示。这里不能塞字符串指针作为消息参数因为发送方线程可能在下一次循环里改变那块内存接收方还没处理完就已经读出不安全的内容了应该把字符串先复制到全局互斥锁保护的缓冲区里或者用new CString在堆上分配接收后再delete。这段设计是整个示例里最值得抄的部分。它体现了一个所有 MFC 网络程序都逃不开的道理socket 收包线程是数据的生产者界面控件是数据的消费者两者之间绝不允许直接指来指去必须通过消息丢到窗口队列里做二次消费。很多人的项目做到一半卡住十有八九就是因为直接在recv的线程里写控件运行时看起来没问题一旦消息来得频繁就崩给你看属于典型的玄学 bug。4. 客户端与协议设计即时通讯不只是往 socket 里塞字符串4.1 客户端线程模型连接、收发线程与界面控件的关系客户端的结构比服务器简单但也有一条必须背下来的规则把 socket 的收发操作全部放到独立线程里。示例的客户端只有一个连接所以设计上开了一个收包线程专门等recv返回值用户点击发送按钮时主线程调用send()把字符串发送出去。这里要注意的是send本身在阻塞模式下几乎不会阻塞因为用户的输入频率远低于网络传输速度所以发送可以直接放在按钮消息里。但如果你的即时通讯工具里要做大文件传输那就别直接在主线程里send要用一个专门的发送线程配合队列否则大文件会一下把界面卡住。收包线程的核心逻辑是recv返回大于 0 说明收到了数据返回 0 说明对方已断开返回SOCKET_ERROR说明连接出错。其中返回 0 的处理是最容易漏掉的——很多人的客户端做成「只能在服务器主动断开时才知道连接断了」而服务器如果长时间保持连接不关闭客户端就一直傻等界面完全没有反应。所以示例里在recv返回 0 时用PostMessage给主窗口发一个断线提醒把连接状态置为离线。4.2 应用层协议设计固定消息头 长度字段搞定粘包与半包TCP 是流式传输recv返回的数据块大小不一定是发送方一次send的数据大小收到的可能是一条消息的一半也可能是两条消息拼在一起——这就是粘包与半包问题。示例里的解决方案特别朴素实用自定义一个应用层帧格式前 4 个字节是消息总长度含头部后面是消息体。// PacketParser.cpp #define PACKET_HEAD_SIZE 4 // 发送封包手动拼数据缓冲区 BOOL SendPacket(SOCKET hSocket, const char* pData, int nDataLen) { // 拼头4 字节网络字节序长度 char* pBuffer new char[PACKET_HEAD_SIZE nDataLen]; int nTotal htonl(nDataLen PACKET_HEAD_SIZE); memcpy(pBuffer, nTotal, PACKET_HEAD_SIZE); memcpy(pBuffer PACKET_HEAD_SIZE, pData, nDataLen); // 一次性 send 出去 int nSent send(hSocket, pBuffer, PACKET_HEAD_SIZE nDataLen, 0); delete[] pBuffer; return nSent (PACKET_HEAD_SIZE nDataLen); } // 接收解析返回一条完整消息剩余数据缓存到下次 int RecvPacket(SOCKET hSocket, char* pOutBuf, int nBufSize, char* pCache, int nCacheLen) { while (TRUE) { if (nCacheLen PACKET_HEAD_SIZE) { // 从缓存里读长度判断是否已有完整消息 int nTotal 0; memcpy(nTotal, pCache, PACKET_HEAD_SIZE); nTotal ntohl(nTotal); if (nCacheLen nTotal) { // 缓存够了取一条完整消息 memcpy(pOutBuf, pCache PACKET_HEAD_SIZE, nTotal - PACKET_HEAD_SIZE); memmove(pCache, pCache nTotal, nCacheLen - nTotal); nCacheLen - nTotal; return nTotal - PACKET_HEAD_SIZE; } } // 缓存不够继续 recv int nRet recv(hSocket, pCache nCacheLen, nBufSize - nCacheLen, 0); if (nRet 0) return nRet; // 0 代表断开, SOCKET_ERROR 代表出错 nCacheLen nRet; } return 0; }这段代码要特别解释两个关键点第一htonl和ntohl保证长度字段在局域网内也使用统一的网络字节序因为这个字段可能是int不同机器的大小端不一样不转换会在一个或多个客户端上出现大数解析错误第二memmove比memcpy安全因为源地址和目的地址重叠时memcpy的行为是未定义的缓存区尾部残留的解包数据恰恰经常产生重叠。示例客户端在RecvPacket里维护了一个局部缓存char cache[1024]收到一次 TCP 数据就追加到底部再从头部尝试解包——这个方法虽然简单但足以支撑几十条消息连续快速到达的场景。如果你要处理更长消息把缓存改成动态增长或者用CByteArray。4.3 公聊和私聊的初步区分消息类型字段的趁早设计很多网上教程把消息体直接做成CString发送时char*整个扔出去接收时原样显示。这样做「能跑」但你要加私聊或者系统提示时就要推倒重来。示例的最大优点是它用了消息类型字段struct MsgHeader { int nType; int nLen; }再加消息体。nType上定义了MSG_LOGIN、MSG_CHAT、MSG_LOGOUT、MSG_HEARTBEAT四个常量虽然示例只实现了MSG_CHAT的完整流程但其余三个留下来做了清晰的可扩展钩子。如果你要在课程设计里做私聊只需再加一个MSG_PRIVATE类型同时在服务器转发时判断消息体里的目标序号即可。5. 实战避坑三个小时多客户端调试里的五个玄学问题5.1 第二个客户端连接后第一个客户端收不到消息现象A 和 B 两个客户端都连上了服务器A 发消息服务器聊天框能看到服务器转发记录但 B 收不到反而 B 的消息 A 也收不到重启服务器只保留一个客户端连接时一切正常。原因定位问题出在服务器的转发逻辑上收到某客户端消息后直接把原 socket 也当成了发送目标。很多人在recv返回数据的线程里循环遍历客户端链表时忘了判断「如果不是来源 socket才发送给其它客户端」。解决转发前加一行if (node.hSocket ! hSource)也就是把来源客户端自身排除掉还有一种情况是链表里存的是临时拷贝的 socket 值连接建立后再打印一遍链表里存的 socket 与实际的 socket 比大小确认存储状态是对的。5.2 多客户端接收时偶尔出现几万个字符乱在一起看起来像被“拼接”了现象客户端连续快速发好几条短消息服务器转发后接收方的聊天框有时能看到两条消息黏在一起比如「你好我好」而不是分两行。原因定位TCP 是流式协议三次send可能被内核合并成一个 TCP 段传输接收方recv就直接读到了一整块大buffer恰好包含两条完整消息但示例代码里的CString直接附加到聊天框没有做拆包。解决这就是前面4.2节协议设计的用途——每个数据包必须带长度字段接收方根据长度把流拆成一条一条的逻辑消息再追加到聊天框。5.3 服务端一收到中文就乱码现象客户端输入「你好」服务器和另外的客户端收到一堆问号或者看不懂的字符。原因定位MFC 默认多字节字符集CString是char*直接一字一字发送但客户端如果开启了 Unicode 字符集CString里的字符在内存中是两字节的wchar_t你发送char*时实际发的是宽字符的底层字节服务器按多字节解析自然全是乱码。解决工程属性里确认所有 MFC 工程都使用「多字节字符集」或者统一在发送前用CW2A把宽字节转成 UTF-8 / GBK接收后用CA2W转回。示例代码里在OnSend中调用了CT2A((LPCTSTR)m_strInput, CP_UTF8)来发送这个思路我给你保留——用CP_UTF8是跨系统的通用解比本地代码页更不容易出问题。5.4 关闭客户端主界面时程序崩溃或服务器提示内存泄漏现象运行示例客户端不点“退出”按钮而是直接点右上角叉号关闭窗口调试器经常报错或者服务器端那个连接线程还挂在链表里。原因定位主窗口的OnClose只销毁了界面没有把收包线程从阻塞recv中唤醒线程还在等待数据窗口销毁后线程还持有CWnd指针一旦recv返回就会去访问已经销毁的窗口。解决标准做法是在OnClose里先给 socket 发一个shutdown强制让recv返回错误然后WaitForSingleObject等线程退出再销毁后期资源。你可以在OnClose中按这个顺序写shutdown(hSocket, SD_BOTH); closesocket(hSocket); WaitForSingleObject(hThread, 3000);把顺序调对再走CDialog::OnClose崩溃就消失了。5.5 服务器长时间运行后新客户端连不上但进程还在现象服务器程序放那跑几天中间关机重启过几次客户端现在新客户端起不来了但服务器进程在任务管理器里还在。原因定位大量客户端连接时accept还没来得及返回旧连接就断开了残留了很多处于TIME_WAIT状态的 socket此时端口被占满或者监听的 socket 已经异常。解决绑定监听 socket 前加SO_REUSEADDR前面代码里已有并且在每次accept循环结束后检查WSAGetLastError()如果是WSAECONNABORTED或者WSAENETDOWN说明连接的握手过程出错了不要慌继续循环如果是监听 socket 本身的致命错误就置一个标志位退出循环并提示用户重启服务器。这套逻辑能保证一台电脑上反复跑 demo 也不会频繁踩到端口被占的问题。6. 进阶用法心跳、私聊与压力验证6.1 加上心跳包让服务器及时剔除死连接TCP 的recv只有在对方进程崩溃或拔网线时才会最终返回错误但这个过程可能长达几十分钟服务器一直把死连接误认为在线。示例工程已经预留了MSG_HEARTBEAT消息类型客户端可以每 10 秒发一个心跳包。服务器端维护一个「最后心跳时间」字段每隔 15 秒检查一次超过 30 秒没收到心跳的客户端直接closesocket并剔除。别小看这一步没有心跳的话做压力测试时会发现连接数虚高明明只有 50 个真实用户列表里却挂着 300 个幽灵连接——这些残留占内存不说消息广播时还会导致send阻塞变慢拉低所有正常用户聊天体验。6.2 私聊扩展只改一个类型字段和转发策略从公聊扩到私聊核心在于把服务器从「广播者」变成「路由器」。消息体里增加nTargetId字段服务器解析到MSG_PRIVATE类型时不向所有客户端广播而是从客户端链表里找到目标 socket 把消息转发过去。注意服务器要维护一个id - SOCKET的映射表客户端登录后注册自己的昵称和序号示例的链表节点里已经预留了strName字段这个映射省不了。我给自己的习惯是登录消息里附上「序号 昵称 IP:端口」服务器打印到调试文本框转发时靠序号定位目标昵称只做主界面显示不再作为索引键因为中文昵称撞名太常见了。6.3 验证服务器并发上限一份八十行的 Python 压测脚本示例工程自带的客户端每次只能开一个实例手动开十个窗口再连服务器太笨了。我一般辅一脚本模拟多个客户端同时连接服务器、定期发消息、统计服务器是否丢消息。做一个简单的并发连接然后用socket.connect_ex建立连接在一秒内反复连几十个口子。脚本不复杂但能快速暴露服务器的一个真实短板默认的listen(5, 10)在端口排队阶段会丢连接并发到 50 左右时新连接就开始失败把listen的第二个参数调到 128以及确认accept线程循环足够快一般就能应付课程设计或小型局域网工具的上限了。# stress_test.py - 模拟 50 个客户端并发连接 import socket, time SERVER_HOST, SERVER_PORT 127.0.0.1, 8888 clients [] # 建立连接 for i in range(50): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((SERVER_HOST, SERVER_PORT)) s.send(fclient_{i}.encode(utf-8)) clients.append(s) # 每 0.5 秒向服务器发一条消息持续三秒 for step in range(6): for idx, s in enumerate(clients): try: s.send(fmsg_{idx}_{step}.encode(utf-8)) except OSError: clients.remove(s) # 连接断开的客户端及时清掉 time.sleep(0.5) # 断开所有连接 for s in clients: s.close() print(stress test finished, alive clients:, len(clients))这个压测脚本关注两个点一是所有连接是否都能成功建立二是消息发送后服务器聊天框是否完整收到全部消息。如果脚本跑完发现clients列表里少了一部分优先检查服务器监听线程是不是没做并发accept以及listen排队长度够不够。我第一次写一对多服务器时把listen参数只填 2压测到第三个客户端连接直接失败后来把排队长度调大了才稳定——从那以后我每次调完服务器都会先跑一遍这个脚本再开客户端界面几秒钟就能看到问题出现在哪一步省下来的排查时间够我多写好几个功能希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑