MFC TCP C/S通信实战:Socket选型、粘包拆包与断线重连

发布时间:2026/10/29 11:21:27
MFC TCP C/S通信实战:Socket选型、粘包拆包与断线重连

简介:面向 Windows 网络编程初学者与 MFC 开发者的 TCP 通信示例包,聚焦在 C/S 架构下用 CSocket 封装完成客户端与服务器之间的连接、收发与关闭流程,适合用来理解 Winsock 初始化、端口绑定监听、接受连接以及消息交互的基本组织方式。压缩包共 4 个文件,由 2 个 cpp 源文件与 2 个 h 头文件组成,整体约 6KB,体量轻巧,主要承载客户端与服务器两端对话框类的声明和实现,便于对照阅读两端职责划分与调用关系。已有 1265 人学习,说明其对入门 Socket 通信具有较好的参考价值。代码围绕 AfxSocketInit、Bind、Listen、Accept、Connect、Send、Receive 与 Close 等关键环节展开,可帮助读者快速建立 TCP 通信的代码框架,理解阻塞式收发与连接状态处理,并在此基础上改造出聊天、文件传输或设备调试等小型网络程序。

1. MFC TCP 的 C/S 程序:先确认它把 Socket 放在哪一层

接手一份基于 socket 通信、用 MFC 实现 TCP 通信的 C/S 架构程序,第一件要确认的事不是界面做得漂不漂亮,而是它把 Socket 藏在了哪一层。MFC 下至少有三条路径:直接调 Winsock API、用 CAsyncSocket 走异步回调、用 CSocket 配 CSocketFile 和 CArchive 做序列化。选错层次的代价不会立刻显形——点对点传几十条字符串时一切正常,等并发上来、粘包出现、界面开始假死,问题会集中挤在消息泵和阻塞调用里爆发。这份资源适合三类人:要做上位机或工控调试工具、需要在 Windows 桌面端快速搭一套可用的 TCP 客户端与服务端、或者要把课程设计里那套"能连上就行"的代码改造成能扛住多连接和断线重连的版本。下面按选型、服务端、客户端协议、排错、验证的顺序拆开讲,每一步都落到能直接抄的代码。

2. 选型先于编码:CAsyncSocket 与 CSocket 的边界与初始化顺序

很多人拿到 MFC 的 socket 封装后直接挑看起来简单的 CSocket 上手,写完发现收包不触发、Send 偶尔少几个字节、关闭窗口时进程崩在析构里。这些问题八成不是 CSocket 本身的锅,而是没搞清楚它和 CAsyncSocket 在消息泵里的位置差异。

2.1 两个封装类在消息泵里的真实差别

CAsyncSocket 是对 WSAAsyncSelect 的薄封装。Create 之后,它会把套接字句柄挂到一个隐藏窗口上,所有网络事件(FD_READ、FD_WRITE、FD_ACCEPT、FD_CLOSE)都以窗口消息的形式投递到这个窗口,再由 MFC 分派到虚函数 OnReceive、OnSend、OnAccept、OnClose。这意味着回调是在有消息泵的线程里执行的,一旦你在 OnReceive 里写了个耗时几十毫秒的解析,整个界面就跟着卡。

CSocket 派生自 CAsyncSocket,但它在阻塞套接字外面套了一层内部消息泵:调用 Receive 时如果底层返回 WSAEWOULDBLOCK,它会自己泵消息等待可读,直到拿到数据或出错。好处是代码写起来像同步调用,坏处是阻塞点变得隐蔽——你以为在等网络,其实在等消息泵,如果此时有其他窗口消息正在排队,重入就可能发生。

选型上我的一般判断是:协议简单、连接数在几十以内、界面响应要求不高,用 CSocket 省事;需要精确控制发送节奏、要处理大流量、或者准备把收发挪到工作线程,直接用 CAsyncSocket 或原生 Winsock,把控制权握在自己手里。混合用最危险,两个类各自维护状态,出问题时很难定位。

对比维度CAsyncSocketCSocket
事件模型WSAAsyncSelect 异步通知阻塞模拟 + 内部消息泵
收发写法OnReceive 里循环 Receive直接顺序调用 Receive/Send
重入风险低,回调由消息驱动高,Receive 内部泵消息
大包处理需自行循环处理短写Send 内部循环,Receive 仍需自行拼
适合场景高并发、需精细控制中小规模、快速出原型

