简介面向 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_guardstd::mutex lock(m_clientMutex); m_clients.push_back(pClient); RefreshClientList(); // 只刷新界面显示不做网络操作 } void CCommDlg::RemoveClient(CClientSocket* pClient) { { std::lock_guardstd::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::vectorCClientSocket* snapshot; { std::lock_guardstd::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 在内部会循环发送直到全部写完看起来不用管但一旦你把类型改成 CAsyncSocketSend 就可能只写一部分并返回实际写入的字节数。为了将来换封装层不用重写业务我习惯自己写一个 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::vectorCClientSocket* snapshot; { std::lock_guardstd::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 出来的会话对象没有 deleteOnClose 里调用了 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_castCString*(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), timeout3) end time.time() seconds while time.time() end: # 一次发 200 字节制造与 4096 接收缓冲区不整除的场景 s.sendall(build_frame(0x02, (fclient-{idx}-.encode() bx * 180))) time.sleep(0.05) s.close() if __name__ __main__: threads [threading.Thread(targetclient_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 字节间隔发送的脚本先跑一遍确认缓冲区在任意切割点都能正确拼回来再继续做别的。希望这些能帮到你少走两段弯路。本文还有配套的精品资源点击获取