news 2026/11/5 3:16:58

从NIC到epoll:百万并发下的内核收包链路与事件通知机制剖析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从NIC到epoll:百万并发下的内核收包链路与事件通知机制剖析

1. 前半程:数据包怎么从网卡落到 socket 队列

聊高并发之前得先搞清楚一件事:我们口中常说的 epoll 百万并发,其实只是"事件通知"这一层的功夫。真正决定一个服务能扛多少连接的第一道关卡,在网卡和协议栈,在数据包从网线进入内核的那条路上。很多人把全部注意力放在 epoll 的 API 上,结果压测时发现连接明明建立起来了,吞吐却上不去,最后排查半天,问题出在收包路径的瓶颈上。所以我想先从这条链路讲起,把 epoll 的"管辖范围"边界划清楚。

1.1 收包链路:中断、NAPI 与软中断

网卡收到一个数据包后,硬件会通过 DMA 把报文直接写入内核预先分配好的 ring buffer(环形描述符队列),然后触发一个硬中断通知 CPU:有新包来了。硬中断里做的事情越少越好,因为硬中断优先级极高,长时间占用会拖垮整个系统。所以现代网卡驱动基本都引入了 NAPI 机制:硬中断里只做"屏蔽当前队列、把这个设备挂到软中断处理队列上、让出 CPU",真正的报文解析和协议栈逻辑全部推迟到软中断或ksoftirqd内核线程里执行。

软中断阶段会调用net_rx_action,把skb从 ring buffer 里取出来,逐层交给 IP、TCP 协议栈处理。TCP 层在这里做校验、去重、乱序重排、窗口更新,最终把数据挂到对应 socket 的接收队列sk_receive_queue上。这个队列有内存上限,也就是我们熟悉的sk_rcvbuf/rmem_max。如果应用迟迟不来 read,队列会满,内核会触发丢包或对端窗口收缩。这就是为什么有些压测场景里,连接数上去了但吞吐掉下来——不是 epoll 慢,是收包队列被塞满了。

1.2 epoll 的边界:它管辖不到的数据路径

注意上面这个过程里,epoll 完全不在场。协议栈把数据放进 socket 接收队列后,会调用sk_data_ready钩子去唤醒等待在这个 socket 上的进程或回调,这个动作才轮到 epoll 登场。

换句话说,epoll 做的是"状态变化的通知",而不是"数据搬运"。数据从网卡到内核,是 DMA 和 NAPI 干的;从内核到用户态,是read/recv干的;epoll 只负责在中间告诉你一句:这个 fd 现在可读了,赶紧去 read 吧。明白了这个边界,你才能理解调优方向:数据通路的问题靠网卡队列、中断号、协议栈参数去解;事件通知的问题才靠 epoll 的参数和用法去解。两者混为一谈,是排查大型网络服务性能问题时最常见的误区。

1.3 为什么必须先把网卡这头摆平

如果你的连接规模到了几十万上百万,网卡侧的问题会被急剧放大。单队列网卡意味着所有收包中断都落在同一个 CPU 上,软中断处理不过来时,/proc/softirqs里的NET_RX会持续暴涨,单核先成为瓶颈。多队列网卡配合ethtool -L打开多个 RSS(Receive Side Scaling)队列,再配合中断亲和性(smp_affinity或irqbalance)把不同队列绑到不同 CPU 上,才能把收包压力摊平。

压测时我有习惯先看一眼mpstat -I CPU的软中断分布,如果某个核soft占比超过 80%,直接去查中断分布和网卡队列数量。把多个接收队列按哈希分布到多核之后,再回头看 epoll 侧的优化才有意义。

2. 注册的不是 fd,而是一个回调:epoll_ctl 在 kernel 里干了什么

大部分文档都会告诉你 epoll 有三个系统调用:epoll_create、epoll_ctl、epoll_wait。但"会用"和"理解内核行为"差距很远。重点在于epoll_ctl,特别是EPOLL_CTL_ADD这一步:它并不只是在一个大数组里登记一个 fd,它在搞一件更有意思的事情——把自己的回调函数挂到目标文件对象(socket)的等待队列上。

2.1 三个系统调用各自的意义

epoll_create创建一个eventpoll对象,这个对象里维护着两颗核心数据结构:一颗红黑树(rbtree)用来管理注册进来的 fd,一个就绪链表(rdllist)用来保存当前"有事件发生"的 fd。epoll_ctl负责往红黑树里增删改某个 fd 的兴趣事件;每次增删都是一次 O(log n) 的树操作。epoll_wait做的事最单纯:检查就绪链表是否为空,不为空就把事件拷到用户态,为空就睡过去,等回调来叫醒。