2.2 工程初始化:AfxSocketInit 与第一处翻车点

MFC 的 socket 支持不是自动打开的。在对话框程序里,必须在应用初始化阶段调用 AfxSocketInit,否则任何 Create 都会失败并返回 WSAStartup 未调用的错误码。常见做法是放在 CWinApp 派生类的 InitInstance 最前面,或者放在主对话框的 OnInitDialog 开头。

// 主对话框初始化:所有 socket 操作之前必须先过这一关 BOOL CCommDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // AfxSocketInit 内部调用 WSAStartup,并准备 CSocket 需要的隐藏窗口 // 失败通常意味着 Winsock 版本不匹配或进程被限制 if (!AfxSocketInit()) { AfxMessageBox(_T("Winsock 初始化失败,端口通信不可用")); return FALSE; } // 之后再 Create / Listen,顺序颠倒会拿到 WSANOTINITIALISED(10093) m_pServer = new CListenSocket(this); if (!m_pServer->Create(m_nPort, SOCK_STREAM)) { DWORD dwErr = m_pServer->GetLastError(); CString s; s.Format(_T("监听套接字创建失败,错误码 %lu"), dwErr); AfxMessageBox(s); return FALSE; } if (!m_pServer->Listen(8)) { AfxMessageBox(_T("Listen 失败,检查端口是否被占用")); return FALSE; } return TRUE; }

这里的参数需要说清楚:Create 的第一个参数是端口号,传 0 表示由系统分配;第二个参数 SOCK_STREAM 指定 TCP;第三个参数留空表示绑定所有网卡地址。Listen 的参数是等待队列长度,不是最大连接数,传 5 到 10 对桌面程序足够,传太大没有意义,传 1 在突发连接时会直接拒绝。

2.3 建一个最小可运行骨架

在写业务逻辑前,先把「创建—监听—接入—收发—关闭」这条链路跑通。最小骨架包含三个类:监听套接字、客户端会话套接字、主对话框。监听套接字只负责 OnAccept,会话套接字负责 OnReceive 和 OnClose,主对话框持有会话列表并负责界面刷新。

// 监听套接字:只管接入,不碰业务 class CListenSocket : public CSocket { public: explicit CListenSocket(CCommDlg* pDlg) : m_pDlg(pDlg) {} virtual void OnAccept(int nErrorCode); private: CCommDlg* m_pDlg; }; void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode != 0) { return; } // 会话对象在堆上创建,所有权随后交给主对话框统一管理 CClientSocket* pClient = new CClientSocket(m_pDlg); // Accept 会把新连接的句柄转移到 pClient 上,失败时必须自己回收,否则泄漏 if (Accept(*pClient)) { m_pDlg->AddClient(pClient); } else { delete pClient; } CSocket::OnAccept(nErrorCode); }

这段代码有两个容易忽略的细节。第一,Accept 的第二、三个参数(地址结构)留空就够用,需要记录对端 IP 时再传 SOCKADDR_IN。第二,new 出来的对象在 Accept 成功后才归主对话框管,失败分支必须自己 delete,否则每来一次失败连接就漏一块内存,长时间跑起来句柄数会一路涨。

骨架跑通的标准很朴素:客户端连上后服务端能收到 OnAccept,客户端断开后服务端能收到 OnClose,中间不崩溃、不漏句柄。这个基线没立住之前不要往里加业务字段,加了也得推倒重来。

3. 服务端落地:监听、Accept 所有权与多客户端分发的四步实现

服务端是整个 C/S 架构里最容易写乱的部分。乱的地方不在监听,而在会话对象的生命周期和并发访问。下面按四步走:建监听、管会话、分发消息、安全关闭。

3.1 监听套接字与 Accept 的所有权转移

监听套接字在程序生命周期内只有一个,创建后一直存在,直到程序退出才 Close。会话套接字则是有多少个连接就有多少个,动态生灭。所有权划分清晰了,后面才不会出现「删了还在用」或者「该删没删」。

