☰
C++单线程select长连接服务端:Flash小游戏实战
2026/11/2 9:19:37 网站建设 项目流程

简介:面向网络编程入门者与Flash小游戏开发爱好者的通信实例包,以一款Flash赛车游戏和配套C++控制台服务器为主线,呈现客户端与服务端之间的TCP交互过程。压缩包共15个文件、约649KB,包含C++源码与头文件、VC6工程及配置、可执行程序和说明文本,另有编译产生的中间文件与调试文件,便于直接运行观察,也能对照源码梳理工程组织。已有237人学习。读者可结合源码理解TCP连接的建立与数据收发,掌握select模型如何同时监控多个套接字、提升并发连接处理效率,并借说明材料了解数据包头、标识与数据体的组织方式,从而把游戏动作、状态同步和反馈信息落到具体协议设计上。适合课程设计、网络编程实验及小型C/S游戏通信原型的参考。

1. 一局 Flash 小游戏背后的长连接:这套服务端到底解决什么问题

本地调试时最典型的一幕:swf 在播放器里跑起来,点「开始」,客户端连上 127.0.0.1:9001,服务端控制台打出accept fd=132,接着走一步棋、服务器广播、双方结算,全程没有一次 HTTP 请求。这套 flash游戏以及服务器 的最小组合里,服务端没有用任何框架,就是一个 C++ 单线程进程加一个 tcp select模型 的主循环,几百个连接全压在一个线程里轮询。

它适合两类人:手里有一批老 swf,想补一个能真正跑起来的服务端;或者想找一个不超过三千行、能把 socket 编程从 bind 一路看到广播的完整对照样本。客户端只管表现和输入,服务器只管判定和广播,两边的边界划得很干净,改起来不会互相牵扯。

2. 项目结构与技术选型:AS3 客户端配单线程 C++ 服务端怎么分活

2.1 目录结构与每个目录的职责

拿到包先别急着编译,把目录扫一遍比直接跑更省时间。这个项目的分层是按「网络层 / 协议层 / 逻辑层」切的,客户端和服务端各有一套,中间靠proto/protocol.md对齐。

flash-game-server/ ├── client/ # AS3 客户端工程,FlashDevelop / Flash Builder 都能开 │ ├── src/ │ │ ├── Main.as # 入口:初始化舞台、读配置、发起连接 │ │ ├── net/SocketClient.as # Socket 封装:连接、收发、拆包 │ │ ├── net/Protocol.as # cmd 常量,必须和服务端头文件逐字对应 │ │ ├── game/GameScene.as # 游戏主场景,收广播后刷表现 │ │ └── ui/LoginPanel.as # 登录面板 │ └── bin/GameMain.swf # 编译产物,用调试版播放器加载 ├── server/ │ ├── src/ │ │ ├── main.cpp # 读 conf、起监听、进主循环 │ │ ├── net/SelectLoop.cpp # select 主循环,整个服务端的心脏 │ │ ├── net/Connection.cpp # 连接对象:fd、状态、收发缓冲、最后活跃时间 │ │ ├── net/PacketCodec.cpp # 长度前缀协议的编解码 │ │ ├── logic/HallHandler.cpp # 大厅:登录、拉房间列表、匹配 │ │ ├── logic/RoomHandler.cpp # 房间:出牌、结算、房间内广播 │ │ └── base/RingBuffer.h # 环形缓冲 │ ├── conf/server.conf # 监听端口、最大连接数、心跳超时秒数 │ └── Makefile ├── proto/protocol.md # 协议文档,改协议先改这里 └── tools/replay.py # 把抓包日志喂回去重放,线上问题靠它复现

net/目录里没有一行游戏逻辑,logic/目录里没有一次recv,这条线守住了,后面加玩法基本不用动网络层。tools/replay.py是个容易被忽略但很值钱的东西:线上出问题时把二进制日志抓下来,本地起一个假客户端回放,比盯着日志猜快得多。

2.2 为什么是 select 而不是多线程或 IOCP

