资讯动态

WinSock2+select多客户端Socket骨架:粘包、心跳与群发

发布时间:2026/10/24 4:17:52 来源:尧图企业网站定制
简介这是一份面向 Windows 平台网络编程学习者的 Socket TCP 通信示例包重点演示服务端与多客户端之间的实时连接、消息群发及二进制文件流传输适合想理解群聊式通信与并发处理的中初级开发者参考。压缩包共 58 个文件、约 16.42MB内含 4 个 cpp 源文件、2 个 h 头文件及 vcxproj、sln 工程配置另有 exe、pdb、obj、tlog 等编译产物和调试日志可据此还原 Visual Studio 2013 下服务端与客户端两个工程的构建结构。目前已有 513 人学习说明该案例对入门 TCP 连接、多线程或异步 I/O 与广播逻辑具有一定参考热度。读者可从中梳理 Socket 创建、连接监听、客户端并发接入、消息转发和文件二进制流传输的实现脉络观察服务端如何维护连接列表并群发消息、客户端如何接收解析数据也可借助 Debug/Release 输出、源码与日志排查编译运行问题作为课程实验、网络编程练习或小型即时通讯原型的起步素材。1. 局域网里一台服务端、几十台客户端这套 socket 骨架到底能干什么前阵子帮一个做现场设备的团队理代码需求很朴素一台 Windows 机器当服务端三十几台工控机跑客户端每 3 秒上报一次温度和转速服务端要能选中某几台把配置指令群发下去。这种活不需要 WebSocket也不需要搭消息中间件原生 TCP 加 socket 就能干完。这个项目就是把这套东西跑通的最小骨架Windows 服务端用 WinSock2 监听端口多客户端并发接入服务端维护一张在线表支持点对点下发和群发消息。适合刚碰网络编程、想跑通一个能同时连几十台机器的 demo 的开发者也适合手里有内网设备、要临时搭一条状态采集和指令通道的工程人员。代码不长但长度前缀、心跳、非阻塞、并发写这些关键点都在里面拿它当底子改成自己的业务协议比从空文件开始省事得多。2. 工程骨架与选型WinSock2 加 select 为什么够用2.1 三个目录各管一段服务端、客户端、协议头这套代码的结构没有花活三个目录切开公共协议单独放服务端和客户端都引用同一份头文件。这样做的好处是协议一改两边同时生效不会出现一端加了个字段、另一端还在按老结构解析的翻车现场。目录作用关键文件说明server/服务端主程序main.cpp、session.h、session.cpp、broadcast.cpp监听、接入、事件循环、群发client/客户端主程序main.cpp、recv_thread.cpp双线程收发、断线重连common/共享协议与工具protocol.h、net_util.h消息帧、字节序转换、切帧函数实际工程里我一般再加一个third_party/放日志库但这份代码为了好读日志直接用printf加时间戳够用。net_util.h里放的是packFrame、tryPopFrame、setNonBlock这类纯函数服务端和客户端都能调避免同一段切帧逻辑抄两遍、改一处漏一处。目录拆清楚以后调试时看崩溃栈落在哪个目录大致就能判断是连接层还是业务层的问题。2.2 select 还是 IOCP先按连接量级选不要一上来就上重的Windows 下做多客户端 TCP绕不开三个模型的选择一连接一线程、select、IOCP。很多教程上来就讲 IOCP但对几十到几百连接的场景select 的代码量和调试成本低得多事件循环是单线程的加日志、打断点都直观。这套代码选的是 select判断依据就是连接量级在内网工具的范围内。模型舒适连接量代码量典型问题阻塞 每连接一线程50 以内最短连接多了线程切换开销大栈内存吃紧select几十到几百短每轮要重建 fd_set遍历全表fd 数量有上限IOCP数千以上长完成端口回调里不能做阻塞操作调试门槛高我一般的做法是先用 select 把协议、心跳、群发这些业务骨架跑通等连接数真的顶到几百以上、CPU 开始被遍历吃掉再把事件层换成 IOCPSession 结构体和协议层原样保留。先跑通再谈吞吐比一上来啃完成端口、结果连粘包都没处理对要划算。这里有个必须先说的点Windows 的fd_set默认只装得下 64 个套接字超过就静默丢弃所以要在包含winsock2.h之前#define FD_SETSIZE 1024这一行忘了连到第 65 台就开始出现连上了但收不到消息的玄学问题。2.3 初始化和监听几行代码里藏着四个必填参数服务端启动的第一段代码就是 WinSock 初始化和建立监听套接字。这段代码短但每个参数填错都会在后面的调试里变成难查的怪现象所以逐行说清楚。// server/main.cpp初始化 WinSock 并建立监听套接字 #define FD_SETSIZE 1024 // 必须写在 winsock2.h 之前否则 fd_set 只装 64 个 #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) // MSVC 下自动链接MinGW 要手动加 -lws2_32 int main() { WSADATA wsa; // 请求 2.2 版本实际拿到的是系统支持的最高版本 if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { printf(WSAStartup failed: %d\n, WSAGetLastError()); return -1; } SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } // 允许端口在 TIME_WAIT 状态下复用调试期反复重启不至于报 10048 BOOL reuse TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)reuse, sizeof(reuse)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡别写成 127.0.0.1 addr.sin_port htons(9000); // 端口必须转网络字节序 if (bind(listenSock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } // SOMAXCONN 让系统用默认的最大等待队列内网几十连接足够 if (listen(listenSock, SOMAXCONN) SOCKET_ERROR) { printf(listen failed: %d\n, WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } // 配合 select 使用accept 不会卡在没连接的时候 u_long nonBlock 1; ioctlsocket(listenSock, FIONBIO, nonBlock); runEventLoop(listenSock); // 进入第 3 章的事件循环 closesocket(listenSock); WSACleanup(); // 和 WSAStartup 必须配对 return 0; }逻辑上就是四步申请 WinSock 环境、建套接字、绑地址、进监听。参数上要盯住几个地方。MAKEWORD(2, 2)表示请求 2.2 版本WSAStartup不调用的话后面所有 socket 函数都会返回WSANOTINITIALISED。SO_REUSEADDR解决的是程序刚退出、端口还处在 TIME_WAIT 时立刻重启会bind失败的问题错误码是 10048开发阶段一天要重启几十次不设这个能烦死人。INADDR_ANY和127.0.0.1的区别决定了别的机器能不能连进来只绑回环地址时本机测试全绿、换台机器就connect超时这是最常见的代码没问题但连不上。失败时看什么bind报 10048 就是端口被占netstat -ano | findstr :9000找到 PID 再决定是杀进程还是换端口报 10049 是地址不可用多半是sin_addr填错了。listen几乎不会失败真失败一般是套接字句柄已经失效。ioctlsocket设非阻塞以后accept和recv返回SOCKET_ERROR且WSAGetLastError()是WSAEWOULDBLOCK属于正常情况不是错误代码里必须把这个分支单独放过去否则连接一多就自己把自己踢下线。3. 服务端核心连接接入、在线表与群发的完整链路3.1 Session 结构体一条连接要记哪些东西服务端的核心不是套接字本身而是围绕每条连接维护的上下文。这份代码里叫Session字段不多但每一块都对应一个实际会用到的东西少一个后面就得补。// server/session.h struct Session { SOCKET sock INVALID_SOCKET; std::string ip; // 对端 IP日志和黑白名单用 int port 0; // 对端端口排查多客户端时区分来源 std::vectorchar recvBuf; // 收包缓存半包跨多次 recv 保留 std::mutex sendMtx; // 同一连接只允许一个线程写 std::chrono::steady_clock::time_point lastActive; // 最后一次收到数据的时间心跳剔除用 std::atomicbool closed{false}; // 标记关闭避免在遍历中直接 erase };recvBuf是这个结构里最容易被忽略、又最不能省的字段。TCP 是字节流一次recv可能拿到半个包也可能拿到一个半包缓冲区就是把没凑齐的字节先存着等下一次数据到了再拼。sendMtx解决的是群发时的写交错问题两个线程同时对同一个套接字send字节流会混在一起客户端解析直接崩。lastActive配合事件循环的超时做掉线判定。closed用原子布尔而不是直接删指针是因为事件循环正在遍历在线表边遍历边删会踩空指针正确做法是先标记、循环结束后统一回收。在线表本身用std::mapSOCKET, Session*或者std::vectorSession*都行前者查找快、后者遍历快。群发是遍历场景我一般用 vector删除时配合closed标记和std::remove_if批量清。要注意的是这张表只在事件循环那个线程里改别的线程要动它必须走任务队列不然加锁加到哪都是死锁。3.2 事件循环每轮重建 fd_set超时用来跑心跳select 的用法有个固定套路每轮循环都要把关心的套接字重新塞进fd_set因为select返回时会把没就绪的清掉不清空重建的话第二轮就漏事件。这是初学者最容易写错的地方表现是第一轮正常后面偶尔丢消息。// server/main.cppselect 主事件循环 fd_set readSet, writeSet; std::vectorSession* sessions; // 在线表 while (g_running) { FD_ZERO(readSet); FD_ZERO(writeSet); FD_SET(listenSock, readSet); // 监听套接字永远关心可读有新连接 for (auto* s : sessions) { if (s-closed) continue; FD_SET(s-sock, readSet); if (s-hasPendingSend) { // 只有真有积压数据才关心可写 FD_SET(s-sock, writeSet); } } timeval tv{1, 0}; // 1 秒超时给心跳扫描留出执行机会 // Windows 下第一个参数没有意义填 0Linux 下要填最大 fd 1 int n select(0, readSet, writeSet, nullptr, tv); if (n SOCKET_ERROR) break; if (n 0) { // 超时没有网络事件正好做心跳检查 checkHeartbeat(sessions); continue; } if (FD_ISSET(listenSock, readSet)) { acceptLoop(listenSock, sessions); // 可能一次来多个连接循环 accept 到 EWOULDBLOCK } for (auto* s : sessions) { if (s-closed) continue; if (FD_ISSET(s-sock, readSet)) onReadable(s); if (FD_ISSET(s-sock, writeSet)) onWritable(s); // 把积压的发送队列吐出去 } reapClosed(sessions); // 统一回收标记为 closed 的会话 }这段代码有三处参数值得单独说。select的第一个参数在 Windows 下被忽略填 0 就行移植到 Linux 才需要传最大描述符加一。timeval不能传nullptr传了就变成永久阻塞心跳检查永远轮不到执行客户端拔了网线服务端还傻等。writeSet只在有积压数据时才加入因为套接字绝大多数时间是可写的无条件关注会让select每轮立刻返回CPU 直接跑满这个坑我在早期项目里踩过一次风扇狂转还以为是被挖矿了。acceptLoop里要循环调用accept直到返回INVALID_SOCKET且错误码是WSAEWOULDBLOCK因为一次select可读可能对应多个排队连接只accept一次会把后面的连接晾在队列里表现是同时开五个客户端只连上两个。新接入的套接字同样要ioctlsocket设成非阻塞然后放进在线表、打一条日志。3.3 收包与切帧长度前缀加缓冲区一次 recv 不等于一个包收包这段是整个服务端最需要写对的地方。业务层希望拿到的是完整消息recv给的却是任意长度的字节块中间必须有一层切帧。// server/session.cpp收包与切帧 const int HEAD_LEN 4; // 4 字节长度前缀 char buf[4096]; int n recv(s-sock, buf, sizeof(buf), 0); if (n 0) { // 对端正常关闭 closeSession(s); return; } if (n SOCKET_ERROR) { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK) { // 非阻塞下 EWOULDBLOCK 是正常现象 closeSession(s); } return; } s-recvBuf.insert(s-recvBuf.end(), buf, buf n); s-lastActive std::chrono::steady_clock::now(); // 循环切帧够头读长度够整包交付业务 while (s-recvBuf.size() HEAD_LEN) { uint32_t bodyLen 0; memcpy(bodyLen, s-recvBuf.data(), HEAD_LEN); bodyLen ntohl(bodyLen); // 发送端用 htonl接收端必须转回来 // 防御异常长度0 和超过 64KB 都当成非法包直接断开 if (bodyLen 0 || bodyLen 64 * 1024) { closeSession(s); return; } if (s-recvBuf.size() HEAD_LEN bodyLen) break; // 半包等下次数据 std::string body(s-recvBuf.data() HEAD_LEN, bodyLen); s-recvBuf.erase(s-recvBuf.begin(), s-recvBuf.begin() HEAD_LEN bodyLen); dispatch(s, body); // 交给业务分发按 cmd 走不同处理 }逻辑就三步先把收到的字节追加到连接自己的缓冲区然后循环判断缓冲区里有没有凑够一个完整包够就切出来交给业务不够就退出等下一轮。参数上HEAD_LEN固定 4 字节能表达 4GB 的长度实际业务限制在 64KB 以内超过就当异常包断开防止对面发个假长度把内存撑爆。ntohl不能省发送端如果用了htonl接收端不转就会读出一个天文数字然后一直等一个永远凑不齐的包表现是客户端明明发了服务端就是不处理。erase每次切包都从头删包多的时候有内存搬移开销。连接数不大时无所谓真上量了改成记录一个读偏移量缓冲区消费到一半再统一压缩能省不少 CPU。dispatch里按命令字分支心跳包直接回一个心跳响应文本包转给群发或者点对点未知命令字记日志丢弃不要因为一个坏包把整条连接断掉除非是长度非法这种结构性问题。3.4 群发循环 send 加每连接一把锁别在主循环里直接删表群发听起来像一次发给所有人落到代码上就是遍历在线表、对每条连接单独send。这里有两个必须处理的问题同一个连接不能被两个线程同时写以及慢客户端不能拖住整个群发流程。// server/broadcast.cpp bool sendAll(Session* s, const char* data, int len) { std::lock_guardstd::mutex lk(s-sendMtx); // 同一连接串行写避免字节流交错 int sent 0; while (sent len) { int n send(s-sock, data sent, len - sent, 0); if (n SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAEWOULDBLOCK) { // 缓冲区满把剩余数据挂到该连接的发送队列等可写事件续发 s-pending.append(data sent, len - sent); s-hasPendingSend true; return true; } return false; } sent n; // send 可能只发出去一部分必须循环 } return true; } void broadcast(const std::string msg) { std::string frame packFrame(msg, CMD_TEXT); // 统一加长度前缀 for (auto* s : g_sessions) { if (s-closed) continue; if (!sendAll(s, frame.data(), (int)frame.size())) { markClosed(s); // 只标记不在遍历中 erase } } }sendAll里的while循环是关键。send的返回值小于请求长度是常态尤其对面接收慢的时候只调一次就认为发完客户端那边就会收到半截消息。WSAEWOULDBLOCK分支处理的是发送缓冲区已满这时候不能阻塞等要把剩余数据挂到连接自己的发送队列靠select的可写事件继续吐。sendMtx保证一条连接的写入是串行的即使业务线程和群发线程同时想给它发消息也不会把两个帧的字节搅在一起。broadcast里markClosed而不是直接删是因为外层正在遍历g_sessions删元素会让迭代器失效。标记完等这一轮遍历结束统一reapClosed回收套接字和内存。群发还有一个隐含的性能问题如果某个客户端网络极差send一直EWOULDBLOCK它的发送队列会越积越长内存跟着涨。常见做法是给每个连接设一个队列上限比如 1MB超了就断开这条连接宁可踢掉一个慢的也不能让整个服务端被它拖死。4. 客户端与协议消息帧、双线程收发与心跳判定4.1 消息帧设计魔数、长度、命令字、序号服务端和客户端要能对上话得先约定包长什么样。这份代码用的是定长头加变长体头里放魔数、长度、命令字和序号四个字段各管一件事。字段长度作用注意事项magic4 字节固定值 0x55AA55AA快速识别错流或非法连接bodyLen4 字节业务体长度网络字节序接收端 ntohlcmd2 字节命令字1 心跳、2 文本、3 指令、4 回执未知命令字丢弃并记日志seq2 字节序号回执匹配用客户端自增服务端原样带回// common/protocol.h #pragma pack(push, 1) // 取消对齐填充保证两端结构体大小一致 struct MsgHeader { uint32_t magic; // 0x55AA55AA uint32_t bodyLen; // 网络字节序 uint16_t cmd; uint16_t seq; }; #pragma pack(pop)#pragma pack(1)这行不能少。默认对齐下MsgHeader可能被填充成 16 字节客户端按 12 字节发、服务端按 16 字节读切帧从一开始就错位而且错得很隐蔽前几个包可能碰巧能解析。实际编码时我一般不用结构体直接memcpy发送而是手写序列化函数一个字段一个字段往缓冲区里塞避免不同编译器对齐策略不一致带来的跨平台问题。魔数的价值在于连接建立后第一包就能验证对面是不是自己人防止端口被别的程序误连。4.2 客户端双线程一个收、一个发主线程读输入客户端的模型和服务端不一样。客户端只有一条连接用阻塞套接字加两个线程最省事收线程一直recv并切帧发线程从标准输入或者业务队列拿数据往外发。阻塞模型下不用处理EWOULDBLOCK代码短、逻辑直。// client/main.cpp std::atomicbool g_run{true}; void recvThread(SOCKET s) { std::vectorchar buf; char tmp[4096]; while (g_run) { int n recv(s, tmp, sizeof(tmp), 0); if (n 0) break; // 0 是对端关闭小于 0 是错误 buf.insert(buf.end(), tmp, tmp n); // 与服务端同一套切帧逻辑复用 common 里的 tryPopFrame while (true) { auto body tryPopFrame(buf); // 凑够一包返回数据并消费缓冲 if (!body.has_value()) break; handleMessage(body.value()); // 按 cmd 分发 } } g_run false; // 收线程退出通知主线程结束 printf(connection closed, reconnecting...\n); } int main() { // ... 建立连接、登录等省略 ... std::thread t(recvThread, sock); std::string line; while (g_run std::getline(std::cin, line)) { auto frame packFrame(line, CMD_TEXT); sendAllBlocking(sock, frame); // 阻塞发送内部循环直到发完 } g_run false; t.join(); closesocket(sock); }收线程里recv返回 0 表示对端正常关闭返回负数表示出错两种情况都退出循环。这里用std::atomicbool而不是普通bool是因为主线程和收线程都会读写这个标志普通布尔在编译器优化下可能被缓存进寄存器导致主线程改了标志收线程看不见程序卡在join上不退出。客户端的发送用阻塞套接字sendAllBlocking内部循环调send直到发完简单可靠服务端那边才需要非阻塞加事件驱动。4.3 心跳与超时剔除30 秒没数据就当掉线TCP 有个让人头疼的特性网线拔了、对端断电本地套接字不会立刻报错连接会停在半开状态服务端以为对面还在群发一直往一个死连接里写。解决办法是应用层心跳。// server每轮 select 超时后调用 void checkHeartbeat(std::vectorSession* sessions) { const auto now std::chrono::steady_clock::now(); for (auto* s : sessions) { if (s-closed) continue; auto idle std::chrono::duration_caststd::chrono::seconds( now - s-lastActive).count(); if (idle 30) { // 30 秒没有任何数据判定掉线 printf(session %s:%d timeout, closing\n, s-ip.c_str(), s-port); closeSession(s); } } }客户端每 5 秒发一个cmd1的空心跳包服务端收到就更新lastActive不回复或者回一个空包都行看协议约定。超时阈值取心跳间隔的 3 倍左右30 秒是个折中值太短会因为网络抖动误踢太长则掉线发现慢。std::chrono::steady_clock是单调时钟不受系统时间调整影响用system_clock的话用户改一下时间就可能触发一批误判。服务端SO_KEEPALIVE也能开但 Windows 下默认两小时才探测一次改注册表或者SIO_KEEPALIVE_VALS才能调短而且它探测的是 TCP 层对应用层假死进程卡住但套接字还在无效。所以心跳这事我一般直接做在应用层可控、可日志、跨平台一致。4.4 发送队列与背压别让慢客户端吃光内存hasPendingSend和pending这两个字段在 3.4 里出现过这里补一下完整机制。当send返回WSAEWOULDBLOCK剩余数据进pending同时把这个连接的套接字加进writeSet。下一轮select发现可写onWritable把pending里的数据继续往外发发完清空并取消可写关注。// server/session.cpp void onWritable(Session* s) { if (s-pending.empty()) { s-hasPendingSend false; return; } int n send(s-sock, s-pending.data(), (int)s-pending.size(), 0); if (n 0) { s-pending.erase(0, n); // 消费已发送部分 } else if (n SOCKET_ERROR WSAGetLastError() ! WSAEWOULDBLOCK) { closeSession(s); return; } if (s-pending.empty()) s-hasPendingSend false; // 背压保护单个连接积压超过 1MB 直接断开防止内存被慢客户端拖爆 if (s-pending.size() 1024 * 1024) { printf(session %s:%d backpressure overflow\n, s-ip.c_str(), s-port); closeSession(s); } }这段的价值在于把发送从同步动作变成可缓冲的异步过程。没有它群发时一个网络差的客户端会让整个broadcast卡在send上后面几十个客户端都收不到消息。队列上限 1MB 是个经验值业务消息如果是几 KB 的指令够缓几百条如果业务本身要传文件那就得改成流控加窗口不能靠无脑堆内存。5. 避坑与排查端口、粘包、并发写和假死5.1 连接与端口层10048、10061 和只能本机连现象一改完代码重启服务端bind直接失败错误码 10048。原因上一次进程退出后套接字还处在 TIME_WAIT 状态端口没释放或者有另一个进程占着 9000 端口。 解决setsockopt加SO_REUSEADDR是必须的再不行就netstat -ano | findstr :9000查到 PIDtaskkill /PID xxx /F干掉占用进程或者临时把端口换掉。开发机上同时开着两个版本的服务端互相抢端口也是这个错误的常见来源。现象二客户端connect返回 10061连接被拒绝。原因服务端没起来、防火墙拦了入站、或者bind时地址写成了127.0.0.1。 解决先确认服务端在监听netstat -ano | findstr LISTENING再检查 Windows 防火墙的入站规则有没有放行该端口最后看sin_addr.s_addr是不是INADDR_ANY。绑回环地址时本机测试一切正常换台机器立刻超时这个错我见过不止一次。现象三客户端连上以后服务端不处理它发的消息但连接没断。原因FD_SETSIZE没改连到第 65 个以后新套接字加不进fd_setFD_SET静默失败select永远不返回这个套接字的事件。 解决在包含winsock2.h之前#define FD_SETSIZE 1024并且检查FD_SET之前sock是否有效。连接数真的超过几百就该考虑换 IOCP 了。5.2 数据与并发层半包、乱序和假在线现象四客户端偶发解析失败日志里是一条被截断的 JSON。原因把 TCP 当成消息队列用默认一次recv就是一个完整包。 解决老老实实加长度前缀维护每连接的recvBuf凑够整包才交给业务。业务层只接受完整消息永远不要在生产代码里写这次应该能收全这种假设。现象五群发之后客户端收到的两条消息粘在一起或者内容交错。原因多个线程同时对同一个套接字send或者send只发出去一部分就返回了。 解决每连接一把sendMtx保证串行sendAll内部while循环直到发完或者进队列。群发函数不要在持锁状态下遍历整张在线表锁的粒度是单条连接不是全局。现象六客户端拔网线服务端显示还在线群发一直失败。原因TCP 半开连接本地感知不到对端已经消失SO_KEEPALIVE默认两小时才探测。 解决应用层心跳加 30 秒超时剔除send返回错误时立刻标记关闭。别指望 TCP 帮你发现掉线它只能保证我发出去的数据对方收到了保证不了对方还活着。现象七服务端跑几个小时以后内存一直涨。原因closed标记了但没回收Session对象和套接字句柄泄漏或者慢客户端的发送队列无上限增长。 解决每轮事件循环结束调一次reapCloseddelete对象前先closesocket给pending设 1MB 上限超了断开。用任务管理器看句柄数只涨不降基本就是没回收。6. 二次开发把这套 demo 改成能用的指令下发通道这套代码当 demo 跑没问题真要落到业务上改动集中在几个点事件循环和切帧这两块基本不用动。改造需求改动位置关键点业务体换成 JSONcommon/protocol.h、dispatch长度前缀保留body 里放 JSON解析失败只丢当前包按房间或分组群发Session加roomIdbroadcast加过滤遍历时判断分组避免给无关客户端发增加登录与鉴权dispatch里加cmdLOGIN分支登录前只允许心跳其他命令一律拒绝连接数上到千级事件层换 IOCPSession和协议层原样保留只换事件驱动服务端下发文件新增分片命令字与seq回执加窗口控制别一次性把文件读进内存拿按房间群发举例改动很小但能看出扩展思路给Session加一个roomId字段登录时由客户端上报群发时按这个字段过滤。// server/broadcast.cpp按房间群发 void broadcastToRoom(const std::string roomId, const std::string msg) { std::string frame packFrame(msg, CMD_TEXT); for (auto* s : g_sessions) { if (s-closed || s-roomId ! roomId) continue; // 只发给同房间 if (!sendAll(s, frame.data(), (int)frame.size())) { markClosed(s); // 失败只标记遍历结束统一回收 } } }验证方法我一般走三步先在本机开五个客户端连上用房间号分成两组发一条消息看是不是只有目标组收到然后把其中一个客户端的网卡禁用 35 秒确认服务端日志里出现 timeout 并且在线表数量减一最后用一个脚本客户端每秒发 100 条、连续发 10 分钟看服务端内存和句柄数是否稳定。三步都过这套骨架才算能交付。改事件层的时候有个顺序问题要注意先保证Session的生命周期由单一对象管理再换 IOCP。IOCP 的完成回调在工作线程里执行如果还像 select 那样到处new和delete很容易出现回调里访问已释放对象崩溃栈还指不到具体位置。我的做法是连接对象用shared_ptr持有回调里只拿弱引用关闭时先投递一个关闭任务等所有在途 IO 完成再真正释放。从那以后我每接一个 socket 相关的活都先把长度前缀、心跳间隔、发送队列上限这三个值写进协议文档再动业务代码。这三样东西事后补的代价比一开始就写进去高太多——补长度前缀要改两端切帧补心跳要改连接状态机补队列要在所有send调用点加判断每一处都是回归测试。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