MFC非指针机制实现双向网络通信:稳定连接管理与消息映射实践 简介这份资源面向希望理解 Windows 下 Socket 网络编程原理的 C 学习者与 MFC 开发者提供一套基于 Visual C 6.0、MFC 与 C/C 实现的双向网络通信完整源码采用非指针机制组织收发逻辑可用于局域网内消息互发场景帮助读者绕开指针封装带来的理解障碍直观掌握监听、连接与数据收发的完整链路。压缩包共 67 个文件约 4.59MB包含客户端与服务端两套工程涵盖 h 头文件、cpp 源文件、rc 资源脚本、ico 图标、dsp/dsw 工程文件以及可直接运行的 exe 可执行程序另附 ReadMe 与 LICENSE 说明工程结构清晰、便于对照阅读。目前已有 158 人学习下载。读者可借此获得一份可编译、可运行的 MFC 网络通信范例通过对比客户端与服务端的 Socket 封装类理解非指针机制下消息收发与连接管理的实现思路并借助可执行文件快速验证通信效果适合作为课程设计、自学练手与排错参考的实践素材。1. 非指针机制的双向网络通信MFC 里为什么值得单独拎出来讲很多人第一次在 MFC 里写网络通信都是照着示例把CAsyncSocket或者CSocket派生一个类然后在OnReceive里delete掉临时对象、在OnAccept里new一个连接对象指针满天飞。跑起来没问题可一旦连接数上来、或者窗口频繁开关内存泄漏和野指针就开始冒头调试起来像在黑匣子里摸路。这个标题里的「非指针机制」说的就是另一条路不靠裸指针管理连接生命周期而是用对象持有、消息映射和值语义把双向通信的收发两端管住。它解决的是 MFC 网络代码里最容易被忽视的稳定性问题适合已经能写单次收发、但被多连接和资源释放折腾过的桌面端开发者。双向通信本身不新鲜新鲜的是在 MFC 这套消息驱动框架下怎么让「非指针」不只是一句口号而是能落地的类设计和消息路由。2. 非指针机制到底指什么从 CAsyncSocket 派生到对象持有2.1 指针机制在 MFC 网络里的典型翻车现场先看一段很多人写过的代码它几乎是 MFC 网络入门的标配// 指针机制的典型写法在 OnAccept 里 new 一个连接对象 void CMyServerDlg::OnAccept() { CClientSocket* pClient new CClientSocket(this); if (!Accept(*pClient)) { delete pClient; // 失败才删成功就交给 OnReceive 管 return; } m_clientList.AddTail(pClient); // 用链表存指针 } void CClientSocket::OnReceive(int nErrorCode) { // 收完数据后某些示例会在这里 delete this // 一旦外面还持有这个指针立刻变野指针 }这段代码的问题不在语法而在所有权。new出来的对象谁负责deleteOnReceive里删了链表里还挂着链表里删了OnReceive可能还在跑。MFC 的消息泵和 socket 通知是异步的指针的生命周期和消息到达顺序没有天然对齐。血泪经验是单连接测试永远正常一上并发就偶发崩溃而且崩溃点往往不在网络代码本身而在后续访问已释放对象的地方。2.2 非指针机制的核心让对象自己管自己非指针机制不是完全不用指针而是不让裸指针成为连接的所有者。常见做法有三条第一用std::shared_ptr或std::unique_ptr持有 socket 对象容器里存智能指针而不是裸指针。这样最后一个引用消失时对象自动析构不需要在OnReceive里手动delete this。第二把连接对象和窗口对象解耦。socket 只负责收发收到数据后通过PostMessage把数据投递给窗口窗口用消息映射处理业务。这样 socket 的生命周期不依赖窗口的OnReceive调用栈。第三用值语义的消息结构体传递数据而不是传递指向缓冲区的指针。std::vectorBYTE或者CString按值传接收方拿到的是独立副本发送方什么时候释放缓冲区都不影响接收方。提示非指针机制的重点是所有权清晰不是禁止使用指针。智能指针、容器、消息传递都是手段目标是让「谁在什么时候释放」这个问题有唯一答案。2.3 双向通信的收发两端怎么用对象持有双向意味着服务端和客户端都要能主动发、被动收。用非指针机制服务端可以这样组织// 服务端用 shared_ptr 管理连接容器存智能指针 class CServerDlg : public CDialogEx { std::vectorstd::shared_ptrCClientSocket m_clients; // ... }; void CServerDlg::OnAccept() { auto pClient std::make_sharedCClientSocket(this); if (!Accept(*pClient)) return; // pClient 离开作用域自动释放不需要 delete m_clients.push_back(pClient); } // 收到数据后不直接处理而是投递消息给窗口 void CClientSocket::OnReceive(int nErrorCode) { BYTE buf[4096]; int n Receive(buf, sizeof(buf)); if (n 0) { std::vectorBYTE data(buf, buf n); // 把数据拷贝一份PostMessage 给窗口 GetParent()-PostMessage(WM_CLIENT_DATA, (WPARAM)this, (LPARAM)new std::vectorBYTE(data)); } }这里PostMessage传递的LPARAM是一个堆上的 vector 指针窗口在消息处理里负责 delete。虽然这里还有一个指针但它的所有权转移是明确的谁 new 谁负责在消息处理里 delete而且这个指针不参与连接对象的生命周期管理。连接对象本身由shared_ptr管不会因为消息没处理完就被释放。参数说明WM_CLIENT_DATA是自定义消息用RegisterWindowMessage或者#define WM_CLIENT_DATA (WM_USER 100)定义。WPARAM传 socket 标识LPARAM传数据指针。窗口处理完消息后必须 delete 数据指针否则泄漏。如果不想在消息里传指针也可以把数据存到窗口的成员队列里用PostMessage只发一个通知窗口收到通知后从队列取数据。这样更符合非指针机制的初衷。3. 用消息映射把双向收发串起来从 OnReceive 到自定义消息3.1 为什么不让 OnReceive 直接处理业务OnReceive运行在 socket 通知的上下文中这个上下文和窗口消息泵不是同一个线程栈。如果在OnReceive里直接更新界面、弹对话框、或者做耗时操作轻则界面卡顿重则死锁。常见做法是OnReceive只做一件事——把数据拷贝出来然后PostMessage给窗口让窗口在消息处理函数里做业务。// 自定义消息定义 #define WM_SOCKET_DATA (WM_USER 200) // 消息映射 BEGIN_MESSAGE_MAP(CServerDlg, CDialogEx) ON_MESSAGE(WM_SOCKET_DATA, CServerDlg::OnSocketData) END_MESSAGE_MAP() // 窗口消息处理在这里做业务不在 OnReceive 里做 LRESULT CServerDlg::OnSocketData(WPARAM wParam, LPARAM lParam) { std::unique_ptrstd::vectorBYTE pData( reinterpret_caststd::vectorBYTE*(lParam)); if (!pData) return 0; // 处理数据比如解析协议、更新界面 CString strText; strText.Format(_T(收到 %zu 字节), pData-size()); m_listLog.AddString(strText); return 0; }逻辑说明OnReceive里new一个 vectorPostMessage把指针传出去窗口收到后立刻用unique_ptr接管处理完自动释放。这样即使OnReceive返回后 socket 对象被释放数据也已经安全地转移到了窗口线程。参数上WM_USER 200要确保不和 MFC 内部消息冲突一般从WM_USER 100开始往上加。3.2 双向发送从窗口到 socket 的非指针调用发送方向同样要避免裸指针。窗口要发数据时不要直接操作 socket 指针而是通过一个发送队列或者直接调用 socket 的Send方法数据用const std::vectorBYTE传入。// 发送函数数据按 const 引用传入socket 内部拷贝到发送缓冲区 bool CClientSocket::SendData(const std::vectorBYTE data) { if (data.empty()) return false; int nSent Send(data.data(), (int)data.size()); return nSent (int)data.size(); } // 窗口调用发送 void CServerDlg::OnBnClickedSend() { CString strText; m_editSend.GetWindowText(strText); std::vectorBYTE data(strText.GetString(), strText.GetString() strText.GetLength()); for (auto pClient : m_clients) { if (pClient pClient-SendData(data)) { // 发送成功 } } }这里SendData接收的是 const 引用调用方不需要关心 socket 什么时候用完数据因为Send内部会把数据拷贝到系统缓冲区。如果发送量很大Send可能只发送一部分需要循环发送或者用OnSend通知继续发。非指针机制下发送缓冲区可以用std::vectorBYTE成员变量持有不需要手动new和delete。3.3 连接关闭时的资源释放顺序双向通信最怕的是关闭时顺序错乱。非指针机制下关闭流程可以这样设计窗口收到关闭请求先调用所有 socket 的ShutDown停止收发。清空m_clients容器shared_ptr引用计数归零socket 对象析构。socket 析构时如果还有未处理的消息消息处理函数里检查数据指针是否有效无效就跳过。void CServerDlg::OnClose() { for (auto pClient : m_clients) { if (pClient) pClient-ShutDown(2); // 2 表示同时关闭收发 } m_clients.clear(); // 所有 shared_ptr 释放socket 析构 CDialogEx::OnClose(); }注意ShutDown之后不要立刻deletesocket因为可能还有OnReceive在排队。用shared_ptr的好处是即使OnReceive还在执行只要它持有shared_ptr的副本对象就不会被销毁。等所有副本都释放析构自然发生。4. 避坑与排查非指针机制下最容易踩的 5 个坑4.1 坑一PostMessage 传指针后忘记释放现象程序运行一段时间后内存持续增长任务管理器里看到私有字节不断上升。原因PostMessage的LPARAM传了new出来的数据指针窗口处理函数里没有 delete或者消息因为窗口关闭被丢弃指针没人管。解决窗口处理函数第一件事就是用unique_ptr接管指针。如果消息可能被丢弃改用值传递或者把数据存到窗口成员队列PostMessage只发通知不带指针。4.2 坑二shared_ptr 循环引用导致 socket 永不释放现象关闭所有连接后socket 对象没有析构端口没有释放重新监听失败。原因socket 持有窗口的shared_ptr窗口又持有 socket 的shared_ptr引用计数永远不归零。解决一方用weak_ptr。通常 socket 不需要持有窗口的强引用用裸指针或者weak_ptr即可。窗口持有 socket 的shared_ptrsocket 只记录窗口句柄HWND发消息用::PostMessage(m_hWnd, ...)。4.3 坑三OnReceive 里做耗时操作导致消息堆积现象客户端发送频率一高界面就卡死日志半天不刷新。原因OnReceive在 socket 通知线程里执行里面做了数据库写入或者界面更新阻塞了后续通知。解决OnReceive只拷贝数据PostMessage给窗口窗口用PostMessage或者队列异步处理。如果数据量很大可以在窗口里开一个工作线程用线程安全队列传递数据。4.4 坑四多线程同时访问 m_clients 容器现象偶发崩溃崩溃点在std::vector的push_back或者遍历迭代器上。原因OnAccept在 socket 线程里往m_clients加元素窗口线程同时在遍历发送容器没有加锁。解决用CRITICAL_SECTION或者std::mutex保护容器。更简单的做法是OnAccept不直接操作容器而是PostMessage给窗口让窗口线程统一增删。这样容器只在窗口线程访问不需要锁。4.5 坑五Socket 对象析构时还有未处理的消息现象关闭连接时概率性崩溃调用栈显示在OnReceive里访问了已释放的成员变量。原因shared_ptr释放了 socket但OnReceive已经被投递到消息队列执行时对象已经没了。解决OnReceive开头检查对象是否有效或者用shared_ptr的weak_ptr在消息处理里提升。更稳妥的做法是窗口关闭时先ShutDown然后处理完所有待处理消息再释放 socket。MFC 里可以用PeekMessage循环清空消息队列。5. 进阶用非指针机制做可复用的双向通信模块5.1 把连接管理抽成一个独立类与其在每个对话框里重复写m_clients和消息映射不如把非指针机制封装成一个CConnectionManager类。它内部用std::mapUINT, std::shared_ptrCClientSocket管理连接对外提供StartServer、StopServer、SendToAll、SendToClient接口窗口只需要注册一个回调或者消息接收点。class CConnectionManager { public: using DataCallback std::functionvoid(UINT clientId, const std::vectorBYTE); bool StartServer(UINT port, DataCallback cb); void StopServer(); void SendToAll(const std::vectorBYTE data); void SendToClient(UINT clientId, const std::vectorBYTE data); private: std::mapUINT, std::shared_ptrCClientSocket m_connections; DataCallback m_callback; UINT m_nextId 1; };这样窗口代码里不再出现裸指针回调函数按值接收数据连接生命周期由CConnectionManager统一管理。DataCallback用std::function而不是函数指针可以绑定窗口成员函数也可以用 lambda 捕获窗口指针。5.2 验证非指针机制是否真的生效写完代码后怎么确认没有指针泄漏和野指针三个验证方法第一用_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF)在 Debug 模式下检查内存泄漏。程序退出时输出窗口会打印泄漏的分配编号对照代码定位。第二反复开关连接 100 次以上观察任务管理器里句柄数和私有字节是否稳定。如果每次开关都涨一点说明有对象没释放。第三在 socket 析构函数里加日志记录析构时的连接 ID。正常关闭时每个连接都应该有一条析构日志数量对得上。CClientSocket::~CClientSocket() { TRACE(_T(Socket %u 析构\n), m_clientId); if (m_hSocket ! INVALID_SOCKET) Close(); }5.3 一个具体技巧用消息队列代替 PostMessage 传指针如果不想在PostMessage里传堆指针可以在窗口类里放一个std::dequestd::vectorBYTE作为接收队列。OnReceive里把数据 push 到队列然后PostMessage(WM_SOCKET_DATA, 0, 0)只发通知。窗口收到通知后从队列 pop 数据。队列访问用std::mutex保护或者用PostMessage的线程亲和性保证只有窗口线程访问队列。// OnReceive 里 { std::lock_guardstd::mutex lock(m_queueMutex); m_recvQueue.push(std::move(data)); } GetParent()-PostMessage(WM_SOCKET_DATA, 0, 0); // 窗口消息处理里 std::vectorBYTE data; { std::lock_guardstd::mutex lock(m_queueMutex); if (m_recvQueue.empty()) return 0; data std::move(m_recvQueue.front()); m_recvQueue.pop(); } // 处理 data这个技巧的好处是彻底去掉了消息里的指针所有数据都在值语义的容器里流转。代价是多一次拷贝和一次锁但对大多数桌面应用来说这点开销远小于调试野指针的时间。我自己的习惯是只要连接数超过 10 个或者收发频率高于每秒 100 次就用队列方案否则PostMessage传unique_ptr也够用。希望帮到你。本文还有配套的精品资源点击获取