很多人第一反应是「select 不是性能差吗」。这话对一半,放到这个场景里就不成立。回合制的 Flash 小游戏,单房间同时在线的操作频率是秒级,包体大多在 100 字节以内,瓶颈根本不在 I/O 多路复用上,而在房间逻辑和广播放大上。

方案连接上限编程复杂度适合场景这个项目里的取舍
select 单线程受 FD_SETSIZE 限制,Linux 默认 1024低,一个循环看全几百连接、逻辑轻采用,改 FD_SETSIZE 可撑到 2048
每连接一线程(阻塞)几百,线程切换开销大中,但要处理锁长连接但连接少不采用,广播要加锁,容易出并发问题
epoll / IOCP万级高,回调或状态机高频实时留作二次开发方向

单线程最大的好处不是快,是可调试。所有连接的状态都在一个线程里变,断点随便打,不会出现「日志顺序对不上」这种玄学问题。代价是任何一个 handler 阻塞都会拖死所有玩家,所以后面的代码里有一条硬规矩:逻辑层不允许做任何阻塞操作,连读文件都不行。

2.3 客户端与服务端的职责边界

Flash 客户端那边,GameScene拿到广播后直接改表现,不做任何规则判定;服务端收到操作请求,先校验当前是不是这个玩家的回合、这张牌在不在手里,校验通过才改房间状态,然后向房间内所有人广播结果。

这条边界听着理所当然,实际写的时候最容易破。常见做法是客户端为了「手感」先本地出牌再等服务端确认,一旦服务端拒绝就要回滚,回滚逻辑写不好就是各种显示错乱。这个项目里索性不做预测,点击后按钮置灰,等服务端广播回来再刷新,几百毫秒的延迟在这种回合制玩法里完全可以接受。把「谁是权威」这件事定死,能省掉后面一大半的排查时间。

2.4 编译与本地跑通的最小步骤

服务端用 Makefile 管理,Linux 下g++直接编,Windows 下把select换成 Winsock 的select,其余代码基本不动。

cd server make clean && make # 产物在 bin/gameserver ./bin/gameserver -c conf/server.conf # 输出:[init] listen on 0.0.0.0:9001, max_conn=1024, heartbeat=30s

conf/server.conf里就四个键:port、max_conn、heartbeat_timeout、log_level。max_conn要和编译时的FD_SETSIZE对齐,写超了不会报错,只会静默丢连接,这个坑后面单独说。

客户端这边,Flash 的Socket在跨域连接时会先去要策略文件。本地调试把 swf 和服务器放同一台机器、用调试版播放器加载bin/GameMain.swf,通常不会触发;一旦换到别的机器上连,就得在 843 端口挂一个策略文件服务,或者用Security.loadPolicyFile("xmlsocket://ip:843")显式指定。

提示:调试版播放器(Flash Player projector 的 debug 版)才能看到trace输出和异常堆栈,正式版播放器出错时是静默的,排查起来非常痛苦。

3. 核心模块拆解:select 主循环怎么把连接管起来

3.1 监听 socket 与 fd_set 的初始化

服务端起来的第一件事是把监听 fd 建好,并且设成非阻塞。这一步写错,accept在连接队列空的时候会把整个进程挂住。

// server/src/net/SelectLoop.cpp —— 初始化阶段 bool SelectLoop::init(const std::string& ip, int port) { listenFd_ = ::socket(AF_INET, SOCK_STREAM, 0); if (listenFd_ < 0) return false; int on = 1; // 端口复用:进程重启时不用等 TIME_WAIT 走完,调试期非常关键 ::setsockopt(listenFd_, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(ip.c_str()); if (::bind(listenFd_, (sockaddr*)&addr, sizeof(addr)) < 0) return false; if (::listen(listenFd_, 128) < 0) return false; setNonBlocking(listenFd_); // 后面 accept 到的每个 fd 也必须设成非阻塞 return true; }

