MFC与TCP Socket多客户端通信实战:粘包、重连与线程安全

发布时间:2026/10/29 23:04:28
MFC与TCP Socket多客户端通信实战:粘包、重连与线程安全 简介基于 MFC 与 Socket 的 TCP 通信示例面向正在学习 Windows 网络编程、希望理解 C/S 架构落地方式的开发者重点解决客户端与服务器如何建立可靠连接并完成数据收发的问题。包内共 4 个文件以 cpp 与 h 源码为主压缩包约 6KB分别对应客户端连接对话框和服务器端对话框的实现与声明便于对照阅读界面交互、套接字对象管理及通信流程。已有 1265 人学习适合作为课程实验、自学练手或小型局域网通信原型的参考。源码围绕 Winsock 初始化、创建 CSocket、服务器绑定监听与接受连接、客户端发起连接以及 Send/Receive、Close 等关键环节展开能帮助读者把 TCP 通信概念对应到 MFC 对话框程序理解双端交互的代码组织方式并积累连接建立、数据收发和资源释放的排错思路对于从 Winsock API 过渡到 MFC 封装实现的读者也有参考价值。1. 一个只能连一台机器的“服务器”先看清 MFC TCP Socket 的 C/S 骨架第一次跑这类程序的人多半会在服务端日志里看到一行“客户端已连接”然后再开第二台客户端界面就毫无反应——因为 accept 只被调用了一次。这个基于 MFC 和 TCP Socket 的 C/S 架构程序做的就是把这条链路补完整服务端同时挂多台客户端、按自定义协议收发数据、客户端能连能断能重连所有网络动作和 MFC 界面线程互不阻塞。它适合两类人想在 Windows 桌面上快速搭一个局域网消息或数据中转的开发者以及刚学完 Socket API、想看看 MFC 里到底怎么把 UI 线程和网络线程拆开的学生。代码量不大但每一个坑都是真的。2. 工程骨架与选型MFC 对话框程序接入 Winsock 的三条路线一套能跑的 C/S 工程第一眼要看的不是界面而是解决方案被切成了几块。常见的组织方式是双工程加一个共享头文件目录服务端和客户端各自是独立的 MFC 对话框应用程序公共的协议定义单独抽出来给两边同时引用避免“服务端改了包头、客户端忘了同步”这种低级翻车。工程骨架理清楚了后面加功能才不会到处打补丁。2.1 目录结构与工程文件清单我一般会把它拆成下面这个样子服务端和客户端在同一个解决方案里用项目依赖保证 Common 先编译路径作用改动频率TcpDemo.sln双工程解决方案含 Server、Client、Common低Common/Protocol.h包头结构、消息类型枚举、长度上限常量高Common/NetUtil.h/.cpp收发封装、大小端转换、Socket 生命周期管理中Server/ServerDlg.h/.cpp服务端主对话框监听、连接表、广播高Server/ClientCtx.h单个连接的上下文结构体中Client/ClientDlg.h/.cpp客户端界面与连接逻辑高Client/RecvThread.cpp客户端接收循环中res/*.rc2、*.ico对话框资源与图标低Protocol.h是这个工程里最该被当回事的文件。它只放纯 C 的结构体和常量不引afxwin.h这样即使以后把服务端换成控制台程序或者干脆用 Python 写测试脚本协议层也能原样复用。下面是我习惯的写法// Common/Protocol.h —— 双端共享只放协议不依赖 MFC #pragma once #include stdint.h // 4 字节网络序长度 1 字节消息类型 N 字节 JSON/文本体 const uint32_t MAX_BODY_LEN 64 * 1024; // 单包上限防恶意长度打爆内存 const uint32_t HEAD_LEN 4; // 长度字段本身 enum MsgType : uint8_t { MSG_TEXT 1, // 普通聊天/日志文本 MSG_HEART 2, // 心跳客户端定时发服务端只回不广播 MSG_FILE 3, // 文件分块预留 MSG_ONLINE 10 // 服务端下发某客户端上线/下线 }; #pragma pack(push, 1) struct PacketHead { uint32_t nBodyLen; // 网络字节序只算 body不含自身 uint8_t nType; // MsgType }; #pragma pack(pop)这里有两个参数值得说明。MAX_BODY_LEN设成 64KB 不是随手写的它决定了单条消息的上限也决定了接收端缓冲区一轮最多能拼多少数据定得太大会让一个恶意客户端用“声明 2GB”的方式把服务端内存吃干定得太小则文件分块和长文本都要反复拆包调试起来很烦。#pragma pack(1)是为了让PacketHead在网络传输时不带编译器填充字节——虽然真正发出去时我更倾向手工拼字节但结构体定义放在协议头里能当“文档”用两边对着看不会理解错。2.2 三条路线对比CSocket、CAsyncSocket 还是原生 WinsockMFC 里做 TCP绕不开这三个选择而选错了后面会一直在填坑。维度CSocketCAsyncSocket原生 Winsock 工作线程阻塞行为同步阻塞调用即卡异步靠消息通知自己控制阻塞/非阻塞依赖消息泵是PumpMessages是OnReceive挂在 UI 线程否大数据量表现界面假死风险高回调里处理久了界面卡顿稳定不占 UI 线程粘包处理需要自己拼需要自己拼需要自己拼多客户端每个连接一个线程成本高单线程轮询管理方便线程或 IOCP可扩展调试难度低但出错信息模糊中高但错误码清楚CSocket的好处是几行代码就能连上坏处是它内部靠PumpMessages抽消息一旦你在Receive里等待整个窗口就没有响应了用户点关闭按钮都点不动。CAsyncSocket好一些但OnReceive是在 UI 线程的消息泵里被调用的你在里面做一次 500KB 的接收加解析界面照样掉帧。我一般会选第三条路直接用 Winsock把收发全部塞进工作线程UI 线程只负责显示。代价是要自己管理线程生命周期和缓冲区但换来的是行为可预期——出了问题是recv返回了 -1 还是 0看错误码就知道不用猜 MFC 内部状态。提示如果只是为了交作业或者内部小工具CSocket完全够用但只要预期会有并发客户端或者大包就别在这上面省时间改起来比重写还贵。2.3 线程模型UI 线程只画界面网络线程只碰 Socket这个工程真正决定稳不稳的不是用了哪个 API而是线程边界划得清不清。我给自己定的规矩只有三条写在ServerDlg.h的注释里第一条UI 线程里绝不出现recv、send、accept这类阻塞调用。第二条工作线程里绝不出现GetDlgItem、SetWindowText、m_list.AddString这类 MFC 控件操作。第三条跨线程只传HWND和裸数据指针用PostMessage回到 UI 线程再操作控件。CWnd*指针本身不是线程安全的MFC 的对象映射表在跨线程访问时会直接触发断言很多人第一次遇到“ASSERT_VALID 失败”就是因为在接收线程里拿this-m_list去AddString了。正确的做法是把m_hWnd一个HWND值保存成成员变量线程里只把数据和句柄传出去。// Server/ServerDlg.h —— 线程入口声明必须是静态函数或全局函数 static UINT WINAPI AcceptThread(LPVOID pParam); static UINT WINAPI RecvThread(LPVOID pParam); // 自定义消息WM_APP 之后的区间归应用自己用不会和系统消息冲突 #define WM_APP_CLIENT_DATA (WM_APP 101) #define WM_APP_CLIENT_ONLINE (WM_APP 102) #define WM_APP_CLIENT_QUIT (WM_APP 103)WM_APP 101这个取值不是随便挑的WM_APP是 Windows 预留的应用私有消息起点往后的数值不会被系统消息占用自定义消息一定要从WM_APP开始往上加别用WM_USER以下的号段那几个号段里有些值已经被通用控件内部用掉了撞上了会出很诡异的行为。3. 服务端核心链路从 listen 到广播一个连接要走完的四步服务端的代码通常集中在ServerDlg.cpp一个文件里看起来乱但流程是线性的初始化 Winsock、创建监听套接字、循环 accept、每个连接起一个接收线程、解析后广播给所有在线连接。把这四步拆开看每一段的参数都有讲究写错一个htonl或者漏一次closesocket后面排查的成本都是小时级。3.1 初始化与监听从 WSAStartup 到 listen监听这一步最容易出的问题是“端口被占用却看不出原因”或者“绑定了 127.0.0.1 导致局域网连不上”。下面这段是服务端启动按钮背后的完整代码// Server/ServerDlg.cpp —— 启动监听 BOOL CServerDlg::StartListen(UINT nPort) { WSADATA wsaData { 0 }; // 请求 2.2 版本实际返回的版本可能更低通常不影响 TCP 使用 if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { Log(_T(Winsock 初始化失败错误码: %d), WSAGetLastError()); return FALSE; } m_hListen socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hListen INVALID_SOCKET) { Log(_T(创建监听套接字失败: %d), WSAGetLastError()); WSACleanup(); return FALSE; } // 服务器重启时端口可能还在 TIME_WAIT加这个选项可以立刻重绑 BOOL bReuse TRUE; setsockopt(m_hListen, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuse, sizeof(bReuse)); sockaddr_in addr { 0 }; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 0.0.0.0监听所有网卡 addr.sin_port htons((u_short)nPort); if (bind(m_hListen, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { Log(_T(bind 失败端口 %u 可能被占用: %d), nPort, WSAGetLastError()); closesocket(m_hListen); m_hListen INVALID_SOCKET; WSACleanup(); return FALSE; } // SOMAXCONN 让系统用默认最大等待队列小规模场景不用自己调 if (listen(m_hListen, SOMAXCONN) SOCKET_ERROR) { Log(_T(listen 失败: %d), WSAGetLastError()); closesocket(m_hListen); m_hListen INVALID_SOCKET; WSACleanup(); return FALSE; } m_bStop false; // _beginthreadex 比 CreateThread 更适合 C 运行时避免 CRT 状态泄漏 m_hAcceptThread (HANDLE)_beginthreadex( nullptr, 0, AcceptThread, this, 0, nullptr); Log(_T(服务端已启动监听端口 %u), nPort); return TRUE; }逐条说参数。MAKEWORD(2, 2)拼出来的是 0x0202代表 Winsock 2.2这是目前 Windows 上最通用的版本不用去纠结 2.1。SO_REUSEADDR解决的是服务端关闭后立刻重启时报WSAEADDRINUSE的问题因为 TCP 连接关闭后会有一段 TIME_WAIT注意它和SO_REUSEPORT不是一回事后者在 Windows 上行为不一致别混用。INADDR_ANY等于0.0.0.0意思是所有网卡都监听如果这里写成inet_addr(127.0.0.1)那么同一局域网里的另一台机器永远连不上这是新手最常踩的一个坑。_beginthreadex而不是CreateThread是因为线程里会用 CRT 函数比如new、printfCreateThread不会初始化 CRT 的每线程数据某些情况下会导致内存泄漏。3.2 接入与连接表vector 加互斥量维护在线客户端accept 必须放在循环里这是前面提到“只能连一台”的根因。接入之后要有个地方存连接我用一个std::vectorClientCtx加一把std::mutex规模在几百连接以内完全够用。// Server/ClientCtx.h —— 单个连接的上下文 struct ClientCtx { SOCKET hSock INVALID_SOCKET; CString strIp; // 对端 IP日志用 UINT nPort 0; // 对端端口 HANDLE hThread nullptr; // 接收线程句柄关闭时要等它退出 bool bAlive true; }; // Server/ServerDlg.cpp —— accept 循环 UINT WINAPI CServerDlg::AcceptThread(LPVOID pParam) { CServerDlg* pThis static_castCServerDlg*(pParam); while (!pThis-m_bStop) { sockaddr_in cliAddr { 0 }; int nLen sizeof(cliAddr); SOCKET hCli accept(pThis-m_hListen, (sockaddr*)cliAddr, nLen); if (hCli INVALID_SOCKET) { // 停止时 shutdown 会让 accept 立刻返回这里不能当致命错误 if (pThis-m_bStop) break; continue; } pThis-AddClient(hCli, cliAddr); } return 0; } void CServerDlg::AddClient(SOCKET hSock, const sockaddr_in addr) { ClientCtx ctx; ctx.hSock hSock; ctx.strIp CString(inet_ntoa(addr.sin_addr)); ctx.nPort ntohs(addr.sin_port); ctx.bAlive true; { std::lock_guardstd::mutex lock(m_mtxClients); m_vecClients.push_back(ctx); } // 每个连接一个接收线程连接数上千时要换成 select 或 IOCP HANDLE h (HANDLE)_beginthreadex(nullptr, 0, RecvThread, this, 0, nullptr); { std::lock_guardstd::mutex lock(m_mtxClients); m_vecClients.back().hThread h; } ::PostMessage(m_hWnd, WM_APP_CLIENT_ONLINE, (WPARAM)hSock, 0); }加锁的粒度要小。AddClient里我拆成两段锁而不是把_beginthreadex也包在锁里因为创建线程本身有开销持锁期间其他线程的广播全被堵住客户端一多就会出现“明明连上了但消息延迟好几秒”的玄学现象。ntohs(addr.sin_port)是把网络字节序转回主机序忘了转的话日志里会打出一个几万的端口号虽然不影响通信但排查时很容易被误导。注意每个连接一个线程的模型在几百连接量级没问题再往上就要换select、WSAEventSelect或 IOCP否则线程切换的开销会把 CPU 吃满。3.3 收包长度前缀协议解决粘包与半包TCP 是字节流recv一次返回的内容既可能是一条完整消息也可能是半条还可能是一条半。不做处理直接当字符串用表现就是偶尔丢消息、偶尔多出一截乱码而且只在网络有延迟时才复现非常难抓。解决办法是给每条消息加长度前缀接收端维护一个残留缓冲区凑够一整包再处理// Server/ServerDlg.cpp —— 每个连接的接收循环 UINT WINAPI CServerDlg::RecvThread(LPVOID pParam) { CServerDlg* pThis static_castCServerDlg*(pParam); SOCKET hSock /* 从参数结构里取出的连接句柄 */; char buf[4096]; std::string cache; // 跨多次 recv 的残留数据 while (!pThis-m_bStop) { int n recv(hSock, buf, sizeof(buf), 0); if (n 0) break; // 对端正常关闭 if (n SOCKET_ERROR) { // 异常含被 shutdown 打断 break; } cache.append(buf, n); // 循环解析一次 recv 可能带出多条完整消息 while (cache.size() 4) { unsigned char* p (unsigned char*)cache.data(); uint32_t bodyLen ((uint32_t)p[0] 24) | ((uint32_t)p[1] 16) | ((uint32_t)p[2] 8) | (uint32_t)p[3]; if (bodyLen 0 || bodyLen MAX_BODY_LEN) { // 长度非法直接断开避免内存被恶意声明打爆 pThis-RemoveClient(hSock); return 0; } if (cache.size() 4 bodyLen) break; // 半包等下一次 recv std::string body cache.substr(4, bodyLen); cache.erase(0, 4 bodyLen); // 堆上拷贝一份由 UI 线程负责 delete跨线程安全 pThis-PostClientData(hSock, new std::string(body)); } } pThis-RemoveClient(hSock); return 0; }这段代码里有几个关键点。长度字段用大端手工拼是因为htonl在两端都是 Windows 时其实也能用但手工拼更容易和别的语言写的测试脚本对齐Python 那边用struct.unpack(I, ...)就能解。bodyLen 0的判断是我后来补的早期版本没有结果某个客户端在异常退出时发了一串零字节服务端就陷入死循环。cache.erase之后cache可能还有剩余数据所以外层用while而不是if这一点写错的表现是“连续发两条消息只收到第一条”很典型。new std::string(body)这份拷贝是必须的。cache在下一轮recv时会被改写如果直接把body.c_str()传出去UI 线程拿到的是悬空指针症状是界面上偶尔出现一段乱码或者直接崩而且崩的位置和网络代码毫无关系。3.4 广播与下线把消息推给所有在线连接广播的实现看起来简单坑在“边遍历边删除”。如果某个连接发送失败处理逻辑里立刻把它从m_vecClients里删掉而外层正在用迭代器遍历这个 vector就会迭代器失效表现为随机崩溃。稳妥的写法是先收集、再删除// Server/ServerDlg.cpp —— 广播给所有在线客户端 void CServerDlg::Broadcast(const std::string body, SOCKET hExcept) { std::vectorSOCKET vecDead; { std::lock_guardstd::mutex lock(m_mtxClients); for (auto c : m_vecClients) { if (!c.bAlive || c.hSock hExcept) continue; if (!SendPacket(c.hSock, body)) { c.bAlive false; vecDead.push_back(c.hSock); // 只标记不在这里删 } } } for (SOCKET s : vecDead) { RemoveClient(s); // 这一步内部会再取锁所以必须放在锁外 } }SendPacket内部要做完整的发送循环因为send的返回值可能小于请求长度尤其是在发送大包时// Common/NetUtil.cpp —— 带长度前缀的发送处理部分发送 bool SendPacket(SOCKET hSock, const std::string body) { if (body.size() MAX_BODY_LEN) return false; uint32_t n (uint32_t)body.size(); char head[4] { (char)((n 24) 0xFF), (char)((n 16) 0xFF), (char)((n 8) 0xFF), (char)( n 0xFF) }; std::vectorchar pkt; pkt.reserve(4 body.size()); pkt.insert(pkt.end(), head, head 4); pkt.insert(pkt.end(), body.begin(), body.end()); int nSent 0; while (nSent (int)pkt.size()) { int ret send(hSock, pkt.data() nSent, (int)pkt.size() - nSent, 0); if (ret 0) return false; // 对端已关闭或网络异常 nSent ret; } return true; }send返回 0 或SOCKET_ERROR都意味着这条连接不能再用不能重试。有些人会在这里写while(true)重试结果服务端卡死在一个已经断开的连接上表现出来就是“服务端还在跑但不响应任何新消息”属于典型的血泪经验。下线路径要做的收尾是三件事shutdown一次让对端知道要断、closesocket释放句柄、WaitForSingleObject等接收线程退出最后才从表里删。少做一步都可能出现端口占用或者线程还在访问已释放的内存。4. 客户端实现连接超时、接收线程和界面刷新的安全姿势客户端的代码比服务端少但用户体验上的问题更集中点连接没反应、界面卡死、断网后一直转圈、重连后收到旧数据。这些都不是协议问题是连接管理和线程边界没处理好。下面按连接、接收、重连三段拆。4.1 连接建立与超时控制别让阻塞 connect 卡住界面默认的connect在连接一个不可达地址时会阻塞十几秒甚至更久期间界面完全无响应用户以为程序挂了。改成非阻塞加select等待就能给出明确的超时时间// Client/ClientDlg.cpp —— 带超时的连接 bool CClientDlg::ConnectTo(const CString strIp, UINT nPort, int nTimeoutSec) { m_hSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hSock INVALID_SOCKET) return false; sockaddr_in addr { 0 }; addr.sin_family AF_INET; addr.sin_port htons((u_short)nPort); if (inet_pton(AF_INET, CT2A(strIp), addr.sin_addr) ! 1) { Log(_T(IP 地址格式不对)); return false; } // 先切成非阻塞connect 立刻返回 WSAEWOULDBLOCK u_long ulNonBlock 1; ioctlsocket(m_hSock, FIONBIO, ulNonBlock); int ret connect(m_hSock, (sockaddr*)addr, sizeof(addr)); if (ret SOCKET_ERROR) { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK err ! WSAEINPROGRESS) { closesocket(m_hSock); m_hSock INVALID_SOCKET; return false; } fd_set wset, eset; FD_ZERO(wset); FD_SET(m_hSock, wset); FD_ZERO(eset); FD_SET(m_hSock, eset); timeval tv { nTimeoutSec, 0 }; int nSel select(0, nullptr, wset, eset, tv); if (nSel 0 || FD_ISSET(m_hSock, eset)) { Log(_T(连接超时或被拒绝)); closesocket(m_hSock); m_hSock INVALID_SOCKET; return false; } } // 连接成功后切回阻塞模式接收线程的逻辑更直观 ulNonBlock 0; ioctlsocket(m_hSock, FIONBIO, ulNonBlock); // 关掉 Nagle小包交互更跟手纯大文件传输反而建议打开 BOOL bNoDelay TRUE; setsockopt(m_hSock, IPPROTO_TCP, TCP_NODELAY, (const char*)bNoDelay, sizeof(bNoDelay)); return true; }select的第一个参数在 Windows 上被忽略传 0 就行这是和 Linux 不一样的地方别照着 Unix 教程写maxfd 1。eset异常集合必须一起传否则某些连接失败比如对端 RST不会被及时感知表现是“等满了超时才报错”。TCP_NODELAY关掉 Nagle是为了让聊天类的短消息立刻发出去代价是包数量变多如果是定时上报传感器数据这种场景反而应该保持开启来合并小包。4.2 接收线程与自定义消息把数据安全地送到界面上客户端接收线程的解析逻辑和服务端一样区别在于收到数据后不能直接改控件而是PostMessage回 UI 线程// Client/ClientDlg.cpp —— 接收线程向 UI 投递数据 UINT WINAPI CClientDlg::RecvThread(LPVOID pParam) { CClientDlg* pThis static_castCClientDlg*(pParam); HWND hWnd pThis-m_hWnd; // 只保存句柄不传 this 给 UI 操作 char buf[4096]; std::string cache; while (!pThis-m_bStop) { int n recv(pThis-m_hSock, buf, sizeof(buf), 0); if (n 0) break; cache.append(buf, n); while (cache.size() 4) { unsigned char* p (unsigned char*)cache.data(); uint32_t len ((uint32_t)p[0] 24) | ((uint32_t)p[1] 16) | ((uint32_t)p[2] 8) | (uint32_t)p[3]; if (len 0 || len MAX_BODY_LEN) { pThis-m_bStop true; break; } if (cache.size() 4 len) break; std::string body cache.substr(4, len); cache.erase(0, 4 len); // 堆拷贝 PostMessageUI 线程负责释放 ::PostMessage(hWnd, WM_APP_CLIENT_DATA, 0, (LPARAM)new std::string(body)); } } ::PostMessage(hWnd, WM_APP_CLIENT_QUIT, 0, 0); return 0; }对应的消息处理函数必须记得delete这是新手最容易漏的一步漏了就是每秒几百字节的稳定内存泄漏跑一晚上才被发现// Client/ClientDlg.cpp —— UI 线程侧处理 LRESULT CClientDlg::OnClientData(WPARAM, LPARAM lParam) { std::unique_ptrstd::string pBody(reinterpret_caststd::string*(lParam)); if (!pBody) return 0; CString strText(CA2T(pBody-c_str(), CP_UTF8)); // 统一按 UTF-8 解码 int nIdx m_listLog.AddString(strText); m_listLog.SetCurSel(nIdx); return 0; }用unique_ptr接管原始指针即使后面CA2T抛异常也不会漏。CP_UTF8这个参数要和服务端编码保持一致两端都用 UTF-8 是唯一省心的选择如果一端用CString的默认多字节一端用 UTF-8中文就会变成问号或乱码而且只在中文消息上出现英文一切正常排查时特别容易误判成“网络丢包”。4.3 断线重连与发送失败把状态机写明白客户端的连接状态至少要有四个未连接、连接中、已连接、重连等待。状态不清楚按钮的启用禁用就会乱用户会在“连接中”又点一次连接创建出两个套接字。重连我一般用一个SetTimer加一个计数简单可靠// Client/ClientDlg.cpp —— 断线后按 3 秒间隔重连最多 5 次 void CClientDlg::OnBnClickedConnect() { if (m_nState STATE_CONNECTING) return; // 防重复点击 m_nState STATE_CONNECTING; UpdateButtons(); if (ConnectTo(m_strIp, m_nPort, 3)) { m_nState STATE_CONNECTED; m_nRetry 0; m_bStop false; m_hRecvThread (HANDLE)_beginthreadex( nullptr, 0, RecvThread, this, 0, nullptr); } else { m_nState STATE_DISCONNECTED; m_nRetry; if (m_nRetry 5) { m_nState STATE_WAIT_RETRY; SetTimer(TIMER_RETRY, 3000, nullptr); } else { Log(_T(重连次数已达上限请检查网络)); } } UpdateButtons(); } void CClientDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_RETRY) { KillTimer(TIMER_RETRY); OnBnClickedConnect(); } CDialogEx::OnTimer(nIDEvent); }SetTimer的 3000 毫秒间隔不是死的实际项目里最好做成指数退避第一次 1 秒、第二次 3 秒、第三次 9 秒避免服务端刚重启时被一堆客户端同时打满。重连成功之后要清空接收缓冲区cache——这个cache是接收线程的局部变量线程结束后自然释放但如果把缓冲区提到成员变量上复用就必须手工清否则会拼进上一轮的残留数据症状是重连后第一条消息前面带着一段旧内容。5. 避坑与排查让这套 Socket 程序在上线前先跑稳的五个坑下面五条都是我在不同环境下实际撞过的每条按现象、原因、解决写遇到类似症状可以先对照。5.1 连接与端口类问题现象服务端日志显示启动成功本机能连上同一局域网的另一台机器连不上提示连接超时。原因bind时写的地址是inet_addr(127.0.0.1)只监听了回环网卡或者本机防火墙没有放行该端口。解决把sin_addr.s_addr改成htonl(INADDR_ANY)这样会监听所有网卡。放行端口这一步要先在服务端机器上放行客户端机器的出站一般默认允许。另外如果客户端填的是localhostgetaddrinfo可能优先返回::1而服务端只监听了 IPv4就会连不上——把客户端地址改成明确的 IPv4 地址即可。现象程序关掉之后立刻重开报bind失败错误码 10048。原因上一个进程的监听套接字还处于 TIME_WAIT或者上一次的closesocket根本没执行到。解决bind之前加SO_REUSEADDR。同时检查退出路径必须在OnClose里先置m_bStop true再shutdown(m_hListen, SD_BOTH)打断阻塞中的accept然后closesocket最后WaitForSingleObject(m_hAcceptThread, 3000)等线程真正退出。少了shutdown这一步accept一直阻塞在等待队列上closesocket之后线程仍在跑退出时就会崩溃。5.2 收发与协议类问题现象偶尔出现两条消息被合并成一条或者一条长消息只显示了一半。原因把recv的返回值当成了“一条消息”没有做长度前缀和缓冲区拼接。TCP 只保证字节顺序不保证消息边界。解决按第 3.3 节的写法加 4 字节大端长度前缀接收端维护cache凑够一整包再解析。测试方法很直接写一个脚本连续send两次小数据再send一次 32KB 的大数据看服务端日志是否稳定解析出三条。现象中文消息显示成问号英文正常。原因两端编码不一致一端按本地代码页GBK处理另一端按 UTF-8。解决协议层统一约定 UTF-8发送前把CString用CT2A(str, CP_UTF8)转成std::string接收后用CA2T(p, CP_UTF8)转回。凡是涉及CString和std::string互转的地方都显式带上CP_UTF8不要依赖默认值。注意recv返回 0 表示对端正常关闭返回SOCKET_ERROR才是异常。有人把两者都当异常结果客户端正常点“断开”时服务端日志里一堆错误真正的问题反而被淹没。5.3 线程与界面类问题现象程序在调试模式下跑到一半弹出ASSERT_VALID失败或者 Release 版随机崩溃崩的位置在 MFC 内部。原因在工作线程里直接操作了 MFC 控件比如m_listLog.AddString(...)。CWnd及其派生类的很多方法会访问线程局部的模块状态跨线程调用不合法。解决工作线程只PostMessage(m_hWnd, ...)把数据打包成堆对象传出去UI 线程的消息处理函数里再操作控件。m_hWnd是HWND值跨线程传递本身是安全的千万不要为了图省事去缓存CWnd*。现象客户端收到大量数据时界面明显卡顿鼠标拖动窗口有拖影。原因SendMessage是同步的UI 线程在等待处理完成或者每条消息都触发一次RedrawWindow。解决换成PostMessage异步投递立即返回并且批量更新列表——把一秒钟内到达的多条消息先攒在std::vector里用一个 100ms 的定时器统一刷到CListCtrl上。这样界面刷新次数从每秒几百次降到十次肉眼完全看不出来差别。6. 二次开发把 demo 改成能扛多客户端的消息中转服务拿到这套代码之后绝大多数人的下一步不是“就这样用”而是往里加东西加消息类型、加文件传输、加在线列表、加日志落盘。改造顺序建议从协议开始因为协议一改两端的收发都要跟着动先定好再写业务能省掉大量返工。6.1 协议扩展先加类型字段再加业务Protocol.h里已经预留了nType但我在第 3 章的代码里为了简化只处理了长度没有解析类型。改造的第一步是把PacketHead真正用起来// Common/Protocol.h —— 扩展后的发送接口 bool SendMsg(SOCKET hSock, MsgType eType, const std::string body); // 实现4 字节长度 1 字节类型 body bool SendMsg(SOCKET hSock, MsgType eType, const std::string body) { uint32_t n (uint32_t)body.size() 1; // 1 是类型字段 if (n MAX_BODY_LEN) return false; std::vectorchar pkt; pkt.reserve(4 n); pkt.push_back((char)((n 24) 0xFF)); pkt.push_back((char)((n 16) 0xFF)); pkt.push_back((char)((n 8) 0xFF)); pkt.push_back((char)( n 0xFF)); pkt.push_back((char)eType); pkt.insert(pkt.end(), body.begin(), body.end()); int nSent 0; while (nSent (int)pkt.size()) { int ret send(hSock, pkt.data() nSent, (int)pkt.size() - nSent, 0); if (ret 0) return false; nSent ret; } return true; }注意长度字段算的是“类型 消息体”不含长度字段自身这个约定必须两端一致否则解包时会差一个字节症状是每次解析出来的 JSON 都少最后一个引号。改完协议后接收端的解析也要同步改成先读 4 字节、再读 1 字节类型、最后读len - 1字节的 body并且len至少要大于等于 1等于 0 或 1 都视为非法包直接断开。6.2 加文件传输分块、限速、断点标记文件传输不要试图一次send整个文件几百 MB 的send会长时间占住线程期间心跳都发不出去。常规做法是按 32KB 分块每块带一个序号字段长度说明文件名长度2 字节大端文件名变长UTF-8总分块数4 字节用于进度条和完整性校验当前块序号4 字节从 0 开始块数据≤ 32KB二进制服务端收到MSG_FILE后按文件名建临时文件按序号追加写入收满总分块数后重命名。这里有个容易忽略的细节多个客户端同时传同名文件会互相覆盖落盘前要把连接 ID 拼进临时文件名比如recv_sock_name.part。6.3 验证方法先写脚本再点界面界面点起来慢而且不容易复现边界情况。我一般先用一段 Python 脚本验证服务端协议脚本能跑通再动 MFC 客户端# tools/probe_server.py —— 命令行验证服务端收发是否正常 import socket, struct, time HOST, PORT 192.168.1.10, 9000 def pack(body: bytes, mtype: int 1) - bytes: payload bytes([mtype]) body return struct.pack(I, len(payload)) payload s socket.create_connection((HOST, PORT), timeout3) s.sendall(pack(hello.encode(utf-8))) # 单包 s.sendall(pack(中文测试.encode(utf-8))) # 编码验证 s.sendall(pack(bx * 32768)) # 大包验证半包拼接 time.sleep(0.5) s.close()这个脚本最有价值的地方是能一次发三个包专门用来验证粘包处理对不对如果服务端只打印出一条日志说明解析循环写成了if如果大包被截断说明cache的拼接逻辑有问题。压测的话把sendall放进循环开 50 个线程各连一次观察服务端 CPU 和内存曲线线程模型的瓶颈一眼就能看出来。6.4 我自己的改造顺序第一版我会先把m_vecClients从vector换成std::mapSOCKET, ClientCtx因为按套接字查找和删除的频率远高于遍历vector的线性查找在几百连接时已经能感觉到延迟。第二版把每连接一线程换成select同一份收发逻辑稍微改一下就能复用。第三版才考虑界面把CListCtrl换成虚拟列表只渲染可视区域的行几千条日志也不会卡。从那以后我每次接这类活都先把协议头定死、把停止标志和线程 join 写进去最后才动界面——顺序反过来的项目我见过太多在关闭窗口时崩溃的。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询