MFC TCP Socket实战:CAsyncSocket异步通信与粘包处理

发布时间:2026/10/24 2:00:29
MFC TCP Socket实战:CAsyncSocket异步通信与粘包处理

简介:这份压缩包面向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/.cpp

ListenSocket和ClientSocket都从CAsyncSocket派生,但职责完全不同:前者一辈子只干一件事——把新连接从内核队列里捞出来;后者是“一线工人”,一个实例对应一条 TCP 连接,负责这条链路上的全部读写和异常处理。把这两件事拆开,后面加“最大连接数限制”“IP 黑名单”才有地方插。

ServerDlg只做三件事:接按钮、写日志、维护在线列表。它不直接持有 socket 的读写逻辑,所有网络事件通过自定义窗口消息回到这里,这样界面线程永远不会被阻塞。

类基类职责生命周期
CListenSocketCAsyncSocket监听端口,处理 OnAccept与主对话框同寿
CClientSocketCAsyncSocket单连接收发、切包、断线检测连接建立时 new,连接关闭后 delete
CServerDlgCDialogExUI、日志、连接表托管整个进程
PacketBuffer无(自写)粘包拆包归属 CClientSocket

2.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又往下派生了一层,内部自带一个隐藏窗口和阻塞循环,配合CSocketFile+CArchive能写出接近文件读写的代码。上手最快,但坑也集中:阻塞特性会卡住 UI 线程;和CArchive搭配时,Receive读到的字节数和对端Send的字节数没有一一对应关系,半包一来就抛异常,而且异常栈往往看不出真正原因。

这个项目选CAsyncSocket,理由很实际:协议体就是二进制块和字符串,没有复杂的对象序列化需求,异步回调配上自己维护的收包缓冲区,控制权在我手里。常见做法是——消息频率低、协议简单,CAsyncSocket足够;真要传大文件、需要做背压控制,反过来用独立工作线程加阻塞 socket,把收发和 UI 彻底隔离,反而更省心。

3. 服务端拆解:监听、accept 和多连接托管

服务端是整条链路的起点,它出问题的表现往往很隐蔽——客户端那边只看到“连接被拒绝”或者“连上三秒就断”,真正的原因藏在服务端的初始化顺序和对象生命周期里。

3.1 初始化和监听:AfxSocketInit 到 Listen

AfxSocketInit()必须在任何 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对应 TCP,SOCK_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里,用CArray<CClientSocket*, 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_cast<CClientSocket*>(wParam); RemoveClient(pClient); // 从在线列表移除 pClient->Close(); // 先关句柄 delete pClient; // 再删对象 return 0; }

PostMessage和SendMessage在这里有本质区别:PostMessage只是投递,立即返回,不会在 socket 回调里同步执行删除逻辑;SendMessage会等待消息处理完才返回,等于在回调里就把自己删了,必崩。

注意Close()要和delete分开写,顺序反了会导致CAsyncSocket析构时去关一个已失效的句柄,调试版会弹断言。

4. 收发层拆解:粘包、半包和跨线程回 UI

TCP 是字节流,不是消息流。这条常识谁都背得出,但真写起来,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 里循环读到 WSAEWOULDBLOCK

OnReceive同样是“有数据了”的边沿通知,一次回调里内核缓冲区可能已经堆了好几 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 跨线程回 UI:PostMessage 加堆对象

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_cast<CString*>(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_REUSEADDR,MFC 里用SetSockOpt:

// ListenSocket.cpp —— 在 Create 之前或之后都可以,但必须在 Listen 之前 int opt = 1; m_pListen->SetSockOpt(SO_REUSEADDR, &opt, sizeof(opt), SOL_SOCKET);

注意SO_REUSEADDR只是让绑定不被TIME_WAIT挡住,不是让两个进程同时监听同一端口。真正同时监听要靠SO_REUSEPORT,Windows 上的语义和 Linux 不一致,跨平台项目别指望它。

另外退出时记得按“先Close所有连接,再Close监听,最后让CWinApp正常返回”的顺序收尾。漏掉某一步的表现是进程在任务管理器里挂着不退,下一次调试端口还是占着。

6. 二次开发:把它改成一个真能干活的工具

这套骨架跑通之后,改造成本主要花在三个地方,按投入产出比排。

第一是加心跳和超时踢人。协议里已经留了MSG_HEART,服务端维护一个map<SOCKET, time_t>,起一个SetTimer每 30 秒扫一遍,超过 90 秒没更新的连接直接Close并从在线列表移除。没有这一步,客户端拔网线、进程被强杀这类情况服务端完全感知不到,在线列表会一直挂着一堆僵尸连接,广播时还要往死连接上写。

第二是把广播做对。别在 UI 线程里循环调SendPacket,客户端一多界面就卡。做法是给每个CClientSocket挂一个std::deque<CByteArray>发送队列,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强杀客户端,看服务端多久能从在线列表里把它清掉。这三种病态输入跑过一遍,基本就敢往设备上挂了。

从那以后我每次写这类带网络的小工具,都会先把“发包不完整、收包不完整、连接突然消失”这三件事的日志打点加进第一版,而不是等出问题了再回来补。调试网络问题最贵的是信息,日志里多一行缓冲区长度,可能省掉一整晚。希望帮到你。

本文还有配套的精品资源,点击获取