Accept 的语义是:从监听套接字上取出一个已完成三次握手的连接,把它的句柄赋给传入的 CSocket 对象。CSocket 版本会先关掉目标对象已有的句柄再赋值,所以 new 出来不 Create 直接传进去是对的,提前 Create 反而多一次系统调用。

// 主对话框中的会话容器:用一个 vector 加互斥量守住 void CCommDlg::AddClient(CClientSocket* pClient) { std::lock_guard<std::mutex> lock(m_clientMutex); m_clients.push_back(pClient); RefreshClientList(); // 只刷新界面显示,不做网络操作 } void CCommDlg::RemoveClient(CClientSocket* pClient) { { std::lock_guard<std::mutex> lock(m_clientMutex); auto it = std::find(m_clients.begin(), m_clients.end(), pClient); if (it != m_clients.end()) { m_clients.erase(it); } } // 延迟删除:OnClose 回调里不能 delete this pClient->PostDelayedDelete(); }

参数和逻辑说明:m_clientMutex 保护的是容器本身,不是套接字。套接字的 Send 不要放在锁里,否则一个慢客户端会把其他客户端全堵住。PostDelayedDelete 的做法是向主窗口投递一条自定义消息,由主窗口在消息处理中 delete,避开在 OnClose 回调栈里销毁自身导致的访问违例。

3.2 客户端表管理与线程安全的容器设计

会话表有两个访问来源:网络回调(增删)和界面操作(比如手动踢人、群发)。如果这两者都在 UI 线程,理论上不会并发,但一旦你把收发挪到工作线程,或者用定时器做心跳检测,就必须加锁。

我一般把会话表设计成「读多写少」:用 std::vector 存储裸指针,所有修改加锁,遍历时不加锁而是先拷贝一份快照。原因是广播时如果持锁遍历并逐个 Send,每个 Send 都可能触发消息泵重入,重入里又去拿同一把锁,直接死锁。

// 广播:先取快照,再在锁外发送,避免重入死锁 void CCommDlg::BroadcastFrame(unsigned char nType, const std::string& payload) { std::vector<CClientSocket*> snapshot; { std::lock_guard<std::mutex> lock(m_clientMutex); snapshot = m_clients; // 拷贝指针列表,开销很小 } std::string frame = BuildFrame(nType, payload); for (CClientSocket* p : snapshot) { // 发送失败说明连接已断,交给 OnClose 走正常回收,这里不删 SendAll(p, frame.data(), (int)frame.size()); } }

这段的关键点是「锁外发送」。快照里的指针在发送期间可能已经被 OnClose 回收,所以更稳妥的版本是用 shared_ptr 或者给每个会话加引用计数。桌面程序连接数不大时,用 shared_ptr 是最省心的,代价是每次拷贝要动原子计数,可以接受。

3.3 消息分发与广播的完整实现

分发层负责把拆好的帧按类型路由出去。类型字段放在帧头里,载荷用 std::string 承载原始字节,不在这里做业务解析——解析放到独立函数里,方便单测。

// 帧到达的统一入口:按类型路由,业务逻辑不写在 socket 类里 void CCommDlg::OnFrame(CClientSocket* pFrom, unsigned char nType, const std::string& payload) { switch (nType) { case 0x01: // 登录:载荷首字节是昵称长度,其后是 UTF-8 昵称 HandleLogin(pFrom, payload); break; case 0x02: // 文本消息:直接转发给所有在线客户端 BroadcastFrame(0x02, payload); break; case 0x03: // 心跳请求:回一个心跳应答,不广播 SendFrame(pFrom, 0x83, std::string()); break; default: // 未知类型不要静默丢弃,记一条日志便于排查协议版本不一致 LogUnknownFrame(pFrom, nType, payload.size()); break; } }

分发函数本身要短,超过三十行就该拆。这样做的好处是排查时能一眼看出某个类型有没有被处理:所有 case 都在一处,未知类型还有兜底日志。心跳请求单独走 0x03/0x83 一对,不和业务消息混在一起,后面做超时判定时逻辑会很干净。

3.4 会话关闭与资源回收顺序

