epoll
1. 两大核心数据结构
在内核里的 eventpoll 结构体(即 epoll 句柄背后的对象)中,维护了两个核心容器:
A. 红黑树 (RBTree) —— 存放“所有待监控的连接”
- 作用:用来存储用户通过
epoll_ctl添加进来的所有 Socket(File Descriptor)。 - 为什么用红黑树?
- 支持高效的查找、插入和删除(复杂度 $O(\log n)$)。
- 防止重复添加(Map 的特性)。
- 当你有 100 万个连接时,内核需要快速判断某个 FD 是否已经在监控列表中,红黑树是最佳选择。
B. 双向链表 (Ready List) —— 存放“已经就绪的事件”
- 作用:只存放那些真的有数据来了(可读/可写)的 Socket 节点。
- 为什么用链表?
epoll_wait只需要把这个链表里的数据“剪切”下来,复制给用户即可。- 它是 $O(1)$ 级别的读取,不需要遍历那 100 万个非活跃的连接。
2. 核心工作流程 (回调机制是关键)
epoll 的高效不仅仅是因为数据结构,更是因为它改变了“操作系统通知进程”的方式。
第一步:建树 (epoll_create)
内核在内存中申请一个 eventpoll 对象,初始化红黑树和就绪链表。、
第二步:注册与回调 (epoll_ctl)
当你调用 epoll_ctl(add) 把一个 Socket 加入 epoll 时,内核做了两件事:
- 把这个 Socket 封装成节点,挂到红黑树上。
- 关键点:给这个 Socket 的设备驱动(TCP 协议栈)注册一个回调函数(
ep_poll_callback)。- 潜台词:“喂,网卡/协议栈,这个 Socket 如果有数据包到了,别干等着,立刻执行这个回调函数通知我!”
第三步:事件触发 (硬件中断 -> 软中断)
当网卡收到数据包:
- 网卡发起硬件中断。
- 内核协议栈处理数据包,发现是给某个 Socket 的。
- 触发回调函数
ep_poll_callback。 - 这个回调函数会做一件事:把这个红黑树上的 Socket 节点引用,扔到“就绪链表” (Ready List) 的末尾。
第四步:收割 (epoll_wait)
当你调用 epoll_wait 时:
- 内核只需要检查 “就绪链表” 是不是空的。
- 如果不空,直接把链表里的节点弹出来,复制到用户态内存。
- 如果空,就把当前进程挂起(Sleep),等待回调函数来唤醒。
3. 总结:Epoll vs Select 的本质区别
为了方便理解,可以用“收卷子”来打比方:
- Select/Poll (轮询模型):
- 老师(用户进程)把全班 50 个学生(Socket)都喊起来。
- 挨个问:“你写完了吗?”(遍历 $O(n)$)。
- 如果只有 1 个人写完了,老师也要问完 50 个人才知道。
- 效率低,且随着人数增加线性下降。
- Epoll (事件驱动模型):
- 老师(用户进程)坐在讲台上休息(Wait)。
- 学生(Socket)桌子上装了按钮(回调函数)。
- 谁写完了,按一下按钮,名字自动显示在讲台的显示屏(Ready List)上。
- 老师只需要看显示屏,把名字记下来即可($O(1)$)。
- 效率极高,不随连接数增加而下降,只与活跃连接数有关。
4. 关键源码结构 (Linux Kernel 伪代码)
struct eventpoll {
// 1. 红黑树的根节点
// 存放所有正在监听的 fd
struct rb_root_cached rbr;
// 2. 就绪链表
// 存放已经触发事件的 fd
struct list_head rdllist;
// ... 等待队列等其他成员
};
5.为什么使用双向链表
在 epoll 的实现(以及绝大多数 Linux 内核数据结构)中,选择双向链表 (Doubly Linked List) 而不是单向链表,主要是为了满足 $O(1)$ 时间复杂度的“任意节点删除” 和 高效的链表合并。
针对 epoll 的具体场景,原因主要有以下三点:
1. 应对“中途取消”的情况 ($O(1)$ 删除) —— 最核心原因
这是一个很容易被忽视的边缘场景,但对内核效率至关重要。
- 场景:假设一个 Socket 有数据来了,回调函数把它放入了“就绪链表”中(等待被
epoll_wait取走)。但是,在用户程序调用epoll_wait之前,用户突然决定不需要这个连接了,调用了epoll_ctl(..., EPOLL_CTL_DEL, ...)把这个 Socket 从监控中删除了。 - 问题:此时,这个 Socket 的节点还在“就绪链表”里!内核必须立刻把它从链表中摘除,否则
epoll_wait可能会返回一个已经被删除的句柄,导致用户程序崩溃或逻辑错误。 - 对比:
- 如果是单向链表:要删除链表中间的某个节点,你必须找到它的前驱节点(Previous Node)。这意味着必须从头遍历链表,时间复杂度是 $O(n)$。如果就绪队列很长,这会严重拖慢
epoll_ctl的速度。 - 如果是双向链表:节点里直接保存了
prev和next指针。既然我们已经持有了这个节点的对象(通过红黑树查找到了它),只需要执行node->prev->next = node->next和node->next->prev = node->prev,就能在 $O(1)$ 时间内把它从队伍中“拎”出来,完全不需要遍历。
- 如果是单向链表:要删除链表中间的某个节点,你必须找到它的前驱节点(Previous Node)。这意味着必须从头遍历链表,时间复杂度是 $O(n)$。如果就绪队列很长,这会严重拖慢
2. 高效的“链表拼接/切割” (List Splicing)
epoll_wait 的内部逻辑并不只是简单的“取出节点”。为了保证线程安全,同时不长时间锁住整个 epoll 实例,内核通常采用“换链”的策略。
- 逻辑:当
epoll_wait被唤醒时,它会将当前的“就绪链表”整体切下来,移到一个临时的“传输链表” (txlist) 上,然后慢慢把数据复制给用户态。这样新的事件可以继续加入原链表,互不干扰。 - 优势:
- 双向链表支持极快地拼接和切割。只需要修改头尾节点的指针,就能把整条链表移动到另一个地方($O(1)$ 操作)。
- 如果是数组,需要发生内存拷贝;如果是单向链表,在处理尾部拼接时往往需要遍历到尾部(除非额外维护尾指针,但灵活性不如双向链表)。
3. Linux 内核的“标准基建” (struct list_head)
这是一个工程上的原因。
-
Linux 内核中定义了一个通用的数据结构
struct list_head,它就是一个标准的双向循环链表。 -
侵入式设计:Linux 的链表不是链表存数据,而是“数据里存链表”。
epoll的节点结构体epitem大概是这样的:struct epitem { struct rb_node rbn; // 红黑树节点 (用于查找) struct list_head rdllink; // 双向链表节点 (用于就绪队列) // ... 其他数据 ... }; -
使用标准双向链表可以使用内核提供的极其成熟、高度优化且宏定义丰富的 API(如
list_add,list_del,list_splice)。既然标准库提供了这么完美的 $O(1)$ 工具,完全没有理由去手写一个功能受限的单向链表。
总结
用生活中的例子打比方:
就绪链表就像是一个排队办业务的队伍。
-
入队:新来的人排到队尾(单向、双向都快)。
-
出队:排第一的人去办业务(单向、双向都快)。
-
特殊情况(双向链表的杀手锏):
排在队伍中间的某个人,突然接到电话说不用办了(EPOLL_CTL_DEL),他需要立马离开队伍。
- 单向链表:他走了,但他后面的人不知道该抓谁的衣角,必须从排头开始数,找到他前面的人,把手搭在他后面的人身上。
- 双向链表:大家本来就手拉手。他只需要对他前面的人说“拉住我后面那个人的手”,对他后面的人说“拉住我前面那个人的手”,然后他直接走人。速度极快,与队伍长度无关。
浙公网安备 33010602011771号