SO_REUSEADDR是调试期最有用的一行,没有它,改完代码重启服务端会时不时报bind: Address already in use,得干等一分钟。listen的 backlog 参数在 Linux 上是已完成队列和未完成队列的参考值,128 对几百连接的场景足够。setNonBlocking是关键,后面所有accept、recv、send都建立在这个前提上——只有 fd 非阻塞,单线程循环才不会被某一个慢客户端卡住。

3.2 主循环三段式:先 select,再读写,最后跑定时任务

整个服务端最核心的就是这二十几行。它的结构可以概括成「每轮重建 fd_set → select 等事件 → 分派读和写 → 跑一次定时扫描」。

// server/src/net/SelectLoop.cpp —— 主循环 void SelectLoop::run() { while (!stop_) { // 1) 每轮必须重建:select 返回后会把 fd_set 就地改写,只有就绪位保留 FD_ZERO(&readSet_); FD_ZERO(&writeSet_); FD_SET(listenFd_, &readSet_); int maxFd = listenFd_; for (auto& kv : conns_) { Connection* c = kv.second; FD_SET(c->fd, &readSet_); // 只有写缓冲里还有积压数据时才关心可写,否则会一直空转 if (c->hasPendingWrite()) FD_SET(c->fd, &writeSet_); if (c->fd > maxFd) maxFd = c->fd; } timeval tv{0, 200 * 1000}; // 200ms 一轮,给心跳扫描留时间片 int n = ::select(maxFd + 1, &readSet_, &writeSet_, nullptr, &tv); if (n < 0) { if (errno == EINTR) continue; // 被信号打断,重来即可 logError("select failed, errno=%d", errno); break; } if (n == 0) { onTick(); continue; } // 超时:只跑定时任务 if (FD_ISSET(listenFd_, &readSet_)) acceptNew(); // 2) 先读后写:读可能产生新数据,也能第一时间发现对端关闭 for (auto& kv : conns_) { Connection* c = kv.second; if (c->closing) continue; if (FD_ISSET(c->fd, &readSet_)) onReadable(c); // 读处理里可能已经把连接标成关闭,这里要再判一次 if (!c->closing && FD_ISSET(c->fd, &writeSet_)) onWritable(c); } // 3) 定时任务:心跳超时、延迟关闭、定时广播 onTick(); } }

几个参数值得单独说。maxFd + 1这个首参在 Linux 下是「检查到哪个 fd 为止」,传小了会导致后面的 fd 永远不被检测,症状是「连接建上了但服务器收不到任何数据」,非常隐蔽;在 Windows 下这个参数被忽略,源码里干脆传 0 也能跑,所以同一份代码跨平台时最容易被这个差异坑到。200ms的超时值不是随便定的:太短会频繁空转浪费 CPU,太长会让心跳超时判定变得粗糙,200ms 意味着超时误差最多 200 毫秒,对 30 秒心跳来说完全够。

循环里遍历conns_的时候不能直接删元素,onReadable里一旦发现对端关闭就把closing置位,真正的close和erase留到onTick里统一做,这样迭代器不会失效。

3.3 连接对象与收发缓冲区

每个连接用一个小结构体表示,里面最要紧的是两个缓冲区和最后活跃时间。

// server/src/net/Connection.h struct Connection { int fd = -1; uint32_t connId = 0; // 业务层用这个,不用 fd,避免 fd 复用导致串号 uint64_t lastRecvMs = 0; // 最后一次收到数据的时间,心跳判定用 bool closing = false; // 标记延迟关闭,循环里不当场 erase ReadBuffer inBuf; // 读缓冲,攒半包用 std::vector<char> outBuf; // 写缓冲,send 没发完的留在这里 size_t outSent = 0; // outBuf 里已经发出多少 bool hasPendingWrite() const { return outSent < outBuf.size(); } // 发送统一入口:先塞缓冲,能发多少发多少,发不完等下次可写事件 void send(const char* data, size_t len) { outBuf.insert(outBuf.end(), data, data + len); } };