一个最简单的骨架长这样,后面所有分析都围绕它展开:

int epfd = epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev = {0}; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[1024]; for (;;) { int n = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == listen_fd) { int conn = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK); ev.events = EPOLLIN; ev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev); } else { handle(events[i].data.fd); } } }

这个骨架看似简单,但内核在里面埋了不少细节。下面拆开EPOLL_CTL_ADD看。

2.2 ep_insert 的两步操作:先挂回调,再问一句"你现在就绪吗"

内核在处理EPOLL_CTL_ADD时调用ep_insert,核心逻辑分两步。

第一步,针对这个新注册的 epitem 初始化一个"轮询表项"(poll table entry),并且把自己的回调函数设置成ep_ptable_queue_proc。然后内核会主动调用一次目标文件的 poll 方法(对 socket 来说就是sock_poll/tcp_poll)。这次调用本质上是"带着我的回调去你生产线里转一圈,同时告诉我你现在有没有现成的事件"。如果文件目前已可读或可写,poll方法会返回对应的 revents,epoll 立刻把这个 epitem 加入就绪链表;如果还没有事件,那就只把回调挂到文件自己的等待队列上,等将来事件发生。

第二步中,"挂回调"是通过poll_wait完成的。poll_wait是 Linux VFS 层的一个通用机制,它会遍历文件等待队列的所有 waitqueue head,调用ep_ptable_queue_proc在其中注册一个eppoll_entry,这个 entry 里保存了回调函数ep_poll_callback。也就是说,从这一刻起,这个 socket 状态一有变化,内核会主动调用ep_poll_callback,而不是等epoll_wait过来轮询。

传统select/poll每次调用都要把所有 fd 从用户态拷到内核态、逐个调用 poll 方法检查状态,这个 O(n) 的全量扫描就是它们扛不住高并发的核心原因。epoll 最大的思想转变就在这里:把"每次问我状态"变成"你状态变了主动告诉我"。回调机制让复杂度从"每次调用扫一遍所有 fd"降到"只处理真正有变化的 fd"。

2.3 "回调代替轮询"到底省了什么

很多人以为 epoll 快是因为"红黑树有 O(log n) 的查找"。其实红黑树只负责管理注册关系,和并发量真正正相关的瓶颈是"是否要全量扫描"。回调机制意味着一个 100 万 fd 的 epoll 实例,在某次epoll_wait只返回了 10 个事件时,内核只对这 10 个事件做拷贝和处理,而不是跑一遍 100 万次检查。

换句话说,系统的开销由"每秒事件发生次数"决定,而不是由"连接总数"决定。这就是它能支撑百万连接的根本逻辑。也正因如此,真正压垮 epoll 的不是连接数量本身,而是某一瞬间大量唤起回调导致的"唤醒风暴"。这个问题我们留到后面讲。

3. 红黑树管关系,就绪链表管结果

要继续深入,就得把 epoll 对象内部的几个核心结构看明白。网上讲 epoll 都会提"红黑树"和"就绪链表",但两者的分工其实经常被混淆。红黑树管的是"你注册过哪些 fd",就绪链表管的是"这些 fd 里哪些现在有事件",这是两套完全独立的逻辑。

3.1 三个关键结构体

内核里涉及 epoll 的常用结构体主要是这三个:

结构体归属作用
eventpoll每次epoll_create一个整个 epoll 实例,持有红黑树根、就绪链表头、等待队列等
epitem每次EPOLL_CTL_ADD一个代表一个被监听的 fd,含 fd 号、事件掩码、红黑树节点、链表节点
eppoll_entry每挂一个等待队列一次把 epitem 的回调绑定到文件对象的某个 waitqueue head 上

eventpoll里的关键字段可以这样看:

  • rbr:红黑树根,key 是(目标文件指针, fd 号)的组合。
  • rdllist:就绪链表头,链着所有当前有事件需要上报的 epitem。
  • wq:等待队列,epoll_wait阻塞时把自己的task_struct挂在这个队列上。
  • poll_wait:这个实例自身被其他 epoll 实例监听时使用,属于"epoll 套 epoll"的高级场景。

epitem里有两个最关键的节点:rbn是红黑树节点,负责挂在rbr上;rdllink是链表节点,负责挂在rdllist上。也就是说,同一个 epitem 同时参与两套数据结构,一个维护静态注册关系,一个维护动态就绪状态。理解这点之后,"为什么删除一个 fd 要 O(log n)"和"为什么取一个事件是 O(1)"就没有冲突了。

