MFC下TCP Socket编程实战:WinSock2多线程通信与粘包处理

📅 发布时间:2026/10/15 15:34:10
MFC下TCP Socket编程实战:WinSock2多线程通信与粘包处理
1. 为什么还要聊MFC下的TCP Socket编程先把话说在前头如果你现在打开搜索引擎搜“TCP Socket编程”跳出来的十有八九是Python的socket模块、Java的SocketChannel或者Go语言里那套极其顺手的net包。MFC听起来像是上个时代的产物。但现实情况是大量工业控制软件、医疗设备上位机、电力监控系统、老牌CAD工具它们的界面层依然是MFC通信层依然是基于CAsyncSocket或者原始WinSock封装出来的TCP通道。你接手一个维护了十几年的项目不可能跟老板说“咱们用Rust重写吧”。所以这篇内容要解决的问题很具体在MFC框架下如何干净、稳定地实现一套TCP Socket通信。不是那种“能跑就行”的Demo而是要考虑线程安全、粘包处理、断线重连、界面响应不卡顿这些实际工程问题。适合两类人看一类是刚进公司被分配去维护老项目的开发者另一类是想理解Windows网络编程底层机制、不想只会调库的进阶学习者。我前后在三个不同规模的MFC项目里做过TCP通信模块踩过的坑从“界面直接卡死”到“客户端断开后服务端资源泄漏”都有。下面把这些经验完整拆开讲代码该给的给原理该说的说能直接抄的地方绝不藏着。2. 整体方案选型与架构设计思路2.1 MFC下三种TCP实现路径的取舍在MFC环境里做TCP通信摆在面前的路其实有三条每条路的适用场景和坑点完全不同。第一条路直接用Windows Socket APIWinSock2。这是最底层的方式WSAStartup、socket、bind、listen、accept、recv、send这些函数直接调。优点是控制力最强任何细节都能自己掌控缺点是所有东西都要手写包括错误处理、阻塞模式管理、多线程调度。我早期做过一个数据采集网关用的就是纯WinSock代码量大概在两千行左右其中真正跟业务相关的不到三分之一剩下的全是通信框架的胶水代码。第二条路使用MFC封装的CAsyncSocket类。这是MFC提供的一个异步Socket封装基于Windows的消息机制通过FD_READ、FD_WRITE、FD_ACCEPT这些事件通知来驱动。它的好处是跟MFC的消息循环天然融合不需要自己起线程就能实现非阻塞通信。但问题也很明显它依赖窗口消息如果在非窗口线程里使用消息泵就没了事件回调根本不会触发。而且CAsyncSocket的封装层次比较浅很多细节还是要自己处理。第三条路使用CSocket类。这是CAsyncSocket的上层封装提供了阻塞式的编程接口内部通过消息泵模拟同步行为。写起来最像传统的阻塞Socket代码直观。但它的致命问题是在UI线程里调用CSocket的阻塞方法界面会直接卡死除非你把它放到工作线程里。而且CSocket的稳定性在高压场景下表现一般我遇到过频繁连接断开时内部状态机混乱的情况。综合下来我的建议是如果是新写的模块且对性能有要求直接用WinSock2 API配合工作线程如果是在已有MFC项目中快速集成且通信量不大用CAsyncSocketCSocket只适合做原型验证或者极低频率的通信场景。下面重点讲WinSock2方案因为它的可控性最强理解了它之后另外两种方式本质上只是封装差异。2.2 通信模型的选择阻塞线程 vs 异步消息确定了用WinSock2之后下一个决策点是通信模型。这里有两种主流做法。阻塞模式独立工作线程Socket设置为阻塞模式accept、recv、send都会阻塞当前线程所以必须把这些操作放在一个独立的工作线程里。主UI线程通过线程间通信比如自定义消息、PostMessage来获取数据或发送指令。这种模型的优点是逻辑线性、代码好读、调试方便缺点是需要自己管理线程生命周期线程同步要小心处理。非阻塞模式事件驱动Socket设置为非阻塞通过select、WSAEventSelect或者IOCP来管理事件。这种方式在高并发场景下性能更好但代码复杂度显著上升。对于MFC上位机这种通常只管理几十个连接的场景非阻塞模型的优势并不明显。我最终选择的是阻塞模式工作线程的方案。原因很实际MFC项目里通常连接数不多一个上位机可能就管几个到下位机的连接阻塞模型的代码可维护性远高于非阻塞模型。而且调试的时候阻塞模型的调用栈清晰出了问题容易定位。非阻塞模型一旦事件处理逻辑写错现象往往是“数据莫名其妙丢了”排查起来非常痛苦。2.3 线程间通信的设计原则工作线程收到数据后不能直接操作UI控件这是MFC的基本禁忌。必须通过消息机制把数据“搬”到UI线程。我采用的是自定义Windows消息的方式// 自定义消息ID #define WM_SOCKET_DATA_RECEIVED (WM_USER 100) #define WM_SOCKET_CONNECTED (WM_USER 101) #define WM_SOCKET_DISCONNECTED (WM_USER 102)工作线程收到数据后把数据打包成一个堆上的结构体指针通过PostMessage发给主窗口。主窗口的消息处理函数负责解析数据并更新界面。这里有一个关键细节用PostMessage而不是SendMessage。SendMessage是同步的会阻塞工作线程直到UI线程处理完毕如果UI线程正忙工作线程就被卡住了影响后续数据接收。PostMessage是异步的把消息丢进队列就返回工作线程可以继续干活。但PostMessage带来一个新问题消息参数只能传两个WPARAM和LPARAM数据量大了传不下。所以需要把数据封装成结构体在堆上分配把指针传过去UI线程处理完后负责释放内存。这个“谁分配谁释放”的规则一定要在团队里说清楚否则要么内存泄漏要么野指针崩溃。3. 核心细节解析与实操要点3.1 WinSock库的初始化与清理WinSock2的使用必须先调用WSAStartup这个大家都知道。但很多人忽略的是WSAStartup和WSACleanup必须配对使用而且WSACleanup的调用次数要和WSAStartup的成功调用次数一致。在一个MFC项目里如果你在多个模块里都调用了WSAStartup就要确保每个模块退出时都调用对应的WSACleanup。我的做法是在CWinApp派生类的InitInstance里统一初始化一次在ExitInstance里统一清理BOOL CMyApp::InitInstance() { WSADATA wsaData; int nRet WSAStartup(MAKEWORD(2, 2), wsaData); if (nRet ! 0) { AfxMessageBox(_T(WinSock初始化失败)); return FALSE; } // 检查版本 if (LOBYTE(wsaData.wVersion) ! 2 || HIBYTE(wsaData.wVersion) ! 2) { WSACleanup(); AfxMessageBox(_T(WinSock版本不支持)); return FALSE; } // ... 其他初始化 } int CMyApp::ExitInstance() { WSACleanup(); return CWinApp::ExitInstance(); }注意MAKEWORD(2, 2)请求的是WinSock 2.2版本。虽然现在Windows都支持更高版本但2.2是兼容性最好的选择没必要追求更高版本。3.2 Socket的创建与选项设置创建Socket本身很简单socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)一行搞定。但创建之后、使用之前有几个Socket选项强烈建议设置。SO_REUSEADDR这个选项的作用是允许绑定到一个处于TIME_WAIT状态的地址。服务端程序重启时如果之前的连接还没完全释放不设置这个选项会导致bind失败报“地址已在使用”。设置方法int nOptVal 1; setsockopt(m_hSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)nOptVal, sizeof(nOptVal));TCP_NODELAY默认情况下TCP协议会使用Nagle算法把小包合并成大包再发送以减少网络上的小包数量。这个机制在批量传输时是好事但在交互式场景下会导致明显的延迟——你发了一个小包协议栈非要等一会儿看有没有更多数据要一起发。对于上位机这种要求实时响应的场景必须禁用Nagle算法int nOptVal 1; setsockopt(m_hSocket, IPPROTO_TCP, TCP_NODELAY, (const char*)nOptVal, sizeof(nOptVal));SO_RCVBUF和SO_SNDBUF这两个选项控制接收和发送缓冲区的大小。默认值在Windows上通常是8KB左右对于高频数据采集场景可能偏小。我一般会把接收缓冲区调到64KBint nBufSize 64 * 1024; setsockopt(m_hSocket, SOL_SOCKET, SO_RCVBUF, (const char*)nBufSize, sizeof(nBufSize));但要注意这个值不是越大越好。设置过大反而会增加内存占用和延迟。64KB到256KB之间是比较合理的范围具体取决于你的数据频率和单包大小。3.3 服务端与客户端的流程差异服务端和客户端的Socket流程有本质区别这里分别说清楚。服务端流程创建监听Socket → 绑定地址和端口 → 开始监听 → 接受连接 → 收发数据 → 关闭连接。其中accept返回的是一个全新的Socket专门用于和这个客户端通信原来的监听Socket继续等待其他连接。很多初学者会搞混这两个Socket用监听Socket去recv结果当然是什么都收不到。客户端流程创建Socket → 连接服务端 → 收发数据 → 关闭连接。客户端不需要bind系统会自动分配本地端口也不需要listen和accept直接connect就行。有一个细节值得注意connect在阻塞模式下会等待直到连接成功或失败默认超时时间可能长达20秒以上。如果服务端不可达界面会卡住很久。解决方案是先把Socket设为非阻塞调用connect后立即返回然后用select检查连接是否完成设置一个合理的超时比如3秒。连接成功后再把Socket设回阻塞模式。3.4 粘包问题的本质与处理策略TCP是字节流协议它不保留消息边界。你发了三次send每次100字节接收端可能一次recv收到300字节也可能分三次每次100字节甚至可能第一次收50字节、第二次收250字节。这就是所谓的“粘包”问题——其实不是TCP的bug而是它的设计如此。处理粘包的标准做法是自定义应用层协议在数据前面加上长度字段。我常用的格式是[4字节长度字段][N字节实际数据]长度字段用网络字节序大端存储表示后面实际数据的字节数。接收端的处理逻辑是先收4字节解析出数据长度N再收N字节这就是一个完整的数据包循环处理直到缓冲区没有足够的数据但这里有个陷阱recv不保证一次就能收到你想要的字节数。你可能调recv要收4字节结果只收到2字节。所以需要一个接收缓冲区来累积数据每次recv后检查缓冲区里是否有完整的数据包。// 简化的粘包处理逻辑 void ProcessReceivedData(char* pData, int nLen) { m_recvBuffer.append(pData, nLen); while (m_recvBuffer.size() 4) { // 解析长度字段网络字节序转主机字节序 uint32_t nPacketLen ntohl(*(uint32_t*)m_recvBuffer.data()); // 检查是否收到完整包 if (m_recvBuffer.size() 4 nPacketLen) break; // 数据还不够等下次recv // 提取完整包 std::string packet(m_recvBuffer.data() 4, nPacketLen); m_recvBuffer.erase(0, 4 nPacketLen); // 处理业务数据 HandlePacket(packet); } }实操心得长度字段用uint32_t而不是int避免负数问题。另外一定要设置一个最大包长度限制比如1MB防止恶意或错误数据导致内存暴涨。4. 完整实操过程与核心代码实现4.1 服务端工作线程的完整实现下面是一个可以直接参考的服务端工作线程实现。核心思路是主线程创建监听Socket后启动一个工作线程线程里循环accept每接受一个连接就再启动一个子线程专门处理这个连接的数据收发。// 服务端主工作线程 UINT ServerThreadProc(LPVOID pParam) { CServerSocket* pServer (CServerSocket*)pParam; while (pServer-m_bRunning) { // 设置select超时避免accept永久阻塞 fd_set readSet; FD_ZERO(readSet); FD_SET(pServer-m_hListenSocket, readSet); timeval tv { 1, 0 }; // 1秒超时 int nRet select(0, readSet, NULL, NULL, tv); if (nRet 0) continue; // 超时或错误继续循环检查退出标志 // 有连接到来 sockaddr_in clientAddr; int nAddrLen sizeof(clientAddr); SOCKET hClient accept(pServer-m_hListenSocket, (sockaddr*)clientAddr, nAddrLen); if (hClient INVALID_SOCKET) continue; // 为每个客户端创建独立的数据处理线程 ClientContext* pCtx new ClientContext(); pCtx-hSocket hClient; pCtx-pServer pServer; pCtx-clientAddr clientAddr; // 记录客户端信息 pServer-AddClient(pCtx); // 启动客户端处理线程 AfxBeginThread(ClientThreadProc, pCtx); } return 0; }这里用select配合超时来替代纯阻塞的accept目的是让线程有机会检查退出标志。否则线程会卡在accept上程序退出时无法正常结束线程。4.2 客户端数据接收线程的实现每个客户端连接对应一个独立线程负责接收数据、处理粘包、通过消息通知UIUINT ClientThreadProc(LPVOID pParam) { ClientContext* pCtx (ClientContext*)pParam; char recvBuf[8192]; std::string accumBuffer; while (pCtx-bConnected) { int nRet recv(pCtx-hSocket, recvBuf, sizeof(recvBuf), 0); if (nRet 0) { // 累积数据并处理粘包 accumBuffer.append(recvBuf, nRet); while (accumBuffer.size() 4) { uint32_t nPacketLen ntohl(*(uint32_t*)accumBuffer.data()); if (nPacketLen MAX_PACKET_SIZE) { // 数据异常断开连接 pCtx-bConnected FALSE; break; } if (accumBuffer.size() 4 nPacketLen) break; // 提取完整包 std::string packet(accumBuffer.data() 4, nPacketLen); accumBuffer.erase(0, 4 nPacketLen); // 通知UI线程 NotifyUI(pCtx-pServer-m_hNotifyWnd, WM_SOCKET_DATA_RECEIVED, (WPARAM)pCtx-nClientId, (LPARAM)new std::string(packet)); } } else if (nRet 0) { // 对方正常关闭连接 pCtx-bConnected FALSE; } else { // 出错 int nErr WSAGetLastError(); if (nErr ! WSAEWOULDBLOCK) { pCtx-bConnected FALSE; } } } // 通知UI连接断开 NotifyUI(pCtx-pServer-m_hNotifyWnd, WM_SOCKET_DISCONNECTED, (WPARAM)pCtx-nClientId, 0); // 清理资源 closesocket(pCtx-hSocket); pCtx-pServer-RemoveClient(pCtx); delete pCtx; return 0; }4.3 数据发送的线程安全处理发送数据看起来简单send一下就行。但在多线程环境下如果多个线程同时向同一个Socket发送数据会出现数据交错的问题。比如线程A要发“AAAA”线程B要发“BBBB”结果接收端可能收到“AABBABAB”这种混乱数据。解决方案是给每个客户端的发送操作加一把锁BOOL SendData(SOCKET hSocket, const char* pData, int nLen) { // 构造带长度头的完整包 uint32_t nNetLen htonl(nLen); std::vectorchar sendBuf(4 nLen); memcpy(sendBuf.data(), nNetLen, 4); memcpy(sendBuf.data() 4, pData, nLen); // 加锁保证发送原子性 CSingleLock lock(m_sendLock, TRUE); int nSent 0; int nTotal 4 nLen; while (nSent nTotal) { int nRet send(hSocket, sendBuf.data() nSent, nTotal - nSent, 0); if (nRet SOCKET_ERROR) { return FALSE; } nSent nRet; } return TRUE; }注意这里的send循环send不保证一次就把所有数据发完特别是在发送缓冲区快满的时候。必须循环发送直到所有字节都发出去了。这个细节很多Demo代码里都忽略了在低负载时看不出问题一旦数据量大就丢数据。4.4 UI线程的消息处理与数据展示UI线程收到WM_SOCKET_DATA_RECEIVED消息后需要解析数据并更新界面。这里的关键是消息处理函数要尽量快不能在里面做耗时操作否则会阻塞消息队列导致界面卡顿。LRESULT CMainFrame::OnSocketData(WPARAM wParam, LPARAM lParam) { int nClientId (int)wParam; std::string* pData (std::string*)lParam; // 解析业务数据 // ... 这里做数据解析 // 更新界面比如追加到列表控件 int nIndex m_listData.GetItemCount(); m_listData.InsertItem(nIndex, GetTimeString()); m_listData.SetItemText(nIndex, 1, CString(pData-c_str())); // 释放内存 delete pData; return 0; }注意delete pData必须在UI线程里做因为内存是在工作线程里new的但所有权已经通过消息转移给了UI线程。如果忘记释放每收到一条数据就泄漏一次内存跑几个小时内存就爆了。5. 常见问题与排查技巧实录5.1 连接建立失败排查表现象可能原因排查方法connect返回SOCKET_ERROR错误码10061服务端未监听或端口不对用netstat -an确认服务端端口处于LISTENING状态bind失败错误码10048端口已被占用检查是否有其他程序占用该端口或设置SO_REUSEADDRaccept返回INVALID_SOCKET监听Socket已关闭或出错检查监听Socket是否有效错误码用WSAGetLastError获取连接成功但收不到数据防火墙拦截或协议不匹配临时关闭防火墙测试确认双方使用相同的应用层协议程序退出时崩溃Socket资源未正确释放确保所有线程退出后再关闭Socket避免多线程同时操作5.2 数据收发异常的典型场景场景一接收端收到的数据不完整。最常见的原因是发送端没有循环send或者接收端没有累积缓冲区。TCP是流协议不能假设一次recv就能收到一个完整包。必须用累积缓冲区长度字段的方式处理。场景二高频发送时数据丢失。检查发送端是否忽略了send的返回值。send返回实际发送的字节数如果小于请求发送的字节数说明只发了一部分剩下的必须继续发。另外检查接收缓冲区是否太小导致数据被丢弃。场景三界面卡死。九成以上的原因是把阻塞Socket操作放在了UI线程。recv在阻塞模式下会一直等待UI线程被卡住消息循环停止界面自然无响应。解决方案是把所有Socket操作放到工作线程UI线程只负责显示。场景四客户端异常断开后服务端资源泄漏。如果客户端直接拔网线或者进程被杀服务端不会立即感知。recv可能返回0正常关闭或者SOCKET_ERROR异常。无论哪种情况都要确保关闭Socket、释放上下文、从客户端列表中移除。我见过一个项目因为没处理这种情况运行一周后服务端积累了上千个僵尸连接最终耗尽文件句柄。5.3 独家避坑经验坑一closesocket之后不要再使用该Socket。Socket句柄被关闭后操作系统可能立即回收并分配给新的Socket。如果你还拿着旧的句柄去操作实际上操作的是另一个无关的Socket行为完全不可预测。我的做法是关闭后立即把句柄设为INVALID_SOCKET并在所有使用前检查有效性。坑二工作线程退出时不要用TerminateThread。TerminateThread是强制杀死线程不会执行清理代码Socket不会关闭内存不会释放。正确做法是设置一个退出标志让线程自然退出。如果线程阻塞在recv上可以用shutdown先关闭Socket的接收方向recv会立即返回线程就能检查退出标志了。坑三注意字节序问题。网络字节序是大端x86机器是小端。所有超过1字节的整数字段在发送前必须用htonl/htons转换接收后用ntohl/ntohs转回来。我见过一个项目因为忘了转换在本地测试正常因为发送和接收是同一台机器字节序相同部署到不同架构的机器上就全乱了。坑四select的第一个参数在Windows上被忽略。Windows的select实现和Unix不同第一个参数nfds没有意义随便传0就行。但为了代码可移植性还是按Unix的习惯传最大文件描述符1。坑五MFC的AfxBeginThread创建的线程默认不是分离的。如果线程函数返回线程对象会被自动清理。但如果你在别的地方保存了CWinThread*指针返回后这个指针就悬空了。要么不保存指针要么设置m_bAutoDelete FALSE并手动管理生命周期。6. 性能优化与扩展方向6.1 缓冲区管理的优化前面用的std::string累积缓冲区在数据量大时会有频繁的内存分配和拷贝。优化方案是使用环形缓冲区Ring Buffer固定大小读写指针循环移动避免频繁的内存分配。class CRingBuffer { public: CRingBuffer(int nCapacity) : m_nCapacity(nCapacity), m_nReadPos(0), m_nWritePos(0) { m_pBuffer new char[nCapacity]; } ~CRingBuffer() { delete[] m_pBuffer; } int Write(const char* pData, int nLen) { int nFree GetFreeSpace(); if (nLen nFree) nLen nFree; int nFirstPart min(nLen, m_nCapacity - m_nWritePos); memcpy(m_pBuffer m_nWritePos, pData, nFirstPart); if (nLen nFirstPart) { memcpy(m_pBuffer, pData nFirstPart, nLen - nFirstPart); } m_nWritePos (m_nWritePos nLen) % m_nCapacity; return nLen; } // ... Read方法类似 private: char* m_pBuffer; int m_nCapacity; int m_nReadPos; int m_nWritePos; };环形缓冲区的优势在于内存一次性分配后续读写都是指针移动没有new/delete开销。对于每秒几千包的高频场景性能提升非常明显。6.2 心跳机制的设计TCP连接在空闲时可能被中间设备路由器、防火墙悄悄断开双方都不知道。解决方案是定期发送心跳包。心跳包的设计要点心跳间隔不宜太短一般15到30秒比较合适。太短浪费带宽太长检测不到断线。心跳包要带序号或时间戳方便检测丢包和延迟。如果连续N次心跳没有响应判定连接断开主动关闭并尝试重连。// 心跳线程 UINT HeartbeatThreadProc(LPVOID pParam) { ClientContext* pCtx (ClientContext*)pParam; while (pCtx-bConnected) { Sleep(HEARTBEAT_INTERVAL); // 比如15000毫秒 if (!pCtx-bConnected) break; // 发送心跳包 HeartbeatPacket hb; hb.nType PACKET_TYPE_HEARTBEAT; hb.nTimestamp GetTickCount(); if (!SendData(pCtx-hSocket, (char*)hb, sizeof(hb))) { pCtx-bConnected FALSE; break; } // 检查上次心跳响应时间 if (GetTickCount() - pCtx-nLastHeartbeatResp HEARTBEAT_TIMEOUT) { pCtx-bConnected FALSE; break; } } return 0; }6.3 断线重连的实现策略客户端断线后自动重连是工业上位机的基本要求。重连策略要注意几点不能立即无限重试否则服务端没恢复时会产生大量无效连接重试间隔应该递增比如1秒、2秒、4秒、8秒上限30秒重连成功后要重新初始化状态不能假设之前的会话还在。UINT ReconnectThreadProc(LPVOID pParam) { CClientSocket* pClient (CClientSocket*)pParam; int nRetryDelay 1000; // 初始1秒 while (pClient-m_bAutoReconnect) { if (pClient-m_bConnected) { nRetryDelay 1000; // 连接正常重置延迟 Sleep(1000); continue; } // 尝试重连 if (pClient-ConnectToServer()) { pClient-m_bConnected TRUE; nRetryDelay 1000; // 通知UI连接恢复 NotifyUI(WM_SOCKET_CONNECTED, 0, 0); } else { // 重连失败延迟递增 Sleep(nRetryDelay); nRetryDelay min(nRetryDelay * 2, 30000); } } return 0; }这套机制我在一个远程监控项目里用了两年多现场网络环境很差但客户端基本能在网络恢复后30秒内自动重连上不需要人工干预。6.4 从单连接扩展到多连接管理当服务端需要管理多个客户端时需要一个客户端管理器来统一维护连接列表、广播消息、处理超时。核心数据结构是一个以客户端ID为键的映射表class CClientManager { public: void AddClient(ClientContext* pCtx) { CSingleLock lock(m_lock, TRUE); m_mapClients[pCtx-nClientId] pCtx; } void RemoveClient(int nClientId) { CSingleLock lock(m_lock, TRUE); auto it m_mapClients.find(nClientId); if (it ! m_mapClients.end()) { m_mapClients.erase(it); } } void Broadcast(const char* pData, int nLen) { CSingleLock lock(m_lock, TRUE); for (auto pair : m_mapClients) { SendData(pair.second-hSocket, pData, nLen); } } private: std::mapint, ClientContext* m_mapClients; CCriticalSection m_lock; };这里用CCriticalSection而不是CMutex因为临界区是进程内锁开销比互斥量小。在MFC项目里CCriticalSection配合CSingleLock是最常用的线程同步组合。7. 调试技巧与工具推荐7.1 网络调试的常用工具调试TCP通信时光看代码是不够的必须借助工具观察实际的数据流。我常用的组合是Wireshark抓包分析能看到每一个TCP报文的内容、时间戳、序列号。排查粘包、丢包、重传问题时不可或缺。TCP调试助手模拟服务端或客户端快速验证协议格式是否正确。在写代码之前先用调试助手把协议跑通能省很多时间。Process Monitor监控进程的Socket操作查看是否有连接失败、端口占用等问题。Performance Monitor监控TCP连接数、发送接收字节数等性能指标评估系统负载。7.2 日志系统的设计网络通信模块必须有详细的日志否则出了问题根本不知道发生了什么。日志要记录连接建立和断开的时间、每次收发的数据长度和关键内容、错误码和错误描述。但日志不能太啰嗦否则高频通信时日志文件会爆炸。我的做法是分级日志ERROR级别记录所有异常INFO级别记录连接状态变化DEBUG级别记录每次收发的数据摘要只记录长度和包类型不记录完整内容。DEBUG日志默认关闭需要时通过配置打开。#define LOG_ERROR(fmt, ...) CLogger::GetInstance().Log(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_INFO(fmt, ...) CLogger::GetInstance().Log(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_DEBUG(fmt, ...) CLogger::GetInstance().Log(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)日志文件要按天或按大小滚动避免单个文件过大。同时要注意日志写入本身的线程安全多个工作线程同时写日志需要加锁。7.3 常见错误码速查错误码含义常见原因10053软件导致连接中止对方主动断开或本地Socket被关闭10054连接被对方重置对方强制关闭或网络中断10060连接超时服务端不可达或网络延迟过高10061连接被拒绝服务端未监听或防火墙拦截10048地址已在使用端口被占用需设置SO_REUSEADDR10038非Socket操作Socket句柄无效可能已被关闭10055缓冲区空间不足系统内存不足或发送频率过高这些错误码在WSAGetLastError()的返回值里能看到排查问题时第一时间查错误码比盲目猜测高效得多。8. 一些实际项目中的体会MFC下的TCP Socket编程技术本身并不复杂难的是把细节处理干净。我见过太多项目Demo阶段跑得挺好一到现场就各种问题界面卡死、数据丢失、内存泄漏、断线不重连。这些问题追根溯源基本都是几个核心点没处理好阻塞操作没放到工作线程、粘包没处理、资源释放不完整、异常情况没考虑。我的建议是在写第一行代码之前先把通信协议定清楚把线程模型画出来把异常流程想明白。协议要包含长度字段和包类型线程模型要明确哪些操作在哪个线程执行异常流程要覆盖连接失败、中途断开、数据异常、程序退出这几种情况。这些想清楚了代码写起来就是水到渠成的事。另外不要迷信框架和库。CAsyncSocket和CSocket看起来省事但一旦出问题排查难度比直接用WinSock2大得多。因为你不清楚MFC在背后做了什么消息是怎么流转的状态机是怎么变化的。直接用WinSock2每一行代码都是你自己写的出了问题也知道去哪里找。最后说一个实际体会网络编程的调试时间往往比编码时间还长。所以从一开始就要把日志和调试工具准备好不要等到出了问题才临时加日志。我现在的习惯是通信模块的第一版代码里就包含完整的日志系统后面调试时直接看日志就行效率高很多。