简介:一套面向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 的底层机制:一个线程如何同时盯住上千个 socket
2.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 用的就是零字节分隔),但要求消息内容里不能出现分隔符,二进制场景下很难保证;长度前缀法没有这个限制,代价是多几个字节的开销,是游戏协议里最常见的选择。
一个够用的格式长这样:
| 字段 | 长度 | 说明 |
|---|---|---|
| len | 4 字节,大端 | 包头之后的消息体长度,不含包头本身 |
| cmd | 2 字节,大端 | 命令字,决定往哪个处理函数分发 |
| body | len 字节 | 业务数据,由具体协议决定 |
大端是网络字节序,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或EWOULDBLOCK(Linux 上这两个值相同)。这不是错误,是「暂时没数据」的正常信号,处理方式就是返回,等下一轮事件。
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 = 0x0001,body 为空),服务端收到就更新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 读缓冲满了没摘 rfds,CPU 瞬间 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(活跃连接数)。
| 维度 | select | poll | epoll |
|---|---|---|---|
| 连接上限 | 1024(FD_SETSIZE) | 受系统 fd 上限约束 | 受系统 fd 上限约束 |
| 每次调用的开销 | 全量拷贝 + 全量扫描 | 全量扫描 | 只返回就绪项 |
| 用户态组装成本 | 每轮重建位图 | 每轮重置revents | 无需重建 |
| 触发方式 | 仅水平触发 | 仅水平触发 | 水平/边沿可选 |
| 适合场景 | 几百连接、逻辑简单 | 中等连接数 | 上万连接、高并发 |
选型上我的判断是:连接数稳定在几百以内,select完全够用,代码简单、跨平台、调试直观,没必要上epoll;一旦连接数要往千以上走,或者单机要扛多个游戏服,就早点换。切换的成本主要在写缓冲和连接管理上,这两块逻辑其实是通用的,把「注册/摘除」抽成poller_add/poller_del两个接口,底层换实现不影响业务代码。
想验证自己写的这套循环到底行不行,不用搞复杂压测。开一个脚本模拟 500 个连接,每个连接每秒发一个包,跑十分钟,然后看三个数:CPU 占用是否稳定、fd 数量在连接全部断开后能否回到基线、内存是否缓慢增长。这三个数任何一个飘了,问题一定在事件循环本身,跟业务逻辑无关。再补一个「所有连接同时发一个 60KB 大包」的用例,专门打半包和缓冲满这两条路径,能暴露大部分边界 bug。
血泪经验是:这类底层 bug 在低负载下几乎不出现,等到线上人数涨上去才爆,而那时候日志里什么线索都没有,只有 CPU 曲线和 fd 数量能看。所以我后来养成了一个习惯:任何事件循环相关的改动,上线前先把「缓冲满」「对端异常关闭」「广播」这三条路径各写一个最小复现用例跑一遍,宁可多花两小时,也别指望线上出问题时还能靠后悔药。希望帮到你。
本文还有配套的精品资源,点击获取