
简介围绕 CAsyncSocket 的 MFC 网络通信示例工程面向具备 C 与 Windows 编程基础、希望掌握异步套接字开发的学习者通过客户端与服务端两个完整项目解决 TCP 连接建立、事件驱动收发与多连接管理等实践问题。整包共 44 个文件以 11 个 h 头文件和 9 个 cpp 源文件为主体辅以 ico、rc 等界面与工程配置资源压缩包约 1.82MB目录按客户端、服务端及调试/发布配置组织便于对照阅读和编译运行。已有 445 人学习下载。示例重点呈现 Create、Connect、Listen、Accept、Receive、Send 等核心调用以及 OnConnect、OnAccept、OnReceive、OnSend、OnClose 等事件处理流程并涉及 GetLastError 错误排查、客户端列表维护和文件分块收发思路。读者可据此理解异步套接字的消息循环机制快速搭建可调试的 C/S 通信原型并在此基础上扩展多客户端与文件传输功能。1. 一个连上却没有任何回调的连接CAsyncSocket 的真实脾气上个月帮人看一个采集上位机服务端用 CAsyncSocket 写的客户端是另一个进程TCP 三次握手明明成功了服务端这边 OnAccept、OnReceive 一个都不触发日志安静得像没启动。最后定位到原因很朴素socket 对象是在一个 std::thread 里 Create 的那个线程从头到尾没有消息循环。这就是 CAsyncSocket 的脾气——它不是回调式异步是消息式异步底层 WSAAsyncSelect 把网络事件投递到窗口消息队列没人取消息事件就永远躺在队列里。CAsyncSocket 是 MFC 对 Winsock 的一层薄封装价值在于单线程管几百个连接、不用线程池、不用自己加锁代价是你必须给它一个活着的消息泵且所有回调都在同一线程里串行执行。适合已经有 MFC 界面、连接数几百以内、协议不算复杂的中小项目。要上万并发或者极致吞吐直接上 IOCP别在这上面死磕。2. 消息泵才是发动机WSAAsyncSelect 到 OnReceive 的完整链路2.1 事件是怎么从内核走到你的虚函数的Winsock 提供两种异步模型一种是重叠 I/OOverlapped完成端口的基础另一种是 WSAAsyncSelect把 socket 和某个窗口句柄绑定并注册关心的事件掩码内核在有事件时向该窗口 Post 一条自定义消息MFC 里就是 WM_SOCKET 一类的内部消息窗口过程再解析消息里的 socket 句柄和事件码找到对应的 CAsyncSocket 对象调用它的虚函数。MFC 在第一次调用 AttachHandle 时会自己创建一个隐藏窗口类型是 CSocketWnd这个窗口创建在「当前调用 Create 的那个线程」里。所以事件的投递目标是「创建线程的消息队列」。这条链路上任何一环断掉回调都不会来线程没有消息队列、线程有队列但不跑 GetMessage/DispatchMessage、窗口被销毁、或者 socket 已经被 Close 但对象还挂在映射表里。初学者最容易误解的一点是以为 CAsyncSocket 内部开了线程。没有。整个 MFC 的 socket 封装里没有任何后台线程它就是老老实实靠消息驱动。理解了这一点后面所有的坑基本都能自己推出来。2.2 AfxSocketInit 的位置和它到底做了什么很多人是在 Create 失败、GetLastError 返回 WSANOTINITIALISED 的时候才想起这一行。AfxSocketInit 干的事情不复杂调用 WSAStartup 加载 2.2 版本的 Winsock 库并把 MFC 内部用于收消息的窗口句柄准备好。它必须在任何 socket 创建之前调用标准位置是 CWinApp 派生类的 InitInstance 第一行。BOOL CCollectApp::InitInstance() { // 必须在任何 CAsyncSocket 对象 Create 之前否则 Create 返回 FALSE // 且 GetLastError() WSANOTINITIALISED if (!AfxSocketInit()) { AfxMessageBox(_T(Winsock 初始化失败检查系统网络组件)); return FALSE; } CWinApp::InitInstance(); CCollectDlg dlg; m_pMainWnd dlg; dlg.DoModal(); return FALSE; }这段代码的关键不在返回值判断而在位置。如果放在对话框的 OnInitDialog 里而对话框成员变量里有 socket 对象、且该对象在构造函数里就 Create那顺序就反了。我一般习惯把 socket 的 Create 全部推迟到 OnInitDialog 里显式执行构造函数只做成员初始化这样顺序永远可控。另外提醒一句工作线程里创建 socket 时AfxSocketInit 是主线程的全局状态工作线程需要自己做模块状态切换AFX_MANAGE_STATE否则 MFC 的隐藏窗口创建可能落到错误的模块上表现是偶发收不到消息这种偶发问题排查起来非常痛苦。2.3 派生类的五个事件回调与最小骨架CAsyncSocket 的派生类只需要重写关心的虚函数不关心的不写也行基类里都是空实现。五个回调的语义分别是OnAccept 只在监听 socket 上有新连接时触发OnConnect 在客户端 Connect 有结果时触发成功或失败都会来OnReceive 在对端有数据到达、或对端关闭、或连接异常时触发OnSend 在发送缓冲区从满变为有空位时触发OnClose 在对端发 FIN 或连接被重置时触发。// NetSocket.h只放骨架业务逻辑通过回调接口外抛 #pragma once #include afxsock.h class CNetSocket : public CAsyncSocket { public: CNetSocket() : m_pSink(NULL) {} virtual ~CNetSocket() {} void SetSink(class INetSink* pSink) { m_pSink pSink; } protected: // nErrorCode 为 0 表示正常事件非 0 时是 Winsock 错误码 // 常见值WSAECONNRESET(10054) 对端强制断开、 // WSAECONNREFUSED(10061) 端口无人监听、 // WSAENETUNREACH(10051) 网络不可达 virtual void OnConnect(int nErrorCode); virtual void OnAccept(int nErrorCode); virtual void OnReceive(int nErrorCode); virtual void OnSend(int nErrorCode); virtual void OnClose(int nErrorCode); private: INetSink* m_pSink; // 业务回调避免 socket 类直接碰 UI }; void CNetSocket::OnReceive(int nErrorCode) { if (nErrorCode 0 m_pSink) m_pSink-OnSocketReadable(this); // 真正收数据放到 sink 里做 }这段骨架体现了一个我踩过坑之后固定下来的做法socket 类只负责事件分发不收数据、不解析协议、不碰界面。因为 OnReceive 是在消息泵线程里执行的一旦在里面做重活整个线程的消息队列就堵住后续的 FD_WRITE 和 FD_CLOSE 全都延迟。把业务处理分离出去至少能让职责边界清楚后面改成线程池处理也容易。参数说明nErrorCode 非零时不要再去调用 Receive直接走错误处理分支。OnClose 里 nErrorCode 为 0 表示对端优雅关闭非 0 一般是 WSAECONNRESET两者都要做资源回收但前者可以先读完缓冲区剩余数据再关。3. 服务端与客户端的最小闭环Create、Listen、Connect 里每个参数的含义3.1 服务端从 Create 到 Accept 的完整时序服务端分两个角色一个监听 socket常驻N 个连接 socket每个连接一个。监听 socket 只需要关心 FD_ACCEPT所以 Create 时可以把事件掩码裁掉一大截减少无意义的窗口消息。// 监听 socket 的初始化放在 OnInitDialog 里 BOOL CCollectDlg::StartServer(UINT nPort) { // 1. 端口复用进程重启时避免 TIME_WAIT 导致绑定失败 BOOL bReuse TRUE; m_listen.SetSockOpt(SO_REUSEADDR, bReuse, sizeof(bReuse)); // 2. Create(端口, 类型, 事件掩码, 绑定地址) // 地址传 NULL 表示绑定所有网卡要只监听本机就传 _T(127.0.0.1) if (!m_listen.Create(nPort, SOCK_STREAM, FD_ACCEPT, NULL)) { CString s; s.Format(_T(Create 失败错误码 %d), GetLastError()); AfxMessageBox(s); // WSAEADDRINUSE(10048) 一般为端口被占用 return FALSE; } // 3. 开始监听backlog 表示尚未 accept 的连接队列长度 if (!m_listen.Listen(32)) { m_listen.Close(); return FALSE; } return TRUE; }参数说明Create 的第一个参数是端口UINT 类型内部会做 htons 转换所以不用自己转字节序第三个参数 lEvent 是事件掩码服务端监听 socket 只需要 FD_ACCEPTListen 的 backlog 在 Windows 上其实是软提示实际生效值由系统动态调整写成 32 或 64 都行写 5 在现代机器上偏小。SetSockOpt 必须在 Create 之后、Listen 之前调用顺序反了不生效。Accept 的写法是整篇里最容易写错的一处。很多人以为要先把新连接对象 Create 一遍再 Accept其实不需要Accept 内部会调 accept 并用 AttachHandle 把返回的 socket 绑定到对象上事件掩码是 MFC 内部固定的一组。void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) return; // 监听 socket 出错一般是资源耗尽稍后重试 // new 出来的对象默认构造句柄无效由 Accept 内部 Attach CClientSocket* pClient new CClientSocket(); if (!Accept(*pClient)) { delete pClient; // Accept 失败必须自己回收否则泄漏 return; } // 取对端地址写日志 SOCKADDR_IN addr; int nLen sizeof(addr); pClient-GetPeerName((SOCKADDR*)addr, nLen); pClient-m_nPeerPort ntohs(addr.sin_port); pClient-m_strPeerIp inet_ntoa(addr.sin_addr); m_pDlg-AddClient(pClient); // 放进容器统一管理 }新连接的 socket 默认事件掩码包含 FD_READ、FD_WRITE、FD_CLOSE 等所以接下来 OnReceive 会被正常触发。如果项目里要限制客户端数量判断逻辑放在 AddClient 里超限就 Close 并 delete别让连接对象无限增长。3.2 客户端Connect 的返回值和 OnConnect 的 nErrorCode客户端这块最容易翻车的是把 Connect 的 FALSE 返回值当成连接失败。异步模式下 Connect 发起后立刻返回此时连接还在握手中函数返回 FALSE 且 GetLastError() 等于 WSAEWOULDBLOCK 才是正常路径真正结果在 OnConnect 里给。BOOL CCollectDlg::ConnectToServer(LPCTSTR lpszIp, UINT nPort) { if (!m_sock.Create()) // 端口传 0由系统分配本地端口 return FALSE; if (!m_sock.Connect(lpszIp, nPort)) { int nErr GetLastError(); // 异步连接刚发起必然走到这里属于正常情况 if (nErr ! WSAEWOULDBLOCK) { m_sock.Close(); return FALSE; } } return TRUE; // 真正的结果等 OnConnect } void CClientSocket::OnConnect(int nErrorCode) { if (nErrorCode 0) { // 连接成功可以立刻发第一条业务报文比如登录包 m_pDlg-OnConnected(this); } else { // WSAECONNREFUSED对端没监听WSAETIMEDOUT网络不通或防火墙丢包 m_pDlg-OnConnectFailed(this, nErrorCode); } }一个实用细节Connect 之前最好先用 gethostbyname 或 InetPton 把 IP 规范化直接传主机名给 Connect 会隐含一次同步 DNS 解析在消息泵线程里做这个会让界面卡一下。如果一定要用主机名建议提前解析好缓存起来或者放到工作线程解析完再回到 UI 线程发起连接。3.3 OnReceive 的缓冲区大小与循环收包OnReceive 只告诉你「有数据可读」读多少、读几次是你的责任。一次只 Receive 一次是新手最常见的写法后果是内核接收缓冲区里的剩余数据要等下一次消息才处理吞吐直接掉一截压力测试时表现为延迟抖动。void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) { Close(); return; } char buf[8192]; for (;;) // 一直读到 WSAEWOULDBLOCK 为止 { int n Receive(buf, sizeof(buf)); if (n 0) { m_parser.Append(buf, n); // 交给组包器不在这里解析 continue; } if (n 0) // 返回 0 表示对端优雅关闭 { Close(); return; } int nErr GetLastError(); if (nErr WSAEWOULDBLOCK) // 读干净了正常退出 break; Close(); // 其他错误直接断开 return; } }参数说明Receive 第二个参数是请求长度返回值是实际拷贝到缓冲区的字节数。缓冲区大小 8KB 是经验值太小调用次数多太大占栈空间如果放在栈上注意默认栈只有 1MB别开 64KB 的局部数组。返回 0 和返回 SOCKET_ERROR 是两件完全不同的事前者是 FIN后者要看错误码。4. 粘包与半包把 TCP 字节流切回消息的组包状态机4.1 长度前缀协议与组包状态机TCP 是字节流没有消息边界这个概念。你发了两个 100 字节的包对端可能一次收到 200 字节也可能分三次收到 60、80、60。协议设计上只有两条路定长包或者长度前缀。前者简单但浪费带宽后者通用业界绝大多数私有协议都用「4 字节大端长度 负载」这个格式。// 长度前缀组包器追加即解析收不全就留着等下一批数据 class CStreamParser { public: void Append(const char* p, int n) { m_buf.insert(m_buf.end(), p, p n); Parse(); } private: void Parse() { while (m_buf.size() 4) { unsigned int nLen 0; memcpy(nLen, m_buf[0], 4); nLen ntohl(nLen); // 网络序转主机序x86 上必须转 // 防御异常长度直接放弃这个连接避免被构造包打爆内存 if (nLen 0 || nLen 4 * 1024 * 1024) { m_buf.clear(); return; } if (m_buf.size() 4 nLen) // 半包等下一批 break; OnPacket(m_buf[4], nLen); // 完整包交给业务 m_buf.erase(m_buf.begin(), m_buf.begin() 4 nLen); } } virtual void OnPacket(const char* p, unsigned int nLen) 0; std::vectorchar m_buf; };这段逻辑有三处值得说。第一处是 ntohl长度字段统一按网络字节序大端发送接收端必须转回来x86 是小端漏了这句就会出现一个 100 字节的包被解析成 1677721600 字节然后因为超长被丢掉现象是「偶尔能通、大部分包解析失败」。第二处是长度上限没有这道防线的话一个恶意或错误的长度字段会让 vector 一直涨到内存耗尽。第三处是 erase 的位置要一次性删掉「4 字节头 负载」只删负载会让下次解析从头开始错位。4.2 OnSend 与发送队列为什么 Send 返回值必须检查Send 的语义和很多人想的不一样它不保证把 nBufLen 字节全部塞进内核返回值是实际接收的字节数。剩余的部分要靠 OnSend 事件在发送缓冲区有空位时继续发。所以只要有大数据量或者网络拥塞不做发送队列就一定会丢数据。void CClientSocket::PostSend(const char* p, int n) { int nSent 0; if (m_sendQueue.empty()) // 队列空才尝试直接发避免乱序 { nSent Send(p, n); if (nSent SOCKET_ERROR) { int nErr GetLastError(); if (nErr ! WSAEWOULDBLOCK) { Close(); return; } nSent 0; // 缓冲区满整体入队 } } if (nSent n) m_sendQueue.append(p nSent, n - nSent); } void CClientSocket::OnSend(int nErrorCode) { if (nErrorCode ! 0) { Close(); return; } while (!m_sendQueue.empty()) { int n Send(m_sendQueue[0], (int)m_sendQueue.size()); if (n SOCKET_ERROR) { int nErr GetLastError(); if (nErr WSAEWOULDBLOCK) return; // 等下一次 OnSend Close(); return; } if (n 0) return; m_sendQueue.erase(m_sendQueue.begin(), m_sendQueue.begin() n); } }关键点在于「队列非空时不能再调用 Send」。一旦有数据滞留在用户态队列新数据必须排在后面否则对端收到的字节顺序就乱了。OnSend 的触发条件是内核发送缓冲区从满变为有空位它不保证你一次能发完所以要用 while 循环发到 WSAEWOULDBLOCK 为止。4.3 几个值得调的 socket 参数参数不在多够用就行。下面这几个是我在做长连接采集时固定会设的选项设置时机作用典型值SO_REUSEADDRCreate 后、Listen 前避免重启时端口占用TRUETCP_NODELAYAccept 后或客户端 Connect 前关掉 Nagle小包低延迟TRUESO_KEEPALIVE连接建立后探测半开连接TRUESO_SNDBUFCreate 后发送缓冲区大小64KBSO_RCVBUFCreate 后接收缓冲区大小64KBTCP_NODELAY 在采集类项目里基本必开因为默认的 Nagle 算法会把多个小包攒起来再发40ms 的延迟对实时性要求高的场景是不能接受的。KEEPALIVE 只能探测到「对端主机关机或不可达」探测不到「对端进程崩溃但主机还在」后者要靠应用层心跳。5. 避坑五个让 CAsyncSocket 安静翻车的现场5.1 现象、原因、解决逐条对第一条Create 成功但回调永远不来。现象是连接能建起来、日志停在 Listen 之后OnAccept 一次都没触发。原因十有八九是 socket 对象创建在了没有消息循环的线程里或者主线程在 DoModal 之前就创建了对象但消息泵还没跑起来。解决办法是确认调 Create 的线程里有 GetMessage / DispatchMessage 循环工作线程里创建就用 AfxBeginThread 并保证线程内跑消息泵或者干脆回到 UI 线程创建。第二条Connect 返回 FALSE 就弹「连接失败」。这是把异步语义当同步用了实际连接正在进行中。改成判断 GetLastError() WSAEWOULDBLOCK 视为正常真正的结果在 OnConnect 的 nErrorCode 里。同样的问题在 Send 和 Receive 上也存在返回 SOCKET_ERROR 时一定要看错误码WSAEWOULDBLOCK 在异步模式下是状态而非错误。第三条OnClose 里 delete this然后程序崩在释放之后。现象的调用栈通常指向已释放对象的虚函数调用或者容器遍历时的野指针。原因是对端断开后OnClose 被调用的同时业务层可能还在用这个对象的指针。稳妥做法是 OnClose 里只做标记并把自己从管理容器里摘出来delete 交给容器统一在安全的时机做如果一定要自删先 Close 再 PostMessage 一个自定义消息让主线程下一轮消息循环里删。第四条界面在做批量解析时假死网络也停了。原因是 OnReceive 在 UI 线程执行里面做了几万条记录的解析和界面刷新消息队列被堵OnSend、OnClose 全部延后表现出来的现象是「处理大数据时连接超时断开」。解法是 OnReceive 里只把数据丢进无锁队列交给工作线程处理UI 用定时器批量刷新或者把整条网络链路挪到工作线程UI 只收通知。第五条进程重启后 Listen 失败错误码 10048。原因是上一个进程的连接还在 TIME_WAIT 状态占着端口。解决办法是在 Listen 之前调用 SetSockOpt(SO_REUSEADDR)注意顺序必须是 Create 之后、Listen 之前。另外检查是否有别的进程包括开发时残留的调试实例占着同一个端口任务管理器里看一眼比盲改代码快。6. 关连接的正确顺序一套我自己每次都走的自检表关闭这块我吃过一次大亏一个采集程序跑一周后连接句柄数涨到几千最后 Create 返回失败。查下来是连接异常时直接 Close 而没有从容器移除对象和句柄一起泄漏。从那以后我把关闭流程固化成了固定顺序写进每个项目的基类里。正确顺序是先从管理容器里摘除防止遍历时被并发修改→ ShutDown(SD_SEND) 半关闭告诉对端我不再发了 → 等对端回 FIN 或者超时 → Close() 释放句柄 → 最后 delete 对象。ShutDown 这一步很多人省掉效果是直接 Close 时如果接收缓冲区还有未读数据内核会发 RST 而不是 FIN对端看到的是「连接被重置」日志里全是 10054排查时容易误判为网络故障。void CClientManager::RemoveClient(CClientSocket* p) { // 1. 先从容器摘除之后的遍历都看不到它 for (int i 0; i m_arr.GetSize(); i) { if (m_arr[i] p) { m_arr.RemoveAt(i); break; } } // 2. 半关闭发送方向让对端收到 FIN 而不是 RST p-ShutDown(1); // 3. 释放句柄 p-Close(); // 4. 对象留给定时器延迟回收避免回调栈里自删 m_garbage.push_back(p); }下面这张表是我每次联调前会扫一眼的清单按顺序过一遍能挡掉大部分低级问题检查项正确状态出错时的典型表现AfxSocketInit 位置InitInstance 第一行Create 返回 FALSE错误码 10093消息泵线程Create 的线程有消息循环连接建立但无任何回调Connect 返回值FALSE WSAEWOULDBLOCK 视为正常误报连接失败收包循环读到 WSAEWOULDBLOCK 才退出高负载下延迟抖动、丢包发送队列Send 返回值小于请求长度就入队大包发送后对端解析错位关闭顺序摘除 → ShutDown → Close → 延迟 delete句柄泄漏、10054 刷屏最后说一个判断连接是否真的活着的技巧别靠 OnClose它只在有事件时才来。定时器每 30 秒给每个连接发一个心跳包连续三次 PostSend 后没收到对应回包就主动 RemoveClient。这套组合拳比单纯依赖 KEEPALIVE 靠谱得多因为 KEEPALIVE 的默认探测周期是两个小时调短了又会影响所有走这个选项的连接。那个句柄泄漏的 bug 之后我养成了一个习惯每次改完网络相关代码先在任务管理器里盯一眼句柄数跑一轮连接建立/断开的压测看数字能不能回到起点。回不去就一定有问题早发现比上线后半夜被告警叫起来强。希望帮到你。本文还有配套的精品资源点击获取