简介围绕服务端与客户端双向通信需求整理的一套 VC MFC Socket 编程实例在 VS2019 下编译运行通过面向需要理解网络通信底层机制、准备实现集群或即时通讯模块的 C 开发者与初中级学习者。程序同时给出聊天信息收发与大型文件传输两条主线重点演示数据包的打包与拆包流程并提供不丢包传输的实现思路对长期困扰新手的内存泄露问题也做了针对性处理可直接作为功能验证、课程设计与二次开发的参照样板。压缩包共 39 个文件、约 262KB包含 h 头文件、cpp 源文件、sln 解决方案、vcxproj 工程文件以及 rc 资源脚本等client 与 server 两个工程结构对称、命名呼应便于相互对照阅读与调试。已有 1322 人学习。读者可从中获取 CSocket 通信的完整代码骨架、粘包与拆包的边界处理方案、大文件分块传输的收发节奏控制以及内存释放的排查经验适合作为网络编程入门到进阶之间的过渡练习。1. 从一次“文件传到 99% 卡死”说起这套 MFC Socket 工程到底顶什么用去年帮一个做设备运维的小团队看他们的内部通讯小工具界面是标准 MFC 对话框左边联系人、右边聊天框还带拖拽发文件。问题很典型几十兆的日志包传到 99% 就不动了中文偶尔变成问号两个人同时发消息还会串成半句话。翻代码发现收数据的地方只调用一次 Receive 就当收全了文件传输既没有包序号也没有偏移量。这套 VC MFC Socket 工程干的活就是把这些坑填平自定义包头做打包拆包接收缓冲区循环取帧文件分块带偏移量再用应用层 ACK 兜住“不丢包”。手上有 MFC 老项目要接网络功能或者课程设计需要聊天加传文件两件套这份代码可以直接当骨架。2. 工程骨架与选型MFC 对话框 CSocket 工作线程怎么搭2.1 VS2019 环境准备MFC 组件与 AfxSocketInit 那一行VS2019 安装器里“使用 C 的桌面开发”这个工作负载要勾上右侧“单个组件”里再单独找一下“C MFC 最新生成工具v142”。很多人卡在第一步不是代码问题而是 MFC 库压根没装新建项目列表里连“MFC 应用”都看不到还以为是 VS 装坏了。新建项目选“MFC 应用”应用程序类型挑“基于对话框”项目名随便起字符集保持 Unicode高级功能里把“Windows 套接字”勾上——这一步只是帮你把 ws2_32.lib 链接进来真正的初始化还得自己写。工程起来之后第一件事是打开 App 类在InitInstance里补上套接字环境// ChatApp.cpp —— 套接字环境必须在任何 CSocket 对象创建之前起好 BOOL CChatApp::InitInstance() { // 少了这一行后面 new CClientSocket 会直接断言失败 if (!AfxSocketInit()) { AfxMessageBox(_T(WinSock 初始化失败检查防火墙或系统组件)); return FALSE; } CWinApp::InitInstance(); CChatClientDlg dlg; m_pMainWnd dlg; dlg.DoModal(); return FALSE; }AfxSocketInit()内部做的就是一次WSAStartup(MAKEWORD(2,2))失败一般是系统 winsock 目录被别的安全软件挂坏了或者进程里已经有代码提前调了WSAStartup但版本对不上。参数没什么可调的但位置很关键必须在任何CSocket派生类实例构造之前所以别把 socket 对象写成全局变量或者 App 类的成员那种初始化顺序是玄学出了事很难查。还有个容易撞的地方项目里如果手动#include winsock2.h又包含afxsock.h编译期会出现一堆重定义。MFC 工程只留afxsock.h它在pch.h里包含一次就够了顺序放在afxwin.h后面。2.2 三个类划清职责监听、连接、界面各管一摊这套工程的类结构不复杂但职责必须切干净否则后面加群聊、加文件传输会互相污染。实际工程里的划分大致是这样类名基类职责CListenSocketCAsyncSocket服务端监听端口OnAccept里为每个新连接创建会话对象CSessionSocketCSocket一条连接的收发持有接收缓冲区负责拆包和分发CChatClientDlgCDialogEx界面逻辑接收自定义消息渲染聊天记录和进度条CProtocol无纯静态工具类包头结构、CRC32、序列化与反序列化服务端用CAsyncSocket做监听是因为OnAccept是事件驱动的不需要为“等待新连接”单独开线程每条连上来的会话再用CSocket加独立接收线程做阻塞式读这样收发逻辑写起来是顺序的比满屏状态机好维护。客户端反过来直接一个CSocket派生类连接服务器同样配一个接收线程。写OnAccept的时候有个血泪经验别在OnAccept里做耗时操作比如查数据库、读文件、弹提示框。CAsyncSocket的回调跑在消息泵里你一卡整个服务的接受队列就堵住了新连接全部超时。// ListenSocket.cpp —— 只负责接客接完立刻转手 void CListenSocket::OnAccept(int nErrorCode) { CAsyncSocket::OnAccept(nErrorCode); if (nErrorCode ! 0) return; // 每个连接一个独立会话对象生命周期交给会话管理器 CSessionSocket* pSession new CSessionSocket(); if (!Accept(*pSession)) { delete pSession; return; } theServer.AddSession(pSession); // 加入在线列表后续群聊转发要用 pSession-StartRecvThread(); // 起接收线程回调里只做这一件事 }Accept的第二个参数不传就是默认地址传了可以拿到对端 IP做白名单或者日志的时候用得上。会话对象千万别用栈上变量Accept之后这个 socket 句柄归新对象所有栈对象一析构连接就断了。2.3 线程模型收数据绝对不能放在 UI 线程新手最常写的版本是点“连接”按钮在按钮响应函数里while(true) { Receive(...) }。跑起来界面立刻白屏拖都拖不动因为CSocket::Receive是阻塞的没数据就一直挂在那个调用上Windows 消息泵彻底停摆。正确的做法是给每条连接配一个工作线程线程里只做“收字节 → 追加缓冲区 → 拆包 → 投递消息”这四件事绝不直接碰控件。// SessionSocket.cpp —— 阻塞式接收线程 UINT CSessionSocket::RecvThreadProc(LPVOID pParam) { CSessionSocket* pThis static_castCSessionSocket*(pParam); char szBuf[8192]; while (pThis-m_bRunning) { // 阻塞读没数据就挂起所以必须跑在独立线程里 int nRet pThis-Receive(szBuf, sizeof(szBuf)); if (nRet 0) { // 0 表示对端正常关闭SOCKET_ERROR 要区分 WSAEWOULDBLOCK break; } pThis-m_recvBuf.append(szBuf, nRet); // std::string 当字节缓冲用 pThis-ParseFrames(); // 循环拆出完整帧 } pThis-NotifyClosed(); return 0; }缓冲区大小 8192 不是随便定的太小会导致拆包循环被频繁唤醒CPU 飙高太大单次阻塞时间变长关闭连接时要等更久。8KB 到 32KB 之间都是合理区间。m_recvBuf用std::string而不用CString是因为它按字节操作、没有编码转换做二进制协议缓冲区更省心。2.4 子线程回 UI用 PostMessage别用 SendMessage线程里拆出来的聊天消息、文件进度最终都要显示到对话框上。直接GetDlgItem(...)-SetWindowText()是必崩的MFC 控件不是线程安全的。标准做法是定义自定义消息用PostMessage丢回主线程// 消息定义放在公共头文件里 #define WM_CHAT_RECV (WM_USER 100) // 收到一条聊天消息 #define WM_FILE_PROGRESS (WM_USER 101) // 文件进度更新 #define WM_PEER_CLOSED (WM_USER 102) // 对端断开 // 子线程堆上分配UI 线程负责 delete ChatMsg* pMsg new ChatMsg(); pMsg-strFrom strFrom; pMsg-strText strText; ::PostMessage(m_hWndOwner, WM_CHAT_RECV, 0, reinterpret_castLPARAM(pMsg));这里用PostMessage而不是SendMessage的原因很实际SendMessage是同步的会一直等到 UI 线程处理完才返回。如果 UI 线程正在把收到的文件块写盘哪怕只写 32KB接收线程就被卡住TCP 接收窗口很快被填满发送端跟着阻塞整个链路的吞吐直接掉一半。用PostMessage是异步投递代价是队列可能堆积所以要配一个上限——超过 500 条就丢弃最旧的非关键消息这一条在文件传输场景里很关键后面还会提到。LPARAM传堆指针UI 侧的响应函数第一件事就是std::unique_ptrChatMsg p(reinterpret_castChatMsg*(lParam));保证异常路径也不漏内存。3. 协议设计自定义包头、粘包拆包与“不丢帧”的实现3.1 用 #pragma pack(1) 定一个 16 字节包头TCP 是字节流它不知道你的一条“消息”在哪结束。不发文件的时候你写Send(hello)对端Receive一次可能收到 “hel”下一次收到 “lo”发文件的时候更乱两个块可能被合并成一次到达。这就是粘包和半包跟网络质量没关系是 TCP 的固有行为。解决办法是给每个逻辑包加一个定长包头包头里写清楚包体有多长接收端照着长度切。#pragma pack(push, 1) // 必须 1 字节对齐否则不同编译选项下大小会变 struct PacketHeader { WORD wMagic; // 0xAB55 魔数收到脏数据时用它快速判定 BYTE byVersion; // 协议版本先留 1以后加字段好兼容 BYTE byType; // 消息类型1 聊天 2 文件头 3 文件块 4 ACK 5 心跳 DWORD dwSeq; // 包序号ACK 和去重都靠它 DWORD dwBodyLen; // 包体字节数不含包头本身 DWORD dwCrc32; // 包体 CRC32文件块必须校验 }; #pragma pack(pop) static_assert(sizeof(PacketHeader) 16, 包头必须正好 16 字节);那个static_assert不是摆设。有一回在另一个工程里把byType从BYTE改成WORD忘了同步接收端包头悄悄从 16 字节变成 18 字节两边都在“正常”拆包就是偶尔错位查了整整一个下午。加上断言编译期就拦住了。wMagic的作用是自愈如果接收缓冲区因为某种原因错位了比如早期版本残留数据读出来的魔数对不上直接把缓冲区清空重新同步比死循环解析垃圾数据强。3.2 发送端打包长度先行、网络字节序、循环发完打包的原则是“先组完整帧再一次发出去”。分两次Send包头和包体中间如果被调度走对端可能先收到包体解析直接乱套。// SessionSocket.cpp —— 组帧并发送返回 false 表示连接已断 bool CSessionSocket::SendFrame(BYTE byType, DWORD dwSeq, const char* pBody, DWORD dwLen) { if (dwLen 1024 * 1024) return false; // 单包上限 1MB超了要分片 PacketHeader hdr { 0 }; hdr.wMagic 0xAB55; hdr.byVersion 1; hdr.byType byType; hdr.dwSeq htonl(dwSeq); // 主机序转网络序x86 是小端 hdr.dwBodyLen htonl(dwLen); hdr.dwCrc32 htonl(Crc32(pBody, dwLen)); std::vectorchar frame(sizeof(hdr) dwLen); memcpy(frame.data(), hdr, sizeof(hdr)); if (dwLen 0) memcpy(frame.data() sizeof(hdr), pBody, dwLen); // Send 的返回值可能小于请求长度必须循环 DWORD dwSent 0; while (dwSent frame.size()) { int n Send(frame.data() dwSent, (int)(frame.size() - dwSent)); if (n 0) return false; dwSent n; } return true; }htonl那三行是跨平台的基础。同一台机器上客户端服务端都是 x86不转也能跑一换到大端设备或者用 Wireshark 抓包看就全乱。别省这个事。while (dwSent frame.size())这个循环同样别省。阻塞模式下Send大部分时候一次能发完但当发送缓冲区快满时它会只发一部分就返回剩下的靠你继续发不循环就等于悄悄丢了一段字节接收端永远等不到完整帧表现就是“传到一半卡死”。3.3 接收端拆包缓冲区循环取帧半包就等着拆包逻辑是整个工程的核心写错一个erase位置就会串包。// SessionSocket.cpp —— 从接收缓冲区里循环切出完整帧 void CSessionSocket::ParseFrames() { while (m_recvBuf.size() sizeof(PacketHeader)) { PacketHeader hdr; memcpy(hdr, m_recvBuf.data(), sizeof(hdr)); // 魔数不对说明流已经错位清空重来 if (hdr.wMagic ! 0xAB55) { m_recvBuf.clear(); break; } DWORD dwLen ntohl(hdr.dwBodyLen); if (dwLen 1024 * 1024) { m_recvBuf.clear(); break; } // 防御性上限 size_t nNeed sizeof(hdr) dwLen; if (m_recvBuf.size() nNeed) break; // 半包等下一次 Receive std::string body(m_recvBuf.data() sizeof(hdr), dwLen); m_recvBuf.erase(0, nNeed); // 消费掉这一帧再继续循环 Dispatch(hdr, body); } }逐段解释一下。while (m_recvBuf.size() sizeof(PacketHeader))是循环条件只有缓冲区里至少有一个包头才值得尝试if (m_recvBuf.size() nNeed) break;是半包出口数据不够就退出循环等下一次erase(0, nNeed)把已消费的字节移出缓冲区注意是nNeed不是dwLen。erase每次都会搬移剩余内存短连接无所谓长连接跑几个小时、传了几百兆之后会有性能损耗优化做法是维护一个读下标m_nReadPos只有当m_nReadPos 64KB时才真正erase一次压缩。3.4 “无丢包”其实分两层TCP 管字节应用层管帧标题里写“无丢包”得说清楚它不是指 TCP 会丢字节——TCP 本身保证有序可靠传输字节不会少。真正会丢的是“帧”来源就三个第一个是只收一次就当收全了也就是章节 1 里那个“99% 卡死”的直接原因。第二个是接收缓冲区被误清空比如魔数校验失败时无条件clear()把后面跟着的合法数据一起扔了正确做法是先定位下一个 0xAB55 再截断。第三个是 UI 消息队列溢出PostMessage投递太频繁主线程处理不过来程序里如果做了“队列满了丢最旧”的限流文件块消息就可能被丢。针对第三个工程里加了一层应用层 ACK发送端维护一个“未确认帧表”每发一帧(dwSeq, frame)就登记进去接收端处理完回一个MSG_ACK带同样的dwSeq发送端收到就从表里删掉。超过 3 秒没确认就重发重发 3 次还不行就判定连接失效。// 简单的未确认表够用且好排查 struct PendingFrame { std::vectorchar frame; DWORD dwSendTick; // GetTickCount()用于超时判定 int nRetry; }; std::mapDWORD, PendingFrame m_pending; // key 是 dwSeq // 定时器里每 500ms 扫一次在发送线程里做别在 UI 线程 void CSessionSocket::CheckRetransmit() { DWORD dwNow GetTickCount(); for (auto it m_pending.begin(); it ! m_pending.end(); ) { if (dwNow - it-second.dwSendTick 3000) { it; continue; } if (it-second.nRetry 3) { NotifyClosed(); return; } RawSend(it-second.frame.data(), (DWORD)it-second.frame.size()); it-second.dwSendTick dwNow; it-second.nRetry; it; } }接收端去重很简单维护一个std::setDWORD m_seenSeq收到dwSeq已在集合里就直接回 ACK 不再处理避免重传导致同一条聊天消息显示两遍。文件块去重更省事因为块里带偏移量重复写同一个偏移位置内容是一样的。4. 核心功能落地登录、群聊转发、文件分块传输4.1 消息类型表与分发入口协议定好之后所有业务都收敛到Dispatch一个函数里对着byType做switch。这张表建议直接写在头文件里当文档用byType名称包体内容方向1MSG_LOGIN用户名 UTF-8客户端 → 服务端2MSG_CHAT发送者 文本双向3MSG_FILE_HEAD文件名 总大小 CRC32客户端 → 服务端 → 对端4MSG_FILE_DATA文件ID 偏移 数据同上5MSG_FILE_END文件ID 总块数同上6MSG_ACK被确认的 dwSeq双向7MSG_HEARTBEAT空双向void CSessionSocket::Dispatch(const PacketHeader hdr, const std::string body) { switch (hdr.byType) { case MSG_CHAT: OnChat(ntohl(hdr.dwSeq), body); break; case MSG_FILE_HEAD: OnFileHead(body); break; case MSG_FILE_DATA: OnFileData(body); break; case MSG_FILE_END: OnFileEnd(body); break; case MSG_ACK: OnAck(ntohl(hdr.dwSeq)); break; default: break; } // 除了 ACK 自己其它帧都要回确认 if (hdr.byType ! MSG_ACK) SendAck(ntohl(hdr.dwSeq)); }OnFileData里做的事要尽量轻拿偏移、Seek、Write、更新进度、PostMessage。别在这里做 CRC 全量校验那会把接收线程拖死CRC 放到MSG_FILE_END里一次性算算完对不上再让发送端重传这是最省事的策略。4.2 群聊转发服务端遍历在线列表聊天功能本身逻辑很薄服务端收到MSG_CHAT之后遍历所有会话对象把原始帧转发出去注意不是重新打包直接转发原始字节最省 CPU也避免反复序列化出错。void CChatServerDlg::Broadcast(const std::vectorchar frame, CSessionSocket* pFrom) { // 加锁保护在线列表Accept 和断开都可能并发改动它 std::lock_guardstd::mutex lock(m_mtxSessions); for (CSessionSocket* p : m_sessions) { if (p pFrom) continue; // 一般不回给自己看产品需求 p-SendRaw(frame.data(), (DWORD)frame.size()); } }这里的坑在于发送本身可能阻塞。如果某个客户端网络特别差Send卡住整个Broadcast就卡住后面所有客户端都收不到消息。稳妥做法是每个会话配一个发送队列加发送线程Broadcast只负责push真正发由各自的线程去做。这套工程里就是这么干的SendRaw内部其实只是往std::deque里塞数据然后SetEvent唤醒发送线程。4.3 文件传输文件头 32KB 分块 偏移量文件传输不能整个读进内存再发几百兆的文件直接把内存干到几个 G。标准做法是分块每块带一个偏移量接收端按偏移Seek写入。先定两个结构体同样要#pragma pack(1)struct FileHead { char szName[260]; // 文件名UTF-8够用就行 ULONGLONG ullSize; // 总字节数 DWORD dwCrc32; // 整文件 CRC32接收完校验用 }; struct FileChunkHead { DWORD dwFileId; // 同时传多个文件时用来区分 ULONGLONG ullOffset; // 本块在文件中的起始偏移 DWORD dwDataLen; // 本块数据长度 };发送循环bool CClientSocket::SendFile(LPCTSTR lpszPath, DWORD dwFileId) { CFile file; if (!file.Open(lpszPath, CFile::modeRead | CFile::shareDenyNone)) return false; ULONGLONG ullSize file.GetLength(); // 1. 先发文件头对端据此创建临时文件并准备进度条 FileHead fh { 0 }; WideCharToMultiByte(CP_UTF8, 0, lpszPath, -1, fh.szName, 259, NULL, NULL); fh.ullSize ullSize; fh.dwCrc32 0; // 简化处理实际可以先扫一遍算出来 SendFrame(MSG_FILE_HEAD, m_dwSeq, (const char*)fh, sizeof(fh)); // 2. 分块发送32KB 是吞吐和延迟的折中 const DWORD CHUNK 32 * 1024; std::vectorchar buf(CHUNK); ULONGLONG ullOffset 0; while (ullOffset ullSize) { UINT nRead file.Read(buf.data(), CHUNK); if (nRead 0) break; FileChunkHead ch { 0 }; ch.dwFileId dwFileId; ch.ullOffset ullOffset; ch.dwDataLen nRead; // 块头和块数据拼在一起发减少一帧开销 std::vectorchar body(sizeof(ch) nRead); memcpy(body.data(), ch, sizeof(ch)); memcpy(body.data() sizeof(ch), buf.data(), nRead); if (!SendFrame(MSG_FILE_DATA, m_dwSeq, body.data(), (DWORD)body.size())) return false; ullOffset nRead; ::PostMessage(m_hWndOwner, WM_FILE_PROGRESS, (WPARAM)(ullOffset * 100 / ullSize), (LPARAM)dwFileId); } SendFrame(MSG_FILE_END, m_dwSeq, nullptr, 0); return true; }CHUNK取 32KB 是实践出来的8KB 太小帧头开销占比高64KB 以上单帧在弱网下重传代价大。32KB 在百兆内网基本能跑满公网也稳。接收端按偏移写入这一点解决了乱序和重传两个问题void CClientSocket::OnFileData(const std::string body) { FileChunkHead ch; memcpy(ch, body.data(), sizeof(ch)); const char* pData body.data() sizeof(ch); // 按偏移写入重传同一块也不会写错位置 m_recvFile.Seek(ch.ullOffset, CFile::begin); m_recvFile.Write(pData, ch.dwDataLen); m_recvFile.Flush(); // 及时落盘避免进程崩了全丢 m_ullRecvBytes ch.dwDataLen; }Flush()这一句有人觉得多余其实它在断电、进程被强杀的时候能救回已经收到的部分配合后面的断点续传就是天然的好搭档。4.4 进度显示与“看起来没反应”的处理进度条更新别每一块都刷。32KB 一块一个 100MB 的文件要发 3200 次每次都SetWindowText界面会明显卡。做法是在 UI 侧记一个上次刷新时间间隔小于 200ms 就只更新成员变量不刷控件。void CChatClientDlg::OnFileProgress(WPARAM wPercent, LPARAM lFileId) { DWORD dwNow GetTickCount(); m_nLastPercent (int)wPercent; if (dwNow - m_dwLastUiTick 200 wPercent ! 100) return; m_dwLastUiTick dwNow; m_progressFile.SetPos(m_nLastPercent); CString str; str.Format(_T(正在接收 %d%%), m_nLastPercent); m_staticTip.SetWindowText(str); }这种“限流刷新”是桌面程序的通用手法聊天窗口里收到大量消息时也一样别一条一条AddString攒 50ms 批量插一次肉眼完全看不出差别CPU 占用能差好几倍。5. 避坑与排查联调时最容易翻车的 5 个现场5.1 程序一启动就断言失败崩在 CSocket 构造现象调试运行还没看到对话框就弹出ASSERT失败调用栈停在CSocket::CSocket里。原因AfxSocketInit()没调或者调了但位置在 socket 对象构造之后。MFC 的CSocket构造函数内部会检查AfxSocketInit建立的内部状态没有就直接断言。解决确认InitInstance的第一行就是AfxSocketInit()且工程里没有任何全局的CListenSocket g_listen;这种写法。全局对象的构造发生在main之前那时候 winsock 环境根本不存在。5.2 中文用户名显示成问号或者方块现象本地调试好好的一换成中文昵称对方看到的就是???偶尔是自己的名字变乱码。原因MFC 工程默认 UnicodeCString是宽字符socket 上跑的是字节流。中间做转换时用了默认的CP_ACP而接收端按 UTF-8 解释两边对不上。解决定死一条规矩——网络上跑的一律是 UTF-8界面上一律是CString。发送前WideCharToMultiByte(CP_UTF8, ...)收到后MultiByteToWideChar(CP_UTF8, ...)。别用CStringA直接强转那玩意跟着系统区域设置走换台机器就变。// 统一封装避免到处手写转换 std::string CvtToUtf8(const CString str) { int nLen WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL); std::string out(nLen 0 ? nLen - 1 : 0, \0); if (nLen 1) WideCharToMultiByte(CP_UTF8, 0, str, -1, out[0], nLen, NULL, NULL); return out; }5.3 传大文件界面假死、内存一路涨到几个 G现象点发送界面直接卡住任务管理器里进程内存每秒涨几十兆几十秒后程序无响应。原因把整个文件Read进一个CByteArray再一次Send或者发送循环写在了按钮的响应函数里。前者吃内存后者阻塞 UI。解决分块读、分块发发送动作全部挪到工作线程UI 只负责收进度消息。同时给发送队列加背压——队列长度超过 64 块就等别无限堆。5.4 两个人同时发消息界面上的内容串成半句现象A 和 B 同时发消息界面上出现“你好在吗”这种拼接或者前一条的后半截跑到了下一行。原因接收缓冲区是全局单例两条连接的Receive往同一个std::string里append拆包时自然串了。解决缓冲区必须是每个会话对象自己的成员m_recvBuf定义在CSessionSocket里而不是CChatServerDlg里。同理ParseFrames也得是会话对象的成员方法加锁反而会掩盖设计错误。5.5 关窗口时断言_AFX_INET_SOCKET或者端口被占用起不来现象关闭对话框时弹断言或者第二次调试启动服务端直接bind失败。原因socket 对象析构前没Close()接收线程还在Receive上挂着。另外服务端重启时上一次的连接处于TIME_WAIT状态bind会报 10048。解决两件事。一是关闭流程写成“置m_bRunning false→Shutdown→Close→ 等线程退出 →delete”顺序不能乱先Close再等线程的话线程里的Receive会返回错误但对象可能已经没了。二是服务端监听 socket 在Create之后、Listen之前加一句BOOL bReuse TRUE; SetSockOpt(SO_REUSEADDR, bReuse, sizeof(bReuse));这个选项只影响bind的行为不会让两个进程抢同一个端口可以放心加。6. 二次开发从“能跑”到“能上线”的三处改造拿到这套代码跑通聊天和传文件只算起点。真要放到实际场景里用我一般会先做三处改造改动量都不大但收益明显。第一处是心跳加断线重连。现在代码里对端断电或者拔网线本地Receive可能长时间不返回界面上就一直显示“在线”。加一个 30 秒的心跳帧服务端连续两个周期没收到就主动Close清理会话客户端侧检测到Receive返回 0 就启动重连定时器指数退避重试。改动位置在CSessionSocket和一个新增的CReconnectTimer验证方法很简单用防火墙规则阻断端口看客户端是否在 60 秒内重连成功、界面状态是否正确翻转。改造点主要改动位置验证方法心跳与重连CSessionSocket::CheckHeartbeat、客户端OnClose分支断网 60 秒后恢复观察是否自动重连断点续传OnFileData增加.part临时文件与已收偏移记录传到中途强杀进程重启后从断点继续多房间群聊Broadcast增加房间 ID 过滤两个房间互发消息确认不串台第二处是断点续传。现在的实现是按偏移写文件已经具备基础。要做完整接收端在MSG_FILE_HEAD时检查同目录下有没有同名.part文件有的话把现有长度通过一个MSG_FILE_RESUME帧回给发送端发送端Seek到那个位置继续发。验证的时候故意在 60% 的位置杀掉接收进程重连后看进度条是不是从 60% 接着走而不是从头再来。第三处是给文件块加 CRC 校验的信道自适应。现在dwCrc32每帧都算跑在慢速设备上 CPU 会有点吃力。我习惯的做法是前 16 个块强制校验统计错误率低于千分之一就降为每 16 块抽检一次一旦抽检失败立刻恢复全量校验。这个策略在弱网下能明显提升吞吐代价是要多维护一个状态位。有个教训一直记着早期我图省事把接收缓冲区直接定义成了对话框类的成员单客户端测试一切正常一上双客户端联调就满屏乱码。从那以后我每次新起一个 socket 工程都会先把“一条连接一个会话对象、一个会话一个缓冲区、一个接收线程”这三条写进注释里再动键盘。希望帮到你。本文还有配套的精品资源点击获取