CAsyncSocket 异步套接字与 MFC 消息驱动 TCP 收发链路
简介:这套 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从监听对象派生,客户端那边它由Create+Connect主动发起。它关心的是FD_READ | FD_WRITE | FD_CLOSE | FD_CONNECT,负责收发、拆包、维护发送队列和连接状态。
职责分清之后有个直接好处:监听对象只活一份,连接对象按连接数动态 new,生命周期边界非常清晰。反过来,如果把监听和收发写在一个类里,客户端复用时就得写一堆if (isServer),改起来会非常难受。
2.2 从 WSAAsyncSelect 到虚函数:一次 FD_READ 是怎么被分发的
把整个链路串起来看是这样的:程序启动时AfxSocketInit()初始化 Winsock 并创建一个不可见的窗口;之后每次Create或Accept,MFC 内部都会对这个 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的四个参数值得单独列一下,改的时候心里有数:
| 参数 | 例子里的取值 | 说明 |
|---|---|---|
| nSocketPort | 9000 | 监听端口;传 0 由系统分配,客户端常用 |
| nSocketType | SOCK_STREAM | TCP 流式;UDP 用 SOCK_DGRAM |
| lEvent | FD_ACCEPT | 监听对象只需接客通知,多写反而增加噪点 |
| lpszSocketAddress | 0.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::deque<std::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里调AfxSocketInit,Winsock 根本没初始化。解决方式很直接,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::map<SOCKET, std::unique_ptr<CClientSocket>>,OnClose里先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里那行清空接收缓冲区的代码,我曾经觉得多余删掉过一次,结果现场出现“重连后第一条命令永远是乱码”,查了整整两天。从那以后我每次改这类网络层,都强制走一遍“断线重连 + 首帧校验”的回归,不管改动看起来多小。希望帮到你。
本文还有配套的精品资源,点击获取