connId这一层看似多余,实际是必须的:fd 是会被复用的,一个连接断开后新连接很可能拿到同一个 fd,如果业务层(比如房间成员列表)存的是 fd,就会出现「新玩家进了老玩家的房间」这类灵异问题。用自增的connId做业务标识,fd 只活在网络层。

outBuf的设计也值得说一句。很多新手直接在onReadable里调send,发不完就丢掉剩余部分,本地测试一切正常,一上真机、一遇到网络抖动就开始丢包。正确做法是所有发送都走缓冲区,onWritable里从outSent位置继续发,发完了才把FD_SET里的可写位去掉,否则一个空闲连接会永远处于「可写」状态,把 CPU 吃满。

3.4 心跳、超时与断线清理

TCP 有个让人很难受的特性:对端拔网线、断电、进程被强杀,这一侧是收不到 FIN 的,连接会一直挂在ESTABLISHED状态。如果不能及时清理,房间里的位置就一直被一个「幽灵玩家」占着。

// server/src/net/SelectLoop.cpp —— 每 200ms 跑一次的定时扫描 void SelectLoop::onTick() { uint64_t now = nowMs(); // 1) 清理延迟关闭的连接 for (auto it = conns_.begin(); it != conns_.end(); ) { Connection* c = it->second; if (c->closing) { ::close(c->fd); delete c; it = conns_.erase(it); } else { ++it; } } // 2) 心跳超时:超过 heartbeatTimeout_ 没收到任何数据就踢掉 if (now - lastHeartbeatScanMs_ >= 1000) { lastHeartbeatScanMs_ = now; for (auto& kv : conns_) { Connection* c = kv.second; if (now - c->lastRecvMs > heartbeatTimeoutMs_) { c->closing = true; LOG_INFO("conn %u heartbeat timeout", c->connId); } } } }

三个参数之间是有配合关系的:客户端每 10 秒发一次心跳包,服务端heartbeat_timeout设 30 秒,扫描周期 1 秒。超时时间至少要是心跳间隔的三倍,留出两次丢包的余量,否则网络稍微抖一下就会误踢。lastRecvMs在onReadable里每次读到数据都要更新,不能只在收到心跳包时才更新——玩家一直在操作的时候本来就有数据往来,没必要强制他再发心跳。

连接断开后还要通知业务层,HallHandler收到onDisconnect(connId)后把该玩家从房间成员里摘掉,如果房间空了就销毁,如果正在对局中则判负或托管。这一步漏了,房间会一直占着内存不释放。

4. 协议编解码:一条消息怎么从字节流里被完整剥出来

4.1 长度前缀的二进制协议设计

TCP 是字节流,没有消息边界。客户端连发两个包,服务端可能一次recv读到 1.5 个包,也可能分三次读到,这就是粘包和半包。解决方式只有两种:分隔符或者长度前缀。二进制协议基本都用后者。

偏移长度字段说明
04bodyLen包体长度(大端),不含包头 8 字节
42cmd消息号,见 Protocol.as / protocol.h
62seq请求序号,响应原样带回,客户端用来配对
8bodyLenbody业务数据,按 cmd 各自定义

固定 8 字节包头,好处是解析逻辑只有一份,新增消息不用改网络层。bodyLen用大端是网络字节序的惯例,AS3 的Socket.readInt()默认就是大端,两端不用做额外转换。seq这一字段在回合制里其实用不太上(服务端是被动广播),但保留着,方便做超时请求匹配。

协议上限一定要有。bodyLen超过MAX_PACKET_SIZE(这个项目里定 64KB)就直接判定为非法包并断开连接,否则一个恶意客户端发个bodyLen = 0x7FFFFFFF,服务端就会去申请 2G 内存。

4.2 读缓冲与粘包拆包

读缓冲区用最简单的方案:一块固定大小的连续内存,加一个已消费游标。够用,而且比环形缓冲好读得多。

