
简介一套基于MFC实现双向网络通信的完整源码工程面向初学Windows网络编程的C开发者目标是帮助理解Socket通信原理并快速实现局域网环境下的客户端与服务端消息收发。工程特意采用非指针机制编写较少引入指针操作的复杂度便于初学者把注意力集中在网络通信流程上开发环境基于Visual C 6.0语言采用MFC与C/C压缩包内同时提供客户端和服务端两套完整程序。资源包共67个文件大小约4.59MB主要包含11个头文件、9个C源文件以及DSP、DSW、RC等MFC工程配置文件并附带两个可直接运行的EXE文件可先运行查看效果再对照源码学习OBJ、PDB等调试文件也能帮助理清编译与链接过程。压缩包中还附有README说明与博客详解链接配合图文能够更顺畅地梳理代码结构。目前已有158人学习适合需要从零入门MFC网络通信、想要获取完整可运行示例的开发者。1. 双向网络通信在MFC里为什么用“非指针机制”收场从一个zip说起解压一个“基于MFC实现的双向网络通信(非指针机制).zip”十有八九你能看到一套基于对话框的MFC工程里面跑着一个能互相发消息的C/S demo。这里的“非指针机制”不是又多了一种协议而是对MFC网络编程里最容易翻车的一个点的规避不靠new出来的socket指针跨回调传递而是让socket对象老老实实地跟随窗口生命周期。“双向”则是客户端能发服务端能回两端同时具备收发能力。这个方案适合刚啃完MFC教程、想写个局域网小工具的新手也适合要接手旧MFC代码、被socket指针噩梦折磨的维护者——搞清这套写法你至少能少踩一半的坑。2. 把CAsyncSocket拉出来双向通信的选型与最小工作模型2.1 为什么是CAsyncSocket而不是CSocket或原始WinSockMFC里封装WinSock有两条常用路线CAsyncSocket和CSocket。很多人一开始图省事选了CSocket因为它自带阻塞语义Receive起来就像读文件一样简单。但CSocket内部为了配合MFC消息泵会偷偷在收发时派发消息这在双向高频通信里很容易把界面拖死尤其在对话框程序里一个Receive卡住整个窗口都跟着转圈。CAsyncSocket则完全不同它默认非阻塞所有事件通过OnReceive、OnAccept等虚函数回调回调发生在MFC消息泵的上下文里。换句话说收发都是“有数据才通知”不会为了等数据把一个线程挂死。双向通信要的就是这种不对称的实时性——服务端在等连接的同时还能处理已连接客户端的包客户端在发消息的同时也能随时收。原始WinSock当然能做但你要自己管理窗口句柄和FD_READ事件映射在MFC里属于纯手工黑匣子。除非是跨平台或性能极限场景否则在MFC工程里维护裸send/recv的收益很低。所以标题里的“MFC实现的双向网络通信”业界最常见、最不累的做法就是CAsyncSocket。选型之后非指针机制才登场。CAsyncSocket的最佳搭档是对象成员、窗口句柄通知和容器生命周期管理而不是回调里临时new一个socket指针再到处传。先把这个设计基调定下来后面所有代码才不会跑偏。2.2 客户端与服务端的双向消息骨架先定义协议再写代码双向通信最怕的是收发双方各说各话。有的demo代码里直接用CString发字符串另一端用固定大小缓冲读字符串一长就粘包一短就半包。真实项目里第一件事是定一个最小报文格式哪怕只是自用的测试工具。我这里用的报文很简单前两个字节是报文总长度含长度字段本身紧接着一个字节的类型后面是数据区。这样即使用户数据里夹杂二进制也不会和帧边界混淆。// NetProtocol.h #pragma pack(push, 1) struct NetPacket { UINT16 len; // 整个报文长度含 len 和 type网络字节序 UINT8 type; // 0x01文本0x02心跳0x03关闭 BYTE data[1500]; // 实际数据len - 3 个字节有效 }; #pragma pack(pop)协议头的设计有几个关键点。第一len必须用UINT16不要把int当传输字段因为两端可能一个是32位一个是64位sizeof(int)不保证相同。第二#pragma pack(1)是必须的不加的话编译器会在len和type之间插入对齐填充字节直接导致解析错位。第三数据区用固定1500字节是为了让示例好写真实项目应该把data缩短或者用BYTE data[1]做成变长结构但那样收发逻辑会更绕。定义好协议后双向通信的收端就要做“攒包”处理。TCP是流OnReceive回调一次给的未必是一个完整报文。比如发送方一次发了100字节接收方可能先收到50字节再收到50字节。所以收端至少要有一个缓冲区把半包暂存直到攒够一个len再交给业务层。这个缓冲区本身就是非指针机制里的典型容器对象用CArrayBYTE, BYTE或std::vectorBYTE都行。2.3 非指针机制用对象成员和智能指针替代裸指针的核心写法非指针机制听起来玄学其实就一句话谁拥有这个socket连接谁就把它作为成员变量不通过new出来的裸指针在回调之间传递所有权。CAsyncSocket不允许拷贝构造所以你没法把一个socket对象塞进std::vectorCAsyncSocket这也是为什么很多老代码最终退化成new CClientSocket然后用CArrayCClientSocket*管理。真正的非指针做法分两种场景。场景一是监听socket和当前活动连接都直接做在对话框类里作为成员对象。场景二是需要同时管理多个连接时用std::unique_ptrCClientSocket放进std::vector让智能指针拥有所有权。二者都不需要显式delete。先看最朴素的成员对象写法。CAsyncSocket的派生类通常长这样// CCommDlg.h 片段 class CCommDlg : public CDialogEx { protected: CListenSocket m_listen; // 监听 socket对象成员 CClientSocket m_client; // 当前连接的 socket对象成员 CArrayBYTE, BYTE m_recvBuf; // 接收缓冲容器对象 afx_msg LRESULT OnNetAccept(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnNetRecv(WPARAM wParam, LPARAM lParam); };这里最值得注意的细节是m_client最初是“空闲”状态没有绑定任何句柄。当需要发起连接时先Create()一个句柄再Connect()。当服务端Accept()一个连接时也是让这个对象Accept(m_listen)。连接结束后调用m_client.Close()让同一个对象下次还能再被Create()复用。这就是非指针机制和“每次new一个新对象”的本质差别——对象永远只有一个状态通过句柄的创建与释放来切换。有的教程会在OnAccept里new CClientSocket然后把这个指针存到全局等OnClose时再delete它。这种做法最大的隐患是事件回调的触发时序你很可能在delete之后系统又派发来一个残留的FD_CLOSE消息此时对象指针已经悬空程序大概率崩溃。用成员对象虽然不能完全消除这种回调竞态但至少对象本身还活着不会因为非法内存访问而翻车。3. 用对话框工程跑通双向回声服务端、客户端与OnReceive闭环3.1 一个MFC对话框工程里放两个socket对象服务端与客户端能同时存在双向通信的测试往往不是开两个exe而是同一个对话框程序里既当服务端又当客户端。好处是单步调试时能看到两端状态坏处是容易混淆消息回调。我的做法是在对话框上放两个组左边是“服务端”控件右边是“客户端”控件各自独立。InitDialog中要做三类初始化先调用AfxSocketInit()然后创建监听socket再创建客户端的socket对象。这里有个先后顺序监听socket要先Create和Listen客户端连接才能被接受。创建监听对象的代码// CCommDlg::OnInitDialog 片段 if (!AfxSocketInit()) { AfxMessageBox(_T(WinSock 初始化失败)); return FALSE; } // 监听 45678 端口非阻塞事件通知掩码包含 FD_ACCEPT if (!m_listen.Create(45678, SOCK_STREAM, FD_ACCEPT, _T(127.0.0.1))) { int nErr m_listen.GetLastError(); AfxMessageBox(_T(监听端口失败端口可能被占用)); return FALSE; } // 开始监听允许 5 个等待连接 if (!m_listen.Listen(5)) { AfxMessageBox(_T(Listen 失败)); return FALSE; } m_listen.SetOwner(GetSafeHwnd()); m_client.SetOwner(GetSafeHwnd());注意Create的四个参数第一个是端口号第二个是套接字类型SOCK_STREAM表示TCP第三个是感兴趣的网络事件掩码第四个是绑定地址。这里绑定127.0.0.1是为了本地测试不走防火墙如果做局域网通信改成INADDR_ANY或具体网卡IP。事件掩码只给FD_ACCEPT就够了因为监听socket只需要处理连接到达事件真正的读写是连接socket的事。SetOwner是实现非指针机制的关键桥梁socket对象不保存对话框指针而是保存窗口句柄HWND。事件回调里通过::PostMessage把通知发给对话框对话框收到消息后再操作自己的成员对象。这样socket类和对话框类完全解耦。3.2 服务端监听与接受的完整代码CAsyncSocket的OnAccept回调触发时并不代表连接已经建立而是“有连接在等待”。你必须在回调里调用Accept才能拿到新连接。非指针机制的做法是OnAccept里不直接操作业务只通知UI线程“可以接受新连接了”。// ListenSocket.cpp void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) return; if (::IsWindow(m_hOwner)) { // 不要在这里 Accept交给对话框成员方法去处理 ::PostMessage(m_hOwner, WM_APP_ACCEPT, 0, 0); } }对话框收到WM_APP_ACCEPT后调用成员对象m_client去接受连接// CCommDlg::OnNetAccept LRESULT CCommDlg::OnNetAccept(WPARAM wParam, LPARAM lParam) { if (m_client.m_hSocket ! INVALID_SOCKET) { // 如果当前已有连接先拒绝或关闭旧的 m_client.Close(); } if (m_client.Accept(m_listen)) { // 设置连接socket的事件掩码读写关闭 m_client.AsyncSelect(FD_READ | FD_CLOSE | FD_WRITE); SetDlgItemText(IDC_STATUS, _T(有客户端接入)); } else { int nErr m_client.GetLastError(); // WSAGetLastError 等于 WSAEWOULDBLOCK 表示暂时没有连接可接受 } return 0; }OnNetAccept里的逻辑要解释两个地方。第一Accept的调用者是m_client不是一个新的临时对象所以这个socket对象在后续所有回调中一直存在。第二AsyncSelect必须重新指定掩码因为m_listen之前的掩码是FD_ACCEPTAccept得到的socket不会自动继承读写通知。很多人在连接后OnReceive完全不触发就是因为漏了这行。3.3 客户端连接与双向发收的完整代码客户端发起连接前也需要先Create一个socket对象。Create的第一个参数是本地端口客户端通常传0让系统自动分配。事件掩码这里要包含FD_CONNECT因为要通知连接是否建立成功。// CCommDlg::OnBnClickedBtnConnect void CCommDlg::OnBnClickedBtnConnect() { // 如果已有句柄先关闭再重建 if (m_client.m_hSocket ! INVALID_SOCKET) m_client.Close(); BOOL ok m_client.Create(0, SOCK_STREAM, FD_READ | FD_WRITE | FD_CONNECT | FD_CLOSE); if (!ok) { AfxMessageBox(_T(创建客户端socket失败)); return; } // 这里填远端的IP和端口 ok m_client.Connect(_T(127.0.0.1), 45678); if (!ok) { int nErr m_client.GetLastError(); if (nErr ! WSAEWOULDBLOCK) { AfxMessageBox(_T(连接失败)); return; } } }Connect是非阻塞的。返回FALSE不代表失败了GetLastError()如果是WSAEWOULDBLOCK说明连接正在建立结果之后通过OnConnect报告。这里的“非指针”体现在m_client既是客户端连接发起者也是服务端接受连接后的承载对象。同一时刻只能有一个角色但这不妨碍双向收发。发送数据时不要直接在按钮事件里用Send一次发完因为大包可能在半路被TCP拆成多次发送。我们需要一个发送队列这在后面第4章细说先看简单发送void CCommDlg::OnBnClickedBtnSend() { CStringA strData; GetDlgItemTextA(IDC_EDIT_SEND, strData); // 构造报文 NetPacket pkt {}; pkt.len htons((USHORT)(strData.GetLength() 3)); pkt.type 0x01; memcpy_s(pkt.data, sizeof(pkt.data), (LPCSTR)strData, strData.GetLength()); int nLen strData.GetLength() 3; int nSent m_client.Send(pkt, nLen); // 如果 nSent ! nLen剩余部分要进发送队列重发 }这段代码有几个坑。hton函数应该写成htons因为len是16位。报文里的len必须转网络字节序否则接收端解出来的长度是颠倒的。另外CStringA是窄字节版本这里故意用A后缀避免中文乱码具体原因在第5章讲。3.4 收到数据后怎么回显OnReceive里别写死循环OnReceive回调表示“缓冲区有可读数据”但它不告诉你数据有多少。一个常见错误是在回调里用while (Receive(...) 0)循环读直到读空。这在半关闭状态下容易卡死甚至疯狂占CPU。正确姿势是“一次回调读尽量多的数据但不要循环读到返回值小于等于0”。// ClientSocket.cpp void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode 0 ::IsWindow(m_hOwner)) ::PostMessage(m_hOwner, WM_APP_RECV, 0, 0); }对话框里的接收处理LRESULT CCommDlg::OnNetRecv(WPARAM wParam, LPARAM lParam) { BYTE tmp[2048] {}; int nRead m_client.Receive(tmp, sizeof(tmp)); while (nRead 0) { // 把读到的字节追加到接收缓冲区 for (int i 0; i nRead; i) m_recvBuf.Add(tmp[i]); nRead m_client.Receive(tmp, sizeof(tmp)); } // 然后从 m_recvBuf 里解析报文 return 0; }这里引入了一个死循环但它的退出条件是Receive返回0或负值不会长时间阻塞因为CAsyncSocket是非阻塞的。Receive返回0表示对端关闭返回负值且有WSAEWOULDBLOCK表示当前没有更多数据此时就应该跳出循环。这样既保证一次回调把能读的都读完又不会在数据没到时硬等。如果你非要在OnReceive里做while (Receive(...) 0)然后里面还调Sleep那界面一定卡给你看。4. 非指针机制的底层细节消息泵、线程亲和性与对象生命周期4.1 消息驱动MFC socket和窗口句柄是绑在一起的CAsyncSocket不是凭空工作的。它在底层会创建一个隐藏的HWND调用WSAAsyncSelect把网络事件和这个窗口句柄绑定。当FD_READ、FD_CLOSE等事件到达时WinSock层会往那个窗口发一条WM_SOCKET消息然后MFC的窗口过程把消息转成OnReceive等回调。这个机制直接决定了三个限制。第一socket对象必须在创建它的线程中使用。如果m_client是在UI线程中Create的那么它的OnReceive就一定会回到UI线程。你绝不能把这个对象拿给工作线程直接调Send因为底层窗口消息还挂在UI线程里跨线程调用会破坏消息关联。第二程序必须要有消息循环。控制台程序里用CAsyncSocket事件永远不会回调因为没人GetMessage。第三窗口句柄和socket对象是双向映射的关系。对象销毁时如果没有关闭底层socket句柄消息泵上可能还挂着对这个句柄的引用下一次消息分发就会访问到已释放的对象。所以非指针机制并不是“随便用对象就不用管生命周期了”而是让对象的存活范围和窗口句柄的存活范围保持一致。做法就是socket对象作为对话框成员在OnDestroy里显式关闭所有socket句柄并且一定要移除AsyncSelect传0再Close。void CCommDlg::OnDestroy() { // 先移除事件关联再关闭句柄 if (m_client.m_hSocket ! INVALID_SOCKET) { m_client.AsyncSelect(0); m_client.Close(); } if (m_listen.m_hSocket ! INVALID_SOCKET) { m_listen.AsyncSelect(0); m_listen.Close(); } CDialogEx::OnDestroy(); }AsyncSelect(0)的语义是“取消所有网络事件通知”。如果不做这一步Close之后底层窗口消息仍可能找到socket对象但对象已经处于无效状态回调里的m_hSocket会变成INVALID_SOCKET容易产生二次错误。4.2 对象生命周期为什么用指针容易崩、用对象成员反而安全很多MFC网络教程喜欢在OnAccept里这样写CClientSocket* pSock new CClientSocket; pSock-Accept(m_listen); m_connList.AddTail(pSock);然后在OnClose里delete pSock。这确实能跑但你要时刻回答一个问题delete之后如果有一个延迟的OnReceive回调还在队列里排队谁来保证这个指针不悬空MFC的消息分发是异步的OnReceive的确可能在你delete之后才被执行。你用指针就必须在回调函数里手动判断对象是否已删除或者用加锁/引用计数来保护。这就等于把一门网络编程做成了内存管理玄学。非指针机制用对象成员时对话框销毁后成员对象跟着销毁但回调入口在对话框销毁时已经因为AsyncSelect(0)被切断。至于多连接场景用std::unique_ptr持有socket对象则在连接关闭时用unique_ptr::reset()释放事件回调依然可能晚一步但此时对象内部状态能安全地判断“句柄已经invalid”不至于访问野指针。这里有个容易被忽略的点CAsyncSocket没有拷贝构造函数。你不能写CArrayCAsyncSocket, CAsyncSocket编译器会直接报错。所以必须用CObList存指针或std::vectorstd::unique_ptrCClientSocket。用unique_ptr隐含了“所有权唯一”和标题的“非指针机制”并不矛盾——你这个“指针”是带所有权语义的智能指针不是裸指针。4.3 双向通信的缓冲设计发送队列与接收缓冲区分开双向通信的“双向”会放大缓冲问题。一边收的同时另一边可能正在发如果收发共用同一个缓冲数据会被搅乱。我的习惯是把接收缓冲和发送队列彻底分开。接收缓冲用于攒包发送队列用于暂存来不及发出去的字节。发送队列的实现要点是每次Send后如果返回值小于待发送长度就把剩下部分存起来并在OnWrite回调里继续发。OnWrite是CAsyncSocket的写事件很多新手不用它导致大包只发了一半就丢了。class CTxQueue { public: void Append(const BYTE* pData, int nLen) { int nOldSize (int)m_buf.GetSize(); m_buf.SetSize(nOldSize nLen); memcpy_s(m_buf.GetData() nOldSize, nLen, pData, nLen); } int Send(CAsyncSocket sock) { if (m_buf.IsEmpty()) return 0; int nSent sock.Send(m_buf.GetData(), (int)m_buf.GetSize()); if (nSent 0) { // 收到多少就移除多少 m_buf.RemoveAt(0, nSent); } return nSent; } BOOL IsEmpty() const { return m_buf.IsEmpty(); } private: CArrayBYTE, BYTE m_buf; };这个Send有个性能问题RemoveAt(0, nSent)会把后面所有元素往前搬数据量大时频繁触发内存移动。改进办法是维护一个m_sentOffset记录已经发到第几个字节只有队列空时才重置为0。不论怎么改往返方向的数据流必须独立这是双向通信的基本盘。接收缓冲区则是另一个角色。它从socket里收字节然后按照NetPacket里声明的len字段判断是否已经有完整报文。缓冲区一直保留半包直到解析出一个完整报文才把属于该报文的字节从头部移除。发送队列和接收缓冲区都用容器对象承载不依赖任何裸指针数组这样内存分配和释放交给CArray自己管理比new BYTE[...]手动delete少一层的犯错机会。5. 双向网络通信的5个常见坑与排查从链接错误到界面卡死5.1 坑链接时出现 unresolved external symbol __WSAFDIsSet现象是工程编译时一堆socket相关函数报“无法解析的外部符号”代码完全复制粘贴却过不了链接。原因是MFC的CAsyncSocket依赖WinSock库但还没把ws2_32.lib链接进来或者用了旧工程的winsock.h导致符号不一致。另一个原因是创建MFC工程时没把“Windows套接字”选项勾上。解决在stdafx.h里显示加入#include afxsock.h然后在工程设置里把ws2_32.lib加到附加依赖项。最稳妥的是在InitInstance最前面调用AfxSocketInit()这个函数会完成WinSock初始化。如果你用的是较早的AfxSocketInit(NULL)需要确认返回TRUE否则后续所有Create都会报WSAStartup未初始化。5.2 坑客户端连上了但OnReceive死活不触发现象是Connect成功端口甚至能用netstat看到但收不到任何数据两边的OnReceive都是安静的。原因是Create或Accept之后没有调用AsyncSelect指定事件掩码。CAsyncSocket的Create默认掩码是FD_READ | FD_WRITE | FD_CLOSE但Create之后如果紧接着Connect连接建立的事件需要FD_CONNECT掩码。服务端Accept得到的socket更特殊它不会自动继承监听socket的事件掩码必须单独AsyncSelect。解决客户端Create的第三个参数写成FD_READ | FD_WRITE | FD_CONNECT | FD_CLOSE服务端Accept成功后立刻m_client.AsyncSelect(FD_READ | FD_CLOSE | FD_WRITE)。只要漏了FD_READ缓冲区有数据也不通知OnReceive。5.3 坑收发中文变成问号或乱码现象是英文字符串正常中文全部变成???或“锟斤拷”。原因是Unicode工程下CString存的是wchar_t你用GetBuffer直接取出字节发出去每个中文字符占2字节但收端按char单字节解析编码就乱了。更隐蔽的是memcpy_s按字符个数而不是字节长度拷贝截断了半个中文字符。解决统一使用CStringA作为网络传输文本类型。GetDlgItemTextA把控件文本转成窄字节发送时strData.GetLength()就是字节数。接收端拼好完整报文后再按需要转回CStringW用于界面显示CString strMsg CA2T((LPCSTR)pkt.data, CP_UTF8); SetDlgItemText(IDC_EDIT_RECV, strMsg);如果你一开始就用UTF-8编码发送收端也要用UTF-8转换不要一边UTF-8一边GBK。跨机器通信时最好固定UTF-8因为Windows中文版下CStringA默认是本地ANSI码页换到英文系统就乱。5.4 坑界面卡死鼠标转圈按钮点了没反应现象是程序收发几次后就整个窗口无响应CPU占用有时高有时正常。原因是OnReceive回调里做了阻塞操作。比如有人在OnReceive里用WaitForSingleObject等数据全部到达或者用while循环不断Receive每次都睡10毫秒。CAsyncSocket本身非阻塞但如果你在回调里让界面线程死等MFC消息泵就被你掐断了。解决OnReceive里只做两件事——把可用数据读走然后抛一个WM_APP_RECV消息给对话框。对话框处理时也绝不能调阻塞函数。如果需要对端回来确认发送方用OnWrite继续发接收方用OnReceive继续收双方都不等待。实在要做耗时的业务解析把数据拷贝出来扔给工作线程工作线程完成后PostMessage回UI。5.5 坑程序退出时崩溃或者第二次连接失败现象是关闭窗口时偶发崩溃报错在dumpCont.cpp或socket相关代码里重新点“连接”按钮一直失败。原因有两个。第一个是对象析构时底层socket句柄还没关闭析构顺序乱掉。第二个是Close之后没有重置socket对象状态句柄残留为INVALID_SOCKET或者消息回调正在访问已经无效的句柄。解决在OnDestroy里按照“先AsyncSelect(0)再Shutdown(2)再Close”的顺序清理。反复重连前先m_client.Close()再Create因为MFC的Close不会自动把m_hSocket重置为INVALID_SOCKET的写法你得保证每次Create前句柄是干净的。如果你用new的socket指针更要小心关闭时delete和回调的时序这也是我坚持用成员对象的原因——起码程序退出时对象在栈上销毁比堆上裸指针少一层不确定性。6. 把双向通道做成正式可用的方案发送缓冲、心跳与断开检测的落地技巧如果你不想让这个demo停留在“能跑”阶段而是准备把它改造成长期挂在后台的小工具我建议先补三个东西发送缓冲的可靠重发、心跳包、断开重连。发送缓冲的完整版要处理“发送一半”的情况不能像前面示例里那样只发一次就完。正确做法是业务层把整个报文交给发送队列队列维护一个内部偏移每次OnWrite事件时尝试把剩余字节发出去。只要队列非空就必须在OnWrite里继续发送直到队列清空。心跳我一般用定时器每10秒发一个type0x02的空报文。对端收到心跳后不需要回包只要持续收到心跳就认为链路活着。断开的判断以TCP的OnClose为准而不是靠心跳超时——心跳超时只能在“对端死机但TCP连接未断开”的极端场景兜底。重连时有个血泪教训不要直接从头再Connect必须先Close旧句柄再Create再Connect。如果m_client上次还在连接状态直接Connect会返回WSAEINVAL。我自己之前就是这样翻车过后来养成了习惯凡是Connect失败一律先重置socket对象。最后说一个验证技巧。你可以在OnRecv里用GetPeerName把对端IP和端口打印出来确认双向通道确实建立。如果你只想快速测试是否通不需要界面就在服务端OnReceive里把收到的字节原样Send回去客户端对比回显数据是否一致。凡是“双向通信”都必须验证两个方向都有数据流过只测单向的全是白干。这套非指针机制的工程写法在我维护过的MFC项目里坚持了好几年没再出现连接回调崩溃之类的夜间急修。 希望这篇笔记能帮你在MFC网络通信上少走点弯路。本文还有配套的精品资源点击获取