ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CAsyncSocket使用例子详解:MFC异步Socket事件驱动模型与避坑指南

CAsyncSocket使用例子详解:MFC异步Socket事件驱动模型与避坑指南 简介CAsyncSocket 示例工程是一份面向 Windows MFC 开发者的网络编程学习资料针对实际项目中常见的异步 TCP 通信需求演示了如何基于 CAsyncSocket 封装快速搭建客户端与服务端应用。压缩包共 44 个文件约 1.82MB以 h/cpp 源码文件为核心配合 rc 资源文件、dsp/dsw 工程文件和 txt 说明文件目录结构清晰方便直接打开编译并对照阅读。示例覆盖了套接字创建、Connect/Listen/Accept 连接建立、Send/Receive 数据收发以及 OnConnect、OnAccept、OnReceive 等事件驱动处理流程客户端与服务端两个完整工程互相配合可运行验证通信效果。代码还延伸到多客户端连接管理、常见错误处理和数据分块传输等实用场景类封装方式便于在真实项目中复用。目前已有 444 人学习下载适合需要理解 MFC 异步网络模型或快速上手 Winsock 编程的读者。 很多刚接触MFC网络编程的朋友一看到CAsyncSocket这个名字就容易犯嘀咕这都什么年代了还有人用这个但说句实在话我在做工业上位机、局域网自定义协议工具和老项目维护时CAsyncSocket依然是Windows桌面端最顺手、最不容易出幺蛾子的方案之一。这篇文章就围绕CAsyncSocket的使用例子把从初始化、服务端搭建、客户端连接、事件通知原理到高频踩坑的完整链路讲透适合正在用Visual Studio写MFC程序、需要快速实现TCP/UDP通信的开发者参考。既然要写CAsyncSocket的使用例子光贴代码肯定不够我更想把每个步骤背后的为什么也讲清楚。毕竟这类类库的资料网上不少但有实操细节、有避坑经验的完整梳理其实不多。1. 动手前先搞清楚CAsyncSocket的定位和真实适用场景1.1 CAsyncSocket、CSocket、原生WinSock API的路线差异我经常被问一个问题MFC里做网络通信到底该用CAsyncSocket还是CSocket这俩虽然长得像但脾气完全不同。CSocket是CAsyncSocket的派生类它把异步事件封装成了同步阻塞式调用Send和Receive在没有数据时会一直卡住线程。这对写简单请求-响应逻辑确实省心但代价是你得为每个连接开一个线程而且CSocket内部要用CFile对象中转处理分包和粘包时远不如CAsyncSocket灵活。CAsyncSocket则更接近原生WinSock的非阻塞模型它把FD_READ、FD_WRITE、FD_ACCEPT、FD_CONNECT、FD_CLOSE这些网络事件映射成MFC可重载的虚函数。你不需要维护线程事件到了框架自动帮你调用对应方法。说白了它是在WinSock API外面裹了一层说人话的事件分发层。至于直接用WinSock API那当然最灵活但你需要自己处理消息循环、WSAAsyncSelect、错误码判断等一堆细节开发效率和代码可读性都会明显下降。CAsyncSocket正好卡在中间保留WinSock的控制力又提供MFC集成的事件模型。1.2 我实际会选择CAsyncSocket的场景基于实际项目经验这几个场景我用CAsyncSocket用得最多局域网内的上位机与设备通信比如通过自定义TCP协议采集设备状态、下发控制指令。需要同时维持几十个客户端连接的监控类程序连接数不大但每个连接都有实时交互。内部工具类软件像网络调试助手、小游戏对战、数据转发中继。在已有的MFC工程里快速加一个通信模块不想为网络部分引入额外的第三方库。CAsyncSocket不太适合的场景也很明确高并发、海量连接、IO密集型的服务端这种场景Windows下该用IOCPLinux下用epoll。CAsyncSocket应付并发量在数百以内的小型服务端和桌面客户端通信是绰绰有余的。我在文章后面会尽量把代码写得能直接搬但选型这个前提心里得有数。2. 从空工程搭出一个能跑的服务端2.1 初始化是很多人翻车的第一道关如果你直接用Visual Studio建一个MFC对话框程序然后引入CAsyncSocket的类十有八九会编译报错。第一步得在预编译头文件stdafx.h新版项目叫pch.h里加上#include afxsock.h注意标准头文件是afxsock.h不是afxwinsock.h这俩名字太像我见过不少人栽在这上面。头文件搞定之后还要在CWinApp派生类的InitInstance里调用AfxSocketInit。这一步干的事情是初始化Winsock库可以理解成给网络模块做一个开机自检。如果不调用后面所有socket操作都会以莫名其妙的失败告终BOOL CMyApp::InitInstance() { if (!AfxSocketInit()) { AfxMessageBox(_T(Winsock初始化失败)); return FALSE; } // ...其他初始化代码 }AfxSocketInit还有个很容易被忽略的特性它只在当前线程初始化Winsock。如果你在UI线程初始化了却想在工作线程里创建socket、接收事件那就必须在那个工作线程里再调用一次。MFC的socket事件通知依赖窗口消息而消息和线程是绑定的这点我在第4章会展开讲。2.2 服务端监听socket的创建与监听先看一个完整的服务端监听类定义我通常把监听socket和通信socket分成两个类职责清晰不会乱// 监听类只负责等别人连 class CListenSocket : public CAsyncSocket { public: virtual void OnAccept(int nErrorCode) override; }; // 通信类负责和已连接客户端收发数据 class CClientSocket : public CAsyncSocket { public: virtual void OnReceive(int nErrorCode) override; virtual void OnClose(int nErrorCode) override; };然后在对话框主窗口里创建监听socketCListenSocket m_listenSocket; // 对话框类成员 // 在某个按钮响应函数或初始化函数里 BOOL bRet m_listenSocket.Create(9527, SOCK_STREAM, FD_ACCEPT); if (!bRet) { int nErr m_listenSocket.GetLastError(); // 处理错误 return; } if (!m_listenSocket.Listen(5)) { int nErr m_listenSocket.GetLastError(); return; }这里Create的第一个参数是本机监听端口第二个是socket类型TCP用SOCK_STREAMUDP用SOCK_DGRAM。第三个参数值得专门说一下事件掩码。监听socket只需要知道有人连接了这一个事件所以传FD_ACCEPT就好。如果你不传第三个参数CAsyncSocket默认会为FD_READ、FD_WRITE、FD_OOB、FD_ACCEPT、FD_CONNECT、FD_CLOSE都注册通知对一个监听socket来说这纯属浪费。还有一点细节Create内部会完成bind操作所以不需要你手动调用Bind。Listen的第二个参数是等待队列长度局域网小规模应用填5到10都够用。2.3 事件方法的重写姿势OnAccept触发之后必须在这个方法里调用Accept去取回新连接这算是CAsyncSocket的规矩。我见过新手在别处调用Accept结果怎么都取不到连接原因是新连接一直待在系统队列里没人认领void CListenSocket::OnAccept(int nErrorCode) { CClientSocket* pClient new CClientSocket(); if (Accept(*pClient)) { // 关键必须为这个新socket重新注册事件掩码 pClient-AsyncSelect(FD_READ | FD_CLOSE); } else { delete pClient; } }这里有一个网上很多例子都没提的关键点Accept出来的新socket不会自动继承监听socket的事件设置。必须手动调用AsyncSelect(FD_READ | FD_CLOSE)否则OnReceive永远不会触发。我当初接手的一个老项目就踩了这个坑表现为客户端能连上但服务端收不到任何数据排查了一整天最后发现就是少了这行。OnReceive是重头戏所有收到数据的处理都在这里完成。一个容易犯的错误是在OnReceive里只读一次就完事。因为TCP是流协议系统通知你可读时可能到达了多个数据包更合理的做法是循环读取直到读空void CClientSocket::OnReceive(int nErrorCode) { char szBuf[1024] {0}; int nRet Receive(szBuf, sizeof(szBuf)); if (nRet 0) { // 正常处理数据这里做简单的回显 Send(szBuf, nRet); } else if (nRet 0) { // 对端关闭连接 Close(); } else { int nErr GetLastError(); // 如果错误不是WSAEWOULDBLOCK说明连接出问题了 if (nErr ! WSAEWOULDBLOCK) { Close(); } } }别在OnReceive里调用基类版本的OnReceive因为基类这个函数本身就是空实现调不调都没区别反而会让人误以为有什么默认逻辑。3. 客户端发起连接异步Connect的完整心智模型3.1 Connect返回FALSE不代表连接失败客户端的Connect是CAsyncSocket最让人迷惑的部分。它的调用方式是CAsyncSocket m_clientSocket; m_clientSocket.Create(); // 不指定端口让系统自动分配 BOOL bRet m_clientSocket.Connect(_T(127.0.0.1), 9527); if (!bRet) { int nErr m_clientSocket.GetLastError(); // 我新手时以为这里就是连接失败大错特错 }这里我必须大声说三遍Connect返回FALSE、GetLastError返回WSAEWOULDBLOCK10035不代表连接失败只代表连接正在后台建立中。对于非阻塞socket连接结果要通过OnConnect事件通知来确认这跟同步socket完全不同。真正正确的连接处理方式是这样的class CMyClientSocket : public CAsyncSocket { public: virtual void OnConnect(int nErrorCode) override; }; void CMyClientSocket::OnConnect(int nErrorCode) { if (nErrorCode 0) { // 连接成功可以开始收发 AsyncSelect(FD_READ | FD_CLOSE); // 或者立刻发送一个包 char szData[] hello server; Send(szData, strlen(szData)); } else { // nErrorCode里就是失败原因 } }只有当OnConnect的nErrorCode为0时才是真正连上了。nErrorCode非0里面存的就是Winsock错误码。在OnConnect回调触发前任何对Send、Receive的调用都是没有意义的因为连接通道还没打通。3.2 Send和Receive的调用边界Send这个函数在非阻塞模式下有个特点它返回的值可能比你传入的长度小。比如你调用Send(buf, 8192)系统可能只发送了3000字节就返回了。这时候剩下的5192字节得由你自己排队等下一次OnSend触发时再继续发送。不过对于大多数局域网小数据量场景单次发送几百字节很少出现发不完的情况。但严谨的代码还是应该判断Send返回值。我自己的经验是如果Send返回SOCKET_ERROR且GetLastError是WSAEWOULDBLOCK说明发送缓冲区满了数据必须缓存下来等OnSend事件继续处理如果返回0同样说明发送被中断了。Receive的边界规则相对简单返回0表示对端关闭连接返回SOCKET_ERROR且错误码是WSAEWOULDBLOCK表示暂时没有更多数据可读返回正数就是实际读取到的字节数。判断连接断开不能只看Receive返回SOCKET_ERROR还要结合错误码。没有数据的正常通知和真正的断线错误码是完全不同的。3.3 主动关闭连接的正确顺序主动关闭连接也有讲究。如果只是简单调用Close在有数据尚未发送完的情况下底层closesocket会直接丢弃未发送的数据。规范的关闭顺序应该是// 先禁掉发送能力等待对端收到剩余数据后自行关闭 ShutDown(2); // 0接收, 1发送, 2全部禁用 // 等对端关闭后会触发本地的OnClose // 届时再调用Close真正释放socket句柄ShutDown(1)的作用是告知对端我不会再发送数据了但接收通道仍然保持这样对端调用Receive时会得到0明白该走关闭流程了。等对端关闭后本地再Close就能保证数据完整地交互完毕。很多人图省事直接Close短连接场景下问题不大但如果你做的是需要可靠收尾的长连接应用这个顺序值得记住。4. 事件通知原理CAsyncSocket为什么能说人话4.1 真正的主角是WSAAsyncSelect很多人用CAsyncSocket用得很顺但被问到究竟是怎么收到OnReceive通知的就说不清楚了。我做个小人机工程的心得把这个机制搞明白以后排错会省一半时间。CAsyncSocket的底层本质是调用了WinSock的WSAAsyncSelect函数。这个函数做的事非常直白告诉系统当这个socket上有我感兴趣的事件发生时向我的窗口发一条消息。系统把FD_READ、FD_CLOSE这些事件和一条窗口消息绑定事件一到消息就投递到窗口过程。MFC的CAsyncSocket内部实现了一个隐藏窗口专门用来接收这些网络事件消息。消息到达后它根据socket句柄找到对应的CAsyncSocket对象再调用对应的OnAccept、OnReceive、OnConnect虚函数。所以你重写那些On开头的函数本质上就是在处理从窗口消息分发过来的回调事件。理解了这一点就能明白为什么CAsyncSocket必须在有消息循环的环境里才能正常工作。如果你在一个没有消息泵的工作线程里创建CAsyncSocket事件通知就永远无法到达socket会变成一个僵尸。4.2 事件掩码为什么要在Create时定好CAsyncSocket的Create有一个事件掩码参数它决定了哪些网络事件会被通知。MFC默认把所有事件都注册了但实际使用中你完全可以按需裁剪。服务端监听socket只需要FD_ACCEPT别让它在FD_READ上浪费不必要的消息投递。Accept出来的通信socket我习惯只注册FD_READ和FD_CLOSE。FD_WRITE一般不需要注册因为大部分时候发送缓冲区都是满的注册了反而会在非阻塞模式下频繁收到可写通知白白消耗CPU。关于AsyncSelect这个调用我再啰嗦一句它对同一个socket重复调用时新的掩码会覆盖旧的设置。所以如果你在某个逻辑分支里调用了AsyncSelect(FD_READ)后来又想让FD_CLOSE也通知必须把FD_CLOSE也一起传进去否则FD_CLOSE就失效了。这是AsyncSelect的一个让人意外的坑。4.3 线程模型和消息泵的隐性要求这也是CAsyncSocket一个深水区socket对象是在哪个线程创建消息就会投递到哪个线程的窗口。理论上讲MFC的CAsyncSocket创建的窗口绑定到了创建它的线程而消息循环通常就在那个线程里跑。如果必须在工作线程里用CAsyncSocket我建议的方案有两个一是在那个工作线程里专门跑一个消息循环PeekMessage/GetMessage DispatchMessage二是不在线程里直接用socket而是把socket操作封装投递回UI线程处理。第二个方案代码更简单但实时性差一些第一个方案性能更好但代码复杂度上升。还要注意不要把同一个CAsyncSocket对象跨线程使用。这就像公交车你在这站上车等它到了别的站已经换了一个灵魂。CAsyncSocket的CAsyncSocket对象和底层句柄绑定是有线程关联的跨线程操作很可能导致断言失败或者数据错乱。我在一个多线程上位机项目里吃过这个亏后来强制约定所有socket对象的创建、事件处理必须在同一个线程内完成问题才彻底消失。5. 高频坑合集我从实际项目里捞出来的排错路径5.1 WSAEWOULDBLOCK是常客但不是杀手在CAsyncSocket的排错记录里WSAEWOULDBLOCK10035可能是出现频率最高的错误码。初次遇到它的人很容易紧张以为连接已经挂了。实际它的意思是你要求我现在做这件事但条件还不满足请稍后再说。比如缓冲区里没有数据你却去Receive系统会返回10035缓冲区满了你却去Send也会返回10035。这两个场景本质上是同一个机制非阻塞socket从来不等做不了就直接告诉你做不了。我总结了一套10035的处理心法Send遇到10035就把数据缓存入队等待OnSend继续处理Receive遇到10035就说明当前数据读完了退出读取循环。把它当业务逻辑来设计而不是当错误处理代码会显得非常干净。5.2 OnReceive里的死循环与全收模式新手很容易把OnReceive写成只读一次就返回结果明明发来两包数据程序只处理了半包。我更喜欢在OnReceive里做全收循环处理void CClientSocket::OnReceive(int nErrorCode) { char szBuf[4096] {0}; while (true) { int nRet Receive(szBuf, sizeof(szBuf)); if (nRet 0) { // 逐字节或按协议解析数据 } else if (nRet 0) { Close(); break; } else { if (GetLastError() WSAEWOULDBLOCK) break; Close(); break; } } }这种做法的好处是不会漏数据。系统通知一次可读如果缓冲区里积压了多个包循环能把它们全部处理完再退出。要特别注意处理包的边界问题TCP是流协议一次Send的数据可能被拆成多段到达多次Send的数据也可能合并成一次就到必须在代码里用协议头中的长度字段做分包。5.3 OnClose触发后必须清理关联资源OnClose这个事件很忠厚它对端正常关闭、连接异常、单方面RST等状况都会触发。很多人的代码在OnClose里只是Close了socket却忘了释放之前为这个连接创建的内存、从连接列表里移除对象久而久之就会内存泄漏。我习惯在OnClose里做三件事关闭socket句柄、把对象从维护的CList或CMap中移除、删除堆上分配的对端对象。如果你是用new创建的CClientSocket记得在OnClose完成资源梳理后再delete this否则对象泄漏和野指针会接踵而至。5.4 排查实测一个连接建立后无法接收数据的完整复盘我想用一次真实排错来结束这部分。当时的现象很明确客户端显示连接建立服务端网上邻居也能看到连接但服务端程序怎么也收不到数据。我第一反应是协议问题检查了数据格式、字节序正常。第二反应是对象生命周期问题检查了pClient在OnAccept里new出来之后有没有被意外释放也正常。最后把注意力放到AsyncSelect上才发现Accept之后没有为新socket调用AsyncSelect注册事件。于是系统把新socket当成完全没有注册任何网络事件的裸socket所有数据到达后都静默丢弃不产生任何通知。那次教训很值钱CAsyncSocket的每个socket实例都要单独注册事件别指望它自动继承。这是我维护网络代码时最常犯、也最隐蔽的一个错误。6. 一个能跑的CAsyncSocket使用例子直接抄作业到这里我干脆给出一个最小但完整的CAsyncSocket使用例子覆盖服务端监听、客户端连接、回显收发和关闭清理。以对话框程序为例假设对话框上有两个按钮分别用于启动服务端和启动客户端。首先是头文件中的socket类定义我放在一个单独的头文件里方便复用#pragma once #include afxsock.h class CListenSocket : public CAsyncSocket { public: virtual void OnAccept(int nErrorCode) override; }; class CServerSocket : public CAsyncSocket { public: virtual void OnReceive(int nErrorCode) override; virtual void OnClose(int nErrorCode) override; }; class CMyClientSocket : public CAsyncSocket { public: virtual void OnConnect(int nErrorCode) override; virtual void OnReceive(int nErrorCode) override; virtual void OnClose(int nErrorCode) override; };实现文件里监听和通信收发都保持简单#include pch.h #include MySockets.h void CListenSocket::OnAccept(int nErrorCode) { CServerSocket* pServer new CServerSocket(); if (Accept(*pServer)) { pServer-AsyncSelect(FD_READ | FD_CLOSE); } else { delete pServer; } } void CServerSocket::OnReceive(int nErrorCode) { char szBuf[1024] {0}; int nRet Receive(szBuf, sizeof(szBuf)); if (nRet 0) { Send(szBuf, nRet); // 简单回显 } else if (nRet 0) { Close(); delete this; } else { Close(); delete this; } } void CServerSocket::OnClose(int nErrorCode) { Close(); delete this; } void CMyClientSocket::OnConnect(int nErrorCode) { if (nErrorCode 0) { AsyncSelect(FD_READ | FD_CLOSE); char szData[] hello server, CAsyncSocket; Send(szData, (int)strlen(szData)); } } void CMyClientSocket::OnReceive(int nErrorCode) { char szBuf[1024] {0}; int nRet Receive(szBuf, sizeof(szBuf)); if (nRet 0) { // 收到服务端回显 } else if (nRet 0) { Close(); delete this; } } void CMyClientSocket::OnClose(int nErrorCode) { Close(); delete this; }在对话框类里创建监听socket的代码void CMyDialog::OnBnClickedBtnStartServer() { if (!m_listenSocket.Create(9527, SOCK_STREAM, FD_ACCEPT)) { AfxMessageBox(_T(创建监听socket失败)); return; } if (!m_listenSocket.Listen(5)) { AfxMessageBox(_T(监听失败)); return; } }创建并连接客户端的代码void CMyDialog::OnBnClickedBtnStartClient() { m_pClientSocket new CMyClientSocket(); m_pClientSocket-Create(); BOOL bRet m_pClientSocket-Connect(_T(127.0.0.1), 9527); if (!bRet) { int nErr m_pClientSocket-GetLastError(); if (nErr ! WSAEWOULDBLOCK) { AfxMessageBox(_T(连接失败)); delete m_pClientSocket; m_pClientSocket NULL; } // 如果错误码是WSAEWOULDBLOCK说明正在连接中等OnConnect回调 } }这套代码你直接搬到MFC向导生成的对话框工程里补上stdafx.h里的afxsock.h和InitInstance里的AfxSocketInit就能跑通一个最简单的局域网聊天雏形。把回显逻辑改成你自己定义的数据结构解析稍加扩展就能变成一个完整的上位机通信模块。我自己在实际项目里用CAsyncSocket写完整个通信层之后最大的感受是这类老牌MFC类库只要理解了它的事件驱动模型开发效率真不比今天用高级框架慢而且排查起来思路特别清晰。最后再分享一个小技巧调试CAsyncSocket程序时在OnReceive和OnSend里打上断点观察事件触发顺序比盯着变量值猜逻辑高效得多事件触发的节奏能直接告诉你底层网络状态是否正常。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进