// server/src/base/RingBuffer.h —— 简化为线性缓冲 + 游标 struct ReadBuffer { static const size_t CAP = 64 * 1024; char buf[CAP]; size_t len = 0; // 已收到的有效字节数 size_t pos = 0; // 已消费到的位置 size_t readable() const { return len - pos; } const char* data() const { return buf + pos; } // 从 socket 读到缓冲区尾部,返回本轮读到的字节数;0 表示对端关闭,-1 表示出错 int readFrom(int fd) { if (len == CAP) return -1; // 缓冲满还没凑出完整包,说明包非法 ssize_t n = ::recv(fd, buf + len, CAP - len, 0); if (n > 0) len += n; return (int)n; } // 返回当前可切出的完整包总长;不够一个包返回 0 size_t nextPacketLen() const { if (readable() < 8) return 0; // 连包头都不够 uint32_t bodyLen = ntohl(*(uint32_t*)data()); if (bodyLen > 64 * 1024) return 0; // 超限,交给上层判非法 size_t total = 8 + bodyLen; return readable() >= total ? total : 0; // 半包返回 0 } void consume(size_t n) { pos += n; if (pos == len) { // 全消费完,直接归零 pos = len = 0; } else if (pos > CAP / 2) { // 攒够一半再搬,减少 memmove 次数 ::memmove(buf, buf + pos, len - pos); len -= pos; pos = 0; } } };

consume里那个「攒够一半再搬」的阈值是性能上的小优化。如果每次消费完都memmove,在高频小包场景下会出现大量无谓的内存拷贝;如果永远不搬,游标会一直往右走直到缓冲耗尽。折中方案是游标过半才搬一次,均摊下来拷贝量很小。

4.3 网络层到逻辑层的分派

onReadable负责把字节流切成一个个完整包,PacketCodec负责把包体解成结构体,Handler负责业务。三层各管一段,任何一层出问题都能单独定位。

// server/src/net/Connection.cpp void SelectLoop::onReadable(Connection* c) { int n = c->inBuf.readFrom(c->fd); if (n == 0) { c->closing = true; return; } // 对端正常关闭 if (n < 0) { c->closing = true; return; } // 出错,统一按关闭处理 c->lastRecvMs = nowMs(); // 心跳计时从这里刷新 // 一次 recv 可能带来多个包,循环切到切不出来为止 while (true) { size_t plen = c->inBuf.nextPacketLen(); if (plen == 0) break; // 半包,等下次 const char* p = c->inBuf.data(); uint16_t cmd = ntohs(*(uint16_t*)(p + 4)); uint16_t seq = ntohs(*(uint16_t*)(p + 6)); PacketCodec::dispatch(c, cmd, seq, p + 8, plen - 8); c->inBuf.consume(plen); } }

PacketCodec::dispatch内部是一张cmd -> handler的表,用std::unordered_map<uint16_t, Handler>或者一个按 cmd 索引的静态数组都行。用表驱动而不是一长串if/else,新增消息只需要加一行注册,不会漏改。

// server/src/net/PacketCodec.cpp using Handler = void(*)(Connection*, uint16_t seq, const char* body, size_t len); static std::unordered_map<uint16_t, Handler> g_handlers; void PacketCodec::regist(uint16_t cmd, Handler h) { g_handlers[cmd] = h; } void PacketCodec::dispatch(Connection* c, uint16_t cmd, uint16_t seq, const char* body, size_t len) { auto it = g_handlers.find(cmd); if (it == g_handlers.end()) { LOG_WARN("unknown cmd=%u from conn=%u", cmd, c->connId); return; // 未知消息只记日志,不断连接 } it->second(c, seq, body, len); } // main.cpp 里集中注册,一眼能看全所有消息 PacketCodec::regist(CMD_LOGIN, HallHandler::onLogin); PacketCodec::regist(CMD_JOIN_ROOM, HallHandler::onJoinRoom); PacketCodec::regist(CMD_PLAY_CARD, RoomHandler::onPlayCard);

