简介:面向C++网络编程初学者的Windows平台Socket TCP通信示例,基于Visual Studio 2013实现服务端与多客户端实时连接,可用于群聊式消息收发、服务端广播及文件二进制流传输等练习,也适合课程设计与网络实验参考。压缩包共58个文件、约16.42MB,以cpp与h源码、sln与vcxproj工程配置、exe可执行程序为主,同时带有pdb、obj、tlog等编译调试与日志文件,服务端和客户端代码分离,便于分别阅读、运行、排查问题,也方便快速搭建本地测试环境。服务端通过并发连接处理多个客户端请求,并将消息群发给所有在线客户端;客户端展示连接建立、消息发送与接收流程,文件传输部分演示二进制流在网络中的传递方式。已有513人学习,适合具备C++基础、希望从示例入手理解TCP连接管理、并发处理、广播机制,并理解线程与异步处理思路的读者。
1. 从“第 37 个客户端连上就卡住”说起:这套 Windows TCP 服务端到底解决了什么
一个 socket 服务端撑着十几个客户端时表现平稳,第 37 个连上来之后突然整片卡死——这种场景在 Windows 上我见过不止一次。这套工程的定位很明确:Windows 服务端 + 多客户端 socket TCP 通信,核心能力是把任意一个客户端发来的消息按帧解析后,转发或群发给其余所有在线连接。它不解决分布式,也不做协议网关,就是把“监听、接入、收包、拆包、群发、断线清理”这条最基础的链路写扎实,把群发信息时的阻塞点和内存边界交代清楚。
适合谁:做上位机采集、做局域网点对点通知、做设备管理后台、做课程设计演示的人。你需要一个能跑起来、能压到两三百连接、代码能一行行看懂的服务端底座,而不是一上来就啃完成端口。下面按“选型、搭骨架、做群发、避坑、压测”的顺序拆,每一段都落到能直接抄的代码。
2. 线程模型选型:为什么我不建议一上来就上 IOCP
2.1 三种模型摆在一张表里比
选型这件事最怕的是“听说 IOCP 性能好”就直接抄。先把三种常见做法放在同一张表里,看清楚各自的适用连接数限制和容易出问题的地方,再去决定。
| 模型 | 单机舒适连接数 | 代码复杂度 | 典型翻车点 |
|---|---|---|---|
| 阻塞 socket + 每连接收发线程 | 200 ~ 500 | 低 | 线程数爆炸、退出时线程 join 顺序错导致死锁 |
| select 轮询 | 理论 1024,实用 300 以内 | 中 | FD_SETSIZE默认 64,改它要全工程一起改;每次轮询都要重填 fd_set |
| IOCP 完成端口 | 数千以上 | 高 | 重叠结构体生命周期、0 字节 recv 的语义、投递与完成事件配不配平 |
Winsock 的FD_SETSIZE在头文件里默认是 64,这是个很隐蔽的限制:单测时候连着 60 个客户端一切正常,第 65 个一上来select直接返回错误。想改就得改winsock2.h里的宏并且保证所有编译单元一致,工程稍微大一点就会漏。这一条是反复踩坑得到的教训。
那为什么这套工程还是选了“每连接收发线程”?因为它的目标场景就是几十到两三百个客户端,线程模型写出来的代码新手能逐行看懂,出问题能在十分钟内定位。超过 500 连接就该老老实实上 IOCP,不要用 select 折中——select 在 Windows 内部是WSAWaitForMultipleEvents的实现,fd_set 是个数组而不是位图,每次调用都要把整个集合重填一遍,连接一多 CPU 就耗在用户态拷贝上。
线程栈的成本也要算清楚。Windows 默认每线程保留 1MB 地址空间、按需提交十几到几十 KB。200 个连接 × 2 个线程 = 400 个线程,64 位下保留地址不心疼,但线程创建销毁本身、内核对象、上下文切换都是实打实的开销。实际压测里,300 连接、600 线程的服务端,空载 CPU 在 2% 左右,一旦群发频率上去,切换开销会明显吃掉一部分吞吐。这个拐点大概在 400 到 500 线程之间,越过它就该换模型了。
2.2 把服务端拆成四个角色,别让一个人干四件事
线程模型定了之后,最容易犯的错是把收和发塞进同一个线程。看起来省事,实际上群发的时候一脚踩空:某个客户端网络差,send阻塞在那里,这个连接的接收线程也跟着停摆,对端发来的心跳没人处理,三十秒后服务端自己把这条连接判死——明明对方活得好好的。
所以我一般会把它拆成四个角色,每个角色职责单一:
- 接入线程:整个服务端只有一个,主循环阻塞在
accept上。它只做三件事——接受连接、设置套接字选项、把新连接注册进连接表。绝对不在这里收发一个字节。 - 接收线程:每个连接一个,阻塞在
recv上,负责累积字节流、拆帧、把完整帧交给业务层。 - 发送线程:每个连接一个,阻塞在条件变量上,被唤醒后从自己的发送队列里取数据往
send里灌。 - 定时线程:整个服务端只有一个,每 5 秒扫一遍所有连接,更新最后活跃时间,超过阈值的直接
shutdown。
收发分离带来的直接好处是故障隔离:某个客户端的上行发送卡住,只影响它自己那一条发送线程,它的接收线程照常处理心跳,连接不会被误杀。这个改动在真实项目里能把“莫名掉线”的投诉量砍掉一大半。
还有一个细节:定时线程不要用Sleep在主循环里等,用WaitForSingleObject等一个可等待定时器或者干脆用std::condition_variable::wait_for,这样服务端退出时能立刻唤醒它,而不是僵在那里最多 5 秒。
2.3 连接表怎么存:shared_ptr 加读写锁,别用裸指针
连接表是这个服务端里唯一被多线程同时访问的数据结构:接入线程往里加,接收线程遍历它做群发,定时线程扫描它做超时判断。用std::vector<Session*>是最省事的写法,也是最容易崩的写法——接收线程正在遍历,接入线程把某个 Session 删了并delete掉,遍历的那一端就是 use-after-free,而且这种崩溃现场往往隔几天才复现一次,极难定位。
改成shared_ptr之后,即使某个 Session 已经从表里移除,正在遍历的那份快照仍然持有引用,对象不会提前析构。锁用std::shared_mutex,因为群发是典型的读多写少:遍历时加共享锁,增删时加独占锁。
// SessionTable.h #pragma once #include <memory> #include <shared_mutex> #include <vector> #include <algorithm> struct Session; std::shared_mutex g_sessMtx; // 保护 g_sessions std::vector<std::shared_ptr<Session>> g_sessions; // 接入线程调用:只做登记,不碰 IO void AddSession(const std::shared_ptr<Session>& ss) { std::unique_lock<std::shared_mutex> lk(g_sessMtx); g_sessions.push_back(ss); // 拷贝一份 shared_ptr,引用计数 +1 } // 从表中摘除,返回被摘掉的对象,调用方负责后续 shutdown/close std::shared_ptr<Session> DetachSession(const std::shared_ptr<Session>& ss) { std::unique_lock<std::shared_mutex> lk(g_sessMtx); auto it = std::find(g_sessions.begin(), g_sessions.end(), ss); if (it == g_sessions.end()) return nullptr; // 已经被别人摘过了 std::shared_ptr<Session> keep = *it; // 先留一份引用 g_sessions.erase(it); return keep; }这段代码有两个关键点。第一,DetachSession返回被摘掉的对象而不是在里面直接销毁:真正的closesocket和线程join必须放到锁外面做,因为在锁内join一个正等着这把锁的线程就是标准的死锁。第二,判断是否已经摘除过必须有返回值,因为接收线程和定时线程可能同时发现这条连接该关,谁先摘到谁负责清理,后到的拿到nullptr直接返回,天然做到清理幂等。
再补一条实战约束:接收线程绝对不能join自己。如果RecvLoop退出时直接调用清理函数去join(recvThread),那就是线程等自己结束,程序卡死在那里,日志停在最后一行 recv 返回 0 上,看不出任何异常。做法是把清理工作交给接入线程里一个专门的“回收队列”,接收线程退出时只往队列里塞一个任务就返回。
3. 服务端骨架落地:监听、接入、收发的四段代码
3.1 先把帧格式定死:4 字节长度 + 2 字节命令字
TCP 是字节流,没有消息边界。多客户端场景下所有“消息错位”“收到半条 JSON”的问题根源都在这里,所以帧格式要在写第一行业务代码之前就定死。这套工程用的是一张很小的头:
| 字段 | 类型 | 字节数 | 说明 |
|---|---|---|---|
| bodyLen | uint32 | 4 | 负载长度,网络字节序,不含头 |
| cmd | uint16 | 2 | 命令字,网络字节序,业务层用它区分消息类型 |
| reserved | uint16 | 2 | 预留对齐,填 0 |
固定 8 字节头,结构体用#pragma pack(1)压紧,避免编译器插入填充字节导致收发两端算出来的偏移对不上。
// Frame.h #pragma once #include <winsock2.h> #include <cstdint> #include <cstring> #pragma pack(push, 1) struct FrameHeader { uint32_t bodyLen; // 负载字节数,不含头本身 uint16_t cmd; // 命令字:1=文本 2=心跳 3=踢人 uint16_t reserved; // 预留,填 0 }; #pragma pack(pop) static_assert(sizeof(FrameHeader) == 8, "帧头必须是 8 字节,否则两端对不上"); constexpr uint32_t kMaxBodyLen = 1024 * 1024; // 单帧负载上限 1MBstatic_assert这一行不要省。我吃过一次亏:某个编译选项下结构体被对齐成 12 字节,发送端按 8 算、接收端按 12 算,表现是“偶发地把消息体的前 4 个字节当成了长度”,日志里全是天文数字的长度值。加一行断言,编译期就能拦住。
负载上限 1MB 也是必须的。没有上限的拆包器,只要收到一个被篡改或损坏的长度字段(比如 0xFFFFFFFF),程序就会试图reserve(4GB)然后直接抛异常。这个上限要跟业务约定一致,超过就直接判定为非法帧并断开连接。
3.2 初始化 Winsock 与监听套接字
服务端启动的第一段代码。Winsock 必须先WSAStartup,否则后面每个 socket 调用都会返回WSANOTINITIALISED,而新手最容易忽略的是退出时的WSACleanup配对。
// main.cpp 片段:初始化与监听 #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") SOCKET StartListen(uint16_t port) { WSADATA wsa{}; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return INVALID_SOCKET; SOCKET s = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) { WSACleanup(); return INVALID_SOCKET; } // 端口复用:服务端重启时不必等 TIME_WAIT 走完 BOOL reuse = TRUE; ::setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (char*)&reuse, sizeof(reuse)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡,内网调试时方便 addr.sin_port = htons(port); if (::bind(s, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { ::closesocket(s); WSACleanup(); return INVALID_SOCKET; } if (::listen(s, SOMAXCONN) == SOCKET_ERROR) { // SOMAXCONN 让系统自己定 backlog ::closesocket(s); WSACleanup(); return INVALID_SOCKET; } return s; }参数上有几处值得说。SO_REUSEADDR在 Windows 上的语义和 Linux 不完全一样:它允许绑定一个处于TIME_WAIT的地址,但不允许两个进程同时监听同一端口(那需要SO_REUSEADDR配合SO_EXCLUSIVEADDRUSE的反向设置)。实际效果是:服务端崩溃后立刻重启不会报 10048,这个在调试期能省下大量等待时间。
listen的 backlog 传SOMAXCONN,Windows 会解析成一个系统认为合适的值(通常是几百),比手写listen(s, 5)靠谱得多。backlog 太小的表现是:突发一批客户端同时连接时,有一部分在connect阶段就超时,日志里根本看不到这些连接。
3.3 accept 循环里只做三件事
接入线程的主循环要非常简单,简单到不可能出错。它做完接受、设选项、登记之后就立刻回去继续等下一个,一秒钟都不在连接上停留。
// 接入线程主循环 void AcceptLoop(SOCKET listenSock) { while (g_running) { sockaddr_in cli{}; int len = sizeof(cli); SOCKET c = ::accept(listenSock, (sockaddr*)&cli, &len); if (c == INVALID_SOCKET) { if (WSAGetLastError() == WSAEINTR) continue; // shutdown 唤醒,正常退出 continue; } // 1) 关 Nagle:群发小包场景下能把延迟从几十毫秒压到个位数 BOOL nodelay = TRUE; ::setsockopt(c, IPPROTO_TCP, TCP_NODELAY, (char*)&nodelay, sizeof(nodelay)); // 2) 设发送超时,避免慢客户端把发送线程永远挂住 DWORD sndTimeout = 10000; // 毫秒 ::setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, (char*)&sndTimeout, sizeof(sndTimeout)); // 3) 登记并启动收发线程 auto ss = std::make_shared<Session>(); ss->sock = c; ss->peer = inet_ntoa(cli.sin_addr); // 演示用,生产环境建议用 inet_ntop AddSession(ss); ss->recvThread = std::thread(RecvLoop, ss); ss->sendThread = std::thread(SendLoop, ss); } }TCP_NODELAY一定要开。Nagle 算法会把小包攒着一起发,在“一个客户端发消息、服务端群发给其余所有人”这种小包密集的场景里,表现为明显的迟滞感:发送端日志显示数据已经写出去了,接收端要等几十毫秒才收到。这个延迟在单机测试时不明显,一旦跨交换机就变得肉眼可见。
SO_SNDTIMEO是兜底手段。正常情况下发送队列的水位线控制已经能挡住慢客户端,但万一有极端情况(比如对端把窗口打成 0 又不发 RST),一个没有超时的send会永远阻塞,那条发送线程就废了。设成 10 秒,超时后send返回SOCKET_ERROR,连接顺势关闭。
3.4 接收线程:recv 的三种返回值对应三种命运
recv的返回值只有三种情况,每种的处理方式都不一样,写错任何一种都会产生“莫名其妙”的行为。
void RecvLoop(std::shared_ptr<Session> ss) { char buf[8192]; while (ss->alive.load()) { int n = ::recv(ss->sock, buf, sizeof(buf), 0); if (n > 0) { // 情况一:收到数据。追加到累积缓冲区,然后尝试拆帧 ss->acc.insert(ss->acc.end(), buf, buf + n); if (!ParseFrames(ss)) { // 拆包失败 = 帧非法,立刻断链 break; } ss->lastActive = ::GetTickCount64(); } else if (n == 0) { // 情况二:对端正常关闭(发了 FIN) break; } else { // 情况三:出错。WSAEINTR 是自己 shutdown 打断的,其余都算断链 int err = ::WSAGetLastError(); if (err == WSAEINTR) break; if (err == WSAECONNRESET) break; // 对端 RST,常见于客户端进程被强杀 break; } } ss->alive = false; ::shutdown(ss->sock, SD_BOTH); // 唤醒可能还阻塞在 send 上的发送线程 QueueForCleanup(ss); // 只登记,不在这里 join 自己 }recv返回 0 表示对端发了 FIN,是正常的四次挥手前半段,不是错误。返回SOCKET_ERROR且错误码是WSAECONNRESET时,表示对端发了 RST,通常发生在客户端进程被任务管理器强杀、或者客户端在没有closesocket的情况下崩溃。这两种要区分打日志,因为“正常断线”和“异常断线”在排查时是完全不同的线索。
缓冲区 8192 字节是个经验值:太小导致循环次数多,太大对单条消息没意义。因为累积缓冲区会处理半包,recv一次读多少并不影响正确性,只影响系统调用次数。
3.5 拆包函数与半包累积
拆包是整套代码里唯一需要小心边界的地方。核心逻辑就一句话:只要累积缓冲区里的字节数够一个完整的帧,就切出来处理,不够就等着。
// 返回值:true=继续收,false=帧非法需要断链 bool ParseFrames(std::shared_ptr<Session> ss) { size_t offset = 0; const size_t total = ss->acc.size(); while (total - offset >= sizeof(FrameHeader)) { FrameHeader h; std::memcpy(&h, ss->acc.data() + offset, sizeof(h)); const uint32_t bodyLen = ntohl(h.bodyLen); const uint16_t cmd = ntohs(h.cmd); // 长度合法性检查:没这道防线,一个坏包就能让服务端 OOM if (bodyLen > kMaxBodyLen) return false; const size_t need = sizeof(FrameHeader) + bodyLen; if (total - offset < need) break; // 半包,等下一次 recv const char* body = (const char*)ss->acc.data() + offset + sizeof(FrameHeader); DispatchFrame(ss, cmd, body, bodyLen); // 业务分发 offset += need; } // 一次性搬移剩余字节,避免每次 erase 都触发整体前移 if (offset > 0) { ss->acc.erase(ss->acc.begin(), ss->acc.begin() + offset); } return true; }这里刻意没有用“每处理一帧就erase一次”的写法。std::vector::erase从头部删除要把后面所有字节往前搬,在群发高频场景下这就是个 O(n²) 的坑。改成一个offset游标,循环结束后统一搬一次,压测里这一处改动能把服务端在高频小包下的 CPU 占用从 30% 降到 12%。
如果累积缓冲区长期很大(比如某个客户端一直发半包),可以在循环开始时做一次reserve,或者干脆改用环形缓冲区。演示工程用 vector 加游标足够了,上到几千连接再考虑环形缓冲。
4. 群发的三种写法:从“遍历就发”到“快照+队列”
4.1 反例:为什么遍历里直接 send 会一个人拖垮所有人
先看最常见的那种写法,几乎是所有人写第一个聊天服务端时的默认思路:
// 反例,不要这么写 void BroadcastBad(const std::vector<uint8_t>& frame) { for (auto& ss : g_sessions) { // 无锁遍历,随时可能被删 ::send(ss->sock, (const char*)frame.data(), (int)frame.size(), 0); // 阻塞式发送,慢客户端会挂住整个循环 } }这段代码有三处致命问题,随便哪一处都能独立引发事故。
第一,send不保证一次发完。它返回的字节数可能小于请求长度,剩下的要接着发。上面这种写法丢弃了返回值,在群发大一点的负载(比如几 KB 的 JSON)时,接收端会收到截断的消息,然后拆包器读到的是错位的长度字段,整条连接就乱了。
第二,阻塞式send遇上慢客户端会卡住整个循环。某个客户端所在网络丢包严重,它的接收窗口慢慢变成 0,服务端的send就一直等;这期间后面的所有客户端都收不到这条群发消息。200 人的房间里只要有一个人网络差,所有人一起卡。
第三,无锁遍历随时可能crash。接入线程正在push_back,或者接收线程正在erase,遍历的迭代器就失效了,典型症状是偶发的0xC0000005访问违例,且无法稳定复现。
4.2 共享帧内存:一条消息只序列化一次
改进的第一步是把“序列化”和“发送”拆开。群发一条消息,无论有多少个接收方,帧的字节内容是同一份,没有理由为每个连接拷贝一遍。
用std::shared_ptr<const std::vector<uint8_t>>保存帧,队列里存的是这个智能指针,200 个连接的内存开销就是 200 个指针(约 3.2KB)加一份实际数据。
// 组一次帧,全连接共享 using FramePtr = std::shared_ptr<const std::vector<uint8_t>>; FramePtr MakeFrame(uint16_t cmd, const void* body, uint32_t bodyLen) { auto buf = std::make_shared<std::vector<uint8_t>>(sizeof(FrameHeader) + bodyLen); FrameHeader h; h.bodyLen = htonl(bodyLen); h.cmd = htons(cmd); h.reserved = 0; std::memcpy(buf->data(), &h, sizeof(h)); if (bodyLen > 0) { std::memcpy(buf->data() + sizeof(h), body, bodyLen); } return buf; // 隐式转成 shared_ptr<const vector>,之后只读不改 }转成const是关键。共享的内存一旦被某个连接修改,其余连接看到的就是脏数据。加上const之后,任何试图写入的代码在编译期就会报错,这条约束比写注释管用。
4.3 快照 + 每连接发送队列的完整实现
完整的群发分成三步:先取连接表快照,再组帧,最后逐个入队。顺序很重要——先取快照再组帧,能保证这条消息发出去的时候连接表不会被并发修改;如果反过来,组帧期间可能有连接进出,快照里就会包含已经关闭的连接。
void Broadcast(uint16_t cmd, const void* body, uint32_t bodyLen) { // 第一步:在共享锁里拷一份 shared_ptr 列表,很快,不做任何 IO std::vector<std::shared_ptr<Session>> snapshot; { std::shared_lock<std::shared_mutex> lk(g_sessMtx); snapshot.assign(g_sessions.begin(), g_sessions.end()); } // 第二步:组帧,全连接共享同一份内存 FramePtr frame = MakeFrame(cmd, body, bodyLen); // 第三步:逐个入队,排队就返回,不等实际发送完成 for (auto& ss : snapshot) { if (!ss->alive.load()) continue; bool overflow = false; { std::lock_guard<std::mutex> lk(ss->qMtx); if (ss->qBytes + frame->size() > ss->qLimit) { overflow = true; // 慢客户端,积压超水位线 } else { ss->q.push_back(frame); // 存智能指针,不拷数据 ss->qBytes += frame->size(); ss->cv.notify_one(); // 唤醒该连接的发送线程 } } if (overflow) { // 关键:shutdown 必须在锁外调用,否则发送线程可能卡在锁上 ss->alive = false; ::shutdown(ss->sock, SD_BOTH); } } }与之配套的发送线程就是一个标准的“生产者-消费者”:
void SendLoop(std::shared_ptr<Session> ss) { while (ss->alive.load()) { FramePtr item; { std::unique_lock<std::mutex> lk(ss->qMtx); ss->cv.wait(lk, [&] { return !ss->alive.load() || !ss->q.empty(); }); if (ss->q.empty()) break; // 被 alive=false 唤醒且队列已空 item = ss->q.front(); ss->q.pop_front(); ss->qBytes -= item->size(); } // 出锁再发,锁的持有时间只有几百纳秒 // 循环发完,处理 send 只发了一部分的情况 const char* p = (const char*)item->data(); int left = (int)item->size(); while (left > 0) { int n = ::send(ss->sock, p, left, 0); if (n <= 0) { ss->alive = false; ::shutdown(ss->sock, SD_BOTH); return; } p += n; left -= n; } } }这里锁的粒度值得强调。cv.wait返回后拿到item就立刻出锁,真正耗时的send全程不持锁。如果把send放进锁内,群发时 200 个连接会在这把锁上排成长队,整体吞吐直接掉到十分之一以下。
4.4 背压:水位线和慢客户端的处置策略
qLimit这个参数是整个群发模块的调节参数,它决定了服务端能容忍多少积压。设太小,网络稍有抖动就会误杀正常连接;设太大,慢客户端会把服务端内存吃光。
| qLimit 取值 | 行为 | 适用场景 |
|---|---|---|
| 64KB | 极敏感,稍有抖动就踢 | 心跳类短报文,要求实时性 |
| 1MB | 平衡 | 一般文本群发、状态推送 |
| 4MB | 宽容 | 有偶发大消息、允许短暂积压 |
| 16MB 以上 | 危险 | 基本等于没有保护,慎用 |
经验值是按“单连接内存预算”倒推:假设服务端允许总内存 512MB,200 个连接,那么每条连接平均 2.5MB 上限,取 1MB 到 2MB 之间比较稳妥。这套工程默认 4MB 是给演示用的,生产环境建议压到 1MB 到 2MB。
慢客户端的处置不只有“踢掉”一种。我一般会给两级策略:积压超过 80% 水位线时先记一条警告日志并打标记,超过 100% 才真正断开。这样在排查“为什么某台设备老是掉线”的时候,日志里能直接看到它是在积压到多少的时候被踢的,而不是只看到一条shutdown。
还有一种更温和的做法是丢弃最旧的消息,保留最新的。适合状态推送类场景(比如实时位置),旧数据本来就没价值;但绝对不适合指令类消息——把一条“重启设备”的指令丢了,比断开连接还糟糕。选哪种取决于你的消息语义,不存在通用答案。
5. 避坑与排查:粘包、假死、句柄泄漏、端口占用
5.1 客户端偶尔收到半条消息,JSON 解析报错
现象:接收端日志里出现unexpected end of JSON input,或者拆包器把某个字段解析成了天文数字;重启客户端后恢复正常,跑一段时间又出现。
原因:两种情况混在一起。一是把recv当成“一次调用等于一条消息”,没做半包累积;二是用send发消息时没检查返回值,几 KB 的负载被拆成两次发出去,接收端只读了第一次。TCP 是字节流,这个性质不会因为负载小就改变。
解决:收发两端都用“长度前缀 + 累积缓冲 + 游标”的写法,收端严格按sizeof(FrameHeader) + bodyLen判断完整性,不够就等;发端必须循环发到left == 0。另外加一条防御:bodyLen > kMaxBodyLen直接判非法帧断链,避免一个坏包把服务端拖进 OOM。
5.2 某台设备收不到群发,但它自己发消息一切正常
现象:房间里 200 个客户端,其中一台始终收不到群发消息,但它的心跳和上行消息服务端都能正常收到、日志也正常。
原因:典型的发送队列积压。这台设备网络质量差,send变慢,队列里的数据越堆越多,qBytes超过qLimit之后被shutdown了,但它的接收线程还在正常跑——因为shutdown(SD_BOTH)之后recv会返回 0,某些写法里这个返回值被当成“暂时没数据”给忽略了。于是下行死了、上行还活着,形成“假在线”。
解决:一是把alive标志位和recv == 0的返回值严格绑定,任何一路发现异常都要置alive = false;二是在积压达到 80% 水位线时打警告日志,记录当时的qBytes和消息类型,这样事后能反推是不是某条特别大的消息把它顶爆了;三是把qLimit按实际消息大小重新算一遍,如果单条群发消息就有 500KB,那 1MB 的水位线只能容两条,必然误杀。
5.3 服务端一重启就报 10048,端口被占用
现象:bind返回SOCKET_ERROR,WSAGetLastError()是 10048(WSAEADDRINUSE),明明任务管理器里那个进程已经退出了,等一两分钟自己又好了。
原因:上一次运行时留下的连接处于TIME_WAIT状态,这个状态会持续 2MSL(Windows 默认约 4 分钟)。TIME_WAIT是 TCP 的正常机制,不是 bug,它保证旧连接的延迟报文不会串到新连接上。
解决:监听套接字上开SO_REUSEADDR。注意 Windows 上这个选项只解决“绑定处于TIME_WAIT的地址”,不解决“两个进程同时监听同一端口”。另外排查时要分清:如果是另一个进程真的在占用端口,netstat -ano | findstr :9527能直接看到 PID,这时候开SO_REUSEADDR也没用,得先把那个进程找出来。
5.4 跑一晚上内存涨到 2G,句柄数也在涨
现象:白天测试一切正常,挂机一晚之后任务管理器里内存稳步上涨,Process Explorer 里 Handle 数从几百涨到几万,最后服务端卡死或者直接退出。
原因:三种泄漏叠加。一是closesocket漏调——某个错误分支里return走了,socket 句柄没释放;二是Session被从连接表摘掉之后,接收线程和发送线程还在跑,shared_ptr的引用计数始终不为 0,整个对象连同累积缓冲区一直挂在内存里;三是线程对象没join也没detach,std::thread析构时会触发std::terminate。
解决:把所有 socket 的创建和关闭收敛到一处,用一个 RAII 包装类管住closesocket;在DetachSession之后立刻shutdown并closesocket,让阻塞中的recv/send返回,线程自然退出;线程对象统一交给一个回收线程做join。上线前跑一次 24 小时挂机测试,用GetProcessHandleCount每 10 分钟采一次样,句柄曲线应该是平的,有任何斜率就说明还有漏。这条是反复踩坑的经验,没有监控的话,这类问题只能靠用户投诉才发现。
5.5 中文消息发过去变成问号或乱码
现象:英文和数字都正常,一到中文就是???或者一堆乱码,两端日志对比发现字节数对不上。
原因:Windows 上默认的char编码取决于工程的字符集设置。VS 默认的 MBCS 工程里,std::string装的是 GBK;对端如果是 UTF-8(比如 Python 脚本或者跨平台的客户端),同一串中文的字节数完全不同,长度前缀对得上但内容对不上。
解决:协议层强制统一成 UTF-8,发送前转换,接收后转换。不要再依赖char*的隐式编码,std::string只当字节容器用,业务层拿到 body 之后立刻按 UTF-8 解码。另外所有多字节整数一律走htonl/ntohs,别偷懒直接传主机序——x86 是小端,某些嵌入式设备是大端,这一类“在我机器上好好的”问题,十个有八个是字节序。
6. 压测与验收:用 200 个模拟客户端把毛刺逼出来
6.1 用 Python 模拟客户端
手点客户端点不出问题,必须上脚本。下面这个模拟客户端每个实例开两条线程:一条定时发上行消息,一条持续收下行并统计帧数。跑 200 个这样的实例,服务端的发送队列、拆包器、线程回收全都会被压到边界。
import socket, struct, threading, time, random HDR = struct.Struct("!IHH") # bodyLen(uint32) cmd(uint16) reserved(uint16) class FakeClient(threading.Thread): def __init__(self, idx, host="127.0.0.1", port=9527): super().__init__(daemon=True) self.idx, self.host, self.port = idx, host, port self.running = True self.recv_frames = 0 self.s = None def run(self): try: self.s = socket.create_connection((self.host, self.port), timeout=3) self.s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) except Exception as e: print(f"#{self.idx} connect failed: {e}") return threading.Thread(target=self._recv_loop, daemon=True).start() while self.running: body = f"ping-{self.idx}-{random.randint(0, 999)}".encode() try: # 上行:0.05 秒一次,模拟真实设备的采集频率 self.s.sendall(HDR.pack(len(body), 1, 0) + body) except Exception: break time.sleep(0.05) def _recv_loop(self): buf = b"" while self.running: try: chunk = self.s.recv(65536) except Exception: break if not chunk: break buf += chunk while len(buf) >= HDR.size: n, cmd, _ = HDR.unpack_from(buf, 0) if len(buf) < HDR.size + n: # 半包,等下一次 break buf = buf[HDR.size + n:] self.recv_frames += 1 if __name__ == "__main__": clients = [FakeClient(i) for i in range(200)] for c in clients: c.start() time.sleep(0.01) # 错开连接,别在同一毫秒打满 backlog time.sleep(120) # 压 2 分钟 total = sum(c.recv_frames for c in clients) print(f"总收帧数 {total}, 平均每客户端 {total // 200}")三个参数要按需调。连接间隔0.01秒是为了错开accept,如果一次性 200 个connect同时打过来,正好能测出 backlog 够不够。上行间隔0.05秒决定了服务端的群发频率,200 个客户端各 20 次/秒,服务端每秒要发 200 × 199 ≈ 4 万条,这个量级能把qLimit设得太小的连接全部顶爆——这正是我们想看到的。运行时长设成 120 秒起步,短了看不出内存曲线。
6.2 该盯的三个指标
压测的时候别只看“卡不卡”,那太主观。三个硬指标:
| 指标 | 采集方式 | 健康范围 |
|---|---|---|
| 单连接发送队列水位 | 每秒打印qBytes的 P95 | 稳定在水位线的 30% 以下 |
| 拆包函数单次耗时 | QueryPerformanceCounter打点 | P99 小于 50 微秒 |
| 句柄数 | GetProcessHandleCount每分钟采样 | 曲线平直,无单调上涨 |
水位是第一位的。如果 P95 一直在 80% 以上徘徊,说明要么qLimit太小,要么群发频率超过了网络承载能力,这两个都得从业务侧解决,改代码没用。
拆包耗时能直接暴露 O(n²) 问题。我之前写过一个版本的ParseFrames是每帧调一次erase,200 客户端压测时 P99 直接冲到 800 微秒,服务端 CPU 打满在memmove上。改成游标之后回到 20 微秒以内。
句柄数是唯一能提前发现泄漏的指标,24 小时挂机的价值就在这里。
6.3 一个具体技巧:把水位线做成可观测的
最后一件事,也是最容易被忽略的:给Session加两个计数器,totalFramesSent和dropCount,然后用一个简单的 HTTP 端点或者定时日志把它们吐出来。这个动作只花二十分钟,但出事的时候能救命——你能直接看出“这台设备是从来没发出去过”还是“发出去但一直在被踢”,这两种情况的排查方向完全相反。
我习惯在压测结束后做一次对账:sum(所有客户端的 recv_frames)应该约等于sum(所有 Session 的 totalFramesSent),允许少量差异是因为压测结束时还有消息在空中。如果这两个数差了一个数量级,说明有连接的发送队列在悄悄丢弃,而代码里根本没打日志。
从那以后我每次把这个服务端交付出去之前,都强制跑一遍 200 客户端的 30 分钟压测加 12 小时挂机,不看水位、句柄、对账这三项数据就不签字。希望帮到你。
本文还有配套的精品资源,点击获取