简介:围绕服务端与客户端双向通信需求整理的一套 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 三个类划清职责:监听、连接、界面各管一摊
这套工程的类结构不复杂,但职责必须切干净,否则后面加群聊、加文件传输会互相污染。实际工程里的划分大致是这样:
| 类名 | 基类 | 职责 |
|---|---|---|
CListenSocket | CAsyncSocket | 服务端监听端口,OnAccept里为每个新连接创建会话对象 |
CSessionSocket | CSocket | 一条连接的收发,持有接收缓冲区,负责拆包和分发 |
CChatClientDlg | CDialogEx | 界面逻辑,接收自定义消息,渲染聊天记录和进度条 |
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_cast<CSessionSocket*>(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_cast<LPARAM>(pMsg));这里用PostMessage而不是SendMessage的原因很实际:SendMessage是同步的,会一直等到 UI 线程处理完才返回。如果 UI 线程正在把收到的文件块写盘(哪怕只写 32KB),接收线程就被卡住,TCP 接收窗口很快被填满,发送端跟着阻塞,整个链路的吞吐直接掉一半。用PostMessage是异步投递,代价是队列可能堆积,所以要配一个上限——超过 500 条就丢弃最旧的非关键消息,这一条在文件传输场景里很关键,后面还会提到。
LPARAM传堆指针,UI 侧的响应函数第一件事就是std::unique_ptr<ChatMsg> p(reinterpret_cast<ChatMsg*>(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::vector<char> 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::vector<char> frame; DWORD dwSendTick; // GetTickCount(),用于超时判定 int nRetry; }; std::map<DWORD, 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::set<DWORD> m_seenSeq,收到dwSeq已在集合里就直接回 ACK 不再处理,避免重传导致同一条聊天消息显示两遍。文件块去重更省事,因为块里带偏移量,重复写同一个偏移位置内容是一样的。
4. 核心功能落地:登录、群聊转发、文件分块传输
4.1 消息类型表与分发入口
协议定好之后,所有业务都收敛到Dispatch一个函数里,对着byType做switch。这张表建议直接写在头文件里当文档用:
| byType | 名称 | 包体内容 | 方向 |
|---|---|---|---|
| 1 | MSG_LOGIN | 用户名 UTF-8 | 客户端 → 服务端 |
| 2 | MSG_CHAT | 发送者 + 文本 | 双向 |
| 3 | MSG_FILE_HEAD | 文件名 + 总大小 + CRC32 | 客户端 → 服务端 → 对端 |
| 4 | MSG_FILE_DATA | 文件ID + 偏移 + 数据 | 同上 |
| 5 | MSG_FILE_END | 文件ID + 总块数 | 同上 |
| 6 | MSG_ACK | 被确认的 dwSeq | 双向 |
| 7 | MSG_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::vector<char>& frame, CSessionSocket* pFrom) { // 加锁保护在线列表,Accept 和断开都可能并发改动它 std::lock_guard<std::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::vector<char> 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::vector<char> 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 工程默认 Unicode,CString是宽字符;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 工程,都会先把“一条连接一个会话对象、一个会话一个缓冲区、一个接收线程”这三条写进注释里,再动键盘。希望帮到你。
本文还有配套的精品资源,点击获取