3.2 就绪链表为什么是 O(1)

epoll_wait的核心路径可以简化成:检查rdllist是否为空,不为空就遍历它,把每个 epitem 的事件通过ep_send_events拷贝到用户态数组,然后清空链表。整个过程不需要去红黑树做任何查找,也不需要遍历所有已注册 fd,所以"取事件"这一步是 O(1) 的——准确说,是 O(ready_count)。

这个设计还有一个容易忽略的好处:即使某个 fd 状态一直没变化,它也不会出现在就绪链表里,内核不需要反复评估它。对于长连接占多数、活跃连接占少数的场景,这个特点尤其值钱。

3.3 红黑树与"百万 fd"的增删改查

再回来看红黑树。它解决的是"按 fd 快速定位 epitem"的问题。每一次EPOLL_CTL_MOD或者EPOLL_CTL_DEL,内核都要通过(file, fd)这组 key 在树里查找到对应节点。红黑树保证查找、插入、删除都是 O(log n)。100 万级别的 fd,log 大约是 20,这个量级完全不是瓶颈。

实际操作中,EPOLL_CTL_MOD的高频场景是边缘触发模式下重新注册事件,比如有些框架用MOD来重新标记 EPOLLIN;低频场景是DEL。两者在百万 fd 上都不需要担心,倒是每次 MOD/DEL 都涉及系统调用上下文切换,积少成多之后 CPU 开销可观。这也是为什么很多高性能框架在连接稳定后会尽量减少 MOD 和 DEL 的次数,尽量用EPOLLONESHOT或边缘触发来降低系统调用频率。

4. 唤醒的两条路:从 sk_sleep 到 ep_poll_callback

这一节是整个链路最微妙的部分。epoll_wait 阻塞之后,是谁、在什么上下文里、通过什么方式叫醒它?答案藏在 socket 唤醒路径和 epoll 回调函数的交互里。如果说 epoll 有一半的"潜规则"藏在这里,毫不夸张。

4.1 数据到达后内核做了什么

数据进入 socket 接收队列后,协议栈会调用 socket 的data_ready回调,对 TCP socket 来说就是sock_def_readable。这个函数做了两件事:一是把 socket 标记为可读,二是调用wake_up_interruptible_sync_poll唤醒等待在该 socket 等待队列上的所有实体。注意这里有个容易被忽视的细节:唤醒是sync的,也就是说回调在软中断上下文里同步执行,而不是另起一个线程延迟处理。

sock_def_readable遍历的等待队列里,可能既有epoll 挂进来的 eppoll_entry,也可能有直接在 socket 上调用read阻塞的进程。epoll 的入口就在这:一旦遍历到ep_poll_callback,内核就会拿这个 epitem 注册的事件掩码和当前实际发生的事件做对比(比如你现在注册的是 EPOLLIN,那 EPOLLOUT 状态变化就不该上报),符合条件就执行关键一步——把该 epitem 追加到 epoll 实例的就绪链表尾部,并对链表头取 spinlock 保护。

只有函数返回后,如果 epoll 实例上有进程在epoll_wait里睡着,才会去唤醒那个进程。这个两步式机制,使得回调加链表全程短小、加锁简单,才能在高频收包场景里压得住。

4.2 就绪事件的上报与 epoll_wait 返回

回到epoll_wait这边。调用进入内核后,核心逻辑是ep_poll。它先检查就绪链表是否为空,不为空就直接进入上报;为空则把自己的task_struct挂到eventpoll的wq等待队列上,然后schedule_timeout睡过去。超时时间到了、网络事件来了、被信号打断,这三种情况都会唤醒它。

唤醒之后走到ep_send_events,这才是最终面向用户态的拷贝。它会遍历就绪链表里的每个 epitem,再次调用目标文件的 poll 方法获取最新 revents,然后把struct epoll_event通过__put_user逐个写入用户态数组。__put_user是无锁的轻量拷贝,和copy_to_user的最主要区别就是它不做整段合法性检查,只检查单次访问的地址,所以性能更好。

这里出现了一个非常关键的差异:就绪链表里的 epitem 在ep_send_events过程中究竟是要保留下来还是移除掉?就是在这个位置,水平触发(LT)和边缘触发(ET)的语义被内核真正区分开了。

4.3 LT 与 ET:内核如何看待"边缘"

epoll_ctl传入的事件掩码里如果带了EPOLLET,内核就认为你要的是边缘触发语义;不带,就是水平触发。网上关于两者差异的解释很多,但从内核实现角度只有一句话:事件上报之后、目标状态仍然可读可写时,LT 会让该 epitem 继续留在/重新加入就绪链表,ET 则会把它从就绪链表里摘下来,不再上报。