关闭顺序错了,症状是退出程序时偶发崩溃,或者任务管理器里句柄数一直不降。正确顺序是:先 Close 套接字,再从会话表移除,最后延迟 delete 对象。Close 会触发对端收到 FIN,本端随后收到 OnClose,所以不要在调用 Close 之后立刻 delete,等 OnClose 自己走回来。

void CClientSocket::PostDelayedDelete() { // 向主窗口投递 WM_APP_DELETE_SOCKET,参数是 this 指针 // 由主窗口在消息循环中 delete,确保不在回调栈内销毁对象 if (m_pDlg && ::IsWindow(m_pDlg->GetSafeHwnd())) { m_pDlg->PostMessage(WM_APP_DELETE_SOCKET, 0, (LPARAM)this); } }

这里有个前提:投递消息之前对象必须还活着,所以 PostDelayedDelete 只能在确定不会再被其他路径访问之后调用。如果会话表里还有别的线程持有的指针,要先确保它已经被移除。我的习惯是把「移除 + 投递删除」打包成一个函数,任何地方要断开连接都调它,不直接操作 delete。

4. 客户端与帧协议:长度前缀、拆包状态机和心跳的三件套

TCP 是字节流,不保留消息边界。你在发送端 Send 了三次,接收端可能一次 Receive 就全拿到,也可能只拿到一半。这个事实不解决,后面所有业务逻辑都是沙上建塔。

4.1 长度前缀帧格式的设计与字节序

帧格式设计的第一原则是「自描述」:接收方只看帧头就知道这一帧有多长,不需要依赖上一帧的状态。我用的格式是 8 字节固定头加变长载荷。

偏移长度字段说明
02魔数固定 0xAB 0xCD,用于检测流错位
21版本当前为 1,便于将来协议升级
31类型区分登录、文本、心跳等
44载荷长度大端无符号整数,不含帧头
8N载荷原始字节,编码由类型决定

字节序必须统一成大端,也就是网络字节序。Windows 是小端机器,直接 memcpy 一个 uint32 进去,在两边都是 Windows 时看起来正常,一旦有第三方工具或脚本参与调试就会读错。写死大端是成本最低的做法。

// 大端读写:不依赖 htonl,跨平台和脚本对接都不会错 static void WriteU32(char* p, unsigned int v) { p[0] = (char)((v >> 24) & 0xFF); p[1] = (char)((v >> 16) & 0xFF); p[2] = (char)((v >> 8) & 0xFF); p[3] = (char)(v & 0xFF); } static unsigned int ReadU32(const char* p) { return ((unsigned int)(unsigned char)p[0] << 24) | ((unsigned int)(unsigned char)p[1] << 16) | ((unsigned int)(unsigned char)p[2] << 8) | (unsigned int)(unsigned char)p[3]; } std::string BuildFrame(unsigned char nType, const std::string& payload) { std::string frame; frame.resize(8 + payload.size()); frame[0] = (char)0xAB; frame[1] = (char)0xCD; frame[2] = (char)0x01; // 协议版本 frame[3] = (char)nType; WriteU32(&frame[4], (unsigned int)payload.size()); if (!payload.empty()) { memcpy(&frame[8], payload.data(), payload.size()); } return frame; }

载荷长度上限要设,不设的话一个畸形帧声明长度 2GB,接收端直接按这个长度去 resize 缓冲区,内存瞬间被打满。我一般设 1MB 上限,超过就判为异常帧并断开连接。这个判断放在拆包循环里,不要放在业务层。

4.2 拆包状态机的实现

拆包的输入是「累积缓冲区 + 新收到的字节」,输出是「若干完整帧 + 更新后的缓冲区」。实现上不需要真正的状态机变量,因为长度前缀格式的解析是无状态的:只要缓冲区够 8 字节就读长度,够长度就取帧,否则跳出等下一次。

void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) { return; } char buf[4096]; // 一次 OnReceive 可能对应多次底层到达,循环取到没有数据为止 for (;;) { int n = Receive(buf, sizeof(buf)); if (n <= 0) { break; } // 0 或错误,交给 OnClose 处理 m_recvBuf.insert(m_recvBuf.end(), buf, buf + n); if (m_recvBuf.size() > kMaxBuffer) // 防止半包攻击撑爆内存 { Close(); return; } } ParseFrames(); CSocket::OnReceive(nErrorCode); }