未知 cmd 只记日志不断开,是刻意这么设计的。客户端和服务端版本不同步的时候(灰度更新时很常见),旧客户端发来一个服务端不认识的消息,断开会导致玩家直接掉线,忽略掉则最多是某个功能不响应,体验上好得多。

4.4 客户端 AS3 侧的收发

AS3 的Socket是异步的,收到数据触发ProgressEvent.SOCKET_DATA,在这个回调里bytesAvailable表示当前可读字节数。写法上跟服务端是一一对应的镜像逻辑。

// client/src/net/SocketClient.as private function onSocketData(e:ProgressEvent):void { // 一次回调可能带来多个包,也可能只有一个半包 while (socket.bytesAvailable >= 8) { var start:int = socket.position; // 记下包头起始位置 var bodyLen:int = socket.readInt(); // 大端,与服务端一致 if (bodyLen > 65536) { // 非法长度,直接断开 close(); return; } if (socket.bytesAvailable < bodyLen) { socket.position = start; // 半包:游标回退,等下次数据 return; } var cmd:int = socket.readShort(); var seq:int = socket.readShort(); var body:ByteArray = new ByteArray(); if (bodyLen > 4) socket.readBytes(body, 0, bodyLen - 4); dispatch(cmd, seq, body); } }

socket.position = start这行是 AS3 侧的核心。AS3 的ByteArray是随机访问的,读到一半发现不够,必须把游标退回去,否则下次进来会从一个错误的偏移开始读,现象就是「第一个包正常,第二个包开始全是乱码」。这个退游标的动作很多人第一次写的时候会漏。

发送侧同样要缓冲:socket.writeInt()之后必须调socket.flush()才会真正发出去,如果bytesPending一直涨,说明对端接收慢或者网络堵了,得做发送节流甚至踢人。

5. 避坑与排查:select 服务器从本地到线上翻过的车

5.1 现象:连接数一过一千,新玩家全部连不上,老玩家正常

原因几乎必然是FD_SETSIZE。Linux 下fd_set是个固定长度的位图,默认 1024 位,fd 值超过 1023 就放不进去,FD_SET会直接越界写内存,select首参虽然传了更大的值但实际检测不到。更麻烦的是这个错误不报错,只表现为「连接被 accept 了但收不到任何数据」。

解决办法有两个方向:编译时加-DFD_SETSIZE=4096把位图撑大,或者在init里显式判断maxFd >= FD_SETSIZE就拒绝新连接并打日志。后者的价值在于让问题暴露出来而不是静默降级。真要上几千连接,就该考虑第 6 章说的 epoll 改造了。

5.2 现象:客户端偶尔「卡半秒」然后一大批消息同时刷出来

典型的写阻塞症状。send在 fd 是阻塞模式、且对端接收窗口满的时候会把进程挂住,整个单线程循环停摆,其他所有玩家的包都堆在系统缓冲里。等对端恢复,积压的数据一次性涌出来,就出现了那个「卡半秒」的观感。

排查方式是看日志时间戳有没有出现整秒的空洞。解决要两步一起做:所有 fd 必须设非阻塞,send返回EAGAIN时把剩余数据留在outBuf等下次可写事件;同时给outBuf设上限(这个项目里定 256KB),超了直接断开——一个持续发不出去的连接,留着也只会拖累别人。

5.3 现象:第一轮 select 正常,第二轮开始有的连接再也不响应

原因在fd_set的语义上。select返回时会就地把传入的fd_set改写成「只有就绪的位被置 1」,没就绪的清零。如果图省事把rset存成成员变量、只在启动时FD_SET一次,第二轮传进去的就是一堆全零的集合,自然什么都检测不到。

这个坑在 Windows 下更隐蔽,因为 Winsock 的select虽然也改写入参,但首参被忽略,代码看起来「能跑」。唯一的办法就是每轮循环老老实实FD_ZERO+ 重新FD_SET,别做任何「复用 fd_set」的优化。

5.4 现象:玩家断网后,服务端日志显示他还「在线」,房间里位置一直占着