具体实现上,ep_send_events先list_del_init把 epitem 从就绪链表摘掉,拿到 revents 拷贝给用户,然后对 LT 情况会再次调用 poll 方法查看当前状态,如果仍然就绪,立刻list_add_tail把它放回链表末尾。ET 情况则不做这个"再次加入"的动作,要求用户必须自身去 drain 数据,直到下一次状态变化产生新的回调,epitem 才会被重新加入就绪链表。

所以 ET 不见得比 LT"高级",它只是在"事件触发频率"上换了一种语义。ET 的优势在于减少 epoll_wait 的重复返回——数据还在缓冲区时,LT 每次调用都会返回该 fd,如果你处理得慢,它几乎永远不会消失,导致同一个 fd 的事件反复占据就绪链表;ET 则保证只在"从无到有"的边缘提醒你一次,提醒完就不占地方。

维度LT 水平触发ET 边缘触发
触发时机状态持续可读/可写时,每次 epoll_wait 都可能返回仅在状态从不可用变为可用时返回一次
内核处理上报后若仍就绪,重新加入就绪链表上报后摘出,不再加入,等待下一次边缘
读数据要求可以不读到头,下次还会继续返回必须一次性读到读不出为止,否则容易丢读
适用场景逻辑简单、追求可靠高吞吐框架、追求减少系统调用

实际做高并发服务时,大部分框架选择 ET + 非阻塞 IO + 循环 read/write 直到 EAGAIN,就是上面表格里的权衡结果。但 ET 有个经典坑:如果你循环 read 时没有判断到 EAGAIN 就退出,数据没读完,而下一个"边缘"又迟迟不来,这个连接就相当于假死了。所以选 ET 必须对非阻塞 IO 的语义有充分的把握,否则老老实实用 LT 反而更稳。

4.4 唤醒风暴与 EPOLLEXCLUSIVE

并发量真正上去之后,最隐蔽的性能杀手是"唤醒风暴"。设想一个监听 fd 上有 16 个线程都在epoll_wait等待同一个事件。某个新连接到达时,内核会把这个事件放进就绪链表,然后唤醒wq上的所有线程。结果 16 个线程全被叫醒,最后只有一个线程 accept 成功,剩下 15 个线程在上下文切换和__put_user空转中浪费 CPU。这就是经典的惊群效应。

早年内核没有有效解法,框架们只能用锁或者单线程 accept 来规避。内核 4.5 之后加入了EPOLLEXCLUSIVE,语义是"唤醒等待队列上的第一个(或符合条件的一个)线程",而不是唤醒所有。把这个标记加在监听 fd 的事件上,可以有效缓解多线程 epoll_wait 带来的惊群。要注意它的使用范围:只有在目标 fd 是监听 socket 时才合法,普通连接 fd 上设置会返回错误。

5. 百万并发真正卡脖子的是哪几个"潜规则"

聊完内核机制,最后落回到实操。真到了百万连接规模,epoll 本身的 API 设计已经不是瓶颈,下面这几个"潜规则"才决定系统成不成。

5.1 accept 群的惊群与分流

即使有了 EPOLLEXCLUSIVE,多线程 accept 依然是高并发服务里最需要小心的部分。我的习惯是:核心服务用单线程 accept,accept 之后立刻把连接 fd 通过某种负载均衡机制(比如按连接 ID 取模、按当前各工作线程负载)分发给工作线程,工作线程各自持有独立的 epoll 实例。每个线程只管理自己的 epoll,互不干扰,就没有惊群问题,也没有跨线程锁竞争。

把 accept 和业务读写分离还有一个附带好处:即便某个工作线程因为业务处理缓慢导致它的 epoll 就绪链表积压,也不会影响其他线程的连接建立。混在一起处理时,一个慢业务往往会拖慢整个 accept 链路,这是线上事故常见原因。

5.2 fd 上限、内存与每个连接的代价

百万连接意味着百万个 fd,也意味着百万个 socket 对象、百万个struct file、百万个任务的文件描述符表项。系统层面有几个限制要提前摆平:

  • ulimit -n:进程级 fd 上限,必须大于目标连接数。
  • fs.file-max:系统级 fd 上限,默认值在百万连接场景经常不够。
  • net.ipv4.ip_local_port_range:主动发起连接的一端,如果对端请求量大,端口范围会先耗尽。
  • 每连接内存:TCP socket 的接收/发送缓冲区、socket 结构体、file 结构体等加在一起,每连接的内存开销可能好几 KB 甚至更高。百万连接就意味着几个 GB 级别的内核内存占用,这还没算用户态的应用缓冲区。

