说起来有点丢人。去年我负责的一个数据分析服务突然连接数飙到 3000 多,整个进程直接卡死。当时用的是最传统的 select 模型,所有 socket 存进 fd_set,每次等通知后遍历一遍。select 的 FD_SETSIZE 上限是 1024,我虽然把服务端监听 socket 也放进去,但客户端一多,fd_set 就溢出了,很多连接直接丢事件。重启后我做了个 dump,发现代码里根本没处理 fd_set 溢出的分支,也就是说,只要超过 1024 个 socket,队列里剩下的事件全被当成垃圾丢掉。这个教训太痛了。后来我去查 poll,第一反应说,你看 poll 没有数量限制,结构体数组啥都能存,但用的时候才发现,每次 poll 都要把几万个 pollfd 结构从用户态拷到内核态,等内核把活动标记好再整包拷回来。这个过程是 O(n) 扫描全量 fd,如果一万个连接里只有一条有数据,你得在内存里遍历九千九百九十九条空转。当然,小流量看不出毛病,真正到量级才会露馅。
我自己的建议是,如果还在用 select,第一件事不是换 epoll,而是先确认自己有没有把管理连接的数据结构和 IO 事件分开。很多教科书为了简单,都把 fd 数组当成连接池来用。实际项目中,连接状态机、超时队列、对方关闭的判断最好单独拆开,否则不管换什么都白搭。这个感受是真实踩出来的,比如 select 返回读事件后,如果你直接从那个 fd 里 recv,你就没想过缓冲区可能已经没数据了?这种属于半关闭形态,客户端发了 FIN 但仍可收,不能直接判定断连。我做网络程序时发现,一堆“假死”连接都是因为对 FIN 处理不严谨。
后来我迁移到 epoll,重点尝试了 LT 和 ET 两种模式。说实话,搞网络编程的人要是没在 ET 上吃过亏,都不好意思说自己是后端。ET 每次事件通知只报一次,如果这次没把数据读完,缓存里剩下的字节不等下次新数据到达就不再通知你。所以 ET 模式下必须用非阻塞 socket,然后 while 循环 read 到 EAGAIN,才能确保数据不丢。这个逻辑本身没问题,但问题在于:很多新手忘了把 socket 设置成 O_NONBLOCK,或者循环里读完了整个 buffer 还在 read,导致 EAGAIN 频繁触发,白白多几次系统调用。我曾经写过一个报文转发模块,开着 ET 模式,处理 10 万条小包,每条包 100 字节,我的 buffer 是 8K,每次批量消费后还剩一堆半包,因为忘加循环读,导致数据积压在 socket 队列里,客户端一直等响应,后来抓包才发现,TCP 窗口变成 0 了。所以说,ET 不适合刚入门的场景,LT 虽然通知重复,但它不会丢事件,代码更容易写对。强烈建议,如果你的业务逻辑不是三五百个连接的小服务,而是高并发边缘业务,先考虑 LT 打好基础,再做 ET 优化。
我是测过一个简单的性能样例,在 Linux 5.10、8 核机器上,单线程 epoll,10 万个连接。等到每轮只有几十个活跃连接时,LT 和 ET 的 epoll_wait 返回量差别不大,LT 平均每轮约 1.1 个事件,ET 是 1.0 个,CPU 耗时都在微秒级。如果每轮都有大量事件,ET 的优势可能体现在减少重复通知的调用上,但你得算算省下的是 epoll_ctl 和事件分发,还是底层驱动遍历?我自己的结论是,在高吞吐密集小包场景,ET 大概能降 5% 的 CPU 使用率,但代价是代码复杂度至少涨 30%,而且还容易埋下丢包雷。我不太理解很多人一上来就追 ET,说句难听的,有些人是为“技术含量”买单,不是为业务收益买单。如果确实要做 ET,建议你自己写一个“读耗尽”的测试用例,非阻塞 socket + recv 循环,在读到 EAGAIN 时判断 errno。注意 EAGAIN 和 EWOULDBLOCK 是同一个值,但在不同系统上最好两个都判断,别只写一个 EAGAIN,Windows 上调 WSAEWOULDBLOCK。
最终我用的方案其实很简单,每个连接一个 recv 和 send 对象,都挂在 epoll 上,但用户态维护一个 ring buffer,初始大小 4 KB,如果读取超过当前容量就触发扩容策略:使用 realloc 成倍增长,上限设为 64 KB。为什么不能每个连接都固定一个 64 KB 的缓冲区?你想想单进程开十万连接,每个连接如果固定分配 8 KB 读写缓冲区,那就是 10 万乘以 16 KB,光裸内存就是 1.6 GB,还不算其他结构体。如果我们用“起始低容量 + 动态扩展 + 淘汰闲置”的做法,十万个连接平均每个只有几百字节缓冲,内存能往下压一个量级。这个经验对异步读取尤其关键。
协议上,我们用的是每个数据包头部 4 个字节表示 payload 长度,4 字节的 header 本身也要做读满判断,不能只读 2 个字节就算读到完整头。很多粘包文章会告诉你“把完整包收下来再处理”,但现实是接收端不知道对方什么时候发完,所以必须自己切分字节流。我见过的一个伙伴实现了类似“读到一个 TCP 连接时,把已有数据全部塞进一个字符串,然后分隔符号来断包”,这在小数据量时好用,一旦二进制流里出现与分隔符相同的字节,直接崩。定义协议时,宁可头部长一点,也要有一个魔数带校验,我就加了一个 0x5A 魔数占位,然后才是长度和类型,虽然浪费了 8 个字节,但解析时能快速过滤非法数据,这种方案的坏处是每个字段都有大小端问题,但字段都固定,服务器统一转成网络字节序,会减少麻烦。