Receive 的第二个参数是缓冲区大小,4096 是经验值:太小导致系统调用次数上升,太大对吞吐提升不明显还占栈空间。循环上限要留意,如果对端持续高速发送,这个 for 可能长时间不退出,界面会假死。稳妥做法是加一个循环次数上限(比如 64 次),达到上限就用 PostMessage 投递一条「继续收」的消息,把控制权交回消息泵。

拆包函数本身不复杂,重点是边界判断的先后顺序:先判魔数、再判长度上限、最后判缓冲区是否够。

void CClientSocket::ParseFrames() { const size_t kHeader = 8; const unsigned int kMaxPayload = 1024 * 1024; while (m_recvBuf.size() >= kHeader) { // 魔数不对说明流已经错位,继续解析只会得到垃圾,直接断开更安全 if ((unsigned char)m_recvBuf[0] != 0xAB || (unsigned char)m_recvBuf[1] != 0xCD) { Close(); return; } unsigned char nType = (unsigned char)m_recvBuf[3]; unsigned int nLen = ReadU32(&m_recvBuf[4]); if (nLen > kMaxPayload) // 长度异常,判为恶意或版本不兼容 { Close(); return; } if (m_recvBuf.size() < kHeader + nLen) // 半包,保留缓冲区等下一次 { break; } std::string payload(&m_recvBuf[kHeader], nLen); m_recvBuf.erase(m_recvBuf.begin(), m_recvBuf.begin() + kHeader + nLen); m_pDlg->OnFrame(this, nType, payload); } }

erase 从头部删除元素是 O(n) 操作,缓冲区大时会有性能损耗。连接数小的桌面程序可以不管,追求极致的话用读指针加定期压缩,把已消费的前缀一次性抹掉。我一般在前缀超过 64KB 时才压缩一次,兼顾简单和效率。

4.3 发送端封装与半包处理

发送侧的问题比接收侧隐蔽。CSocket::Send 在内部会循环发送直到全部写完,看起来不用管,但一旦你把类型改成 CAsyncSocket,Send 就可能只写一部分并返回实际写入的字节数。为了将来换封装层不用重写业务,我习惯自己写一个 SendAll。

bool SendAll(CSocket* pSock, const char* pData, int nLen) { int nSent = 0; while (nSent < nLen) { int n = pSock->Send(pData + nSent, nLen - nSent); if (n == SOCKET_ERROR) { int err = pSock->GetLastError(); // 发送缓冲区满:让出时间片后重试,不要忙等打满 CPU if (err == WSAEWOULDBLOCK) { ::Sleep(1); continue; } return false; // 其他错误一律视为连接不可用 } if (n == 0) { return false; } nSent += n; } return true; }

参数说明:pData 指向完整帧起始地址,nLen 是帧总长度。返回值 false 时调用方不要重试,直接走断开流程,因为此时连接状态已经不可信。Sleep(1) 不是最优解,但比忙等好,桌面程序里这点延迟可以接受;对延迟敏感的场景应该改用发送队列加 FD_WRITE 通知。

4.4 心跳与断线判定

TCP 连接在物理断开后,本端不一定立刻知道。拔网线、对端进程被杀、中间设备超时清理会话表,这些情况下本端可能几分钟都收不到任何事件。心跳就是给连接加一个应用层的活性判定。

做法很简单:主对话框开一个 5 秒定时器,遍历所有会话,给每个会话发一个心跳请求帧,同时检查上一次收到数据的时间。超过 15 秒没有任何数据往来,就主动 Close。

void CCommDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == IDT_HEARTBEAT) { std::vector<CClientSocket*> snapshot; { std::lock_guard<std::mutex> lock(m_clientMutex); snapshot = m_clients; } ULONGLONG now = ::GetTickCount64(); for (CClientSocket* p : snapshot) { // 三个心跳周期没有任何往来,判定为死连接 if (now - p->LastActiveTick() > 15000) { p->Close(); continue; } SendFrame(p, 0x03, std::string()); // 心跳请求,载荷为空 } } CDialogEx::OnTimer(nIDEvent); }

LastActiveTick 要在每次收到完整帧时更新,不是每次 OnReceive 时更新——收到半包不代表对端活着,可能只是缓冲区里的残留。15 秒这个值不是固定的,局域网可以缩到 9 秒,跨广域网的部署要放宽到 30 秒以上,否则网络抖动会造成大量误判重连。

5. 避坑与排查:MFC TCP 程序最容易翻车的五个现场

下面五条都是实际调试中反复出现的,按「现象 → 原因 → 解决」记录,每条都能对上具体代码位置。

5.1 现象:客户端连上了但服务端收不到任何数据

原因通常是两处。一是服务端在 OnReceive 里只调了一次 Receive,而客户端把登录帧和后续消息分两次 Send,两次到达被合并成一个 TCP 段,第一次 Receive 拿到的数据里已经包含两帧,但代码只解析了第一帧就把缓冲区清了。二是 CSocket 的 OnReceive 没有在末尾调用基类实现,导致内部状态没有推进,后续事件不再投递。

解决方式是把「收」和「拆」彻底分开:OnReceive 只负责把字节追加进缓冲区并触发拆包,拆包函数用 while 循环把缓冲区里所有完整帧都取干净,取到不够一帧才退出。末尾保留 CSocket::OnReceive(nErrorCode) 调用。

5.2 现象:程序运行一段时间后句柄数持续上涨

原因集中在 Accept 失败分支和 OnClose 分支。Accept 返回 FALSE 时 new 出来的会话对象没有 delete;OnClose 里调用了 Close 但没有从会话表移除,对象一直挂在 vector 里,虽然句柄已关,但对象本身和它持有的缓冲区都不释放。

解决方式是给会话对象定义唯一的销毁入口。所有断开路径统一调用 RemoveClient,由它完成「从表里摘除 + 投递延迟删除消息」两件事,其他地方一律不直接 delete。排查时可以在任务管理器里观察句柄数曲线,正常应该是平稳的锯齿,单调上升就说明有泄漏。

5.3 现象:退出程序时崩溃,调用栈停在 CSocket 析构

原因是对象在 OnClose 回调栈里被 delete 了。OnClose 是虚函数,回调返回后基类代码还要访问 this 指针的成员,此时对象已经析构,访问的就是已释放内存。这类崩溃在调试版下偶尔不出现,发布版下必现,属于典型的玄学问题。

解决方式是延迟删除:OnClose 里只做标记和投递消息,真正的 delete 放到主窗口的消息处理函数里执行。如果用的是模态对话框,还要确保 PostMessage 的目标窗口在 delete 发生时仍然有效,程序退出前先把所有会话清空。

5.4 现象:中文消息收到后变成乱码或截断

两个独立原因。乱码来自编码不一致:发送端用 CString 的 GetBuffer 直接取字节,在 Unicode 编译配置下拿到的是 UTF-16,接收端按 UTF-8 解析自然全乱。截断来自长度计算:用 strlen 算长度,遇到多字节字符或中间出现 0x00 就提前结束。

解决方式是统一走 UTF-8。发送前用 WideCharToMultiByte 把 CString 转成 UTF-8 字节串,长度用转换后的返回值,不要用 strlen。协议头里的长度字段填字节数而不是字符数,接收端原样取出再用 MultiByteToWideChar 转回显示。

5.5 现象:客户端连接被拒绝,错误码 10061

原因有三种常见情况。端口号在服务端和客户端写得不一致,一个用了宏一个用了字面量;服务端绑定了 127.0.0.1 而不是 0.0.0.0,导致只有本机能连;服务端程序重启后端口处于 TIME_WAIT 状态,短时间内无法重新绑定。

解决方式是:端口号统一定义在一个头文件里,两端引用同一个常量;Create 的地址参数留空表示绑定所有网卡;调试阶段给监听套接字加 SO_REUSEADDR,通过 SetSockOpt 设置,避免频繁重启时被 TIME_WAIT 卡住。生产环境是否开启要看具体部署策略,不要无条件打开。

6. 进阶验证:UI 线程安全通道、压测脚本与一份回归清单

代码能跑通只是起点,能不能在真实环境里稳住,取决于两件事:界面和数据是否解耦,以及有没有一套能反复执行的验证手段。

6.1 把网络回调与控件更新隔开

MFC 控件不是线程安全的。只要收发逻辑跑在工作线程里,任何直接调用 SetWindowText、InsertItem 的动作都可能触发断言或崩溃。标准做法是定义一个自定义消息,网络侧只投递数据,界面侧在消息处理里更新。

// 自定义消息:wParam 传类型,lParam 传堆上的字符串指针 #define WM_APP_PUSH_LOG (WM_APP + 101) // 网络线程侧:拷贝数据到堆,交给 UI 线程释放 void CCommDlg::PostLog(const CString& s) { CString* p = new CString(s); if (!PostMessage(WM_APP_PUSH_LOG, 0, (LPARAM)p)) { delete p; // 投递失败必须自己回收,否则泄漏 } } // UI 线程侧:取出、使用、释放 afx_msg LRESULT CCommDlg::OnPushLog(WPARAM, LPARAM lParam) { CString* p = reinterpret_cast<CString*>(lParam); if (p) { m_listLog.AddString(*p); delete p; } return 0; }

这条通道的关键是所有权明确:谁 new 谁负责在投递失败时 delete,接收方负责在使用后 delete。用 SendMessage 会阻塞网络线程直到界面处理完,高频率日志场景下不可用,PostMessage 是唯一选择。

6.2 用脚本做多客户端压测

手工开几个客户端测不出问题,连接数上到二三十、消息频率提上来,粘包和半包才会暴露。写个脚本模拟并发客户端最省事。

import socket, struct, threading, time MAGIC = b'\xAB\xCD' def build_frame(msg_type: int, payload: bytes) -> bytes: # 与 C++ 端保持完全一致:8 字节头,长度字段大端 return MAGIC + bytes([1, msg_type]) + struct.pack('>I', len(payload)) + payload def client_worker(idx: int, host: str, port: int, seconds: int): s = socket.create_connection((host, port), timeout=3) end = time.time() + seconds while time.time() < end: # 一次发 200 字节,制造与 4096 接收缓冲区不整除的场景 s.sendall(build_frame(0x02, (f"client-{idx}-".encode() + b"x" * 180))) time.sleep(0.05) s.close() if __name__ == "__main__": threads = [threading.Thread(target=client_worker, args=(i, "127.0.0.1", 9000, 30)) for i in range(50)] for t in threads: t.start() for t in threads: t.join()

脚本里有两个刻意设计:载荷长度取 180 字节左右,和 4096 的接收缓冲区不成整数倍,能稳定制造半包;50 个线程同时发,能测出服务端会话表和广播路径上的锁竞争。跑完之后看服务端进程的句柄数、内存占用和界面响应,三个指标都平稳才算过关。

6.3 每次改动后强制走一遍的回归清单

我现在改完网络相关代码,不管改动多小,都会按下面这张表过一遍,一条不落。

检查项判定标准失败时的第一反应
前后端帧格式一致性魔数、版本、长度字节序完全对齐抓包比对前 8 字节
半包处理脚本按 1 字节间隔发送仍能正确解析检查拆包循环的边界条件
粘包处理一次 Receive 收到多帧能全部消费检查 while 是否被 break 提前跳出
断线重连拔掉对端后 15 秒内本端判定断开检查心跳定时器和 LastActiveTick 更新点
句柄回收连续连接断开 500 次后句柄数回到基线检查 Accept 失败分支的 delete
退出清理关闭主窗口不崩溃、不残留后台线程检查是否在回调栈里 delete 对象
中文传输含中文和特殊符号的消息原样往返检查编码转换和长度计算

这张表里最容易被跳过的是「半包处理」和「退出清理」,因为它们平时不出现。我自己就吃过亏:一套跑了半年的采集程序,在客户现场因为网络设备分片导致大量半包,程序直接把不完整的数据当成一整帧解析,字段全错位,日志里看不出来任何异常,只表现为数据偶尔跳变。排查了两天才定位到拆包循环里少了一个长度判断。从那以后我每次改完拆包逻辑,都会用那个 1 字节间隔发送的脚本先跑一遍,确认缓冲区在任意切割点都能正确拼回来,再继续做别的。希望这些能帮到你,少走两段弯路。

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