单机百万长连接实战:epoll、io_uring、kqueue 怎么选,以及我踩过的 7 个坑
一、先说那个让我熬到凌晨三点的 conntrack
去年帮一个做行情推送的团队做调优,机器是 32 核 128G,Ubuntu 22.04,内核 5.15.0-91,双口 25G 的 Mellanox 网卡。他们的服务是纯推送,客户端连上之后基本只收不发,平均每个连接每秒 3 到 5 个包、100 字节上下。目标很朴素:单机 100 万连接。
压到 7 万左右的时候出事了。现象特别邪门:客户端 connect 成功,三次握手也完成了,ss 里能看到 ESTABLISHED,但就是收不到任何数据,服务端日志干干净净,CPU 也不高。我第一反应是 epoll ET 模式下有地方没读到 EAGAIN,把代码翻了两遍,还加了计数器,确认每个连接都正确地读到底了。
后来是同事随口提了一句"你看下 dmesg",一行 nf_conntrack: table full, dropping packet 就躺在那里。nf_conntrack_max 默认只有 65536,而我为了模拟弱网环境在测试机上加了条 iptables DROP 规则,直接触发了 conntrack 记账。包在 netfilter 那一层就被丢了,压根到不了 socket,所以应用层什么都看不到。查了两个多小时,就因为没第一时间看 dmesg。
那台机器后来我把 nf_conntrack_max 调到 2097152、hashsize 调到 524288,内存多花了大概 400M,问题消失。如果你也在压连接数,先别急着改代码,按这个顺序过一遍:
sysctl -w fs.file-max=2097152
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.ipv4.ip_local_port_range="10000 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_max=16777216
sysctl -w net.ipv4.tcp_rmem="4096 262144 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 262144 16777216"
sysctl -w net.ipv4.tcp_mem="786432 1048576 26777216"
sysctl -w net.netfilter.nf_conntrack_max=2097152
注意 somaxconn 只是 listen 队列的上限,实际生效值还要和 listen() 的 backlog 参数取小。另外 systemd 托管的服务记得在 unit 文件里写 LimitNOFILE,光改 /etc/security/limits.conf 对 systemd 拉起来的进程是没用的——这个坑我认识的人里至少三个踩过。
二、epoll 的成本到底在哪
先泼盆冷水:epoll_wait 本身被讨论得太多了,但它真不是你的瓶颈。真正贵的地方有三个。
第一是 epoll_ctl。红黑树插入是 O(log n) 没错,但多个线程共享同一个 epoll fd 的时候,内核里那把锁会让你的 ADD/MOD 操作排队。我做过一个很土的测试:8 个线程往同一个 epoll fd 里各加 10 万个 fd,总耗时 3.2 秒;改成每线程一个 epoll 实例,总耗时 0.9 秒,差了 3.5 倍。所以"一个大 epoll + 一堆线程"这个模型,在连接频繁建立/断开的场景下基本是自找麻烦。
第二是 ET 和 LT 的差别被严重夸大。LT 模式下内核要帮你把就绪事件一直挂在 ready list 上直到处理完,ET 只是省掉了一部分重复通知(LT 也不会每次重新扫红黑树)。老实说,一份写对的 LT 代码和一份写对的 ET 代码,在推送场景下我的测量差距是 3%~7%,但 ET 写错的概率高一个数量级——少读一个 EAGAIN、或者 EAGAIN 之后忘了重新挂 EPOLLOUT,都能让整个连接卡死。所以我给团队的建议是:只有 syscall 开销真的进了火焰图前三,再考虑 ET。
第三是唤醒粒度。epoll_wait 返回的是一个数组,maxevents 给多少很关键。50 万空闲连接 + 1 万活跃连接的场景下,给 64 和给 1024,我实测单核 CPU 占用是 6.1% vs 7.3%,但后者 P99 延迟更差。给小了 syscall 次数暴涨,给大了单次处理变长、延迟尾部变烂,得按你的业务形态调。
三、io_uring:我测了两周,然后放弃了
先给结论:对长连接推送/网关这类业务,io_uring 不值得。除非你在做 NVMe 直连的存储引擎,或者跑 40G/100G 的 L4 转发。
理由不是性能。我用 liburing 2.4 写了个和 epoll 版本功能完全一致的 echo + push 服务,8 核机器,1GbE(家里就这条件),压测结果:
- epoll + ET + 8 进程 SO_REUSEPORT:142k QPS,P99 1.8ms
- io_uring(不开 SQPOLL,multishot accept + multishot recv):151k QPS,P99 1.6ms
6% 左右。纸面上有提升,但你要付出的代价是:
- 内核版本碎片化。multishot accept 我记得是 5.19 才合进主线的,我在一台 5.15 的机器上折腾了半天才发现压根没这个 opcode。CentOS 7 的 3.10 想都不要想,RHEL 8 的 4.18 也基本残废。
- 一些发行版在收紧它。2023 年 Google 在 ChromeOS 上禁用了 io_uring(安全面太大),Ubuntu 后来也引入了
kernel.io_uring_disabled这个 sysctl。你写好的东西可能在某次安全策略更新后被一键关掉。 - 出问题的时候几乎没有趁手的工具。epoll 卡住了,
strace -p一眼能看出是哪个 fd 没读;io_uring 卡住了,你看到的就是 submit 之后 CQE 不回来,剩下全靠猜。 - SQPOLL 早年要 root,现在普通用户也能开,但它会被 RLIMIT_MEMLOCK 和 cgroup 的 memory 限制卡住,容器里跑尤其容易踩。而且 SQPOLL 本质是个忙等的内核线程,会一直烧掉一个核——你省下的 syscall 是拿 CPU 换的。
我的判断:io_uring 的甜点区是"每秒几十万次小 IO 且想省 syscall"的场景,比如数据库引擎、本地文件服务。网络编程里,一个连接每秒也就几个到几十个包,syscall 从来不是瓶颈,瓶颈在内存带宽和 cache miss 上。
四、kqueue:在 macOS 上写服务端的三个坑
我平时在 MacBook 上写代码、跑本地测试,所以 kqueue 绕不开。
kqueue 的设计确实比 epoll 优雅:kevent 是批量的,一次系统调用能同时提交变更和取事件,不像 epoll 得 epoll_ctl + epoll_wait 来回两次。语义上 EV_CLEAR 约等于 EPOLLET,EV_EOF 比 EPOLLHUP 更可靠。
但 macOS 真不是拿来当服务器的:
- 默认
kern.maxfiles是 12288,kern.maxfilesperproc是 10240。一个稍微像样的测试就得改,而且改完重启还容易失效(我一般写进 /etc/sysctl.conf 再手动sysctl -w一遍)。 SO_REUSEPORT在 macOS 上不做负载均衡。Linux 3.9 之后内核会帮你按哈希分流,macOS 上它只是"允许重复绑定",多个进程抢 accept 照样惊群。- 没有
EPOLLEXCLUSIVE的等价物(那是 Linux 4.5 加的),多进程 accept 同一端口,在连接建立密集时 CPU 会很热闹。
所以我的做法是:Mac 上只跑功能测试,连接数超过 5000 的压测一律丢到 Linux 上。kqueue 的性能数字在 FreeBSD 上才好看,那又是另一个生态了。
五、我现在的默认选择
多进程 + SO_REUSEPORT + 每进程独立的 epoll,一个 event loop 一个线程,回调里绝不做阻塞操作。8 个 worker,内核负责 accept 分流。这个组合在 32 核上跑 100 万空闲连接,内存大概 6.8G(每个连接的应用层对象我压到了 320 字节以内),空闲时 CPU 占用 2% 左右。
顺便说一个没什么人提、但特别要命的点:别在主线程里做 JSON 序列化。行情推送这种场景,一条消息序列化十几微秒,几万条就是几百毫秒的延迟尖刺。我后来把序列化挪到预计算 + 内存池复用的路径上,P99 直接从 12ms 掉到 2.3ms。
六、卡住了怎么查(抄作业版)
ss -s看总览;ss -lnt看 listen 队列,Send-Q 持续大于 0 说明 accept 跟不上。cat /proc/net/sockstat看 TCP inuse 和 orphan,orphan 上千基本说明你在频繁短连接。netstat -s | grep -i -E "listen|overflow|drop|retrans",listen queue overflow 是最容易被忽略的指标。dmesg -T | tail -50,nf_conntrack table full、too many orphaned sockets 都在这里。cat /proc/net/softnet_stat,第二列是 backlog 溢出计数,网卡软中断处理不过来时它会长。- 实在不行上
perf top -p <pid>,看看是不是在_raw_spin_lock上打转。
最后说句实话:网络编程里 90% 的"性能问题"最后都不是网络的问题,是配置、是锁、是你的数据结构有 cache miss。我调过的项目里,光是把连接对象按 cache line 对齐、把热字段挪到前 64 字节,就拿到过 15% 的提升。这比换 io_uring 划算多了。