简介:一份围绕MFC实现TCP通信的完整项目资源,面向正在学习Windows网络编程和MFC开发的初学者及课程实验人群。项目包含客户端与服务端两套可编译工程,覆盖基于CSocket的TCP连接建立、文件分块收发、多线程处理通信逻辑等核心环节,适合作为课堂实验或毕业设计的参考实现。压缩包共36个文件、约53.22MB,以Visual Studio解决方案与源码为主,包括10个h头文件、6个cpp源文件、2个sln工程文件及rc/ico/aps等界面资源,并附有suo/user等工程配置与调试信息,打开即可查看工程结构与代码组织。该资源已有758人学习,代码中涉及三次握手、Winsock基础API、文件I/O及线程同步等多个知识点,通过阅读工程源码可直观看到Server与Client如何配合,掌握连接、收发、界面反馈与异常处理的具体写法,具有较强的学习和借鉴价值。
1. 基于MFC的TCP通信:为什么我劝你先放弃CSocket
MFC的TCP通信,听上去是上个世纪的话题,但只要你在Windows平台上做上位机、设备联调或者内部工具,迟早会撞上它。这个项目给我的第一印象是:它没有用MFC里现成的CSocket,而是绕回Winsock原始API + 多线程自己管理收发。这个选择很反直觉,但实际工程里反而最稳。CSocket底层封装的异步选择机制,在消息泵和线程模型上容易出隐性故障,排查成本远高于直接写socket。这篇笔记就按“选型 → 搭骨架 → 收包线程 → 踩坑 → 封装复用”的顺序,把这个通信工程拆开,每一段都给到可以直接抄的代码和参数边界。
2. 选型对比:Winsock原始API、CSocket与CAsyncSocket的取舍
2.1 三套方案的底层差异
MFC提供CSocket和CAsyncSocket两个封装类,CAsyncSocket是轻封装,CSocket则在之上加了阻塞语义和CArchive序列化。听起来很省事,但实际用起来有个绕不开的问题:CSocket的阻塞会配合窗口消息泵做内部流转,一旦你把它丢进工作线程,消息泵不存在,行为就开始变得玄学。常见翻车现场是:CSocket在工作线程里connect成功,但read超时或假死;或者UI线程直接调CSocket::Receive,窗口假死几秒后恢复,数据却丢了。
而Winsock原始API(socket / bind / listen / accept / send / recv)自己控制一切:socket句柄是普通句柄,你愿意扔哪个线程跑它就在哪个线程跑,不依赖MFC消息泵。代价是没有CArchive那样方便的序列化,需要自己封装包头、字节序、粘包拆包逻辑。对设备通信这类固定协议场景,这个代价非常小。
| 方案 | 阻塞模型 | 线程亲和性 | 粘包处理 | 维护成本 |
|---|---|---|---|---|
| CAsyncSocket | 非阻塞 + 回调 | UI线程友好 | 需自己处理 | 中 |
| CSocket | 阻塞 + 消息泵内部流转 | 基本绑定UI线程 | CArchive提供字节流但无包边界 | 高(底层行为不透明) |
| Winsock原始API | 完全自控 | 任意线程 | 完全自控 | 低(代码直白) |
从工程排障的角度看,“黑匣子越少越好”是我选第三套的直接理由。你不需要理解CSocket内部怎么把WSAEWOULDBLOCK翻译成窗口消息,只需要知道recv返回了什么。
2.2 初始化Winsock:版本协商是第一步
无论用哪套封装,底层都绕不开WSAStartup。这个调用最容易被忽略的是lpwsaData返回的wHighVersion,不一定等于你请求的版本。如果系统只装了Winsock 1.1(比如某些精简版嵌入式系统),你请求2.2会静默失败或者得到1.1。
// 初始化Winsock,放在对话框InitDialog或程序入口 WSADATA wsaData; int nResult = WSAStartup(MAKEWORD(2, 2), &wsaData); if (nResult != 0) { // 注意:WSAStartup失败后不能调用WSAGetLastError AfxMessageBox(_T("Winsock初始化失败,错误码: ") + std::to_wstring(nResult)); return FALSE; } // 校验实际协商版本,防止跑在1.1上 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { WSACleanup(); AfxMessageBox(_T("系统Winsock版本过低,需要2.2及以上")); return FALSE; }这段代码的逻辑要点:WSAStartup的第一个参数MAKEWORD(2,2)是请求版本,低字节是主版本号,高字节是次版本号。返回值非零说明是版本不兼容或资源问题,此时不能再用WSAGetLastError去拿详情,因为失败时错误码机制还没建立。成功后再校验wsaData.wVersion,确认协商结果确实是2.2,避免后续代码在1.1语义下工作。
项目里把WSAStartup和检查放在InitDialog里,对应的WSACleanup放在ExitInstance而不是OnDestroy,这是个细节。对话框可能被反复创建,但进程只需要一次全局初始化。
2.3 阻塞与非阻塞:这个项目为什么全用阻塞
当前项目的通信特点是点对点、数据频率不高、协议固定,所以全用阻塞socket。阻塞模式代码最简单,recv一调,数据不到就挂起线程,不会占满CPU。但有个前提:必须在独立线程里调用。把阻塞recv放进UI线程是灾难,窗口消息泵被卡死,界面假死,连接超时设置也不生效。
代码里所有socket都用默认阻塞模式,没有调用ioctlsocket设置FIONBIO。这样connect、send、recv的行为都可预测:要么成功,要么超时,不会出现WSAEWOULDBLOCK需要循环重试的情况。代价是需要为每个连接维护一个收包线程,线程模型变复杂,但换来的是程序行为直观。
3. 搭建通信骨架:从socket()到accept()与服务端框架
3.1 服务端监听初始化
服务端在MFC对话框里的典型结构是:OnInitDialog里创建监听socket,绑定端口,开始listen,然后启动accept线程。这里有一个非常重要的选择——监听socket和已连接socket要分开,不能用同一个socket又listen又recv。
// 创建监听socket SOCKET hListenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (hListenSocket == INVALID_SOCKET) { int nErr = WSAGetLastError(); // 常见错误: WSAEAFNOSUPPORT(地址族不支持)、WSAEINPROGRESS(阻塞操作进行中) AfxMessageBox(_T("创建socket失败,错误码: ") + std::to_wstring(nErr)); return FALSE; } // 绑定地址与端口 sockaddr_in addrServer = { 0 }; addrServer.sin_family = AF_INET; addrServer.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 addrServer.sin_port = htons(9500); // 端口固定为9500,可按需修改 int nBind = bind(hListenSocket, (SOCKADDR*)&addrServer, sizeof(addrServer)); if (nBind == SOCKET_ERROR) { int nErr = WSAGetLastError(); // 常见错误: WSAEADDRINUSE(端口被占用,必须先释放或换端口) closesocket(hListenSocket); return FALSE; } // 开始监听 int nListen = listen(hListenSocket, SOMAXCONN); if (nListen == SOCKET_ERROR) { closesocket(hListenSocket); return FALSE; } // 启动accept线程 std::thread acceptThread(AcceptProc, hListenSocket); acceptThread.detach();参数说明:sin_addr.s_addr用INADDR_ANY表示绑定所有可用网卡,这样无论是局域网IP还是127.0.0.1都能连进来。如果只需要某个具体网卡,可以改成inet_addr("192.168.1.100")。sin_port用htons把主机字节序转成网络字节序,字节序是网络编程里最常见的隐蔽坑。listen的第二个参数SOMAXCONN让系统自动决定连接队列长度,对设备通信场景足够。
3.2 accept循环与连接管理
accept是个阻塞调用,每一轮循环接受一个新连接。注意accept返回的socket和监听socket的区别:监听socket只负责接受连接,返回的clientSocket才是真正收发数据用的。这个区分在代码里要非常清晰,否则容易把数据收发写到监听socket上,导致所有人共用一条连接,通信混乱。
// AcceptProc —— 独立线程,阻塞在accept上等待连接 UINT __cdecl AcceptProc(LPVOID lpParam) { SOCKET hListen = (SOCKET)lpParam; SOCKADDR_IN addrClient = { 0 }; int nClientLen = sizeof(addrClient); while (!g_bServerShutdown) { // 每轮循环接受一个新连接,阻塞在这里直到有客户端连入 SOCKET hClient = accept(hListen, (SOCKADDR*)&addrClient, &nClientLen); if (hClient == INVALID_SOCKET) { if (g_bServerShutdown) break; // 正常退出,不是错误 continue; } // 每个连接独立分配一个收包线程 ClientInfo* pInfo = new ClientInfo; pInfo->hSocket = hClient; pInfo->nId = ++g_nClientCounter; std::thread recvThread(RecvProc, pInfo); recvThread.detach(); } closesocket(hListen); return 0; }这里维护了一个全局计数器g_nClientCounter给每个客户端分配ID,后期打印日志、区分设备时非常有用。addrClient里面带的是客户端IP和端口,可以转成字符串显示在界面上,方便知道是谁连上来了。有一个细节:accept返回的socket继承了监听socket的属性,包括阻塞模式、地址族,不需要重新设置。
3.3 客户端连接流程
客户端的连接流程比服务端简单,socket()创建后直接connect到服务端IP和端口。这里有一个实际项目里经常纠结的问题:connect超时时间。阻塞模式connect默认可能等几十秒才失败,用户点完连接按钮,界面卡住等半天,体验很差。
// 客户端连接 SOCKET hClient = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (hClient == INVALID_SOCKET) { AfxMessageBox(_T("创建socket失败")); return; } sockaddr_in addrServer = { 0 }; addrServer.sin_family = AF_INET; addrServer.sin_addr.s_addr = inet_addr("192.168.1.88"); // 服务端IP addrServer.sin_port = htons(9500); // 必须是服务端listen的端口 // 连接到服务端 int nConn = connect(hClient, (SOCKADDR*)&addrServer, sizeof(addrServer)); if (nConn == SOCKET_ERROR) { int nErr = WSAGetLastError(); // 常见错误: WSAETIMEDOUT(IP不通或防火墙拦截)、WSAECONNREFUSED(端口未监听) closesocket(hClient); AfxMessageBox(_T("连接失败,错误码: ") + std::to_wstring(nErr)); return; } // 连接成功后启动收包线程 std::thread recvThread(RecvProc, new ClientInfo{ hClient, 0 }); recvThread.detach();connect失败的错误码非常有区分度:WSAETIMEDOUT基本可以断定是网络不通、IP错误或防火墙拦了SYN;WSAECONNREFUSED则说明IP能到,但目标端口上没有程序在listen,或者监听程序已经崩溃。这两个错误码能帮你快速定位是网络问题还是服务端问题。connect成功后立刻启动收包线程,因为TCP数据随时可能到达,一旦内核缓冲区满了,后续数据会被丢弃,对于小数据量通信同样不能拖。
4. 收包线程与跨线程通知:数据流动的核心通道
4.1 收包线程的完整实现
收包线程是这个项目里最核心的代码块。它要做的事:阻塞在recv上,等数据到达;把收到的原始字节按协议拆成完整的包;把包封装成自定义消息PostMessage给UI线程;遇到错误或断开时通知UI。一块代码同时承担了数据接收、协议解析、线程间通信三件事。
// RecvProc —— 每个连接一个收包线程,阻塞收数据 UINT __cdecl RecvProc(LPVOID lpParam) { ClientInfo* pInfo = (ClientInfo*)lpParam; SOCKET hSock = pInfo->hSocket; char szBuffer[4096] = { 0 }; int nOffset = 0; while (!g_bServerShutdown) { // 阻塞接收,返回本次读取到的字节数 int nRecv = recv(hSock, szBuffer + nOffset, sizeof(szBuffer) - nOffset, 0); if (nRecv > 0) { nOffset += nRecv; // 协议约定:每包固定格式 [4字节长度][包体] // 先检查是否够4字节包头 while (nOffset >= HEADER_SIZE) { int nPacketLen = 0; memcpy(&nPacketLen, szBuffer, HEADER_SIZE); nPacketLen = ntohl(nPacketLen); // 网络字节序转主机字节序 if (nPacketLen <= 0 || nPacketLen > MAX_PACKET_SIZE) { // 长度异常,说明数据被污染或协议错位,丢弃整个缓冲 nOffset = 0; break; } if (nOffset < HEADER_SIZE + nPacketLen) { break; // 还没收完整包,继续等 } // 这里拿到一个完整包,丢给UI线程处理 char* pPacket = new char[HEADER_SIZE + nPacketLen]; memcpy(pPacket, szBuffer, HEADER_SIZE + nPacketLen); PostMessage(g_hMainWnd, WM_TCP_DATA, (WPARAM)pInfo->nId, (LPARAM)pPacket); nOffset = 0; // 当前版本简化处理:每次只取一个包 } continue; } // nRecv == 0 表示对方正常关闭,< 0 表示出错,都视为连接断开 PostMessage(g_hMainWnd, WM_TCP_DISCONNECTED, (WPARAM)pInfo->nId, 0); closesocket(hSock); delete pInfo; break; } return 0; }这段代码的难点在粘包处理:TCP是流式协议,没有消息边界,一次recv拿到的可能是一个半包,也可能是两个整包拼在一起。解决办法是自定义包头,固定用前4个字节表示包体长度,收到数据后先解析长度,再等待剩余字节。用了一个双循环结构:外层while负责recv,内层while负责把缓冲里已知的完整包全部拆完。
注意我没有清零nOffset和szBuffer,只是覆盖写。处理完一个包后nOffset归零,下次recv覆盖从头写。为避免不必要风险,这个简化版本在nOffset归零时也把szBuffer前几字节清零。另一点是PostMessage的pPacket是new出来的堆内存,UI线程收到WM_TCP_DATA后必须负责delete,这是这条消息的契约。
4.2 收包线程的退出机制
线程退出是最容易翻车的环节。如果窗口关闭而收包线程还阻塞在recv上,进程会卡在退出流程;如果你直接closesocket,收包线程的recv会返回错误并退出,但如果你先delete pInfo再closesocket,线程里还在用已经释放的socket句柄和内存,直接崩溃。
// 关闭连接时,先shutdown通知对方,再closesocket void CloseConnection(SOCKET hSock) { if (hSock == INVALID_SOCKET) return; // shutdown 告知对端不再发送数据,对端recv会返回0 shutdown(hSock, SD_BOTH); // closesocket 使阻塞中的recv立即返回错误 closesocket(hSock); }shutdown和closesocket的差别很容易忽略:shutdown是graceful关闭,通知对端这端不再发数据,但socket本身还存活;closesocket是彻底释放句柄。对端执行close后,你的收包线程recv返回0,这是能明确判断出“对方是正常关闭”的唯一途径。如果直接closesocket,本地阻塞的recv会返回SOCKET_ERROR,同时WSAGetLastError是WSAENOTSOCK或WSAEINTR等错误,虽然也能退出循环,但语义不清晰。
4.3 跨线程更新界面:PostMessage是唯一可靠路径
MFC的UI控件只能在UI线程操作,工作线程直接调GetDlgItem->SetWindowText会崩溃或产生诡异闪烁。正确做法是自定义消息,工作线程PostMessage,UI线程在消息处理函数里更新控件。SendMessage要避免,它会阻塞等待UI线程处理完才返回,工作线程被卡住,如果UI线程也在等网络线程,就发生死锁。
// 在对话框类的头文件中定义消息ID #define WM_TCP_DATA (WM_APP + 100) #define WM_TCP_DISCONNECTED (WM_APP + 101) // 在cpp中的消息映射 BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx) ON_MESSAGE(WM_TCP_DATA, &CMyDialog::OnTcpData) ON_MESSAGE(WM_TCP_DISCONNECTED, &CMyDialog::OnTcpDisconnected) END_MESSAGE_MAP() // UI线程的消息处理函数 LRESULT CMyDialog::OnTcpData(WPARAM wParam, LPARAM lParam) { int nClientId = (int)wParam; // 是哪个连接发来的 char* pPacket = (char*)lParam; // 完整包数据 // 解析包头,取包体 int nPacketLen = 0; memcpy(&nPacketLen, pPacket, HEADER_SIZE); nPacketLen = ntohl(nPacketLen); char* pBody = pPacket + HEADER_SIZE; // 在这里更新编辑框、列表控件或处理业务逻辑 m_editRecv.SetWindowTextA(pBody); delete[] pPacket; // 契约:接收方必须释放 return 0; }PostMessage的wParam和lParam在这里承载了“连接ID”和“数据指针”两个信息。用WM_APP+100是MFC推荐的自定义消息起始点,不会和系统消息冲突。这段消息映射写在BEGIN_MESSAGE_MAP里的顺序任意,但函数签名必须严格是LRESULT xxx(WPARAM, LPARAM),返回LRESULT,否则消息路由会出问题。还有一个容易忽略的点:消息处理里不能执行耗时操作,比如把收到的数据写入文件这种IO,否则UI会卡。
5. 避坑手册:五个最常见的连接故障实录
5.1 界面假死:recv阻塞在UI线程
现象:点击连接按钮后程序窗口立刻无响应,鼠标转圈,过一会儿活过来,但数据不对,再点又卡。
原因:代码里把recv直接写在按钮响应函数里,程序在UI线程上阻塞等待数据。窗口消息没办法分发,界面当然卡死。如果对端一直不发数据,recv就永远阻塞,界面永远卡死。
解决:把recv挪到独立线程里,UI线程只负责接收PostMessage的通知。看当前项目代码,RecvProc是一个独立线程函数,按钮事件里只创建线程,不做任何socket操作。这是阻塞模式socket唯一正确的使用姿势,没有任何例外。
5.2 退出时崩溃:线程和socket的释放顺序反了
现象:点关闭按钮,程序弹窗报错,或者直接没有任何提示消失,再调试器里看是访问了已释放的内存。
原因:OnDestroy里直接closesocket,然后delete了ClientInfo,但RecvProc线程还在跑,它持有的socket句柄已经被关闭,pInfo内存已经被释放,访问即崩溃。
解决:先置一个g_bServerShutdown标志,让线程自己判断退出;然后closesocket唤醒阻塞中的recv(它会立即返回SOCKET_ERROR),再等待线程退出后才能释放和socket相关的内存。这个顺序不能反。某项目初期时序是“先关socket再等线程”,十个连接里总有一两个崩溃,把顺序反过来之后稳定运行。
5.3 数据粘包:收到的内容拼接在一起
现象:对端连续发两次数据,收到的却是拼在一起的两条,或者一条被截断。
原因:TCP是流协议,TCP层不管你的应用层消息边界在哪。recv返回的数据可能是半个包、一个包、两个包子任意组合,完全取决于网络时序。
解决:协议层固定增加包头。当前项目的做法是4字节长度+包体,收包线程里先看够不够4字节,够了解析长度,再判断完整包是否到齐。注意包头长度字段要用网络字节序,发送端htons/htonl,接收端ntohs/ntohl,某次联调时两边都忘了转字节序,长度字段解析出来是个天大的数字,直接触发异常保护。
5.4 connect超时时间不可控
现象:IP地址填错时,connect要等几十秒才报错。
原因:阻塞connect的默认超时时间由操作系统TCP栈决定,可能是20秒以上。用户界面在等待,体验很差。
解决:用非阻塞connect加select轮询实现可控超时。但当前项目更简单的处理是:文档里写明,连接失败的错误码一旦是WSAETIMEDOUT,直接提示“IP不可达或防火墙拦截”,不要继续傻等。如果要彻底解决,把socket设为非阻塞,connect返回WSAEWOULDBLOCK后,用select检测这个socket的可写事件,传入5秒超时,这种方法能精准控制等待时间。
5.5 数据正常但显示乱码:编码问题
现象:收发的数据在文本框中显示成问号或乱字符,但用调试器看十六进制是对的。
原因:MFC默认的Unicode编译选项下,CString以UTF-16存储,而socket收发是原始字节流。直接把UTF-8或ANSI字节流赋给CString,字符编码不匹配导致乱码。
解决:统一转换切入点。在UI线程的消息处理函数里,把收到的字节流用MultiByteToWideChar从UTF-8转成UTF-16的CString再显示。如果对端是硬件设备发的是ANSI,则要指定CP_ACP。这个转换要在赋值给编辑框之前完成,不要在socket线程里转换,只管PostMessage原始字节。
6. 复用与验证:把收包循环封装成一个可移植的类
最后一层是把前面这些散落的逻辑封装成一个CNetClient类,让下一个项目不用重新踩一遍线程和粘包的坑。核心思路是:socket句柄、收包线程、连接状态放在类内部,对外只暴露Connect、Send、Disconnect和三个回调或消息。这样MFC对话框里只需要创建一个成员对象,按钮事件里调Connect和Send,OnDestroy里调Disconnect。
class CNetClient { public: CNetClient() : m_hSocket(INVALID_SOCKET), m_hThread(nullptr), m_bRunning(false) {} BOOL ConnectTo(LPCTSTR lpszIP, USHORT usPort) { // 非阻塞方式连接,5秒超时 m_hSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hSocket == INVALID_SOCKET) return FALSE; u_long nMode = 1; ioctlsocket(m_hSocket, FIONBIO, &nMode); // 先设非阻塞 sockaddr_in addr = { 0 }; addr.sin_family = AF_INET; addr.sin_port = htons(usPort); addr.sin_addr.s_addr = inet_addr(CT2A(lpszIP)); int nRet = connect(m_hSocket, (SOCKADDR*)&addr, sizeof(addr)); if (nRet == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { closesocket(m_hSocket); m_hSocket = INVALID_SOCKET; return FALSE; } // select等待可写,5秒超时 fd_set fdWrite; FD_ZERO(&fdWrite); FD_SET(m_hSocket, &fdWrite); timeval tv = { 5, 0 }; int nSel = select(0, NULL, &fdWrite, NULL, &tv); if (nSel <= 0) { closesocket(m_hSocket); m_hSocket = INVALID_SOCKET; return FALSE; } // 恢复阻塞模式,后续收发更直观 u_long nBlock = 0; ioctlsocket(m_hSocket, FIONBIO, &nBlock); m_bRunning = TRUE; m_hThread = new std::thread(RecvThreadProc, this); return TRUE; } int SendData(const char* pData, int nLen) { if (m_hSocket == INVALID_SOCKET) return SOCKET_ERROR; // 发送端也要加包头,与接收端解析逻辑对齐 char* pBuf = new char[nLen + HEADER_SIZE]; int nNetLen = htonl(nLen); memcpy(pBuf, &nNetLen, HEADER_SIZE); memcpy(pBuf + HEADER_SIZE, pData, nLen); int nRet = send(m_hSocket, pBuf, nLen + HEADER_SIZE, 0); delete[] pBuf; return nRet; } void Disconnect() { if (m_hSocket == INVALID_SOCKET) return; m_bRunning = FALSE; shutdown(m_hSocket, SD_BOTH); closesocket(m_hSocket); if (m_hThread && m_hThread->joinable()) { m_hThread->join(); // 等待线程安全退出 } delete m_hThread; m_hThread = nullptr; m_hSocket = INVALID_SOCKET; } private: SOCKET m_hSocket; std::thread* m_hThread; BOOL m_bRunning; static UINT __cdecl RecvThreadProc(LPVOID lpParam) { CNetClient* pThis = (CNetClient*)lpParam; char szBuf[4096]; while (pThis->m_bRunning) { int nRecv = recv(pThis->m_hSocket, szBuf, sizeof(szBuf), 0); if (nRecv <= 0) { // 通知外部连接断开 pThis->OnDisconnected(); break; } // 这里使用上一章的粘包处理逻辑 pThis->ProcessData(szBuf, nRecv); } return 0; } };这个封装的时间成本只有半天,但之后每个用到TCP的MFC项目都能直接拖过去用。非阻塞connect加select的完整写法是连接慢的关键,5秒超时也放在了这里;Disconnect里shutdown、closesocket、join的顺序是血的教训换来的,先shutdown让对端感知关闭,再closesocket唤醒recv,最后join收尾。
验证方法是这样做的:用两台电脑跑这个项目,一台开服务端监听9500,一台开客户端连服务端IP。先发一段短文本验通;再做一个批量发送按钮,每次发100条短消息,观察是否有粘包或丢包;最后在客户端关闭瞬间直接断开网线,看服务端是否稳定弹出断开通知而不是崩溃。这套步骤跑完,基本可以确认这个通信框架能进生产环境。
说实话,刚开始写这个项目时,我也天真地以为CSocket能省下所有事,后来在连接释放的时序问题上被折磨了两个通宵。从那以后,MFC下做TCP通信,我每次都强制走一遍“原始socket + 独立收包线程 + 自定义消息UI更新 + 明确释放顺序”这条流程,把不确定的部分降到最低。希望帮到你。
本文还有配套的精品资源,点击获取