如果上一篇解决的是“它大体怎么工作”,那这一篇想解决的就是另一个问题:
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 内部至少有两套“组织方式”:
- 红黑树:管理“我现在监听着哪些 fd”
- 就绪链表:管理“哪些 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 这一层,很多核心思想其实是相通的。
区别主要只是:有的把底层细节暴露给你,有的帮你封装掉了。