epoll 底层原理 —— 从 PCB 到就绪链表的完整路径
前置阅读:手写 epoll 版 TCP echo 服务器(了解上层 API 后再读这篇,体验最佳)
一个问题:epoll_wait 为什么不用遍历全部 fd?
select/poll 每次都要扫描全部 fd:
// poll:遍历 2048 个槽位,找就绪的
for (int i = 0; i < NFDS; i++) {
if (_fds[i].fd == –1) continue;
if (_fds[i].revents & POLLIN) { /* 找到了 */ }
}
epoll 直接拿到就绪列表,而且上下文直接还给你:
int n = epoll_wait(epfd, events, 64, –1);
for (int i = 0; i < n; i++) { // 只遍历就绪的 n 个
Conn *conn = events[i].data.ptr; // 注册时存的指针,原样返回
}
为什么? 答案不在 API 层面,在内核。epoll 用了三样东西:红黑树、回调函数、就绪链表。
一张图贯穿内核数据链路
task_struct (你的 ./server 进程)
│
└─ files → files_struct
│
└─ fd_array[] ──── 每个元素是 struct file*
│
┌────────┴────────┐
▼ ▼
fd[3] (epoll) fd[4] (socket)
│ │
struct file struct file
┌──────────┐ ┌──────────┐
│f_op→epoll│ │f_op→socket│
│private_data│ │private_data│
└────┬──────┘ └────┬──────┘
│ │
▼ ▼
struct eventpoll struct socket
┌───────────────┐ │
│rbr (红黑树根) │ └─ sk → struct sock
│rdllist(就绪链表)│ ┌─────────────────┐
│wq (等待队列) │ │sk_data_ready │← 回调函数指针
└───┬───────────┘ │sk_user_data │← 指向 epitem
│ └────────┬─────────┘
│ 红黑树上挂 │
▼ ▼
struct epitem 数据到了就调
┌──────────────┐
│rbn ← 挂红黑树(一直都在)
│rdllink ← 挂就绪链表(数据来了才挂)
│ep ← 反指 eventpoll
│ffd ← 红黑树搜索 key
│event ← 你注册的 {EPOLLIN, data.ptr=&conn}
└──────────────┘
这张图就是全文的核心。下面分步走完整个链路。
第一部分:socket 是怎么创建的
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
内核做了什么:
| ① 分配 fd | 遍历 fd_array 找空位 | 得到 fd=4 |
| ② 创建外壳 | alloc_file() | file->f_op = socket_file_ops |
| ③ 创建 socket | kmalloc(sizeof(struct socket)) | file->private_data = sock |
| ④ 创建协议层 | kmalloc(sizeof(struct sock)) | sock->sk = sk |
| ⑤ 绑定 | fd_install(4, file) | fd_array[4] = file |
此时 sock 上挂着一个默认回调:
sk->sk_data_ready = sock_def_readable; // 默认回调,等 epoll_ctl 来覆盖
sk->sk_user_data = NULL;
指针链:fd_array[4] → file → private_data → socket → sk
第二部分:epoll 实例是怎么创建的
int epfd = epoll_create1(0);
| ① 分配 fd | 遍历 fd_array | 得到 fd=3 |
| ② 创建外壳 | alloc_file() | file->f_op = epoll_file_ops |
| ③ 创建大脑 | kmalloc(sizeof(struct eventpoll)) | rbr 空,rdllist 空,wq 空 |
| ④ 绑定 | file->private_data = ep | fd_install(3, file) |
struct eventpoll 全貌:
struct eventpoll {
struct rb_root rbr; // 红黑树根 → 所有被监控的 fd
struct list_head rdllist; // 就绪链表头 → 有数据的 fd
wait_queue_head_t wq; // 等待队列 → 谁在 epoll_wait 里睡觉
};
创建完是空的:红黑树没有节点,就绪链表没有元素,等待队列没有线程。
指针链:fd_array[3] → file → private_data → eventpoll
第三部分:epoll_ctl 注册时发生了什么
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.ptr = conn; // conn 是你自己定义的连接对象指针
epoll_ctl(epfd, EPOLL_CTL_ADD, conn->fd, &ev);
epoll_data 是一个 union:
typedef union epoll_data {
void *ptr; // 存指针 → 最常用,可以把连接对象存在内核里
int fd; // 存 fd → 但 fd 你本来就知道,存它意义不大
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll 把整个 ev 原样拷贝进内核的 epitem 里。事件触发时,再从 epitem 原样拷回给用户态——中间不拆、不碰、不修改。
这就是 select/poll 做不到的事。select 返回的是"第 i 个 fd 就绪了",你要自己再查一遍辅助数组才知道这个 fd 对应哪个连接对象。epoll 直接把你的指针还给你,一次解引用到位。
| ① 找 eventpoll | epfd → fd_array[3] → file → private_data → ep |
| ② 找 sock | listenfd → fd_array[4] → file → private_data → socket → sk |
| ③ 创建 epitem | 分配内存,填入 fd、event、反向指针 epi->ep = ep |
| ④ 挂红黑树 | 以 {fd, file} 为 key 插入,O(log n),检查重复 |
| ⑤ 装回调 | 直接操作 sock:sk->sk_data_ready = ep_poll_callback; sk->sk_user_data = epi; |
步骤 ⑤ 是关键。 epoll_ctl 不经过红黑树,直接操作 socket 底层 sock 对象。它做了两件事:把 sk_data_ready 从默认的 sock_def_readable 替换成 ep_poll_callback,同时把 sk_user_data 设为指向 epitem——这样回调触发时才能从 sock 反向找到 epitem。
struct epitem 全貌:
struct epitem {
struct rb_node rbn; // 线①:挂在 eventpoll 的红黑树上(一直都在)
struct list_head rdllink; // 线②:挂在 eventpoll 的就绪链表上(数据来了才挂)
struct eventpoll *ep; // 线③:反向指针——回调时靠它找到 eventpoll
struct epoll_filefd ffd; // {fd, file} —— 红黑树搜索 key
struct epoll_event event; // 你注册的 events + data.ptr,原样保存
};
三根线的职能:
线① rbn → 红黑树增删改查时用 ← 一直在树上
线② rdllink → 数据来了挂到就绪链表,取走就摘 ← 临时挂载
线③ ep → 回调手里只有 epitem,通过它找到 eventpoll(家在哪儿)
注意内核的设计:同一个 epitem 对象,同时嵌在红黑树(rbn)和就绪链表(rdllink)两个容器里。 不是复制,是同一个对象的不同成员挂在不同容器上。
第四部分:数据到达 —— 回调是怎么发生的
这是 epoll 区分于 select/poll 的核心机制。
协议栈怎么找到 sock? TCP 包里携带四元组 {src_ip, src_port, dst_ip, dst_port}。内核有一张全局 hash 表 tcp_hashinfo,四元组一查就定位到对应的 struct sock。找到 sock 后,数据放进接收缓冲区,然后——
// 协议栈收到数据后的操作
sk->sk_data_ready(sk); // 这个函数在 epoll_ctl 时已被替换!
回调函数的 4 跳:
void ep_poll_callback(struct sock *sk) {
struct epitem *epi = sk->sk_user_data; // ① sock→epitem(epoll_ctl 时设的)
list_add_tail(&epi->rdllink, &epi->ep->rdllist); // ②③ epitem 挂到 eventpoll 的就绪链表
wake_up(&epi->ep->wq); // ④ 叫醒 epoll_wait 上睡觉的线程
}
| 1 | 协议栈 | sock | hash 表 tcp_hashinfo(四元组→sock) |
| 2 | sock | epitem | sk->sk_user_data(epoll_ctl 时设的) |
| 3 | epitem | eventpoll | epi->ep(反向指针) |
| 4 | epitem | 就绪链表 | epi->rdllink 挂到 eventpoll.rdllist |
整个通知路径不走红黑树。 红黑树只在注册/删除时用,数据通知只走回调 + 就绪链表——O(1)。
第五部分:epoll_wait 怎么取出就绪事件
int epoll_wait(epfd, events, maxevents, timeout);
检查 eventpoll.rdllist 是否为空
│
├─ 空 + timeout = -1 → 当前线程挂到 wq 上睡觉
│ 等回调里的 wake_up 来叫醒
│
└─ 不空 → 遍历就绪链表(只遍历就绪节点!)
for (每个就绪 epitem) {
events[i] = epi->event; // events + data.ptr 原样拷给你
list_del(&epi->rdllink); // 从就绪链表摘下——但 epitem 仍在红黑树上!
}
return i; // 返回就绪个数
你的代码拿到后:
int n = epoll_wait(epfd, events, 64, –1);
for (int i = 0; i < n; i++) {
Conn *conn = (Conn *)events[i].data.ptr; // 直接拿到你的连接对象,不需要查映射表
int fd = conn->fd;
}
——O(n) 的 n 是就绪个数,不是监控总数。这就是 epoll 比 select/poll 快的根本原因。
第六部分:ET 和 LT —— 内核做了什么,你代码该怎么写
先说一个生活里的类比
你家有两个传感器:
-
水位传感器(LT — 水平触发):只要水位超过警戒线,警报就一直响。你过来排掉了半缸水,但因为水位还在线上,警报继续响——直到你排到线下为止。
-
门铃(ET — 边缘触发):按一下,响一声。按完就不响了。你戴着耳机没听到?那是你的事,门铃不会再响。
epoll 的 LT 和 ET 就是这个区别:
- LT:只要 socket 接收缓冲区里还有数据,每次 epoll_wait 都告诉你"这个 fd 可读"
- ET:数据到达的瞬间通知你一次。之后如果缓冲区还有数据你没读完,不会再通知。你只能靠自己的循环读到完
内核到底做了什么不同
回到我们前面第五部分 epoll_wait 的流程。在遍历就绪链表、list_del 摘下 epitem 之后,内核多了一个判断:
list_del(&epi->rdllink); // 先摘下
if (epi->event.events & EPOLLET) {
// ET 模式:摘完就完了,什么都不做
} else {
// LT 模式:多问一句"这个 fd 现在还就绪吗?"
epi->ffd.file->f_op->poll(epi->ffd.file, &pt);
// poll 返回"还有数据" → 立刻挂回就绪链表
// 所以下次 epoll_wait 还能拿到同一个 fd
}
对 socket 来说,f_op->poll 指向的就是 tcp_poll:
// net/ipv4/tcp.c(简化)
unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait) {
struct sock *sk = sock->sk;
unsigned int mask = 0;
if (!skb_queue_empty(&sk->sk_receive_queue)) // 接收队列非空
mask |= EPOLLIN; // → 标记可读
return mask;
}
内核做的事情很朴素:查一下接收队列是不是空的,非空就告诉你还能读。
这就是 LT 的秘密——不是内核"记住"了你没读完,而是每次 epoll_wait 结束后都重新确认一次。就像水位传感器不是记住你上次没排完,而是每次都在测水位。
用户态代码差在哪
LT(默认,不用加任何 flag):
ev.events = EPOLLIN; // 没有 EPOLLET,就是 LT
ev.data.ptr = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn->fd, &ev);
while (1) {
int n = epoll_wait(epfd, events, 64, –1);
for (int i = 0; i < n; i++) {
Conn *conn = (Conn *)events[i].data.ptr;
char buf[1024];
int len = read(conn->fd, buf, sizeof(buf)); // 读一次就行
// 没读完?没关系,下次 epoll_wait 还会通知你
}
}
ET(加 EPOLLET):
ev.events = EPOLLIN | EPOLLET; // 加了 EPOLLET
ev.data.ptr = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn->fd, &ev);
while (1) {
int n = epoll_wait(epfd, events, 64, –1);
for (int i = 0; i < n; i++) {
Conn *conn = (Conn *)events[i].data.ptr;
char buf[1024];
// 必须写内层循环!否则剩在缓冲区的数据不会再触发通知
while (1) {
int len = read(conn->fd, buf, sizeof(buf));
if (len == –1) {
if (errno == EAGAIN) break; // 读空了,正常退出
// 真正的错误
break;
}
if (len == 0) { /* 对端关闭 */ break; }
// 处理 buf …
}
}
}
EAGAIN 是怎么回事
你注意上面 ET 的代码里有一个 errno == EAGAIN。这个东西需要解释清楚。
read() 是系统调用。当你读一个非阻塞 socket:
- 缓冲区有数据 → read 返回读到的字节数
- 缓冲区空了 → read 不会阻塞等数据,而是立即返回 -1,并设 errno = EAGAIN
EAGAIN 的意思就是:“现在没数据了,但 fd 本身是正常的,你等会儿再试试。”
所以 ET 模式的铁律是:
不读到 EAGAIN 不要停。停了就可能丢事件。
因为一旦你从内层 while 退出而没读完,剩余数据不会触发新的通知——ET 只通知"数据从无到有"这个状态变化,不通知"数据还有"这个状态。
ET 为什么还要设计出来?LT 不好吗?
LT 更好用,但 ET 有一个 LT 做不到的事:避免惊群。
场景:一个多线程程序,多个线程都在 epoll_wait 同一个 epfd。一个连接来了数据——
-
LT 模式:内核通知 -> 唤醒一个线程 -> 这个线程读了一点数据但没读完 -> list_del + poll 检查 -> 发现还有数据 -> 挂回就绪链表 -> 再唤醒一个线程。数据没读完就会反复唤醒,多个线程被叫起来抢同一个 fd,这就是惊群。
-
ET 模式:内核通知一次 -> 唤醒一个线程 -> 这个线程循环读到 EAGAIN -> 结束。不会再额外唤醒。 一个 fd 一次只唤醒一个线程处理到底。
怎么选
| 学习 / 写 Demo / 一般项目 | LT | 默认行为,不容易出 bug |
| 高并发服务器 | ET | 避免反复通知,配合非阻塞 IO 性能更好 |
| 多线程共用一个 epfd | ET | 避免惊群 |
| 还不能熟练处理 EAGAIN | LT | 先用 LT 写对,再切 ET |
一个比较诚实的结论:除非你真的在写 Nginx 级别的服务器,LT 完全够用。ET 如果不配合非阻塞 IO,能写出 bug 来。
补充:EPOLLOUT —— 什么时候能写?
全文都在讲读,但你写服务器总要发数据。EPOLLOUT 的坑比 EPOLLIN 多。
为什么不能上来就注册 EPOLLOUT
新手常这么写:
ev.events = EPOLLIN | EPOLLOUT; // 同时监听读写
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
然后发现 epoll_wait 疯狂返回,CPU 飙到 100%。
原因很简单。回到第六部分 tcp_poll 的逻辑——LT 模式下内核查"现在可写吗?":
// 发送缓冲区没满 → 可写
if (sk_stream_memory_free(sk)) // 大部分时候都是 true!
mask |= EPOLLOUT;
TCP 的发送缓冲区默认 16KB~几 MB,你一次 write 才发几个字节。缓冲区几乎永远是空的,所以 EPOLLOUT 几乎永远触发。 每次 epoll_wait 都立刻返回可写,就是死循环。
正确的用法:按需注册,用完就摘
// 1. 初始只注册 EPOLLIN
ev.events = EPOLLIN;
ev.data.ptr = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn->fd, &ev);
// 2. 要发送数据时,直接 write
int n = write(conn->fd, buf, len);
if (n == –1 && errno == EAGAIN) {
// 发送缓冲区满了!这时才注册 EPOLLOUT,等内核通知可写
conn->out_buf = buf; // 把没发完的数据存起来
conn->out_len = len;
ev.events = EPOLLIN | EPOLLOUT;
epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd, &ev);
}
// 3. 收到 EPOLLOUT 通知后
if (events[i].events & EPOLLOUT) {
int n = write(conn->fd, conn->out_buf, conn->out_len);
// …处理…
if (全部发完) {
// 摘掉 EPOLLOUT!不然下次又疯狂触发
ev.events = EPOLLIN; // 只保留读
epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd, &ev);
}
}
核心原则:EPOLLOUT 是给"发送缓冲区从满变不满"这个事件用的,不是给"缓冲区一直有空"用的。 平时不要挂,write 失败(EAGAIN)了才挂,发完了立刻摘。
红黑树 vs 回调:两套系统,各干各的
epoll_ctl(ADD):
├── 系统 A:epitem 挂红黑树 → 负责"增删改查"(管理用,O(log n))
└── 系统 B:sock 上装回调 → 负责"数据通知"(通知用,O(1))
数据到达:只走系统 B,绕过红黑树,O(1)
epoll_wait:只碰就绪链表,O(就绪个数)
epoll_ctl(DEL):两套系统一起清理——红黑树摘节点 + 拆掉 sock 回调
红黑树只在管理时用,数据通知完全不经过它。
如果直接 close(fd) 不调 DEL 呢? 内核在 close 时自动处理——__fput → eventpoll_release,从红黑树摘除 epitem、把 sk_data_ready 恢复成默认回调、释放内存。不会泄露,但显式 DEL 是更好的习惯。
VFS:万物皆文件
epoll、socket、普通文件,在用户态都是 int fd。内核靠 struct file 统一:
struct file {
struct file_operations *f_op; // 函数指针表(C 语言的多态)
void *private_data; // 指向真正的实体
};
| epoll | epoll_file_ops | struct eventpoll |
| socket | socket_file_ops | struct socket |
| 普通文件 | ext4_file_ops | struct inode |
同一个 close(fd),根据 f_op 走不同执行路径——C 语言用函数指针表实现多态。
内核角色速查
| task_struct | 进程描述符(PCB) | 进程的身份证 |
| files_struct | fd 表管理器 | 进程的所有文件入口 |
| struct file | VFS 统一外壳 | 不管底层是啥,上层都是 file |
| struct socket | BSD socket 层 | socket 和 sock 之间的桥梁 |
| struct sock | 协议层 | TCP/UDP 的真正实现,回调装在这里 |
| struct eventpoll | epoll 大脑 | 红黑树根 + 就绪链表头 + 等待队列 |
| struct epitem | 一个被监控的 fd | 两个钩子(树的、链表的)+ 反向指针 + 用户数据 |
| struct epoll_event | 用户态结构体 | 唯一你在代码里能碰到的 |
select 是登记簿,poll 是名单卡。epoll 是装了呼叫铃的总管——内核在 socket 上装回调,数据一到自动挂链、主动通知。O(1) 就绪通知,O(就绪个数) 返回,这就是 epoll 快的本质。
上一篇:手写 epoll 版 TCP echo 服务器




