
简介一套面向Flash小游戏与后端服务通信学习的客户端—服务器配套示例围绕TCP连接与select I/O多路复用展开Flash赛车小游戏作为客户端与控制台服务器交换动作指令、状态和反馈数据帮助读者理解小型网络游戏的通信流程。资源共15个文件压缩包约649KB以C源码和头文件为核心配有VC6的dsp/dsw工程文件、exe可执行程序以及obj、pdb、pch等编译中间产物和ReadMe说明便于直接打开工程对照调试。已有237人学习。读者可据此梳理TCP面向连接通信、select同时监控多个套接字的基本用法理解客户端与服务器之间数据包格式的设计思路并借鉴请求处理、状态存储与并发连接管理的实现方式适合作为网络编程入门到进阶的实践案例。1. 单线程扛住几千在线Flash 游戏服务器的 select 模型为什么反直觉早期的 Flash 页游服务器一台机器挂着几千个在线玩家是常态很多实现里既没有线程池也没有协程只有一个 while 循环和一次 select 调用。刚接触的人容易觉得这不合理一个玩家卡住全局不就跟着卡事实恰恰相反正因为把「等待」这件事从线程手里收回到内核才让单线程盯住海量空闲连接变得可行。Flash 游戏长连接的特点是绝大多数时间没数据偶尔来一个几十字节的操作包线程模型在这种负载下绝大部分时间都耗在阻塞和唤醒上调度开销比业务本身还大。搞懂 tcp select 模型本质上是搞懂一个线程如何同时盯着上千个文件描述符并且准确知道谁真的有数据可读。2. select 的底层机制一个线程如何同时盯住上千个 socket2.1 fd_set 是一张位图不是一份连接清单很多人第一次看 select 的签名会以为它接收一个 fd 数组其实fd_set在 Linux 上是一张定长位图。它的定义大致是这样的/* 内核中的定义用户态看到的 fd_set 与之等价 */ #define __FD_SETSIZE 1024 typedef struct { unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(unsigned long))]; } __kernel_fd_set;在 64 位机器上unsigned long是 8 字节1024 / 64 16也就是 16 个 8 字节整数合计 128 字节。这 128 字节就是 select 能同时管理的连接上限的全部来源后面第五章要讲的「超过 1024 个 fd 直接翻车」也是从这里来的。四个宏做的事情非常朴素/* 概念等价实际实现由 glibc 提供 */ #define FD_ZERO(set) memset((set), 0, sizeof(fd_set)) #define FD_SET(fd, set) ((set)-fds_bits[(fd) / 64] | (1UL ((fd) % 64))) #define FD_CLR(fd, set) ((set)-fds_bits[(fd) / 64] ~(1UL ((fd) % 64))) #define FD_ISSET(fd, set) (((set)-fds_bits[(fd) / 64] ((fd) % 64)) 1UL)看懂这四个宏select 的性能特征基本就清楚了。FD_SET是位运算O(1)很快FD_ISSET也是 O(1)也很快。真正慢的地方不在于操作单个 fd而在于每次调用 select 前后你都得把整张位图清一遍、填一遍、再遍历一遍检查谁就绪。位图里有 1024 个位但如果你只关心 5 个连接内核和你自己仍然要按nfds参数的范围去扫只是扫描区间被压缩了。这也解释了一个常见困惑为什么监听 fd 和连接 fd 的编号最好连续。位图是按位号索引的select 的nfds参数取的是「最大 fd 加一」如果最大 fd 是 3000 号而实际只有 3 个连接内核仍然要把 0 到 3000 号这一段扫一遍。fd 编号连续且紧凑是 select 服务器性能良好的前提之一。2.2 一次 select 调用在内核里走了哪几步把 select 拆开看一次调用大致分五个阶段理解这五个阶段比背 API 有用得多。第一阶段是参数校验和拷贝。内核先检查nfds是否超过FD_SETSIZE、超时值是否合法然后用get_fd_set把用户态的读集合、写集合、异常集合各自拷进内核栈。注意这里是拷贝不是映射三个集合各 128 字节一次就是几百字节的复制加上用户态和内核态之间的来回切换调用频率一高这部分固定成本就显出来了。第二阶段是轮询注册。内核在do_select里对[0, nfds)区间内的每一个位做判断这个位被置了且对应的 fd 有效就调用该文件对象的poll方法问一句「你现在就绪吗」。socket 的poll实现会检查接收队列有没有数据、发送缓冲有没有空间、连接状态是否发生了变化。这里的关键是每个 fd 都被真实地问了一遍不管有没有事件。连接数从 100 涨到 1000这部分开销就涨 10 倍这就是 select 的 O(n) 体现在内核侧的一面。第三阶段是睡眠。如果这一轮扫下来一个就绪的都没有当前进程就挂到所有被监视 fd 的等待队列上然后调度出去。这里用的是「所有」意味着任何一个 fd 上有数据到达、触发软中断、唤醒等待队列进程都会被叫醒。被叫醒之后不能直接返回因为不知道是哪个 fd 就绪只能从头再扫一遍。第四阶段是超时唤醒。struct timeval超时值到期后进程同样被唤醒重新扫描一遍如果还是没有就绪的就把返回值设成 0 返回。第五阶段是结果回写。内核把扫描过程中标记为就绪的位整理好用set_fd_set拷回用户态。用户态拿到的已经不是送进去的那张位图了就绪位置 1、未就绪位置 0。同时 Linux 会把超时值改成「剩余时间」这一点在循环里要特别注意每次调用前都得重新赋值。这五步串起来select 的模型就清晰了用户态准备一张「我关心谁」的位图内核遍历一遍问「谁好了」然后把「谁好了」的位图还给你。整个过程是「问」出来的不是「通知」来的这决定了它的成本结构。2.3 就绪扫描的 O(n) 到底慢在哪有人会说扫 1024 个位而已现代 CPU 一瞬间的事怎么会慢单次确实快问题出在调用频率上。假设服务器有 2000 个连接这已经超过 select 上限了先不考虑一次扫描假如耗 10 微秒一秒扫 1000 次就是 10 毫秒。听起来还行。但真实的游戏服务器不是这么跑的玩家点击、移动、聊天包是零散到达的事件循环可能一秒被唤醒几百次到几千次每次唤醒都要全量扫一遍 2048 个位。更糟糕的是这 2048 个位里通常只有一两个是真就绪的剩下 99.9% 的扫描是纯粹的浪费。我在一个模拟项目里做过粗略对比连接数 500 左右时select 版本的事件循环 CPU 占用在 5% 上下还能接受连接数涨到 900 时同样是那些玩家、同样大小的流量CPU 直接冲到 30% 以上。业务逻辑一行没改纯粹是扫描成本涨上去了。这也是为什么后来 poll 把位图换成了pollfd数组不再受 1024 限制但仍然要全量扫epoll 干脆把「注册」和「取就绪」拆成了两步。提示select 的 O(n) 是两次的一次在内核里问所有 fd一次在用户态检查所有位。优化的时候两个方向都要看光把 fd 数量降下去不够用户态那层for循环里做FD_ISSET的次数同样要压。2.4 水平触发没读完的数据下次还会通知你select 是水平触发的。所谓水平就是「只要条件成立就一直告诉你」。接收缓冲里还有 100 字节没读完下一次 select 立刻又返回可读哪怕这中间没来任何新数据。这个特性有好处不用怕漏事件一次没处理完下次还能补上。但坏处也很明显如果代码里只recv了一次、读走 50 字节就回去继续循环剩下 50 字节会让 select 立即返回事件循环空转CPU 瞬间打满。正确的读法是在一次可读通知里循环读到读不出为止/* 一次可读事件里把内核缓冲抽干读到 EAGAIN 才停 */ int do_read(conn_t *c) { for (;;) { ssize_t n recv(c-fd, c-rbuf c-rbuf_len, RBUF_SIZE - c-rbuf_len, 0); if (n 0) { c-rbuf_len n; parse_packets(c); /* 尽量多解析出完整包 */ if (c-rbuf_len RBUF_SIZE) { return 0; /* 缓冲满了得先把 rfds 摘掉 */ } continue; } if (n 0) { /* 对端关闭这是唯一可靠的断线信号 */ close_conn(c); return -1; } if (errno EAGAIN || errno EWOULDBLOCK) { return 0; /* 正常这轮读干净了 */ } if (errno EINTR) { continue; /* 被信号打断重试 */ } close_conn(c); return -1; } }这段代码里几个参数和分支值得说明。RBUF_SIZE是每个连接固定分配的读缓冲大小通常设成 4KB 到 16KB太小会导致频繁半包太大则一千个连接就是十几兆内存要先算清楚。读缓冲满时那个return 0是配合事件循环用的上层在下一轮组装rfds时要判断rbuf_len RBUF_SIZE就把这个 fd 从读集合里摘掉等解析逻辑腾出空间再挂回去。如果不摘水平触发会让 select 立刻返回可读而你又读不进新数据结果就是死循环空转。这是 select 服务器里最经典的翻车点之一第五章还会展开。recv返回 0 是对端正常关闭 FIN返回 -1 且 errno 为ECONNRESET是异常断开。这两种都要及时关 fd、从位图里清掉否则 fd 会一直漏最终撞上 1024 的天花板。3. TCP 是字节流不是消息流Flash 客户端的封包边界怎么划3.1 XMLSocket 与 Socket通道形态决定了协议怎么设计Flash 平台给客户端提供了两套 socket 能力它们对消息边界的处理完全不同这直接决定了服务器该写什么样的解析器。第一种是XMLSocket它把消息当成以\0结尾的文本处理。客户端发一个字符串底层自动补上零字节服务器发回来的数据也被当作字符串遇到\0就切一刀切出来的每段作为一个事件抛给客户端。换句话说XMLSocket 帮你做了分隔符切包你不需要自己算长度但代价是消息里不能出现\0而且文本编码带来额外的体积和解析开销。第二种是二进制Socket它在较新的 Flash Player 版本里才有收发的是ByteArray。这条通道是裸的 TCP没有任何消息边界收到多少字节就是多少字节。灵活性最大代价是封包格式、粘包处理、字节序全部得自己做。两者还有一个共同的坑连接建立后不一定能马上发数据。真正上线时这些通道都要先做一轮安全策略确认客户端会去特定端口拉一份策略文件拿到之后才允许后续通信。策略文件缺失或端口不对表现是「连接建上了但数据发不出去」日志里看不到任何报错只有客户端那边静默失败。做模拟项目时我因为这个问题卡了大半天服务端accept成功、recv一直返回 EAGAIN看上去像协议写错了实际是握手阶段没走完。3.2 粘包与半包的成因三个因素叠在一起「TCP 是面向字节流的」这句话谁都会背但真正理解它为什么会破坏消息边界得看三个具体因素怎么叠加。第一个因素在发送端。应用层连续调两次send一次 30 字节一次 20 字节内核不一定发两个 TCP 段。Nagle 算法会把小数据攒一攒再发两个包被合并进同一个段是常事。你以为是两条消息对端收到的是 50 个连续字节。第二个因素在网络中间。MTU 是 1500 字节一个 3000 字节的包会被 IP 层分片到达对端再重组。重组是 IP 层的事TCP 层看到的仍然是连续字节流但分片丢失会导致整段重传重传之后到达的字节在时间上和前后文错开了对应用层的感觉就是「半个包先到剩下半个过一会才来」。第三个因素在接收端。recv的语义是「有多少给你多少最多给你这么多」它不保证填满你给的缓冲区。你传一个 4096 字节的缓冲来了 100 字节它就返回 100。三个因素合起来结论是接收方无法从数据本身判断消息在哪结束。这不是 TCP 的缺陷是它的设计。TCP 只保证字节有序、不丢不重把「消息」这个概念留给应用层去定义。3.3 长度前缀协议一个能落地的封包格式解决边界问题的主流做法有两种分隔符法和长度前缀法。分隔符法简单XMLSocket 用的就是零字节分隔但要求消息内容里不能出现分隔符二进制场景下很难保证长度前缀法没有这个限制代价是多几个字节的开销是游戏协议里最常见的选择。一个够用的格式长这样字段长度说明len4 字节大端包头之后的消息体长度不含包头本身cmd2 字节大端命令字决定往哪个处理函数分发bodylen 字节业务数据由具体协议决定大端是网络字节序Flash 端的ByteArray默认也是大端两边对齐就不用额外转换。如果选小端客户端和服务端必须同时约定任何一边写错都表现为「收到的长度字段是个天文数字」然后解析器直接把连接关掉排查起来很隐蔽。解析代码的关键是处理「不完整」和「一次多个」这两种情况#define MAX_BODY (64 * 1024) /* 单条消息体上限防伪造长度耗尽内存 */ typedef struct { uint32_t len; /* 网络字节序body 长度 */ uint16_t cmd; /* 网络字节序 */ } __attribute__((packed)) pkt_head_t; /* 从读缓冲里循环取出所有完整包剩余字节前移保留 */ int parse_packets(conn_t *c) { while (c-rbuf_len (int)sizeof(pkt_head_t)) { pkt_head_t *h (pkt_head_t *)c-rbuf; uint32_t body_len ntohl(h-len); uint16_t cmd ntohs(h-cmd); if (body_len MAX_BODY) { return -1; /* 长度字段不可信直接断开 */ } int total (int)sizeof(pkt_head_t) (int)body_len; if (c-rbuf_len total) { break; /* 半包等下一次 recv 补齐 */ } dispatch(cmd, c-rbuf sizeof(pkt_head_t), body_len); memmove(c-rbuf, c-rbuf total, c-rbuf_len - total); c-rbuf_len - total; } return 0; }MAX_BODY这个检查不能省。长度字段来自网络如果恶意客户端发一个0x7FFFFFFF不做上限判断就会去算一个两 G 的偏移要么直接崩要么后面分配内存时被拖死。__attribute__((packed))是让结构体不做对齐填充保证sizeof(pkt_head_t)严格等于 6 字节否则编译器可能把它补成 8 字节收发两端算出来的偏移就错位了。memmove而不是memcpy是因为源区间和目的区间在同一个缓冲区里且有重叠用memcpy在部分实现上会出问题。每解析完一个包就前移一次连接数多、包量大的时候这个拷贝也是成本更好的做法是维护一个读偏移量缓冲区尾部不够了才整体前移一次。3.4 解析状态机不完整的包放在哪上面那份代码隐含了一个前提读缓冲足够大半包可以原地等待。这个前提在一千个连接下不一定成立。假设每个连接固定 8KB 缓冲1000 个连接就是 8MB还行但如果某个客户端因为网络差半包在半路卡了十几秒这 8KB 就一直被占着占得多了内存就上去了。工程上的做法是把解析拆成状态机包头和包体分开处理。读到不足 6 字节时只累积字节数不解析够 6 字节了算出body_len然后判断缓冲里还差多少差分多少就记在连接结构体上等后续数据补齐。这样缓冲可以按需增长也可以设一个「单连接最多缓存 32KB 半包」的上限超了就断开防止慢速攻击把内存吃光。状态机还有一个好处它把「协议解析」和「业务处理」解耦了。dispatch里只负责按cmd分发解析器不关心具体业务业务层也不关心字节怎么拼的。后面要加协议版本、加压缩标志位改动都集中在解析器里。注意Flash 客户端的ByteArray读写位置是共享的readInt之后位置指针会前移。写协议的时候一定要在读完包头之后立刻把len存成局部变量不要一边读一边用bytesAvailable判断否则很容易把「剩余可读字节」和「包体长度」搞混表现出来就是偶尔丢一个包而且只在包体较大时出现。4. 服务器主循环的实现accept、read、write 三段式怎么排4.1 所有 fd 必须非阻塞EAGAIN 是正常返回不是错误select 服务器的第一原则任何可能被 select 监视的 fd都必须是非阻塞的。这包括监听 fd也包括accept返回的每一个连接 fd。原因是 select 的就绪通知只是「这一刻看起来可以读」从通知发出到你真正调用recv中间隔着你自己的循环遍历、业务分发可能已经过去了几百微秒。这段时间里对端完全有可能把数据读走或者把连接关掉等到你真正recv时数据没了。如果 fd 是阻塞的这个recv就会一直卡住整个单线程事件循环停摆所有其他玩家一起掉线。非阻塞模式下这种情况返回 -1 且errno是EAGAIN或EWOULDBLOCKLinux 上这两个值相同。这不是错误是「暂时没数据」的正常信号处理方式就是返回等下一轮事件。accept也一样。监听 fd 可读意味着「有连接排队的可能性」但在你调用accept之前那个连接可能已经被对端重置了。非阻塞accept会返回EAGAIN或者返回一个随后马上失败的 fd这种情况常见于ECONNABORTED。所以accept也要放在循环里一直取到EAGAIN为止并且对每个新 fd 立刻设上O_NONBLOCK/* accept 循环 新连接初始化 */ for (;;) { struct sockaddr_in addr; socklen_t len sizeof(addr); int fd accept(listen_fd, (struct sockaddr *)addr, len); if (fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; /* 取完了 */ if (errno EINTR) continue; if (errno EMFILE || errno ENFILE) { /* fd 耗尽先关掉一个空闲连接腾位置否则会死循环 */ reap_idle_conn(); continue; } break; } set_nonblocking(fd); /* 必须漏了就是一颗定时炸弹 */ set_nodelay(fd); /* 游戏包小关掉 Nagle 降延迟 */ conn_add(fd); }set_nodelay是另一个实践中的选择。Nagle 算法会攒小包对吞吐有利但对延迟不利而游戏的操作包通常只有几十字节攒包的 40 毫秒延迟玩家能明显感觉到。关掉之后包变多但在局域网/同城机房场景下这个代价可以接受。EMFILE分支也值得单独说。进程能打开的 fd 数有上限一旦耗尽accept会失败但监听 fd 仍然是可读的连接还在队列里下一轮 select 又通知你你又accept失败形成 100% CPU 的死循环。正确做法是留一个「备用 fd」遇到EMFILE就关掉备用 fd、accept一个连接、立刻关掉它、再把备用 fd 打开用一个连接换一次喘息机会。4.2 事件循环骨架三段式的顺序有讲究把前面的东西拼起来主循环的骨架大致如下fd_set rfds, wfds; struct timeval tv; while (running) { FD_ZERO(rfds); FD_ZERO(wfds); FD_SET(listen_fd, rfds); /* 监听 fd 只关心可读 */ int maxfd listen_fd; for (int i 0; i conn_count; i) { conn_t *c conns[i]; if (c-state CONN_CLOSED) continue; if (c-rbuf_len RBUF_SIZE) FD_SET(c-fd, rfds); /* 缓冲满则摘掉 */ if (c-wbuf_len 0) FD_SET(c-fd, wfds); /* 有积压才关注可写 */ if (c-fd maxfd) maxfd c-fd; } tv.tv_sec 0; tv.tv_usec 10000; /* 10ms定时器精度与空转的折中 */ int n select(maxfd 1, rfds, wfds, NULL, tv); if (n 0) { if (errno EINTR) continue; /* 被信号打断重新组装 */ break; } if (n 0) { do_timer(); continue; } /* 纯粹超时只跑定时器 */ if (FD_ISSET(listen_fd, rfds)) accept_new(); for (int i 0; i conn_count; i) { conn_t *c conns[i]; if (c-state CONN_CLOSED) continue; if (FD_ISSET(c-fd, rfds)) do_read(c); if (c-state CONN_CLOSED) continue; /* 读可能触发关闭 */ if (FD_ISSET(c-fd, wfds)) do_write(c); } do_timer(); /* 心跳检查、超时踢人、定时广播 */ }几个参数和顺序上的细节。maxfd 1是nfds内核只扫[0, nfds)所以maxfd必须准确维护漏更新会导致部分 fd 被静默忽略症状是「有些玩家偶尔收不到消息」。tv每次循环都要重新赋值因为 Linux 会把它改成剩余时间不重置的话第二次开始超时就是乱的。读和写之间那个if (c-state CONN_CLOSED) continue;很容易漏。do_read里可能因为对端关闭或者协议错误直接把这个连接关掉此时c-fd已经无效再拿它去do_write就是典型的 use-after-close表现是偶尔往一个被复用给别的连接的 fd 上写数据串包非常难查。do_timer()放在最后统一跑而不是散落在各处是为了让同一时刻的超时判定基于同一个时间戳避免心跳逻辑里出现「这一轮判了存活下一轮又说超时」的抖动。4.3 写缓冲select 只告诉你「能写」不告诉你「写多少」select的写就绪语义是「内核发送缓冲区至少有 1 字节空间」不是「你上次那 20KB 全都能塞进去」。所以发送不能直接send完事必须有用户态写缓冲队列。逻辑是业务层产生数据就往写缓冲尾部追加如果队列从空变非空就在下一轮把这个 fd 挂进wfds。事件循环看到可写就循环send发出去多少从缓冲头减掉多少直到遇到EAGAIN表示内核缓冲满了剩下的继续排队等下一轮。/* 把写缓冲尽量推出去推不完就留着等下次可写 */ int do_write(conn_t *c) { while (c-wbuf_len 0) { ssize_t n send(c-fd, c-wbuf c-wbuf_off, c-wbuf_len, MSG_NOSIGNAL); if (n 0) { c-wbuf_off n; c-wbuf_len - n; continue; } if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { return 0; /* 内核满了保留剩余数据 */ } if (n 0 errno EINTR) { continue; } close_conn(c); /* EPIPE / ECONNRESET对端没了 */ return -1; } c-wbuf_off 0; c-wbuf_len 0; return 0; }MSG_NOSIGNAL这个标志很重要。往一个已经被对端关闭的连接写数据内核默认会发SIGPIPE进程的默认行为是直接终止。单线程服务器上一个玩家意外断电就能把整个服务打死这个 bug 的现场表现就是「进程毫无征兆地消失没有任何日志」。加上这个标志错误变成EPIPE返回值代码里能正常处理。写缓冲要有上限。如果某个客户端网络极差服务器一直往队列里塞数据、发不出去内存就会一直涨。常见做法是设一个比如 256KB 的上限超了就判定为「客户端处理不过来」直接断开。游戏里还有更细的策略如果积压的是位置同步包直接丢弃旧的只留最新的因为玩家只关心最新位置。4.4 心跳与超时Flash 玩家关掉浏览器服务端为什么感知不到Flash 客户端被直接关掉标签页或者浏览器崩溃时TCP 层并不会立刻发 FIN。操作系统要等到下一次尝试发送、收到 RST或者 keepalive 超时之后才知道对端没了。默认的 TCP keepalive 是两小时等它等于永远不知道。结果是玩家已经走了服务器这边连接还挂着占着一个 fd、一份缓冲还在给他广播消息。解决办法是应用层心跳。客户端每隔固定时间发一个极小的包比如cmd 0x0001body 为空服务端收到就更新last_active时间戳。定时器每轮检查一遍超过比如 3 个心跳周期没动静的主动断开。这里有三个参数要一起定客户端心跳间隔、服务端判定超时的阈值、定时器精度。客户端 5 秒发一次服务端阈值设 15 秒定时器 10 毫秒扫一遍这个组合比较稳。阈值设太紧网络抖一下就把正常玩家踢了太松死连接堆积fd 很快就撞上限。定时器本身的实现也别搞复杂。一千个连接每轮全量扫一遍时间戳代价可以忽略。真正要注意的是别在定时器里做阻塞操作比如写数据库、发 HTTP 请求那会让整个事件循环卡住所有玩家一起顿一下。5. 避坑与排查select 服务器最常见的五个翻车点5.1 fd 编号超过 1024进程直接段错误现象在线人数涨到某个值之后服务进程毫无征兆地崩溃core 文件里栈顶是FD_SET或者select。崩溃点没有业务逻辑看着莫名其妙。原因fd_set只有 1024 位FD_SET(fd, set)展开是fds_bits[fd / 64] | ...当fd 1024时fd / 64已经越界往结构体外面写内存。这不是返回错误是直接踩内存后果取决于踩到了什么可能是崩溃也可能是某个全局变量被悄悄改掉几小时后才以别的形式爆出来。select的nfds参数超过FD_SETSIZE时内核会返回EINVAL但用户态的FD_SET早就已经越界写完了。解决在conn_add里加一道硬检查if (fd FD_SETSIZE) { close(fd); return -1; }。同时把连接数上限控制在 900 左右留出余量因为进程的标准输入输出错误三个 fd 也占着号。真需要更多连接就得换 poll 或 epoll这不是调参数能绕过去的。5.2 读缓冲满了没摘 rfdsCPU 瞬间 100%现象某个玩家发送异常大的数据包或者客户端出 bug 狂发包服务端 CPU 立刻打满所有玩家一起卡。原因读缓冲满了以后recv读不进新数据但内核接收队列里还有数据select 是水平触发立刻又返回可读事件循环又调do_read又读不进去又返回。这个循环在微秒级空转CPU 直接吃满。解决组装rfds时判断c-rbuf_len RBUF_SIZE才把 fd 挂上去解析逻辑腾出空间后再挂回。这行的位置很关键写在do_read内部没用必须写在事件循环组装阶段。同时给单连接读缓冲设上限超了就断开防止单个客户端拖垮全局。5.3 每次循环重建位图的开销被低估现象连接数上去之后即便流量很小CPU 也有一个稳定在 20% 到 30% 的底噪profile 出来大头不在业务函数上。原因每轮循环都要FD_ZERO两次、遍历所有连接FD_SET两次、再遍历一次FD_ISSET两次。1000 个连接就是 6000 次宏操作每秒循环几百次就是几百万次加上 select 系统调用本身的两次位图拷贝全是固定成本。解决把「连接数组」换成「活跃连接数组」只用紧凑数组遍历FD_ZERO之后先memcpy一份模板位图而不是逐个FD_SET把定时器精度从 10 毫秒放宽到 30 毫秒减少循环次数。这些都是治标真要根治还是得换 epoll把「全量注册」变成「增量注册」。5.4 半开连接堆积fd 悄悄漏光现象服务跑几天之后accept开始失败errno是EMFILE重启就好过几天复现。原因玩家直接断网、拔网线、强制关闭浏览器进程这些情况 TCP 层发不出 FIN服务端连接一直处于 ESTABLISHED。如果没有心跳机制这些连接永远不会被回收。另外close_conn里如果只调了close(fd)但忘了把state置成CONN_CLOSED或者忘了从活跃数组里摘掉fd 号也会漏。解决强制心跳超时close_conn做成一个统一函数closefd、置状态、清缓冲、从活跃表移除四件事写在一个地方禁止散落在accept的EMFILE分支里做兜底回收按last_active从旧到新踢掉几个连接腾位置。上线前跑一次「模拟 500 个连接全部异常断开」的压测看 fd 数量能不能回落到基线。5.5 广播时的写操作把循环卡住现象玩家人数多了以后某个玩家放一个全屏技能全服广播所有客户端同时顿一下几百毫秒到几秒。原因广播逻辑写成「遍历所有连接逐个send」。前面说过send在非阻塞 fd 上不会卡但遍历本身是同步的1000 个连接就是 1000 次系统调用加上每次的数据拷贝一轮下来几十毫秒。如果这个广播是在处理某个玩家的请求时同步做的那个玩家的请求处理时间就被拉长到几十毫秒紧接着其他玩家的请求排在这后面雪崩。解决广播不要直接send只往各连接的写缓冲里追加数据实际发送交给事件循环里的do_write。更进一步把广播内容做成一份共享的只读缓冲各连接只记录偏移量避免每个连接复制一份。如果确实需要压缩压缩一次所有连接共用压缩后的结果。6. 从 select 到 epoll这套机制的设计取舍与验证select的价值在于它把「等待多个事件」这件事抽象成了一个系统调用让单线程服务器成为可能。它的每一个设计选择都能解释位图是为了让用户态组装成本最低水平触发是为了不丢事件nfds参数是为了让内核少扫一段。代价也都从这里来位图定长所以有 1024 上限全量扫描所以连接多了就慢每次调用都要拷进拷出所以频率高了就亏。后来的方案是在这三个点上分别拆解。poll把位图换成数组去掉了 1024 的硬限制但仍然全量扫epoll把「注册」和「取就绪」彻底分开注册只在增删连接时发生一次取就绪时内核只返回真正有事件的那几个把 O(n) 降到了 O(活跃连接数)。维度selectpollepoll连接上限1024FD_SETSIZE受系统 fd 上限约束受系统 fd 上限约束每次调用的开销全量拷贝 全量扫描全量扫描只返回就绪项用户态组装成本每轮重建位图每轮重置revents无需重建触发方式仅水平触发仅水平触发水平/边沿可选适合场景几百连接、逻辑简单中等连接数上万连接、高并发选型上我的判断是连接数稳定在几百以内select完全够用代码简单、跨平台、调试直观没必要上epoll一旦连接数要往千以上走或者单机要扛多个游戏服就早点换。切换的成本主要在写缓冲和连接管理上这两块逻辑其实是通用的把「注册/摘除」抽成poller_add/poller_del两个接口底层换实现不影响业务代码。想验证自己写的这套循环到底行不行不用搞复杂压测。开一个脚本模拟 500 个连接每个连接每秒发一个包跑十分钟然后看三个数CPU 占用是否稳定、fd 数量在连接全部断开后能否回到基线、内存是否缓慢增长。这三个数任何一个飘了问题一定在事件循环本身跟业务逻辑无关。再补一个「所有连接同时发一个 60KB 大包」的用例专门打半包和缓冲满这两条路径能暴露大部分边界 bug。血泪经验是这类底层 bug 在低负载下几乎不出现等到线上人数涨上去才爆而那时候日志里什么线索都没有只有 CPU 曲线和 fd 数量能看。所以我后来养成了一个习惯任何事件循环相关的改动上线前先把「缓冲满」「对端异常关闭」「广播」这三条路径各写一个最小复现用例跑一遍宁可多花两小时也别指望线上出问题时还能靠后悔药。希望帮到你。本文还有配套的精品资源点击获取