我见过不少团队把ulimit -n改了之后依然连不上,就是因为忘了调net.core.somaxconn和 listen 的 backlog。accept 队列溢出时,连接直接以 RST 结束或滞留在 SYN 队列里,表现就是"端口通、但连接建立不成功"。

5.3 收包队列与 CPU 分布

前面提过的网卡队列问题,在百万场景下会以更残酷的方式暴露。如果网卡只有一个队列,所有收包中断都落在同一个 CPU 上,这个核既要跑软中断、又要跑协议栈、还要执行ep_poll_callback,几乎必然成为热点。多队列网卡 + RSS 哈希 + 中断亲和性配置,是必须做的基本功。

判断瓶颈位置有个很实用的方法:top里看si(软中断占用)和us(用户态占用)的分布。如果一个核的si特别高,优先查网卡队列和中断;如果一个核的us特别高,再查你的业务逻辑和 epoll 事件处理方式;如果sys高,查系统调用频率和锁竞争。方向错了,改半天配置没用。

5.4 用 perf 验证而非凭空猜测

每次调优我都会先跑一遍perf top或者抓个perf record,看看热点的真实位置。ep_poll_callback在高频收包时出现在热点里是正常的;但如果它占比过高,说明唤醒频率远高于业务处理能力,这时候考虑合并事件、减少唤醒次数(比如用EPOLLEXCLUSIVE、用 ET、或者调整收包软中断的处理节奏),不要盲目加线程。

根据我个人的落地经验,百万连接压测最容易翻车的地方,不是一个宏大的内核优化,而是"连接建立成功但这个 CPU 已经烧到 100%"这种局部热点。先把/proc/interrupts的中断分布、/proc/softirqs的 NET_RX 分布、每个线程的epoll_wait调用量对齐,再回头看 epoll 的参数配置,思路会清楚得多。每次改动只动一个变量,记录前后对比,这种笨办法在调网络底层时反而是最有效率的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 9:46:32

Word截图粘贴到内网OA编辑器丢失?完整解决方案与代码实现

凡是做过军工、涉密单位这类内网OA系统的朋友&#xff0c;十有八九都碰到过同一个需求&#xff1a;用户从Word文档里复制一篇带截图的材料&#xff0c;粘到系统集成的编辑器里&#xff0c;结果图片不见了&#xff0c;或者变成一串看不懂的本地路径&#xff0c;再或者整段内容被…

作者头像 李华
网站建设 2026/10/23 13:39:03

拧紧工艺数字化:制造业转型中不可忽视的隐形支柱

拧紧工艺这玩意儿&#xff0c;在制造业现场太容易被忽略了。大家一提到数字化转型&#xff0c;想到的都是MES、ERP、工业互联网、大数据平台&#xff0c;却很少有人专门去聊一个工位上每天重复几千次的动作——把螺栓拧到规定扭矩。可偏偏就是这“拧一下”的动作&#xff0c;直…

作者头像 李华
网站建设 2026/11/4 10:32:25

通义千问开源大模型实战:从权重部署到LoRA微调的落地笔记

简介&#xff1a;这份技术资料面向大语言模型研究者、算法工程师与AI应用开发者&#xff0c;聚焦阿里通义千问QWEN系列的开源技术报告&#xff0c;帮助读者系统理解从预训练到人类对齐的完整技术路线。资源包内含1个PDF文件&#xff0c;大小约1.53MB&#xff0c;即QWEN Technic…

作者头像 李华
网站建设 2026/10/22 18:14:00

朱雀AI率太高被驳回?推荐4款靠谱的朱雀降AIGC工具!

现在不管是做自媒体写文案&#xff0c;还是写职场汇报、学生写文稿&#xff0c;大部分人都会用AI偷懒提效。 但踩坑的人真的巨多&#xff0c;AI写完直接复制粘贴发布&#xff0c;转头就被朱雀检测出超高AI率&#xff0c;稿件打回、内容限流&#xff0c;忙活半天全白费。 很多…

作者头像 李华
网站建设 2026/10/19 1:14:08

Linux下静态库与动态库从入门到实践:制作、链接与排查

1. 库的本质&#xff1a;编译流程里的最后一环&#xff0c;先搞清楚再动手我最早被动静态库这个概念绊住&#xff0c;是在某次项目里编译一个底层通信模块。代码写完了&#xff0c;编译也过了&#xff0c;结果到链接阶段直接报了一堆undefined reference&#xff0c;当时满屏的…

作者头像 李华