MFC TCP Socket实战:CAsyncSocket异步通信与粘包处理
发布时间:2026/10/24 2:00:07 锦皓数字建站

简介这份压缩包面向Windows平台网络编程入门与进阶学习者围绕基于Socket的TCP通信用MFC搭建客户端/服务器C/S架构示例帮助理解连接建立、数据收发与连接关闭的完整流程适合课程实验、自学练习和项目原型参考。包内共4个文件以2个cpp源文件和2个h头文件组成整体约6KB体量轻便便于直接对照阅读与二次练习。已有1264人学习下载说明其在MFC网络编程练习场景中具有一定参考价值。代码围绕CSocket对象的使用展开覆盖Winsock初始化、服务器绑定监听与接受连接、客户端连接服务器以及Send/Receive收发数据和Close释放资源等关键环节能够帮助读者把抽象协议概念落到具体调用上。读者可借此梳理MFC对Winsock的封装方式理解客户端与服务器对话框类的职责划分并在此基础上调试、扩展自己的TCP通信程序。1. 从一次“连不上”的调试说起这套 MFC TCP 程序到底解决什么问题把 MFC、TCP、Socket 三个词凑进同一个工程真正卡人的往往不是 API 名字记不住而是 MFC 的消息循环、线程模型和窗口生命周期怎么跟网络收发共存。这套 C/S 架构程序把服务端监听、客户端连接、消息收发、日志显示拆成几个职责清晰的类用 CAsyncSocket 的异步事件模型驱动收发适合做局域网聊天、设备指令下发、上位机通信这类小体量工具的原型也适合刚接触网络编程、想看清一次连接从建立到断开完整链路的开发者。它不追求高并发指标追求的是结构清楚、每一步都能打断点看明白。2. 工程结构与选型先定 CAsyncSocket 和 CSocket 的边界动手写代码之前工程怎么切分、用哪套 socket 封装比后面写多少行都关键。很多人一上来就把CAsyncSocket塞进对话框类里结果一个OnReceive里既有界面刷新又有协议解析改到第三个功能就再也理不清了。2.1 一份可以直接编译的双工程目录这个项目按“共享协议 服务端 客户端”三块切Common放两端都要用的头文件Server和Client各是一个独立的 MFC 对话框工程编出来的两个 exe 可以分别跑在两台机器上也可以在同一台机器上开两个实例自测。MfcTcpCS/ ├── Common/ │ ├── Protocol.h // 包头结构、消息类型枚举、字节序转换 │ └── PacketBuffer.h // 收包缓冲区负责粘包/半包切分 ├── Server/ │ ├── ServerApp.h/.cpp // CWinApp 派生InitInstance 里调 AfxSocketInit │ ├── ServerDlg.h/.cpp // 主对话框日志、在线列表、广播下发 │ ├── ListenSocket.h/.cpp // CAsyncSocket 派生只负责 accept │ └── ClientSocket.h/.cpp // 每个连接一个实例负责收、发、切包 └── Client/ ├── ClientApp.h/.cpp ├── ClientDlg.h/.cpp └── ClientSocket.h/.cppListenSocket和ClientSocket都从CAsyncSocket派生但职责完全不同前者一辈子只干一件事——把新连接从内核队列里捞出来后者是“一线工人”一个实例对应一条 TCP 连接负责这条链路上的全部读写和异常处理。把这两件事拆开后面加“最大连接数限制”“IP 黑名单”才有地方插。ServerDlg只做三件事接按钮、写日志、维护在线列表。它不直接持有 socket 的读写逻辑所有网络事件通过自定义窗口消息回到这里这样界面线程永远不会被阻塞。类基类职责生命周期CListenSocketCAsyncSocket监听端口处理 OnAccept与主对话框同寿CClientSocketCAsyncSocket单连接收发、切包、断线检测连接建立时 new连接关闭后 deleteCServerDlgCDialogExUI、日志、连接表托管整个进程PacketBuffer无自写粘包拆包归属 CClientSocket2.2 三种写法怎么选原生 Winsock、CAsyncSocket、CSocket原生 Winsock 最透明WSAStartup、socket、bind、listen、select全部自己管代价是事件分发要手写一个select循环套着几十行FD_ISSET判断新手很容易在错误处理上栽跟头。CAsyncSocket是 MFC 对 Winsock 的薄封装底层通过异步窗口消息把FD_ACCEPT、FD_READ、FD_WRITE、FD_CLOSE转成虚函数回调也就是常说的OnAccept、OnConnect、OnReceive、OnSend、OnClose。它不会阻塞调用线程天然适合挂在对话框上跑。CSocket又往下派生了一层内部自带一个隐藏窗口和阻塞循环配合CSocketFileCArchive能写出接近文件读写的代码。上手最快但坑也集中阻塞特性会卡住 UI 线程和CArchive搭配时Receive读到的字节数和对端Send的字节数没有一一对应关系半包一来就抛异常而且异常栈往往看不出真正原因。这个项目选CAsyncSocket理由很实际协议体就是二进制块和字符串没有复杂的对象序列化需求异步回调配上自己维护的收包缓冲区控制权在我手里。常见做法是——消息频率低、协议简单CAsyncSocket足够真要传大文件、需要做背压控制反过来用独立工作线程加阻塞 socket把收发和 UI 彻底隔离反而更省心。3. 服务端拆解监听、accept 和多连接托管服务端是整条链路的起点它出问题的表现往往很隐蔽——客户端那边只看到“连接被拒绝”或者“连上三秒就断”真正的原因藏在服务端的初始化顺序和对象生命周期里。3.1 初始化和监听AfxSocketInit 到 ListenAfxSocketInit()必须在任何 socket 调用之前执行它内部完成了WSAStartup并把版本号协商好。放到CWinApp::InitInstance的最前面是最省事的做法漏掉这一步后面所有 socket 调用都会返回WSAStartup未执行的错误而且错误码不直观。// ServerApp.cpp BOOL CServerApp::InitInstance() { // 必须在任何 socket 调用之前完成 Winsock 初始化 if (!AfxSocketInit()) { AfxMessageBox(_T(Winsock 初始化失败程序退出)); return FALSE; } CWinApp::InitInstance(); CServerDlg dlg; m_pMainWnd dlg; dlg.DoModal(); return FALSE; // 对话框关闭即结束不再进入默认消息循环 }InitInstance的返回值这里特意给了FALSE。原因是主界面是模态对话框DoModal返回意味着用户已经关窗此时如果再进CWinApp::Run的消息循环进程会卡在一个没有窗口的循环里任务管理器里看着像僵尸进程。真正开始监听的动作放在对话框的按钮响应里方便反复启停// ServerDlg.cpp —— “开始监听”按钮 void CServerDlg::OnBnClickedBtnListen() { if (m_pListen ! nullptr) return; // 防重复点击 m_pListen new CListenSocket(this); if (!m_pListen-Create(m_nPort, SOCK_STREAM)) { DWORD err GetLastError(); AppendLog(_T(创建监听套接字失败错误码 %u), err); delete m_pListen; m_pListen nullptr; return; } if (!m_pListen-Listen(5)) { AppendLog(_T(Listen 失败错误码 %u), GetLastError()); m_pListen-Close(); delete m_pListen; m_pListen nullptr; return; } AppendLog(_T(已在 %u 端口开始监听), m_nPort); GetDlgItem(IDC_BTN_LISTEN)-EnableWindow(FALSE); }Create的第一个参数是端口传 0 会让系统随机分配一个可用端口这在做多实例自测时很有用通过GetSockName能拿回实际端口。第二个参数SOCK_STREAM对应 TCPSOCK_DGRAM是 UDP写错了不会编译报错但行为完全不同。Listen的参数是 backlog指的是内核等待队列的长度不是最大并发连接数很多人以为调大它就等于提高并发上限实际上它只影响“三次握手完成但还没被 accept 取走”的连接能排多长队。这个值一般给 5 到 10 就够局域网工具用。GetLastError返回 10048 是端口被占用八成是上一次调试时进程没退干净返回 10013 是权限不够通常是端口号小于 1024。3.2 OnAccept一次事件里把队列收干净CAsyncSocket的OnAccept是边沿触发式的通知——内核告诉你“有连接进来了”但不会告诉你“有几个”。如果回调里只Accept一次而恰好同一瞬间来了三个客户端剩下的两个就会一直躺在队列里直到下一次有新连接触发事件才被顺手取走。表现出来就是“客户端连上了但服务端列表里没有”或者“第二个客户端连不上”。// ListenSocket.cpp void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) { m_pDlg-AppendLog(_T(OnAccept 错误码 %d), nErrorCode); CAsyncSocket::OnAccept(nErrorCode); return; } // 循环 accept直到队列里没有待处理连接 for (;;) { CClientSocket* pClient new CClientSocket(m_pDlg); if (!Accept(*pClient)) // 失败说明队列空了或者出错 { delete pClient; // 这里必须回收否则每来一次事件泄漏一个对象 break; } m_pDlg-AddClient(pClient); // 交给主对话框托管进入在线列表 } CAsyncSocket::OnAccept(nErrorCode); }Accept的第一个参数是“用来承载新连接的CAsyncSocket对象”所以必须在调用前 new 出来。失败时一定要delete这是最容易漏的一行——每次空 accept 都会泄漏一个对象和它内部持有的 SOCKET 句柄跑一晚上能把句柄耗光最后表现成“新连接全都失败”。循环的退出条件就是Accept返回FALSE此时GetLastError通常是WSAEWOULDBLOCK意思是队列已经空了正常情况不用当错误处理。3.3 连接对象托管和销毁谁 new 谁 delete服务端的连接表放在CServerDlg里用CArrayCClientSocket*, CClientSocket*或者std::vector都行。关键约定只有一条对象由对话框创建、由对话框删除CClientSocket自己绝不delete this。断线时OnClose会被触发但此时对象还在 MFC 的异步通知链路里直接删自己会导致回调返回后访问已释放内存典型表现是关闭客户端后服务端弹“内存不能为 read”。// ClientSocket.cpp void CClientSocket::OnClose(int nErrorCode) { // 不能在这里 delete this抛回 UI 线程统一释放 if (m_pDlg ! nullptr) m_pDlg-PostMessage(WM_CLIENT_GONE, (WPARAM)this, 0); CAsyncSocket::OnClose(nErrorCode); } // ServerDlg.cpp —— 自定义消息的处理 LRESULT CServerDlg::OnClientGone(WPARAM wParam, LPARAM) { CClientSocket* pClient reinterpret_castCClientSocket*(wParam); RemoveClient(pClient); // 从在线列表移除 pClient-Close(); // 先关句柄 delete pClient; // 再删对象 return 0; }PostMessage和SendMessage在这里有本质区别PostMessage只是投递立即返回不会在 socket 回调里同步执行删除逻辑SendMessage会等待消息处理完才返回等于在回调里就把自己删了必崩。注意Close()要和delete分开写顺序反了会导致CAsyncSocket析构时去关一个已失效的句柄调试版会弹断言。4. 收发层拆解粘包、半包和跨线程回 UITCP 是字节流不是消息流。这条常识谁都背得出但真写起来Receive一次读到半条消息、或者两条消息粘在一起的情况几乎每个人都会遇到一次。收发层的设计目标就一个让上层拿到的一定是完整的一条消息。4.1 定长包头加变长包体协议头固定 12 字节包体长度可变这样切包逻辑最简单也最不容易出错。// Protocol.h #pragma once #include WinSock2.h #define PKT_MAGIC 0x4D464354 // MFCT用来快速判断是不是自己的协议 #pragma pack(push, 1) // 必须 1 字节对齐否则 sizeof 会带上填充字节 struct PacketHeader { UINT32 magic; // 魔数防串协议 UINT32 length; // 包体字节数网络字节序 UINT16 type; // 消息类型 UINT16 reserved; // 预留方便以后扩展 }; #pragma pack(pop) enum PacketType : UINT16 { MSG_LOGIN 1, // 客户端上线 MSG_TEXT 2, // 文本消息 MSG_HEART 3, // 心跳 MSG_LOGOUT 4 // 主动下线 }; static const UINT32 MAX_BODY_LEN 1024 * 1024; // 单包上限 1MB防恶意超长包#pragma pack(push, 1)这行不能省。默认对齐下UINT32 UINT32 UINT16 UINT16刚好 12 字节没问题但只要以后加一个UINT8字段编译器就会补 3 个填充字节两端算出来的sizeof不一致切包直接错位而且错得很随机——改了结构体的人自己测没事别人拉下来就崩。length字段走网络字节序发送前htonl收到后ntohl。x86 是小端两端都是 Windows 时看着“不转也能跑”一旦以后接个 ARM 板子或者用别的语言写客户端立刻翻车。这个转换成本几乎为零没有理由省。4.2 OnReceive 里循环读到 WSAEWOULDBLOCKOnReceive同样是“有数据了”的边沿通知一次回调里内核缓冲区可能已经堆了好几 KB。只Receive一次的话剩下的数据要等下一次事件而如果对端就发了这一批然后不说话剩下的字节会一直卡着表现成“消息只收到一半就没了”。// ClientSocket.cpp void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) { OnPeerGone(); return; } char buf[4096]; for (;;) { int n Receive(buf, sizeof(buf)); if (n SOCKET_ERROR) { int err GetLastError(); if (err WSAEWOULDBLOCK) break; // 读空了正常退出 OnPeerGone(); // 其他错误按断线处理 return; } if (n 0) { OnPeerGone(); // 对端正常关闭 return; } m_recvBuf.Append(buf, n); } ParseBuffer(); // 缓冲够了就切包分发 CAsyncSocket::OnReceive(nErrorCode); }三个退出条件要分清楚WSAEWOULDBLOCK是“暂时没数据了”属于正常n 0是 FIN 包对端主动关了其他错误码才是真异常比如 10054 是连接被对端重置。ParseBuffer负责把缓冲区里的字节按包头切开// ClientSocket.cpp void CClientSocket::ParseBuffer() { while (m_recvBuf.GetSize() (int)sizeof(PacketHeader)) { PacketHeader hdr; memcpy(hdr, m_recvBuf.GetData(), sizeof(hdr)); if (hdr.magic ! PKT_MAGIC) { Close(); return; } // 不是本协议直接断 UINT32 bodyLen ntohl(hdr.length); if (bodyLen MAX_BODY_LEN) { Close(); return; } // 防御性检查 int total sizeof(hdr) (int)bodyLen; if (m_recvBuf.GetSize() total) break; // 半包等下一批数据 Dispatch(hdr.type, m_recvBuf.GetData() sizeof(hdr), (int)bodyLen); m_recvBuf.RemoveHead(total); // 消费掉这一包 } }bodyLen的合法性检查看起来多余实际上是一条防线如果对端发来一个声称 4GB 长度的包头没有这行判断后面的GetSize() total永远为真缓冲区会一路涨到内存爆掉而日志里什么都不会打印属于典型的黑匣子问题。4.3 Send 只发出去一部分怎么办Send的返回值是“本次实际写进内核发送缓冲区的字节数”它可能小于你请求的长度。局域网内几乎不会发生但在批量广播或者对端接收窗口满的时候就会出现。忽略返回值、直接认为发送成功是这类程序最常见的隐性 bug。// ClientSocket.cpp bool CClientSocket::SendPacket(UINT16 type, const void* body, UINT32 bodyLen) { if (m_hSocket INVALID_SOCKET) return false; PacketHeader hdr; hdr.magic PKT_MAGIC; hdr.length htonl(bodyLen); hdr.type type; hdr.reserved 0; // 先把包头和包体拼成一个连续缓冲区再发避免两次 Send 之间插进别的包 CByteArray pkt; pkt.SetSize(sizeof(hdr) bodyLen); memcpy(pkt.GetData(), hdr, sizeof(hdr)); if (bodyLen 0) memcpy(pkt.GetData() sizeof(hdr), body, bodyLen); int total (int)pkt.GetSize(); int sent 0; while (sent total) { int n Send(pkt.GetData() sent, total - sent); if (n SOCKET_ERROR) { int err GetLastError(); if (err WSAEWOULDBLOCK) { // 发送缓冲区满简单项目里直接视为失败交给上层重连 return false; } return false; } sent n; // 必须累加否则会重复发送同一段数据 } return true; }把包头和包体拼成一次Send是刻意的如果先发头、再发体中间被别的线程插进来发了一个包对端就会按“头-别人的头-别人的体-自己的体”切直接错乱。要么拼成整块发要么加发送队列串行化简单项目选前者。WSAEWOULDBLOCK在发送侧的处理要按场景定聊天工具可以失败重试广播场景最好维护一个发送队列等OnSend回调来了再继续。这里为了代码量控制直接返回失败由上层提示“发送失败”。4.4 跨线程回 UIPostMessage 加堆对象CAsyncSocket的回调运行在 MFC 的异步通知线程里直接在这些回调里操作控件轻则界面卡顿重绘异常重则弹断言。稳妥做法是把数据打包用PostMessage投递到主线程。// ClientSocket.cpp void CClientSocket::Dispatch(UINT16 type, const void* body, int len) { // 堆上分配接收方负责 delete避免跨线程共享 CString CString* pMsg new CString((LPCSTR)body, len); switch (type) { case MSG_TEXT: m_pDlg-PostMessage(WM_PEER_TEXT, (WPARAM)pMsg, (LPARAM)m_hSocket); break; case MSG_HEART: m_pDlg-PostMessage(WM_PEER_HEART, (WPARAM)m_hSocket, 0); delete pMsg; // 心跳不带内容立刻回收 break; default: delete pMsg; break; } } // ServerDlg.cpp —— 主线程处理 LRESULT CServerDlg::OnPeerText(WPARAM wParam, LPARAM lParam) { CString* pMsg reinterpret_castCString*(wParam); AppendLog(_T([%u] %s), (unsigned)lParam, *pMsg); delete pMsg; // 谁接收谁释放 return 0; }堆对象是必须的PostMessage是异步的如果用栈对象地址消息还没被处理函数地址就失效了。同理不能直接传CString引用。约定好“接收方负责 delete”代码里每一处都照做就不会漏。CString这里按LPCSTR构造意味着协议体按 ANSI 处理。如果客户端要发中文又不想踩编码坑统一改成 UTF-8发送端把CStringW转 UTF-8 字节流接收端用MultiByteToWideChar转回来再显示两端一致就行最怕的是一端用CStringA一端用CStringW。5. 避坑与排查MFC Socket 最容易出问题的四个位置这一节的现象都是我在调试这套结构时真实撞过的顺序按“从启动到退出”排。5.1 连接建立阶段的坑现象一点了“连接”客户端界面直接卡死几秒然后提示超时。原因在 UI 线程里用了CSocket的阻塞式Connect或者用CAsyncSocket::Connect之后立刻弹了个模态框。前者会把消息循环堵死后者会让OnConnect永远进不来——CAsyncSocket的事件是靠窗口消息驱动的模态对话框自己跑了一套消息循环异步通知被吞掉了。解决连接动作只调Connect立即返回结果全在OnConnect里处理用nErrorCode判断成功与否。非要在等待期间给用户反馈用非模态对话框或者直接置灰按钮加一句“连接中”。超时交给系统Connect本身有内核级超时想要更短就自己起个定时器超时后Close掉重来。现象二服务端显示监听成功客户端就是连不上错误码 10061。原因Windows 防火墙拦了。第一次运行编出来的 exe系统会弹一个“是否允许访问网络”的框如果当时点了取消之后这个程序的所有入站连接都会被静默丢弃而且不会再弹第二次。解决到防火墙的“允许应用通过”里把这个 exe 勾上或者调试期间临时关掉专用网络的防火墙。顺带一个经验调试时用 127.0.0.1 先确认逻辑通不通再换局域网 IP 测能把“防火墙问题”和“代码问题”分开。绑定地址用INADDR_ANY而不是127.0.0.1后者只接受本机连接别的机器连过来会被拒。5.2 收发阶段的坑现象三中文消息收到一半是乱码或者最后几个字变成方块。原因发送端按CString的GetLength()算字节数接收端按字节构造CString。CString在 Unicode 工程里是宽字符GetLength()返回的是字符数一个汉字算 1占内存却是 2 字节长度对不上。另一个常见原因是直接用strlen算 UTF-8 字符串长度遇到中文也会偏。解决协议体一律按字节流处理。发送前明确转成std::string或者CByteArray长度取字节数接收端用MultiByteToWideChar(CP_UTF8, ...)转回宽字符再显示。两端的编码约定写进Protocol.h的注释里谁改谁负责。现象四客户端发 10 条消息服务端日志里只有 3 条内容还是拼接的。原因OnReceive只调了一次Receive剩下的字节留在内核缓冲区等下次事件而客户端是连续Send的短时间内不会再触发新事件数据就一直卡着。另一种情况是切包逻辑里RemoveHead的长度算错把下一包的头也吃掉了。解决按 4.2 的写法Receive循环到WSAEWOULDBLOCK为止切包时严格用sizeof(PacketHeader) ntohl(length)作为消费长度别凭感觉写常量。调试期在ParseBuffer里加一行日志打印缓冲区当前长度和切出来的包长跑十几条消息就能看出规律。5.3 关闭和退出阶段的坑现象五关掉客户端之后服务端再启动就报端口被占用10048。原因TIME_WAIT状态主动关闭的一方会在这个状态停留几十秒。调试时反复启停端口就一直被上一个进程占着。解决监听前给 socket 设SO_REUSEADDRMFC 里用SetSockOpt// ListenSocket.cpp —— 在 Create 之前或之后都可以但必须在 Listen 之前 int opt 1; m_pListen-SetSockOpt(SO_REUSEADDR, opt, sizeof(opt), SOL_SOCKET);注意SO_REUSEADDR只是让绑定不被TIME_WAIT挡住不是让两个进程同时监听同一端口。真正同时监听要靠SO_REUSEPORTWindows 上的语义和 Linux 不一致跨平台项目别指望它。另外退出时记得按“先Close所有连接再Close监听最后让CWinApp正常返回”的顺序收尾。漏掉某一步的表现是进程在任务管理器里挂着不退下一次调试端口还是占着。6. 二次开发把它改成一个真能干活的工具这套骨架跑通之后改造成本主要花在三个地方按投入产出比排。第一是加心跳和超时踢人。协议里已经留了MSG_HEART服务端维护一个mapSOCKET, time_t起一个SetTimer每 30 秒扫一遍超过 90 秒没更新的连接直接Close并从在线列表移除。没有这一步客户端拔网线、进程被强杀这类情况服务端完全感知不到在线列表会一直挂着一堆僵尸连接广播时还要往死连接上写。第二是把广播做对。别在 UI 线程里循环调SendPacket客户端一多界面就卡。做法是给每个CClientSocket挂一个std::dequeCByteArray发送队列UI 线程只负责push真正的Send放在各自的OnSend回调里推进写不动了就停等下一次可写事件继续。这个改造大概几十行但决定了这套东西能带 5 个客户端还是 50 个。第三是换成二进制协议。现在包体是文本解析靠字符串字段一多就难维护。把MSG_LOGIN的包体定义成struct同样#pragma pack(push, 1)读写时逐字段memcpy配合包头里的type做分发扩展新消息只要在Protocol.h里加一个枚举值和一个结构体。改造点改动位置大致工作量解决的问题心跳超时CServerDlg 定时器半天僵尸连接、在线状态不准发送队列CClientSocket一天UI 卡顿、大广播丢包二进制协议Protocol.h ParseBuffer一天字段扩展、解析性能验证方法上我会准备两个客户端实例再加一个用脚本写的假客户端。脚本那边故意一次只发 3 个字节把包头拆成两批送专门验证半包处理再故意连上不登录验证服务端的超时逻辑最后用taskkill强杀客户端看服务端多久能从在线列表里把它清掉。这三种病态输入跑过一遍基本就敢往设备上挂了。从那以后我每次写这类带网络的小工具都会先把“发包不完整、收包不完整、连接突然消失”这三件事的日志打点加进第一版而不是等出问题了再回来补。调试网络问题最贵的是信息日志里多一行缓冲区长度可能省掉一整晚。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。