简介:这份资源面向希望理解 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出来的对象,谁负责delete?OnReceive里删了,链表里还挂着;链表里删了,OnReceive可能还在跑。MFC 的消息泵和 socket 通知是异步的,指针的生命周期和消息到达顺序没有天然对齐。血泪经验是:单连接测试永远正常,一上并发就偶发崩溃,而且崩溃点往往不在网络代码本身,而在后续访问已释放对象的地方。
2.2 非指针机制的核心:让对象自己管自己
非指针机制不是完全不用指针,而是不让裸指针成为连接的所有者。常见做法有三条:
第一,用std::shared_ptr或std::unique_ptr持有 socket 对象,容器里存智能指针而不是裸指针。这样最后一个引用消失时对象自动析构,不需要在OnReceive里手动delete this。
第二,把连接对象和窗口对象解耦。socket 只负责收发,收到数据后通过PostMessage把数据投递给窗口,窗口用消息映射处理业务。这样 socket 的生命周期不依赖窗口的OnReceive调用栈。
第三,用值语义的消息结构体传递数据,而不是传递指向缓冲区的指针。std::vector<BYTE>或者CString按值传,接收方拿到的是独立副本,发送方什么时候释放缓冲区都不影响接收方。
提示:非指针机制的重点是所有权清晰,不是禁止使用指针。智能指针、容器、消息传递都是手段,目标是让「谁在什么时候释放」这个问题有唯一答案。
2.3 双向通信的收发两端怎么用对象持有
双向意味着服务端和客户端都要能主动发、被动收。用非指针机制,服务端可以这样组织:
// 服务端:用 shared_ptr 管理连接,容器存智能指针 class CServerDlg : public CDialogEx { std::vector<std::shared_ptr<CClientSocket>> m_clients; // ... }; void CServerDlg::OnAccept() { auto pClient = std::make_shared<CClientSocket>(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::vector<BYTE> data(buf, buf + n); // 把数据拷贝一份,PostMessage 给窗口 GetParent()->PostMessage(WM_CLIENT_DATA, (WPARAM)this, (LPARAM)new std::vector<BYTE>(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_ptr<std::vector<BYTE>> pData( reinterpret_cast<std::vector<BYTE>*>(lParam)); if (!pData) return 0; // 处理数据,比如解析协议、更新界面 CString strText; strText.Format(_T("收到 %zu 字节"), pData->size()); m_listLog.AddString(strText); return 0; }逻辑说明:OnReceive里new一个 vector,PostMessage把指针传出去,窗口收到后立刻用unique_ptr接管,处理完自动释放。这样即使OnReceive返回后 socket 对象被释放,数据也已经安全地转移到了窗口线程。参数上,WM_USER + 200要确保不和 MFC 内部消息冲突,一般从WM_USER + 100开始往上加。
3.2 双向发送:从窗口到 socket 的非指针调用
发送方向同样要避免裸指针。窗口要发数据时,不要直接操作 socket 指针,而是通过一个发送队列或者直接调用 socket 的Send方法,数据用const std::vector<BYTE>&传入。
// 发送函数:数据按 const 引用传入,socket 内部拷贝到发送缓冲区 bool CClientSocket::SendData(const std::vector<BYTE>& 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::vector<BYTE> data(strText.GetString(), strText.GetString() + strText.GetLength()); for (auto& pClient : m_clients) { if (pClient && pClient->SendData(data)) { // 发送成功 } } }这里SendData接收的是 const 引用,调用方不需要关心 socket 什么时候用完数据,因为Send内部会把数据拷贝到系统缓冲区。如果发送量很大,Send可能只发送一部分,需要循环发送或者用OnSend通知继续发。非指针机制下,发送缓冲区可以用std::vector<BYTE>成员变量持有,不需要手动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_ptr,socket 只记录窗口句柄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::map<UINT, std::shared_ptr<CClientSocket>>管理连接,对外提供StartServer、StopServer、SendToAll、SendToClient接口,窗口只需要注册一个回调或者消息接收点。
class CConnectionManager { public: using DataCallback = std::function<void(UINT clientId, const std::vector<BYTE>&)>; bool StartServer(UINT port, DataCallback cb); void StopServer(); void SendToAll(const std::vector<BYTE>& data); void SendToClient(UINT clientId, const std::vector<BYTE>& data); private: std::map<UINT, std::shared_ptr<CClientSocket>> 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::deque<std::vector<BYTE>>作为接收队列。OnReceive里把数据 push 到队列,然后PostMessage(WM_SOCKET_DATA, 0, 0)只发通知。窗口收到通知后从队列 pop 数据。队列访问用std::mutex保护,或者用PostMessage的线程亲和性保证只有窗口线程访问队列。
// OnReceive 里 { std::lock_guard<std::mutex> lock(m_queueMutex); m_recvQueue.push(std::move(data)); } GetParent()->PostMessage(WM_SOCKET_DATA, 0, 0); // 窗口消息处理里 std::vector<BYTE> data; { std::lock_guard<std::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也够用。希望帮到你。
本文还有配套的精品资源,点击获取