简介这套 CAsyncSocket 使用示例面向具备一定 Windows 与 MFC 基础的网络编程学习者围绕异步 TCP 客户端/服务端通信展开可用于理解 Winsock 封装、消息驱动套接字操作及多客户端管理思路。示例覆盖套接字创建与连接、监听与接受连接、数据收发、客户端列表维护以及 OnConnect、OnAccept、OnReceive、OnSend、OnClose 等事件处理并涉及 GetLastError 错误排查、多线程处理和分块文件传输等实践点。压缩包为 rar 格式共 44 个文件约 1.82MB以 h 头文件和 cpp 源文件为主体配合 ico、rc 等资源与工程配置文件目录中包含客户端和服务端两个示例工程便于对照阅读。目前已有 445 人学习下载适合作为 MFC 异步套接字入门与项目参考帮助读者快速把握客户端—服务端通信的代码组织方式和事件驱动编程框架。1. CAsyncSocket 使用例子三个按钮背后是一条被窗口消息驱动的收发链路某开发者拿着一个 CAsyncSocket 使用例子工程来问界面上就“监听”“连接”“发送”三个按钮点下去日志框空空如也不报错也不回显。翻开源码才发现OnAccept、OnReceive、OnConnect 全被写成了空壳虚函数新手照着抄当然什么都收不到。CAsyncSocket 是把 Winsock 异步事件包了一层窗口消息的产物不用自己开线程轮询但代价是回调必须填满、消息泵必须跑起来。这份例子适合需要在 MFC 桌面程序里塞一条轻量 TCP 链路、又不想引入第三方网络库的人上位机、设备调试、内网小工具这类场景最对路。2. 异步套接字的骨架CAsyncSocket 把 Windows 消息藏在了哪里先讨论 CAsyncSocket 与原始 socket 的根本区别它不依靠 select 或轮询来感知事件而是把 socket 句柄与一个隐藏窗口绑定由 Windows 在事件就绪时投递一条自定义窗口消息。理解这一点后面所有的“怎么只收到一次”“为什么在工作线程里不回调”才有解释的落点。这个项目里的代码量其实不大一个监听类、一个连接类、一个协议头文件加起来两三百行但每一行都在跟这套消息机制打交道。2.1 三类套接字对象的职责划分工程里最终只有三个类承担网络职责。第一个是CAsyncSocket基类本身MFC 提供的封装了 socket 句柄、WSAAsyncSelect调用以及一组虚函数回调用户不该直接实例化它。第二个是监听对象CListenSocket它在服务端只做两件事Create出一个绑定端口的流式套接字并进入Listen状态然后在OnAccept里把新连接“派生”成第三个类。它自己不收数据所以事件掩码只需要FD_ACCEPT越少越好。第三个是连接对象CClientSocket服务端和客户端共用同一份实现。服务端那边它由Accept从监听对象派生客户端那边它由CreateConnect主动发起。它关心的是FD_READ | FD_WRITE | FD_CLOSE | FD_CONNECT负责收发、拆包、维护发送队列和连接状态。职责分清之后有个直接好处监听对象只活一份连接对象按连接数动态 new生命周期边界非常清晰。反过来如果把监听和收发写在一个类里客户端复用时就得写一堆if (isServer)改起来会非常难受。2.2 从 WSAAsyncSelect 到虚函数一次 FD_READ 是怎么被分发的把整个链路串起来看是这样的程序启动时AfxSocketInit()初始化 Winsock 并创建一个不可见的窗口之后每次Create或AcceptMFC 内部都会对这个 socket 调一次WSAAsyncSelect把关心的事件掩码和那个隐藏窗口的句柄一起登记进去某个事件就绪时Windows 往隐藏窗口投递一条私有消息窗口过程收到消息后根据WSAGETSELECTEVENT取出事件类型再转调对应CAsyncSocket对象的虚函数。这套设计的关键含义有两层。第一层事件掩码是你自己声明的声明里没有的事件对应的虚函数永远不会被调到——这是新手最常见的静默失败来源。第二层隐藏窗口属于“创建这个 socket 的那个线程”如果创建发生在工作线程而该线程没有消息循环通知就投递无门回调自然不触发。// 显式声明关心哪些事件漏掉哪个对应的虚函数就永远不响 BOOL CClientSocket::CreateClientSocket() { // nSocketPort 传 0表示本地端口由系统分配客户端一般这么做 // lEvent 是位掩码这里要读、写、关闭、连接完成四类通知 return Create(0, SOCK_STREAM, FD_READ | FD_WRITE | FD_CLOSE | FD_CONNECT); }上面这段的四个参数分别对应端口、套接字类型、事件掩码、绑定地址。nSocketType用SOCK_STREAM是 TCP改成SOCK_DGRAM就是 UDP但 UDP 下FD_CONNECT和FD_CLOSE的语义完全不同别混着用。lEvent建议显式写全不要依赖默认值一是可读二是改的时候不容易漏。绑定地址留空表示走默认网卡需要只监听某个内网口时再传具体 IP。2.3 工程目录与关键文件清单这类例子工程通常是一个标准 MFC 对话框程序加几个自建类目录扁平改起来不用满地图找文件AsyncSocketDemo/ ├── AsyncSocketDemo.sln ├── AsyncSocketDemo/ │ ├── AsyncSocketDemo.cpp // 应用入口InitInstance 里调 AfxSocketInit │ ├── AsyncSocketDemoDlg.cpp // 主对话框负责 UI、定时器、套接字生命周期 │ ├── CListenSocket.h/.cpp // 监听套接字只处理 OnAccept │ ├── CClientSocket.h/.cpp // 连接套接字收发、拆包、发送队列 │ └── Protocol.h // 帧头结构、命令字常量、编解码小函数 └── README.mdProtocol.h单独拆出来是有讲究的。帧格式一旦定下来服务端和客户端必须严格同源放在同一个头文件里被两边 include比两边各抄一份常量安全得多。实践里我会把这个文件做成不依赖 MFC 的纯 C 头将来换到别的框架也能直接搬走。3. 服务端拆解监听、Accept、收发各写在哪一行服务端是整个例子里最容易写错的一半因为它涉及动态对象和循环调用。按顺序拆成四段看初始化、接客、收数据、发数据。3.1 AfxSocketInit 与监听套接字的 Create 参数先确认入口。AfxSocketInit()必须在使用任何CAsyncSocket之前调用按惯例放在InitInstance的最前面。这一步返回失败基本只有一个原因Winsock 版本协商出了问题或者环境本身不允许创建套接字。忘了调它后面所有Create都会失败而且错误码含糊得像玄学。// 应用启动时初始化 Winsock必须在任何套接字对象之前 BOOL CAsyncSocketDemoApp::InitInstance() { if (!AfxSocketInit()) { AfxMessageBox(_T(Winsock 初始化失败)); return FALSE; } CWinApp::InitInstance(); // ... 创建主对话框 return TRUE; } // 监听套接字初始化端口从界面读失败要给出具体原因 BOOL CListenSocket::StartListen(UINT nPort, CWnd* pOwner) { m_pOwner pOwner; // FD_ACCEPT 是监听对象唯一需要关心的事件 if (!Create(nPort, SOCK_STREAM, FD_ACCEPT, _T(0.0.0.0))) { // 常见原因端口被占用、地址不合法、权限不够 int nErr GetLastError(); TRACE(_T(Create 失败错误码 %d\n), nErr); return FALSE; } // backlog 传 5 即可内网工具没必要开太大队列长度受内核上限约束 if (!Listen(5)) { TRACE(_T(Listen 失败错误码 %d\n), GetLastError()); Close(); return FALSE; } return TRUE; }Create的四个参数值得单独列一下改的时候心里有数参数例子里的取值说明nSocketPort9000监听端口传 0 由系统分配客户端常用nSocketTypeSOCK_STREAMTCP 流式UDP 用 SOCK_DGRAMlEventFD_ACCEPT监听对象只需接客通知多写反而增加噪点lpszSocketAddress0.0.0.0绑定所有网卡只开内网时可写具体地址Listen的 backlog 参数是“已完成三次握手但还没被 Accept 的连接队列”长度不是最大连接数很多人在这里理解错以为调成 1000 就能扛 1000 个并发。真正的并发上限取决于连接对象的管理方式和系统句柄数。3.2 OnAccept 里派生连接对象的正确姿势OnAccept一进来通常意味着“至少有一条连接在排队”但排队数量可能不止一条。快速连点或批量重连时一次通知里可能积压了好几个所以必须循环Accept直到失败为止。每一步都要保证新 new 出来的对象有明确的归属否则就是句柄泄漏。// 监听套接字收到连接请求循环接客直到队列抽干 void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) { CAsyncSocket::OnAccept(nErrorCode); return; } while (true) { // 每条连接一个独立对象谁创建谁负责释放 CClientSocket* pClient new CClientSocket(m_pOwner); if (!Accept(*pClient)) { delete pClient; // WSAEWOULDBLOCK 表示队列已空是正常退出条件不是错误 if (GetLastError() ! WSAEWOULDBLOCK) { TRACE(_T(Accept 异常错误码 %d\n), GetLastError()); } break; } // 登记到连接表交给业务层登记失败要立刻 Close delete if (!RegisterClient(pClient)) { pClient-Close(); delete pClient; break; } } CAsyncSocket::OnAccept(nErrorCode); }这里有两个细节。第一Accept传入的是一个引用MFC 会把这个新 socket 的句柄挂到对象上所以对象必须提前构造好不能用栈上的临时变量接一下就丢。第二WSAEWOULDBLOCK在这里是正常信号表示内核里没有更多待接连接了如果把它当错误弹框用户会看到一堆莫名其妙的提示。3.3 OnReceive 的缓冲区、半包与循环读取OnReceive是最容易写出 bug 的地方。TCP 是字节流Receive返回的字节数和业务层的“一条消息”没有任何对应关系可能一次读到半条也可能一次读到三条半。所以这里要做的只有两件事——把字节搬进自己的接收缓冲区然后调用拆包函数反复尝试切出完整帧。拆包用长度前缀最稳帧格式定成“4 字节大端长度 2 字节命令字 负载”这种结构长度字段只算负载部分读的时候先凑够 4 字节头再判断。// 连接套接字收到数据先搬进缓冲区再循环拆帧 void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) { CloseAndNotify(); return; } char szBuf[4096]; while (true) { int nRet Receive(szBuf, sizeof(szBuf)); if (nRet SOCKET_ERROR) { int nErr GetLastError(); if (nErr WSAEWOULDBLOCK) // 内核缓冲区抽干了 break; CloseAndNotify(); // 真错误断开 return; } if (nRet 0) // 对端正常关闭 { CloseAndNotify(); return; } m_recvBuf.append(szBuf, nRet); // 追加到自定义缓冲区 m_lastActive GetTickCount(); // 有心跳时顺便刷新活跃时间 } ParseFrames(); // 尝试切出完整帧 CAsyncSocket::OnReceive(nErrorCode); } // 定帧协议4 字节大端长度 2 字节命令字 负载 void CClientSocket::ParseFrames() { while (m_recvBuf.size() 6) { uint32_t nLen 0; memcpy(nLen, m_recvBuf.data(), 4); nLen ntohl(nLen); // 网络序转主机序 // 长度必须做上限校验否则一个畸形包就能把内存撑爆 if (nLen 0 || nLen 1024 * 1024) { CloseAndNotify(); return; } // 缓冲区里还不够一整帧先返回等下一批数据 if (m_recvBuf.size() 6 nLen) return; uint16_t nCmd 0; memcpy(nCmd, m_recvBuf.data() 4, 2); std::string payload(m_recvBuf.data() 6, nLen); m_recvBuf.erase(0, 6 nLen); DispatchFrame(nCmd, payload); // 交给业务分发 } }ParseFrames里的while不能换成if一次回调里缓冲区可能已经攒够好几帧只切一帧剩下的又得等下一批数据来推。长度上限那道校验也别省内网环境同样会碰到协议版本不匹配或者数据错位的情况没有这道闸门程序的表现就是随机崩。3.4 OnSend 与发送队列为什么不能直接 Send 大块数据Send的返回值只代表“这次塞进内核缓冲区的字节数”不代表对端收到了。内核缓冲区满了Send返回SOCKET_ERROR加WSAEWOULDBLOCK这时候数据一个字节都没出去你要是直接丢弃业务上就是丢消息。正规做法是维护一个待发队列业务层只管往队尾压帧一个FlushSendQueue负责把队首尽量往外推推不动就停等OnSend再来推。// 业务层调用入队 尝试冲刷不直接操作 socket void CClientSocket::SendPacket(uint16_t nCmd, const std::string payload) { std::string frame; uint32_t nLen htonl((uint32_t)payload.size()); frame.append((const char*)nLen, 4); frame.append((const char*)nCmd, 2); frame.append(payload); m_sendQueue.push_back(frame); FlushSendQueue(); } // 把队首尽可能往外推推不动就留在队列里等 OnSend void CClientSocket::FlushSendQueue() { while (!m_sendQueue.empty()) { const std::string buf m_sendQueue.front(); int nSent Send(buf.data(), (int)buf.size()); if (nSent SOCKET_ERROR) { int nErr GetLastError(); if (nErr WSAEWOULDBLOCK) return; // 内核满了等 FD_WRITE CloseAndNotify(); // 真错误直接断 return; } if (nSent (int)buf.size()) { // 只发出去一部分剩余部分留在队首下次从这里继续 m_sendQueue.front() buf.substr(nSent); return; } m_sendQueue.pop_front(); } } // 内核腾出空间后FD_WRITE 会回调到这里 void CClientSocket::OnSend(int nErrorCode) { if (nErrorCode 0) FlushSendQueue(); CAsyncSocket::OnSend(nErrorCode); }队列用std::dequestd::string而不是std::queue因为“只发出去一部分”时要在队首原地改内容queue拿不到 front 的引用。另外m_sendQueue会被网络回调和业务线程同时访问时得加互斥单线程 UI 场景下可以省但要写清楚注释避免以后有人加线程时踩坑。4. 客户端拆解异步 Connect 的返回值和断线重连客户端比服务端少了“接客”这一段但多了一个反直觉的点Connect返回FALSE不代表连接失败。4.1 Create 与 Connect 的调用顺序异步套接字的Connect会立刻返回实际的三次握手在后台进行结果通过OnConnect回调通知。所以返回FALSE且GetLastError()是WSAEWOULDBLOCK时属于正常流程表示“请求已发出等结果”。// 发起异步连接FALSE WSAEWOULDBLOCK 是正常路径不是失败 BOOL CClientSocket::ConnectTo(LPCTSTR lpszHost, UINT nPort) { if (!CreateClientSocket()) // 见 2.2 的 Create 封装 return FALSE; if (!Connect(lpszHost, nPort)) { int nErr GetLastError(); if (nErr ! WSAEWOULDBLOCK) { // 地址解析失败、端口非法这类才是真失败 TRACE(_T(Connect 失败错误码 %d\n), nErr); Close(); return FALSE; } } m_state STATE_CONNECTING; // 进入“连接中”状态 return TRUE; }Create里的本地端口必须传 0让系统分配。手动指定客户端端口的话重连时会撞上TIME_WAIT报“地址已在使用”这种错误看起来像玄学其实就是端口没放干净。4.2 OnConnect / OnClose 里的状态机连接状态无非四个断开、连接中、已连接、重连等待。所有回调只做状态迁移和通知不在这里做业务也不在这里弹框界面刷新交给上层。// 连接结果回来成功进已连接失败进重连等待 void CClientSocket::OnConnect(int nErrorCode) { if (nErrorCode ! 0) { m_state STATE_WAIT_RETRY; m_nRetryCount; NotifyOwner(WM_SOCK_CONNECTFAIL, nErrorCode); // 通知界面 Close(); CAsyncSocket::OnConnect(nErrorCode); return; } m_state STATE_CONNECTED; m_nRetryCount 0; m_lastActive GetTickCount(); NotifyOwner(WM_SOCK_CONNECTED, 0); CAsyncSocket::OnConnect(nErrorCode); } // 对端关闭或链路异常清状态、清发送队列、通知重连 void CClientSocket::OnClose(int nErrorCode) { m_state STATE_DISCONNECTED; m_sendQueue.clear(); // 断线后旧数据没有重发价值 m_recvBuf.clear(); // 半包也必须丢否则下次拼错帧 NotifyOwner(WM_SOCK_CLOSED, nErrorCode); CAsyncSocket::OnClose(nErrorCode); }OnClose里清空m_recvBuf这一行别省。断线时缓冲区里大概率躺着半帧不清掉的话重连后收到的新数据会跟这半帧拼在一起切出来的就是乱码级的错误命令排查起来非常痛苦。4.3 心跳定时器与超时判定半开连接是内网环境里的常客网线拔了、对端进程被强杀本地 socket 还觉得自己是连着的。判定办法只有心跳加超时。定时器不要塞进套接字类。CAsyncSocket本身没有窗口SetTimer需要一个HWND硬凑会引入额外依赖。更自然的做法是把定时器放在持有窗口的对话框里到点调用套接字的方法。// 心跳由对话框驱动套接字只负责发帧和记录时间 void CClientDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_HEARTBEAT) { if (m_client.GetState() ! STATE_CONNECTED) { m_client.TryReconnect(); // 未连接则尝试重连 } else { DWORD now GetTickCount(); // 三个心跳周期没有任何入站数据判定链路已死 if (now - m_client.GetLastActive() 3 * HEARTBEAT_INTERVAL) { m_client.CloseAndNotify(); return; } m_client.SendPacket(CMD_HEARTBEAT, std::string()); } } CDialogEx::OnTimer(nIDEvent); }GetTickCount()的精度是毫秒级但分辨率大约 15.6 毫秒做秒级心跳判定绰绰有余做毫秒级时序分析就不够那种场合得换QueryPerformanceCounter。心跳间隔一般取 5 到 10 秒超时取三倍这个组合在网络抖动和发现延迟之间比较平衡。5. 排查与避坑端口占用、只收一次、UI 卡死、对象泄漏下面这五条是我在类似工程里真实翻过的车每条都写成“现象—原因—解决”遇到了可以直接对号入座。5.1 现象Create 返回 FALSE界面没有任何提示现象是点了“启动监听”按钮什么都没发生日志框也不打东西。原因通常有三类端口被上一轮没退干净的进程占着绑定地址写成了本机不存在的 IP或者干脆忘了在InitInstance里调AfxSocketInitWinsock 根本没初始化。解决方式很直接Create失败立刻把GetLastError()打出来10048 是地址占用10049 是地址不可用10093 是没初始化对着码查比猜快一百倍。5.2 现象OnReceive 只进来一次后面数据全丢现象是客户端连发三条消息服务端只处理了第一条。原因多半是在OnReceive里只调了一次Receive把“一次回调”当成了“一条消息”剩下的字节留在内核缓冲区更隐蔽的一种是重写了OnReceive却忘了在末尾调用CAsyncSocket::OnReceive(nErrorCode)破坏了基类的后续处理。解决方式固定成两件事Receive用while循环读到WSAEWOULDBLOCK为止拆包用while切到不足一帧为止。5.3 现象点一下“发送”界面直接假死现象是按钮按下去窗口拖不动任务管理器显示无响应。原因是在网络回调里做了耗时操作——同步写日志文件、刷新几千行的列表控件、或者调用了阻塞版本的Receive等着对端回包。网络回调跑在 UI 线程的消息泵里你在里面睡觉整个界面就跟着睡。解决方式是回调里只做“搬字节 拆帧 入队”业务处理和界面刷新通过PostMessage丢回消息队列或者丢给工作线程。5.4 现象跑一天之后句柄数一直涨现象是程序看着正常用任务管理器一看句柄数从一百多涨到几千最后Create开始随机失败。原因基本锁定在OnAccept里new出来的CClientSocket没有对应释放对端断开后只调了Close()对象本身还在堆上。解决方式是把连接对象收进一张std::mapSOCKET, std::unique_ptrCClientSocketOnClose里先Close()再从表里erase析构交给智能指针切忌在回调内部直接delete this。5.5 现象关闭程序时弹 Debug Assertion Failed现象是点右上角关闭调试版弹一个断言框发布版偶尔静默崩溃。原因是销毁顺序错了——对话框先没了套接字对象还在某个延迟到达的FD_CLOSE打到已经析构的窗口指针上。解决方式是在OnDestroy里先遍历所有连接对象Close()并清空容器再调基类CDialogEx::OnDestroy()同时把通知上层的窗口指针改成HWND加::IsWindow()判断比裸CWnd*稳。提示这五条里有四条都能靠“先打错误码、再打状态迁移日志”提前发现别等出问题才加日志。6. 二次开发把它改成能进项目的通信层这份例子的价值不在代码本身而在它给出了一个足够干净的起点。真要往项目里塞我会先做两件事把CClientSocket里的业务回调抽成一个纯虚接口ISessionSink让套接字类只依赖接口不依赖对话框这样命令行工具、服务、单元测试都能复用同一份实现再把发送队列的锁补上让SendPacket可以被工作线程安全调用。验证改造有没有把语义改坏我的办法是写一个不依赖界面的小回归本地起监听连上去依次发 1 字节、1KB、1MB 三档数据每档各发 1000 次对比收发字节数和帧数是否严格相等再人为在中间拔网线、杀进程确认重连后m_recvBuf是空的、第一帧能被正确切出来。这套流程跑通基本就不会在上线后翻车。血泪经验是OnClose里那行清空接收缓冲区的代码我曾经觉得多余删掉过一次结果现场出现“重连后第一条命令永远是乱码”查了整整两天。从那以后我每次改这类网络层都强制走一遍“断线重连 首帧校验”的回归不管改动看起来多小。希望帮到你。本文还有配套的精品资源点击获取