把 epoll 往下挖一层:红黑树、socket、回调通知到底是怎么串起来的

5 阅读8分钟

如果上一篇解决的是“它大体怎么工作”,那这一篇想解决的就是另一个问题:

epoll 里那棵红黑树,和具体的 socket 到底是怎么对应起来的?
既然数据到来时似乎能直接找到节点,那为什么还要专门维护一棵红黑树?

这几个问题一旦想清楚,epoll 的底层逻辑就真正连上了。

上一篇文章详见从 IO 多路复用到 Redis 事件循环,顺手理清 Java、Go、JavaScript 和 Windows的事件循环 - 掘金

一、红黑树节点对应的,不是 IP 字符串,而是 fd 背后的内核对象

很多人刚接触 epoll 时,很容易脑补成这样一种模型:

红黑树里放的是“IP:端口”这样的东西,数据包一到,内核就拿着目标 IP 和端口去树里查。

这个理解不太准确。

在 Linux 里,socket 首先会以 文件描述符 fd 的形式暴露给用户态。
你在应用里看到的可能只是一个整数,比如 3、4、5。

但这个整数只是入口,它后面真正串起来的是这样一条链:

fd
 │
 ▼
struct file
 │
 ▼
struct socket

也就是说,epoll 管的核心对象并不是“IP 字符串”本身,而是:

  • fd
  • fd 对应的 struct file
  • 再往后关联到 struct socket

真正存入 epoll 管理结构里的,也是这一层对象关系。

所以更准确的说法应该是:

epoll 红黑树里的节点,主要是围绕 fd 和 file 建立索引,socket 再通过 file 关联上去。

IP 和端口当然存在于 socket 相关结构里,但它们不是 epoll 红黑树的直接主键。

二、epoll 里到底存的是什么

当应用调用 epoll_ctl,把一个 fd 加进 epoll 监听集合时,内核会创建一个对应的管理项。可以把它粗略理解成一个 epitem 节点。

这个节点里通常会保存几类关键信息:

  • 它对应哪个 fd
  • 它关联哪个 struct file
  • 它关心哪些事件,比如可读、可写
  • 它在红黑树里的位置
  • 它在就绪链表里的位置

所以,epoll 内部至少有两套“组织方式”:

  1. 红黑树:管理“我现在监听着哪些 fd”
  2. 就绪链表:管理“哪些 fd 现在已经准备好了”

这两个结构是配合使用的,不是二选一。

三、数据包到来时,内核到底是怎么找到对应节点的

这个过程如果只看表面,会觉得很神秘。其实拆开之后并不玄。

先说一个最重要的结论:

数据到来时,内核不是拿着 IP 和端口再去红黑树里全量搜索。

真正发生的事更像下面这样。

第一步:注册监听时,不只是把节点插进树里

epoll_ctl 把某个 socket 对应的 fd 加进 epoll 时,内核做的不只是“插入红黑树”。

它还会把 epoll 自己的一套等待/回调机制,挂到这个 socket 的等待队列上。

可以把它理解成:

这个 socket 以后只要状态变了,比如变得可读了,就顺着这条回调链去通知 epoll。

也就是说,在“建立监听关系”这个时刻,内核已经把后面的通知路径搭好了。

第二步:数据包到来后,先找到的是 socket,不是红黑树节点

当网卡收到一个 TCP 包,内核网络协议栈会先根据连接信息把这个包交给对应的 socket。

这里确实会用到地址、端口这些信息,但它解决的是:

这个包属于哪个 socket?

而不是:

这个包对应 epoll 红黑树里的哪个节点?

也就是说,网络层先做的是 socket 层面的定位。

第三步:socket 触发等待队列上的回调

当内核发现这个 socket 现在可读了,它会唤醒挂在 socket 等待队列上的那些回调。

而 epoll 之前已经把自己的回调挂在这里了。

于是,epoll 对应的那一项就会被标记为“就绪”,并挂到 epoll 的就绪链表里。

整个过程更接近下面这张图:

网络包到来
   │
   ▼
内核协议栈根据连接信息找到 socket
   │
   ▼
socket 状态变化(可读/可写)
   │
   ▼
触发等待队列上的 epoll 回调
   │
   ▼
对应的 epitem 放入就绪链表
   │
   ▼
epoll_wait 返回

所以,epoll 的高效,不是因为它在每个包到来时都去树里搜一遍,而是因为:

监听关系早就建好了,数据来了之后走的是“socket 回调 -> 就绪链表”这条路。

四、既然都能通过回调直接把节点挂到就绪链表了,为什么还要红黑树