TCP 半开连接。对端进程被强杀或者断网,这一侧收不到 FIN,select也不会报告可读,连接就一直挂着。靠recv返回 0 是发现不了的,因为根本没有机会调recv。

解决只能靠应用层心跳。客户端 10 秒发一次,服务端记录lastRecvMs,30 秒没更新就踢。要注意的是心跳包本身也要走正常协议,别去写 TCP 的SO_KEEPALIVE——系统默认的保活时间是两小时,对游戏来说等于没有。

5.5 现象:服务端进程毫无征兆地退出,日志最后一条是某个玩家发消息

对已经关闭的 socket 调send,内核会给进程发SIGPIPE,默认行为是直接终止进程,连栈都来不及打。单线程服务端遇到这个,所有在线玩家瞬间全掉。

标准做法是在main开头就signal(SIGPIPE, SIG_IGN),然后在send的返回值里判断EPIPE并主动标记连接关闭。这样进程不会死,只是那一个连接断掉。客户端的Socket侧也一样,往已关闭的 socket 里写会抛IOError,要接住并走重连流程,不能让它冒泡到UncaughtErrorEvent。

6. 二次开发:把 select 换成 epoll、加房间匹配的改造路径

连接数真的上到两三千之后,select 那套循环会开始吃力,不是因为 CPU,而是因为每轮都要把所有 fd 从用户态拷进内核态,几千个 fd 扫一遍的开销很可观。改造没有必要推倒重来,网络层和逻辑层已经分开了,只换SelectLoop这一层就行。

项select 版epoll 版
事件注册每轮循环重建fd_set连接建立时epoll_ctl(ADD)一次
触发模式只有水平触发默认水平,可改边缘触发
连接上限受FD_SETSIZE约束受内存和ulimit -n约束
就绪列表需要自己遍历所有 fdepoll_wait直接返回就绪数组
改动面—只改SelectLoop,Connection和 handler 不动

改造的核心是把「每轮重建」换成「一次性注册」。连接建立时注册一次,关闭时EPOLL_CTL_DEL,事件循环里拿到的events[]数组就是已经就绪的 fd,不用再扫全表。

// server/src/net/EpollLoop.cpp —— 改造后的注册逻辑 void EpollLoop::addConn(Connection* c) { epoll_event ev{}; ev.events = EPOLLIN | EPOLLET; // 边缘触发,配合非阻塞一次读到 EAGAIN ev.data.fd = c->fd; epoll_ctl(epfd_, EPOLL_CTL_ADD, c->fd, &ev); // 边缘触发下,写缓冲从空变非空时必须手动把 EPOLLOUT 加上, // 否则内核不会再通知可写,积压的数据永远发不出去 if (c->hasPendingWrite()) modOut(c, true); }

这段代码里有两个容易翻车的地方,都是我在别处踩过之后才记住的。第一,边缘触发意味着「只在状态变化时通知一次」,读的时候必须循环recv直到返回EAGAIN,否则剩下的数据就留在内核缓冲里没人管了。第二,可写事件不能像 select 那样每轮判断,得在写缓冲从空变非空时手动MOD加上EPOLLOUT,发完再摘掉;漏了这一步,症状是「小包能发,稍大一点的包发到一半就卡死」,而且极难定位。

另一个常见的改造方向是在HallHandler里加匹配队列。现在的实现是玩家自己选房间,改匹配的话加一个std::vector<uint32_t> queue_存排队的connId,每次onJoinQueue进来就尝试配对,配对成功就创建房间并往两个连接各推一条CMD_MATCH_OK。这块不需要碰网络层,纯逻辑改动,改完直接用tools/replay.py回放一批历史请求验证配对结果就行。

从那以后我每次动网络层之前,都会先用 replay 脚本跑一遍基准:压 500 个模拟连接,每个连接每秒发一个包,记录 CPU 和内存曲线。这次改完 epoll 之后再跑同样一组数据,如果曲线不对,问题一定出在我刚改的这几十行里,而不用怀疑到业务代码上去。这套习惯救了我好几次。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询