Netty与高性能网络服务、Linux高并发网络编程实战、从epoll到Netty:物联网接入层技术剖析、深入理解I/O多路复用、服务端网络编程进阶指南
深入Linux内核:epoll到底是怎么工作的
0 写在前面
上一篇文章聊了 select、poll、epoll 的宏观对比,算是把 epoll 的设计思想讲了个大概。但说实话,光知道"红黑树加链表"这几个字,遇到实际问题时还是会有种似懂非懂的感觉。
这一篇我想再往下挖一层,看看 epoll 在 Linux 内核里到底长什么样。不会逐行分析源码(那样太枯燥了),而是挑出几个关键的数据结构和函数调用链,把 epoll 的工作原理串成一条线。理解了这条线,后面看 Netty 的源码或者排查线上问题都会轻松很多。
1 epoll的三个系统调用
先快速回顾一下 epoll 的三个 API,因为后面的分析都是围绕它们展开的:
c
int epoll_create(int size); // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 管理fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件
epoll_create 中的 size 参数在 Linux 2.6.8 之后已经没有实际意义了(早期实现用的是 hash 表,size 用来指定 hash 桶的数量,后来换成了红黑树),但调用时仍然必须传一个大于 0 的值,这是为了向后兼容。
这三个系统调用分别对应了 epoll 的三个阶段:创建 、注册 、等待。我们一个一个来看。
2 epoll_create:在内核中安家
当你调用 epoll_create(1) 时,内核会做什么?
它会进入 do_epoll_create() 函数,主要做两件事:
第一,调用 ep_alloc() 分配并初始化一个 eventpoll 结构体。这个结构体就是 epoll 实例在内核中的"本体",所有后续操作都是围绕它展开的。
第二,调用 anon_inode_getfile() 把这个 eventpoll 对象包装成一个匿名文件,然后通过 get_unused_fd_flags() 分配一个文件描述符返回给用户态。
所以你在用户态拿到的那个 epfd,本质上就是一个文件描述符,指向内核中的 eventpoll 对象。这也解释了为什么 epoll 的 API 设计和文件操作那么像------因为底层确实就是文件系统的那套机制。
3 eventpoll:epoll的大脑
eventpoll 是整个 epoll 机制的核心数据结构,值得仔细看看它里面都装了什么:
c
struct eventpoll {
spinlock_t lock; // 自旋锁,保护结构体访问
struct mutex mtx; // 互斥锁,防止使用时被删除
wait_queue_head_t wq; // epoll_wait 使用的等待队列
wait_queue_head_t poll_wait; // file->poll() 使用的等待队列
struct list_head rdllist; // 就绪事件的双向链表
struct rb_root rbr; // 管理所有fd的红黑树
struct epitem *ovflist; // 事件溢出链表
struct user_struct *user; // 创建者
struct file *file; // 关联的文件对象
// ...
};
这里面有三个字段特别关键,我把它画成一张概念图来帮助理解:
eventpoll
|
+-- rbr (红黑树) <-- 存储所有被监控的fd
| |
| +-- epitem (fd=3)
| +-- epitem (fd=7)
| +-- epitem (fd=12)
| +-- ...
|
+-- rdllist (双向链表) <-- 存储已就绪的fd
| |
| +-- epitem (fd=7) <-- 这个fd有数据了
| +-- epitem (fd=12) <-- 这个fd也有数据了
|
+-- wq (等待队列) <-- epoll_wait阻塞的进程在这里等
红黑树 rbr 是"花名册",记录了所有你要监控的 fd。就绪链表 rdllist 是"待办事项",只有状态发生变化的 fd 才会出现在这里。等待队列 wq 则是"休息室",调用 epoll_wait 的进程在没有就绪事件时会在这里睡觉。
这三个结构各司其职,配合起来就构成了 epoll 的工作闭环。
4 epitem:每个fd的档案卡
红黑树上的每个节点都是一个 epitem 结构体,它是对被监控 fd 的完整描述:
c
struct epitem {
struct rb_node rbn; // 红黑树节点
struct list_head rdllink; // 就绪链表节点
struct epoll_filefd ffd; // fd信息
struct eppoll_entry *pwqlist; // 等待队列
struct eventpoll *ep; // 所属的eventpoll
struct epoll_event event; // 注册的事件
// ...
};
你可以把 epitem 理解成一张"档案卡"。每个被监控的 fd 都有一张这样的卡片,卡片上记录了这个 fd 关心什么事件(读?写?异常?)、属于哪个 epoll 实例、以及各种链表指针(方便把它挂到不同的数据结构上)。
为什么要用红黑树来存这些卡片?因为当连接断开时,你需要通过 fd 快速找到对应的 epitem 并从 epoll 中移除。红黑树的 O(logN) 查找保证了这个操作的效率。如果用链表的话,最坏情况要遍历全部节点,那和 select 就没什么区别了。
5 epoll_ctl:注册fd的全过程
现在来看最复杂的部分------当你调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) 时,内核到底做了什么?
整个调用链是:epoll_ctl → do_epoll_ctl → ep_insert(以添加操作为例)。
ep_insert 的核心流程大概是这样的:
第一步,分配并初始化 epitem。 内核从 slab 缓存中分配一个 epitem 对象,把 fd、事件类型等信息填进去。
第二步,设置回调函数。 这一步非常关键。ep_insert 会调用被监听文件的 poll 接口。如果这个文件是一个 socket,那实际调用的就是 tcp_poll()。tcp_poll() 内部会调用 poll_wait(),最终触发 ep_ptable_queue_proc() 函数。
ep_ptable_queue_proc() 做了什么呢?它创建了一个等待队列项,把回调函数设置为 ep_poll_callback,然后把这个等待队列项挂到 socket 自身的等待队列上。
翻译成人话就是:告诉 socket,"以后你有事了(数据到了、连接断了之类的),记得叫我一声,我的电话号码是 ep_poll_callback"。
第三步,插入红黑树。 调用 ep_rbtree_insert() 把 epitem 插入到 eventpoll 的红黑树中。
第四步,检查是否已经就绪。 如果这个 fd 在注册的时候就已经处于就绪状态(比如缓冲区里已经有数据了),就直接把它挂到就绪链表 rdllist 上,并唤醒可能在 epoll_wait 中阻塞的进程。
整个过程可以用一个序列来表示:
epoll_ctl(ADD)
→ ep_insert()
→ 分配 epitem
→ tfile->f_op->poll() [即 tcp_poll()]
→ poll_wait()
→ ep_ptable_queue_proc()
→ 在socket等待队列上注册 ep_poll_callback
→ ep_rbtree_insert() [插入红黑树]
→ 如果已就绪 → 加入 rdllist → 唤醒等待进程
6 ep_poll_callback:事件到达时的闹钟
前面反复提到的 ep_poll_callback,是整个 epoll 事件驱动模型的核心。当网卡收到数据,经过中断处理,最终会唤醒 socket 等待队列上的回调函数。如果这个 socket 被 epoll 监控了,那么被唤醒的就是 ep_poll_callback。
这个回调函数的逻辑非常简洁:
c
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key) {
// 把就绪的epitem加入就绪链表
list_add_tail(&epi->rdllink, &ep->rdllist);
// 唤醒在epoll_wait中阻塞的进程
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
return 1;
}
就两步:把对应的 epitem 挂到就绪链表上,然后叫醒在等着的进程。
简单,但高效。不需要遍历任何数据结构,不需要做任何检查,事件来了直接挂链表、叫人。O(1) 的时间复杂度。
这也是为什么 epoll 在"大量连接、少量活跃"的场景下表现特别好的根本原因------大部分时候什么都不会发生,只有那几个活跃连接才会触发回调。连接数再多,不活跃的连接就是静默的,不会产生任何开销。
7 epoll_wait:等待与返回
最后来看看 epoll_wait 的工作流程。
当用户态调用 epoll_wait(epfd, events, maxevents, timeout) 时:
- 检查就绪链表
rdllist是否为空 - 如果不为空,把链表中的就绪事件复制到用户态的 events 数组中,返回就绪数量
- 如果为空且 timeout > 0,把当前进程挂到 eventpoll 的等待队列
wq上,让出 CPU - 进程被
ep_poll_callback唤醒后,重新检查就绪链表,复制事件,返回
注意第二步的"复制"。在早期的 epoll 实现中,这里确实需要从内核空间向用户空间拷贝数据。但 epoll 通过 mmap 技术,让内核和用户空间共享同一块物理内存,所以这个"复制"实际上就是一次内存读取,不需要跨越内核态和用户态的边界。
这也是 epoll 相比 select/poll 的一个隐性优势:select/poll 每次调用都要把全部 fd 从用户空间拷贝到内核空间再拷贝回来,而 epoll 只在 epoll_wait 返回时拷贝就绪的事件------通常只是全部 fd 中很小的一部分。
8 EPOLLONESHOT:一个有用的选项
在实际开发中,还有一个 epoll 选项值得了解:EPOLLONESHOT。
考虑这样一个场景:你的物联网平台用线程池来处理设备消息。线程 A 从某个 socket 读取了数据,正在处理。处理过程中,这个 socket 又来了新数据,线程 B 被唤醒去读取。两个线程同时操作同一个 socket,数据就乱套了。
EPOLLONESHOT 就是解决这个问题的。设置了 EPOLLONESHOT 的 fd,在被触发一次之后会自动从 epoll 中"禁用",不再触发任何事件。直到处理完数据的线程主动调用 epoll_ctl 重新注册这个 fd,它才会再次生效。
这样就保证了同一个 socket 在同一时刻只会被一个线程处理,避免了竞态条件。在多线程的物联网网关中,这个选项非常实用。
9 小结
把 epoll 的完整工作流程串起来,其实就是一条清晰的链路:
epoll_create() → 创建 eventpoll(红黑树 + 就绪链表 + 等待队列)
epoll_ctl(ADD) → 创建 epitem → 注册回调到 socket → 插入红黑树
socket收到数据 → 触发 ep_poll_callback → epitem 挂入就绪链表 → 唤醒进程
epoll_wait() → 检查就绪链表 → 复制事件到用户态 → 返回
理解了这条链路,你就理解了 epoll 的全部精髓。没有魔法,没有玄学,就是几个精心设计的数据结构加上一个回调机制,把"轮询"变成了"通知"。
下一篇文章,我们会从内核态回到用户态,看看 Java 是如何通过 NIO Selector 来使用 epoll 的,以及这中间隔着多少层抽象。