很多人学网络编程,开篇就是 epoll,查 man page、抄 echo server,跑完 demo 觉得“我也能写高并发服务了”。可真到线上,连接一多,CPU 飘了、吞吐掉了、机器莫名其妙重启,你再回头翻代码,发现自己除了 epoll_ctl 和 epoll_wait 之外,对内核里那条“网卡 → 协议栈 → socket → 就绪链表 → 用户进程”的链路一无所知。我带过不少刚入门的同事,几乎每个人栽的跟头都一样:只把 epoll 当成一个“多路复用 API”,没把它当成一条完整数据链路的终点。这篇文章就沿着这条链路往下走,把 epoll 百万并发背后真正起作用的东西拆开,顺带把几个常被忽略的“潜规则”讲透。
1. 先理清链路:网卡、内核、socket、epoll 各自是谁
1.1 一条数据到底走过了哪些关卡
很多文章一上来就讲 epoll_create 返回值、events 数组怎么填,我觉得这个顺序反了。你得先知道数据是从哪来的,才理解 epoll 为什么“快”。
一条 TCP 数据从对端到达本机,粗略要经过这些关卡:
- 数据帧落在网卡物理端口,PHY/MAC 层完成信号解码和帧校验;
- 网卡通过 DMA 把帧内容写到内核预分配的内存区(环形缓冲区);
- 网卡触发硬件中断,CPU 中断处理程序只做“快进快出”,登记一下“有包要收”,随后调用软中断机制;
- 软中断/内核线程
ksoftirqd出场,把环形缓冲区里的帧取出,封装成sk_buff; sk_buff穿越链路层(eth_type_trans)、网络层(IPv4 的ip_rcv)、传输层(TCP 的tcp_v4_rcv),找到对应的 socket;- 数据进入 socket 的接收队列,同时触发“可读”回调;
- 如果这个 socket 被某个 epoll 实例监控着,回调会把对应的
epitem一把塞进 epoll 实例的就绪链表; epoll_wait所在的线程被唤醒,把就绪链表上的事件复制到用户态events数组;- 你的业务代码里
read/recv才真正把数据拷走。
这条链路,前 5 步是“网卡到 socket”,后面 3 步是“socket 到 epoll 到进程”。不少做业务的同学觉得 1-5 和自己没关系,但恰恰在这里,tcp_mem、网卡队列长度、softnet_backlog 这些参数会先一步卡死你。你 epoll_wait 半天不返回,不是 epoll 不行,是包根本没走到协议栈。
1.2 网卡是在“推”,不是在“等你拉”
服务端开发经常有个直觉错误:以为数据来了之后是内核“存着”,然后你调用 recv 时内核才去网卡取。不是这样。
网卡本身是个独立的小电脑,有 MAC 控制器、FIFO、DMA 引擎。帧一到位,它直接通过 DMA 把自己内部缓冲区里的数据搬到系统内存,根本不需要 CPU 动手。搬完之后,它再发一个中断通知 CPU:“东西放你抽屉里了。”这里有个很贴切的类比:快递柜。快递员(网卡)把包裹放进柜子(内核内存),然后发短信(中断)叫你去取。你去取件(recv)的时候,包裹早就躺在柜子里了。如果你把取件理解成“快递员现在才送”,节奏就全错了。
理解这一点,你就明白为什么网卡驱动、队列长度、DMA 缓冲区大小会影响收包性能。数据量大时,发短信(中断)的频率本身也可能成为瓶颈,这就是后面要讲的 NAPI:把中断压下去,改成批量开柜取件。
1.3 socket 不是普通“文件”,它背着一堆队列
再往用户态说一点。你在 C 里拿到的是一个 int fd,但 fd 只是 struct file 的索引。往里走,struct file 关联到 struct socket,再往里是 struct sock,TCP 场景下具体是 struct tcp_sock。真正装数据的容器是接收队列 sk_receive_queue 和发送队列 sk_write_queue,队列节点是 sk_buff。
平时我们用 read(fd, buf, n) 时,实质操作的是这个 socket 的接收队列。数据有没有“可读”,主要是取决于接收队列里有没有数据。epoll 监测的本质,就是想知道这个状态何时变化。所以我会建议初学者把“fd”翻译成“一个带着队列的通道”,否则你会很难理解:为什么一个 fd 可以又写又读?为什么调用 epoll_ctl 时要传 fd 本身?因为它等的是这个队列的“非空”事件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据进内核:中断、NAPI 与 sk_buff
2.1 硬件中断只喊一句“来活了”,剩下的交给软中断
网卡 DMA 完,发起硬件中断。这个中断信号到达 CPU 后,CPU 会立刻跳进驱动注册的中断处理函数。这个函数不能干重活:关掉当前网卡中断,把工作丢给 NET_RX_SOFTIRQ 这个软中断,然后返回。为什么?因为中断上下文里不能睡眠、不能长时间占有 CPU,否则整个系统的响应都会崩。你可以把硬件中断想成电话铃:响一下提个醒,把事情记在小本子上,然后你继续手头工作。真正处理收包的是软中断。
你能不能看到软中断在干活?可以。/proc/softirqs 里 NET_RX 那行就是在增长的。我压测时习惯先看这个文件:如果 NET_RX 涨得飞快,说明收包热点确实在内核协议栈;如果它不涨但应用没反应,那问题大概率更早,要么网卡队列,要么驱动状态。这是一个非常便宜的排查切入口。
2.2 NAPI:关掉中断之后,用轮询代替狂轰乱炸
如果每个包都触发一次硬件中断,那包速率上来,中断风暴能把 CPU 打满,网卡吞吐反而上不去。所以 Linux 网络收包基本都是 NAPI 机制:中断只负责“叫醒”,叫醒之后进入轮询模式,驱动一次性把环形缓冲区里的多个包都取出来,直到取空或者达到本轮预算(net.core.netdev_budget 默认 300)。
用快递柜类比:以前每到一个包裹,快递员打个电话,你从工位跑过去取一次;现在快递员隔一段时间集中发一条短信,你去开柜把里面的十件快递一次全抱走。代价是延迟稍微高一丁点,换取吞吐大幅提升。这个方向对高并发特别重要,因为你的目标是百万连接,不是 10 微秒极致延迟。
2.3 sk_buff 穿越协议栈,找到那个 socket
驱动把帧从环形缓冲区取出后,封装成 struct sk_buff,这是 Linux 网络栈里最核心的结构体:头部、数据指针、各层协议头部偏移,全都指向同一块内存。netif_receive_skb 之后进入链路层,识别出以太网类型;然后 IP 层校验版本、做分片重组、查路由;最后 tcp_v4_rcv 进 TCP,做校验、处理乱序、更新滑动窗口,最终找到该连接对应的 struct sock。
这里有个很多人没意识到的点:内核找 socket 不是遍历,而是查哈希表,四元组做 key。所以连接多到百万,查找代价也不会线性上涨。这也是 epoll 模型能支撑大规模连接的前提之一。协议栈处理完后,如果有新数据放进了接收队列,且 socket 上有等待“可读”的进程,内核会调用 sk_data_ready 回调去通知。这个回调点,就是 epoll 链条的下一个驿站。
2.4 数据拷贝的“第一次”和“第二次”
整个链路里,数据是“零拷贝”拿到内核的吗?不完全。网卡 DMA 已经算一次“免 CPU”的搬运,但用户态读数据时,recv 从内核缓冲拷贝到用户态缓冲,这一趟免不掉。splice/sendfile 可以把某些场景的第二次拷贝抹掉,但常规 socket 读,该拷还得拷。
这跟百万并发的关系在于:不是每个连接每个时刻都有数据要拷贝。epoll 的价值,是让进程只在活跃连接出现时才被唤醒,不在空闲连接上空转。拷贝本身的开销保留着,但没有为“不活跃”的连接浪费任何 CPU。记住这个逻辑,后面所有“潜规则”都好懂了:并发量 100 万、活跃量 100,CPU 吃的只是那 100 的拷贝成本。
3. epoll 三件套:红黑树、就绪链表、等待队列
3.1 epoll 实例内核里到底存了点什么
调用 epoll_create,内核分配一个 struct eventpoll。这个结构体里最核心的三个成员是:
rbr:红黑树根节点,保存所有被监控的 fd 对应的epitem;rdllist:双向链表,保存当前“已经就绪”的事件条目;wq:等待队列,睡眠在epoll_wait上的线程挂在这里。
epoll_ctl(EPOLL_CTL_ADD/MOD/DEL) 做的就是红黑树节点增删改,以及绑定/解绑回调。每添加一个 fd,要分配一个 epitem。这个节点虽然小,一百多字节量级,但百万级就约一两百 MB 了。很多人一上来要做“百万连接”,没准备这个内存,第一轮压测就被内核 OOM——不是 epoll 不行,是你没给它发粮饷。
3.2 就绪链表怎么从“空”变得越来越有货
这个结构是整个 epoll 设计的灵魂。我再强调一遍:就绪链表不是被某个线程主动“扫描”出来的,它是被协议栈回调喂出来的。
当 TCP 协议栈把数据放进 socket 接收队列后,会调用在该 socket 等待队列上注册的回调函数。epoll 注册的回调是 ep_poll_callback。它的核心动作只有两个:
- 把该 socket 对应的
epitem加入eventpoll的就绪链表(如果还没在链上); - 唤醒在
wq队列上等待的进程。
这个设计的精妙之处在于:一次收包过程中,协议栈只做一次链表插入。而在传统 select/poll 里,内核每次调用都要把用户传进来的 fd 集扫一遍,连接越多,扫描越贵。epoll 相当于把“扫描”换成了“回调登记”,把成本从 O(全部连接) 摊到了 O(活跃连接)。
3.3 epoll_wait 返回给你的是什么
当调度器把睡着的线程叫醒,内核进入 ep_events_ready,做的事很直接:遍历就绪链表,把里面的 epitem 对应的事件抄成 struct epoll_event,填进你传入的 events 数组,然后返回事件个数。这里有几个细节值得留意:
epoll_wait返回的是“就绪事件”,不是“数据字节数”。你拿到EPOLLIN,只能说明套接字可读,实际能读到多少还得recv才知道。- 如果在超时时间内没有任何事件,返回 0。
- 就绪链表里的节点被摘除后,如果水平触发模式下状态仍然可读,下次
epoll_wait会重新加入;边缘触发模式不会。 - 一次调用能返回的事件数受你传入的
maxevents限制。如果一次返回没处理完,剩下的应该留给下一轮epoll_wait——前提是状态仍可读。
3.4 水平触发和边缘触发的实质差异
网上讲 LT/ET 的文章里,经常拿“电平”和“边沿”打比喻。我换个接地气的说法:水平触发是“每轮点名都问你要不要”,边缘触发是“状态刚变化时只喊你一次”。
拿“可读”来说。数据从无到有,第一次填进接收队列,LT 和 ET 都会触发一次通知。区别在于:你这次 recv 只读走了一部分,还留了半包数据在队列里。下一轮 epoll_wait,LT 还会再次通知你“有数据”,ET 则不再吱声,直到有新数据从无到有地到达。
所以 ET 模式几乎强制配合非阻塞 IO:你必须在一个事件里循环 recv 到 EAGAIN,把数据尽量读完,否则会丢事件。LT 模式宽松很多,读多读少都会再通知,这也是为什么新手更适合从 LT 开始。选型上没有绝对优劣:Nginx、Redis 这类成熟项目,在 Linux 上用 epoll 时多数也采用默认的水平触发,原因很简单:事件模型越简单越不容易丢事件。ET 能带来的收益,远没有很多人想象的那么大。
3.5 惊群问题与 EPOLLEXCLUSIVE
如果你的服务是多线程/多进程同时 epoll_wait 同一个 epoll 实例,旧内核在事件到达时,会把队列里所有等待者都唤醒,这叫惊群。唤醒的一堆线程里只有少部分能抢到事件,别的白醒一次,白白上下文切换。
Linux 4.5 之后有了 EPOLLEXCLUSIVE 标志,配合 EPOLL_CTL_ADD 使用,内核只会唤醒第一个等待者,从根上消除惊群。另一种更常见的做法是多进程各自 epoll_create,再配合 SO_REUSEPORT 让多个进程监听同一个端口,由内核负责分发新连接。高并发 IM 网关经常是这套组合。这里我想特别提醒:惊群不一定出现在 accept 时,也出现在你共享同一个 epoll fd 的场景。排查时用 perf sched 看 wakeup 次数,如果暴涨,优先查惊群。
4. 百万并发的真实账本:不只是 epoll 的事
4.1 先解决文件描述符和端口“户口”
100 万连接,意味着服务端至少要同时持有 100 万个被 accept 出来的套接字 fd。于是第一道坎是文件描述符上限。Linux 下有两层限制:进程级 RLIMIT_NOFILE(shell 里用 ulimit -n 看),系统级 fs.file-max。默认进程限制经常是 1024,这种配置连压测都撑不到第二波连接就被 EMFILE 挡住了。
调法是在内核参数和启动脚本里同步改。系统层面可以临时写:
bash复制sysctl -w fs.file-max=2097152
进程层面一般写在 systemd 的 LimitNOFILE=1048576,或者 shell 的 ulimit -n 1048576 再启动。注意:有些语言运行时会在进程启动时读取这个限制,启动之后再改是没用的。
上面这是“户口”问题。然后看端口:服务端监听一个端口,可以接收大量连接,因为 TCP 四元组靠远端 IP/端口区分。但客户端那端要发起百万连接,本地可用的临时端口范围是有限的,四元组成本完全不一样。百万并发主要指的是服务端承载能力,但底层资源的账,哪一端都跑不掉。
4.2 每个连接到底吃多少内存
启动一个空连接不跑业务,内核侧内存也未必少。一个 ESTABLISHED TCP socket 的内核对象包括 struct sock、定时器、拥塞状态、接收/发送缓冲相关量,空闲态通常也要几 KB 以上。可一旦有流量,sk_buff 队列会按拥塞窗口、读写缓冲限制动态生长,峰值可能到几十 KB。换算下来:10 万连接 × 10 KB = 1 GB,100 万连接就奔着几十 GB 去了。这不夸张,真实高并发服务里内存瓶颈比 CPU 更常见。
所以做容量规划时,必须把 net.ipv4.tcp_mem、net.ipv4.tcp_rmem、net.ipv4.tcp_wmem 一起看。tcp_mem 是 TCP 全局内存压力的三档阈值,超过压力线之后,内核会开始丢 SYN、主动回收缓冲,而不是无限膨胀。很多人以为调大 rmem/wmem 就行,其实没看 tcp_mem 这个全局水位,调多大都会被压回去。
4.3 事件模型和 CPU 的真实关系
百万连接,不能靠“一个线程跑一个 epoll 慢慢处理”通吃。这里要区分两个指标:连接数和活跃连接数。100 万空闲连接,单线程 epoll 很轻松;100 万连接同时都在收发,单核 CPU 绝对转不过来。epoll 的价值是把代价集中在活跃事件上,但每个活跃事件的处理(协议栈、拷贝、业务逻辑)仍然是硬成本。
所以生产环境普遍是主从 reactor 或多 worker 模型:主线程/主进程 accept 后把 fd 分发给多个 worker,每个 worker 有自己的 epoll 实例,自己只处理一部分连接。Nginx 就是 worker 进程各自跑 epoll,Redis 则是单线程但定位是低延迟小数据量。你用 epoll 做一个视频平台网关,和数据量大、活跃度高的在线 IM,工程选型完全不一样。
4.4 连接数、活跃数与队列溢出的博弈
“百万并发”这句话在技术圈喊得响亮,但真正服务端架构里,人们更关心的是“同一时刻有多少连接在发生读写”,也就是并发活跃数。100 万连接里只有 1000 个活跃,单机可以扛;但如果 100 万个都同时活跃,别做梦,再好的事件模型也扛不住。
这里有个容易被忽视的配套工程:心跳与闲置连接回收。真正常驻后台的连接,多数时间都是空的。如果业务协议不做心跳,死连接会一直挂在 ESTABLISHED 状态上骗过你的统计,慢慢耗尽内存和 fd。我见过一个系统就是连接数缓慢上涨、每周自动重启一次,最后定位到是客户端异常退出但没发 FIN。所以高并发服务上线前,一定把 TCP keepalive 或应用层心跳设计好。
5. 实操里的坑和排查经验
5.1 一压测就卡死:队列参数的“三层堵点”
我自己的压测经验:先处理“三层堵点”,再调业务代码。
第一层是 net.core.somaxconn。它控制 listen 套接字的已完成连接队列长度,默认 128。压测连发几千个请求,accept 还没来得及取,积压队列满了,内核就会直接丢连接。第二层是 net.ipv4.tcp_max_syn_backlog,半连接队列长度,SYN 洪泛或者握手慢时卡这里。第三层是 net.core.netdev_max_backlog,协议栈收包队列长度,包太猛、软中断来不及处理时丢包。
按经验,压测前会做一组调整:
bash复制sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.core.netdev_max_backlog=10000
改完再压,很多“服务撑不住 500 连接”的假象会消失。这些是全局参数,线上要评估影响范围,别在无人值守的机器上乱调。
调完 sysctl 再看代码:accept 循环里有没有长时间阻塞操作。如果 accept 后立刻做 DNS 解析或数据库连接,会把监听队列堵住。正确做法是 accept 出来的 fd 先设非阻塞、丢进 epoll,业务处理放到事件回调里。
5.2 EAGAIN 处理不当,ET 就变成“掉事件黑洞”
ET 模式最经典的坑:回调里 recv 没循环到 EAGAIN。
伪逻辑大概是这样:
c复制while (1) {
n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) handle(buf, n);
else break; // EAGAIN 表示读干净了;返回 0 表示对端关闭
}
很多初版代码写的是只 recv 一次,然后回到 epoll_wait。ET 模式下,只要这个 fd 没有新数据到达,它就不会再出现在就绪链表里,丢数据没商量。排查这种问题时,最容易蒙在鼓里的现象是:连接偶尔卡住,等下一个连接建立、触发同 fd 上的新事件后,旧数据才被处理。遇到这种“延迟处理、数据不全”的情况,基本就是循环读这件事上偷了懒。
5.3 accept 惊群与 SO_REUSEPORT 的取舍
如果你的服务是多 worker,最简单的老做法是:每个 worker 都 fork 出来,共享同一个 listen fd,然后各自 accept。早期 Linux 存在 accept 惊群,所有 worker 都醒来抢,浪费严重。后来内核做了限制,accept 唤醒变成了“只叫一个”,好一些。但多 worker 各自持有一个 listen fd,用 SO_REUSEPORT 让内核做连接分发,是现在更流行的做法。
SO_REUSEPORT 的坑在于连接分发是近似随机的,按四元组哈希取模。如果有 worker 卡死,新连接仍然会发给它,直到连接超时。所以用它的时候必须配合 worker 健康检查,比如每个 worker 定期上报状态。如果你是单进程多线程,最好用一个 epoll 实例加 EPOLLEXCLUSIVE,不要每个线程一个实例还共享同一个 fd,也别用多线程同时 accept 同一个 fd 再自己加锁——那会锁到怀疑人生。
5.4 排查工具:ss、softnet_stat、perf 的一个顺序
连接数上不去或吞吐掉,我建议按这个顺序查:
ss -s看 TCP 状态分布,是 SYN_RECV 堆积还是 ESTABLISHED 涨不上去;ss -nt state established '( sport = :8080 )' | wc -l看指定端口活连接数;- 看
/proc/net/softnet_stat,第二列如果持续增长,说明协议栈入口丢包,优先查netdev_max_backlog和中断绑核; perf top看热点,是在tcp_v4_rcv还是在_raw_spin_lock,后者大概率是锁竞争;strace -p <pid> -e trace=epoll_wait看系统调用频率,频率过高意味着事件处理太碎,该考虑批量聚合了。
这里面 softnet_stat 是最容易被忽略的:它按 CPU 维度统计收包丢弃。第二列非 0 且持续变大,基本就是软中断处理不过来,可以考虑把网卡队列的中断绑到特定 CPU,或用 irqbalance 合理分散。
5.5 常见问题速查表
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
| 连接数到 1 万左右上不去 | RLIMIT_NOFILE 不够 |
ulimit -n、systemd LimitNOFILE |
| 压测报 “Address already in use” | 大量 TIME_WAIT | 确认短连接是否过多,必要时启用 tcp_tw_reuse(仅对出站连接生效)或改长连接 |
epoll_wait 返回 0 但业务有延迟 |
数据包没到协议栈 | 查 softnet_stat、网卡 ring buffer、驱动状态 |
| accept 失败报 EMFILE | fd 耗尽 | 看 ss -s,临时提高 fd 上限,检查是否有 fd 泄漏 |
| 单核 CPU 飙到 100%,多核空闲 | 中断/软中断集中 | 调整网卡多队列、RPS/RFS、中断绑定 |
| 网卡状态异常,接口 DOWN 或驱动未加载 | 驱动/固件问题 | ip link、ethtool -p 确认物理链路,再看驱动版本 |
表格里放了一些通用的网卡问题,因为这些现象在高并发压测时经常被误判成应用层 bug。记得先排除底层网络,再查代码。
最后说点我自己的体会:以前我给新人讲 epoll,总是忍不住从 epoll_create 讲起,结果对方问了我三个问题——为什么网卡有数据我不 read 它不消失?为什么就绪链表自己会“长”出来?为什么调大文件描述符数量还报 EMFILE?我才意识到,用户态 API 只是这条链路的最后一小段。真正吃透 epoll,得把网卡收包、NAPI、协议栈、sk_data_ready 回调这条链路走完。现在我自己排查高并发问题,也习惯先看 /proc/softirqs 和 softnet_stat,再回代码里找原因。如果你的服务刚好也在压测上卡脖子,不妨把这个顺序也试试。这里面的“潜规则”说白了不是秘籍,是把内核行为理解透之后的顺其自然。