这个问题特别关键,也是最容易被忽略的地方。

很多人理解到“回调可以直接找到 epitem 并放进 ready list”之后,就会自然产生一个疑问:

那树还有什么用?

答案是:

回调机制解决的是“有事件发生时怎么通知”;红黑树解决的是“整个监听集合怎么管理”。

这两者根本不是在做同一件事。

1. 回调负责的是“事件发生之后”

当某个 socket 上真的来了数据,回调路径要做的是尽快把它放进就绪队列,让 epoll_wait 能拿到结果。

这一段追求的是响应快。

2. 红黑树负责的是“事件发生之前”

在事件发生之前,epoll 还要做很多管理工作,比如:

  • 新增一个 fd 到监听集合
  • 删除一个 fd
  • 修改一个 fd 关心的事件类型
  • 判断某个 fd 是否已经在集合里
  • 管理整个 epoll 实例里所有被监听对象

这些事本质上都是“集合管理”问题,而不是“事件通知”问题。

也就是说,回调能让某个已经存在的节点很快进入就绪链表,但前提是:

这个节点得先被可靠地放进 epoll 的管理体系里。

红黑树就是这个管理体系的重要部分。

3. 为什么选红黑树,而不是别的结构

再往下问一步,就到了另一个常见问题:

那为什么偏偏是红黑树?

原因不是一句“它最快”就能解释清的。更准确地说,是因为它在内核这种场景里比较均衡。

第一,性能稳定

红黑树的插入、删除、查找,最坏情况都能保持在 O(log n)

这点很重要。
内核更怕的是性能突然抖一下,而不是平均意义上快一点慢一点。

比如哈希表平均查找可能是 O(1),听起来更漂亮,但一旦碰到哈希冲突、扩容、重排,延迟就可能出现波动。对内核来说,这种不可预测性并不讨喜。

第二,增删改查都比较平衡

epoll 不是只查不改。

高并发服务里,连接会不断建立、断开、修改关注事件。
所以它需要的是一个在插入、删除、查找上都比较稳的结构,而不只是某一项特别快。

第三,内存管理更干净

红黑树节点是一个个独立对象。删掉一个节点,就释放一个节点。
这类结构在长期运行的服务里更容易控制内存行为。

第四,它负责的是“监听集合”,不是“活跃队列”

很多人觉得既然 ready list 已经能直接拿就绪事件了,树就显得多余。其实不是。

ready list 只存当前活跃的那部分 fd。
而红黑树存的是整个被监听集合

一个 epoll 实例可能监听几万个连接,但同一时刻真正活跃的只是几十个或几百个。
这两类数据天然就应该用不同结构去维护。

所以,最准确的理解应该是:

红黑树不是为了替代回调,也不是为了替代 ready list;它是在负责另一件事——把所有被监听对象稳定地组织起来。

五、epoll 的这几个结构,最好分开记

把 epoll 理顺之后,其实就三层:

1. fd / file / socket:对象关联层

这是“监听的对象到底是谁”的问题。

fd -> file -> socket

2. 红黑树:监听集合管理层

这是“我当前到底监听了哪些对象”的问题。

3. 回调 + 就绪链表:事件分发层

这是“哪个对象现在真的有事了”的问题。

把这三层分开看,很多混乱都会消失。

六、Redis、Java、Go 的网络模型到底靠的是什么

到这里,可以把第一篇提到的那个问题再收一遍。

无论是 Linux 原生 epoll,还是 Redis 的事件循环,还是 Java 的 Selector/Netty,还是 Go 的 netpoller,核心网络 IO 都不是靠“定时任务轮询”驱动的。

它们真正依赖的是:

  • 内核事件通知
  • 等待队列
  • 就绪队列
  • 线程或协程唤醒

Redis 当然有时间事件。
JavaScript 当然也有 setTimeout
Go 当然也有各种 timer。

但这些定时器解决的是另一类任务调度问题,不是网络数据到来本身的检测机制。

在网络 IO 这件事上,现代高性能框架走的都是同一条路:

没数据就休眠,有数据就由内核通知。

七、最后把整件事压成一句话

如果只记一句话,我觉得可以记这个:

epoll 不是靠“反复检查所有连接”变快的,而是靠“把监听集合、就绪队列和通知路径分开管理”变快的。

Redis、Java、Go、Node.js 这些不同生态,看起来写法差别很大,但往下走到网络 IO 这一层,很多核心思想其实是相通的。
区别主要只是:有的把底层细节暴露给你,有的帮你封装掉了。