资讯动态

Windows TCP多客户端服务端:线程模型、拆包与群发背压

发布时间:2026/10/24 5:43:35 来源:尧图企业网站定制
简介面向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. 线程模型选型为什么我不建议一上来就上 IOCP2.1 三种模型摆在一张表里比选型这件事最怕的是“听说 IOCP 性能好”就直接抄。先把三种常见做法放在同一张表里看清楚各自的适用连接数限制和容易出问题的地方再去决定。模型单机舒适连接数代码复杂度典型翻车点阻塞 socket 每连接收发线程200 ~ 500低线程数爆炸、退出时线程 join 顺序错导致死锁select 轮询理论 1024实用 300 以内中FD_SETSIZE默认 64改它要全工程一起改每次轮询都要重填 fd_setIOCP 完成端口数千以上高重叠结构体生命周期、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::vectorSession*是最省事的写法也是最容易崩的写法——接收线程正在遍历接入线程把某个 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::vectorstd::shared_ptrSession g_sessions; // 接入线程调用只做登记不碰 IO void AddSession(const std::shared_ptrSession ss) { std::unique_lockstd::shared_mutex lk(g_sessMtx); g_sessions.push_back(ss); // 拷贝一份 shared_ptr引用计数 1 } // 从表中摘除返回被摘掉的对象调用方负责后续 shutdown/close std::shared_ptrSession DetachSession(const std::shared_ptrSession ss) { std::unique_lockstd::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_ptrSession 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”的问题根源都在这里所以帧格式要在写第一行业务代码之前就定死。这套工程用的是一张很小的头字段类型字节数说明bodyLenuint324负载长度网络字节序不含头cmduint162命令字网络字节序业务层用它区分消息类型reserveduint162预留对齐填 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 传SOMAXCONNWindows 会解析成一个系统认为合适的值通常是几百比手写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_sharedSession(); 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_ptrSession 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_ptrSession 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::vectoruint8_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_ptrconst std::vectoruint8_t保存帧队列里存的是这个智能指针200 个连接的内存开销就是 200 个指针约 3.2KB加一份实际数据。// 组一次帧全连接共享 using FramePtr std::shared_ptrconst std::vectoruint8_t; FramePtr MakeFrame(uint16_t cmd, const void* body, uint32_t bodyLen) { auto buf std::make_sharedstd::vectoruint8_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_ptrconst vector之后只读不改 }转成const是关键。共享的内存一旦被某个连接修改其余连接看到的就是脏数据。加上const之后任何试图写入的代码在编译期就会报错这条约束比写注释管用。4.3 快照 每连接发送队列的完整实现完整的群发分成三步先取连接表快照再组帧最后逐个入队。顺序很重要——先取快照再组帧能保证这条消息发出去的时候连接表不会被并发修改如果反过来组帧期间可能有连接进出快照里就会包含已经关闭的连接。void Broadcast(uint16_t cmd, const void* body, uint32_t bodyLen) { // 第一步在共享锁里拷一份 shared_ptr 列表很快不做任何 IO std::vectorstd::shared_ptrSession snapshot; { std::shared_lockstd::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_guardstd::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_ptrSession ss) { while (ss-alive.load()) { FramePtr item; { std::unique_lockstd::mutex lk(ss-qMtx); ss-cv.wait(lk, [] { return !ss-alive.load() || !ss-q.empty(); }); if (ss-q.empty()) break; // 被 alivefalse 唤醒且队列已空 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 以上危险基本等于没有保护慎用经验值是按“单连接内存预算”倒推假设服务端允许总内存 512MB200 个连接那么每条连接平均 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_ERRORWSAGetLastError()是 10048WSAEADDRINUSE明明任务管理器里那个进程已经退出了等一两分钟自己又好了。原因上一次运行时留下的连接处于TIME_WAIT状态这个状态会持续 2MSLWindows 默认约 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也没detachstd::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, host127.0.0.1, port9527): super().__init__(daemonTrue) 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), timeout3) self.s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) except Exception as e: print(f#{self.idx} connect failed: {e}) return threading.Thread(targetself._recv_loop, daemonTrue).start() while self.running: body fping-{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是每帧调一次erase200 客户端压测时 P99 直接冲到 800 微秒服务端 CPU 打满在memmove上。改成游标之后回到 20 微秒以内。句柄数是唯一能提前发现泄漏的指标24 小时挂机的价值就在这里。6.3 一个具体技巧把水位线做成可观测的最后一件事也是最容易被忽略的给Session加两个计数器totalFramesSent和dropCount然后用一个简单的 HTTP 端点或者定时日志把它们吐出来。这个动作只花二十分钟但出事的时候能救命——你能直接看出“这台设备是从来没发出去过”还是“发出去但一直在被踢”这两种情况的排查方向完全相反。我习惯在压测结束后做一次对账sum(所有客户端的 recv_frames)应该约等于sum(所有 Session 的 totalFramesSent)允许少量差异是因为压测结束时还有消息在空中。如果这两个数差了一个数量级说明有连接的发送队列在悄悄丢弃而代码里根本没打日志。从那以后我每次把这个服务端交付出去之前都强制跑一遍 200 客户端的 30 分钟压测加 12 小时挂机不看水位、句柄、对账这三项数据就不签字。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