今天,我们就从Linux内核源码的角度,彻底搞懂这个问题的本质。读完这篇文章,你会明白:
- epoll到底快在哪里?(用数据说话)
- 内核用了哪些黑科技?(红黑树+就绪链表+回调机制)
- 和select/poll的本质区别是什么?
一、性能对比:数据不会骗人
1. 一组震撼的benchmark数据
先看一组来自《The Linux Programming Interface》书中的性能测试数据(单位:毫秒):
| 监控操作次数 | select | poll | epoll |
| 10 | 0.73 ms | 0.61 ms | 0.41 ms |
| 100 | 3.0 ms | 2.9 ms | 0.42 ms |
| 1,000 | 35 ms | 35 ms | 0.53 ms |
| 10,000 | 930 ms | 990 ms | 0.66 ms |
看到差距了吗?10,000次操作时,epoll比select快1400倍!
这不是魔法,而是算法复杂度的碾压:
- select/poll: O(n) — 线性遍历所有fd
- epoll: O(m) — 只处理 m 个就绪的fd(通常m << n)
但很多人只记住了这个结论,却不知道内核是怎么做到O(m)的。
💡 注意:这里的”监控操作次数”是指调用select/poll/epoll_wait的次数。在实际高并发场景中,假设你有10,000个并发连接,每次调用select需要遍历所有10,000个fd,而epoll只处理就绪的那几个,性能差距会更加明显。
2. 为什么要先提select/poll?
要理解epoll的优势,必须先知道select/poll的痛点。
select/poll的工作流程(简化版):复制
用户态 内核态
| |
|--- select(fds, 1024) --->|
| | 1. 把1024个fd从用户态拷贝到内核态
| | 2. 遍历这1024个fd,挨个检查是否就绪
| | 3. 把就绪的fd拷贝回用户态
|<--- 返回就绪fd数量 ------|
| |
| 4. 用户态再遍历1024个fd,找出哪些真正就绪1.2.3.4.5.6.7.8.9.
问题出在哪?
- 问题1:每次调用都要全量拷贝1024个fd,每次select调用都要从用户态→内核态拷贝一遍。
- 问题2:内核要全量遍历假设你监听10000个连接,但实际只有3个就绪,内核还是要遍历全部10000个。
- 问题3:用户态还要再遍历一遍内核只告诉你”有3个fd就绪”,但不告诉你是哪3个,用户态要再遍历10000个去找。
这就是O(n)复杂度的根源。
二、epoll的三大核心机制(重点)
epoll之所以能做到O(1),靠的是三大黑科技:
- 红黑树 — 管理所有被监听的fd
- 就绪链表 — 只存储就绪的fd
- 回调机制 — fd就绪时自动加入链表
我们一个个拆解。
三、黑科技1:红黑树 — 高效管理海量fd
1. 内核数据结构揭秘
当你调用epoll_create()时,内核会创建一个eventpoll对象(位于fs/eventpoll.c):复制
struct eventpoll {
spinlock_t lock; // 自旋锁,保护就绪链表
struct mutex mtx; // 互斥锁,保护红黑树
wait_queue_head_t wq; // 等待队列(epoll_wait阻塞在这)
struct rb_root_cached rbr; // 红黑树根节点 ⭐
struct list_head rdllist; // 就绪链表 ⭐⭐
...
};1.2.3.4.5.6.7.8.
重点看这两个字段:
- rbr — 红黑树,存储所有被监听的fd
- rdllist — 双向链表,存储已就绪的fd
2. 红黑树的作用
Q: 为什么用红黑树?
因为需要频繁的增删查操作:
- epoll_ctl(ADD) — 添加fd
- epoll_ctl(DEL) — 删除fd
- epoll_ctl(MOD) — 修改fd
- 内核内部查找fd是否存在
红黑树的性能:
- 查找:O(log n)
- 插入:O(log n)
- 删除:O(log n)
对比数组/链表:
- 数组查找:O(log n) 二分查找,但插入删除O(n)
- 链表查找:O(n),插入删除O(1)
红黑树在增删查场景下综合最优。
3. 红黑树的节点:epitem
每个被监听的fd在红黑树中对应一个epitem节点:复制
struct epitem {
struct rb_node rbn; // 红黑树节点
struct list_head rdllink; // 链入就绪链表的节点
struct epoll_filefd ffd; // 被监听的文件描述符
struct eventpoll *ep; // 所属的epoll对象
struct epoll_event event; // 用户注册的事件(EPOLLIN/EPOLLOUT等)
struct list_head pwqlist; // poll等待队列链表 ⭐
...
};1.2.3.4.5.6.7.8.9.
关键字段:
- rbn — 挂在红黑树上的节点
- rdllink — 当fd就绪时,通过这个字段链入就绪链表
- pwqlist — 这是实现回调的关键(后面详讲)
4. 红黑树的比较函数
内核如何判断两个epitem的大小?看比较函数ep_cmp_ffd():复制
static int ep_cmp_ffd(struct epoll_filefd *p1, struct epoll_filefd *p2)
{
// 先比较 file 指针地址
if (p1->file > p2->file)
return 1;
if (p1->file < p2->file)
return -1;
// 地址相同,再比较 fd
return p1->fd - p2->fd;
}1.2.3.4.5.6.7.8.9.10.
为什么先比较file指针?
因为fd只是一个整数索引,可能被重用。而struct file指针在文件未关闭前是唯一的。
5. 图解:红黑树存储结构
复制
eventpoll
┌──────────┐
│ lock │
│ mtx │
│ wq │
│ rbr ──→│ 红黑树根节点
│ rdllist │
└──────────┘
│
↓
红黑树(管理所有fd)
[epitem:fd=5]
/ \ [epitem:fd=3] [epitem:fd=8] / \ / \ [fd=1] [fd=4] [fd=7] [fd=10] 每个epitem节点包含: – fd信息(文件描述符) – 用户关心的事件(EPOLLIN/EPOLLOUT) – 回调队列 pwqlist
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
添加fd时(epoll_ctl ADD):
- 创建epitem节点
- 调用ep_rbtree_insert()插入红黑树 — O(log n)
- 注册回调函数到fd的等待队列
删除fd时(epoll_ctl DEL):
- 调用ep_find()在红黑树中查找 — O(log n)
- 从红黑树删除 — O(log n)
- 清理回调队列
四、黑科技2:就绪链表 — 只关心就绪的fd
1. 就绪链表的设计思想
核心思想:不要遍历全部fd,只处理就绪的。
epoll维护了一个双向链表rdllist,专门存储已就绪的fd。复制
struct eventpoll {
...
struct list_head rdllist; // 就绪链表 ⭐
...
};1.2.3.4.5.
当fd就绪时(比如socket收到数据):
- 内核触发该fd的回调函数 ep_poll_callback()
- 回调函数把对应的epitem加入rdllist
当调用epoll_wait时:
- 直接遍历rdllist(只有就绪的fd)
- 把就绪事件拷贝到用户空间
- 返回就绪fd数量
2. 图解:就绪链表的工作流程
复制
步骤1:初始状态,红黑树有10000个fd,就绪链表为空
eventpoll 红黑树(10000个fd)
┌──────────┐ ● ● ● ● ●
│ rdllist │ → NULL ● ● ● ● ●
└──────────┘ ● ● ● ● ●
步骤2:fd=5 和 fd=8 的socket收到数据,触发回调
内核网络层
↓ 数据到达
[socket fd=5] → 触发回调 → ep_poll_callback()
↓
把 epitem(fd=5) 加入 rdllist
[socket fd=8] → 触发回调 → ep_poll_callback()
↓
把 epitem(fd=8) 加入 rdllist
步骤3:就绪链表变成这样
eventpoll
┌──────────┐
│ rdllist │ → [epitem:fd=5] ⇄ [epitem:fd=8]
└──────────┘
步骤4:用户调用 epoll_wait()
只遍历 rdllist(2个fd),不遍历红黑树(10000个fd)
返回给用户:
events[0].fd = 5, events[0].events = EPOLLIN
events[1].fd = 8, events[1].events = EPOLLIN1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.
关键点:
- 不管有多少fd被监听,epoll_wait只处理就绪的那几个
- 复杂度:O(就绪fd数量),接近O(1)
3. 为什么rdllist是双向链表?
Q: 为什么不用单链表或数组?
因为需要频繁的插入和删除:
- 当fd就绪时 → 插入链表尾部(O(1))
- 当epoll_wait处理后 → 可能需要把fd从链表移除(O(1))
- 当fd被epoll_ctl(DEL)时 → 需要从链表移除(O(1))
双向链表的删除操作是O(1),单链表需要O(n)找前驱节点。
五、黑科技3:回调机制 — 自动加入就绪链表
1. 最核心的问题:fd怎么知道自己就绪了?
这是整个epoll机制最精妙的部分!
当你调用epoll_ctl(ADD, fd)添加一个socket时,内核做了什么?
关键步骤(源码位于ep_insert()函数):复制
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
struct epitem *epi;
struct ep_pqueue epq;
// 1. 分配 epitem 节点
epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
// 2. 初始化 epitem
INIT_LIST_HEAD(&epi->rdllink); // 初始化就绪链表节点
INIT_LIST_HEAD(&epi->pwqlist); // 初始化poll等待队列链表
epi->ep = ep;
epi->event = *event;
// 3. 关键:注册回调函数 ⭐⭐⭐
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
// 4. 调用fd的poll函数,建立回调关系
revents = ep_item_poll(epi, &epq.pt);
// 5. 插入红黑树
ep_rbtree_insert(ep, epi);
// 6. 如果fd当前已经就绪,加入就绪链表
if (revents && !ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
if (waitqueue_active(&ep->wq))
wake_up(&ep->wq); // 唤醒阻塞在epoll_wait的进程
}
return0;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.
2. 回调函数:ep_poll_callback()
核心中的核心:当socket收到数据时,内核会调用这个函数。复制
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode,
int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
spin_lock_irqsave(&ep->lock, flags);
// 1. 检查是否是我们关心的事件
if (key && !((unsignedlong)key & epi->event.events))
goto out_unlock;
// 2. 如果epitem还没在就绪链表中,就加入 ⭐
if (!ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
// 3. 唤醒阻塞在 epoll_wait 的进程
if (waitqueue_active(&ep->wq))
wake_up(&ep->wq);
out_unlock:
spin_unlock_irqrestore(&ep->lock, flags);
return1;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.
这个函数的作用:
- 当socket收到数据时,被内核自动调用
- 把对应的epitem加入就绪链表rdllist
- 唤醒阻塞在epoll_wait的进程
3. 回调是怎么建立的?
关键函数:ep_ptable_queue_proc()复制
static void ep_ptable_queue_proc(struct file *file,
wait_queue_head_t *whead,
poll_table *pt)
{
struct epitem *epi = ep_item_from_epqueue(pt);
struct eppoll_entry *pwq;
// 1. 分配一个poll等待队列项
pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL);
// 2. 设置回调函数为 ep_poll_callback
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
// 3. 把这个等待队列项加入到 socket 的等待队列
add_wait_queue(whead, &pwq->wait);
// 4. 记录到 epitem 的 pwqlist
pwq->next = epi->pwqlist;
epi->pwqlist = pwq;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.
建立回调的过程:复制
1. 用户调用 epoll_ctl(ADD, socket_fd)
↓
2. 内核创建 epitem,调用 ep_insert()
↓
3. ep_insert() 调用 socket->ops->poll()
↓
4. socket的poll函数会调用 poll_wait()
↓
5. poll_wait() 最终调用我们注册的 ep_ptable_queue_proc()
↓
6. ep_ptable_queue_proc() 创建等待队列项,设置回调为 ep_poll_callback
↓
7. 把等待队列项加入到 socket 的等待队列
完成!现在socket收到数据时,会自动触发 ep_poll_callback()1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.
4. 图解:回调机制完整流程
复制
用户态 内核态 网卡/驱动
| | |
|-- epoll_ctl(ADD,fd) --> | |
| | |
| 创建epitem |
| 注册回调到socket |
| | |
| eventpoll |
| ┌──────────┐ |
| │ rdllist │ → NULL |
| └──────────┘ |
| ↓ |
| 红黑树:[epitem:fd=5] |
| ↓ |
| pwqlist → [eppoll_entry] |
| ↓ |
| wait_queue_entry |
| 回调=ep_poll_callback |
| ↓ |
| socket的等待队列 |
| |
| 等待数据... |
| | |
| | <----数据到达---|
| | |
| 内核网络层 |
| 唤醒socket等待队列 |
| ↓ |
| 执行回调:ep_poll_callback |
| ↓ |
| 把epitem加入rdllist ⭐ |
| ↓ |
| 唤醒epoll_wait进程 |
| | |
|<--- 返回就绪fd ---------| |
| |1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.
六、epoll_wait的实现:只看就绪链表
1. epoll_wait做了什么?
复制
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
int res = 0;
// 1. 检查就绪链表是否为空
if (!ep_events_available(ep)) {
// 2. 如果为空,把当前进程加入等待队列
init_waitqueue_entry(&wait, current);
__add_wait_queue_exclusive(&ep->wq, &wait);
// 3. 睡眠,等待被唤醒(或超时)
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (ep_events_available(ep) || timeout <= 0)
break;
timeout = schedule_hrtimeout_range(timeout, ...);
}
// 4. 被唤醒后,移除等待队列
__remove_wait_queue(&ep->wq, &wait);
}
// 5. 扫描就绪链表,拷贝到用户空间 ⭐
if (ep_events_available(ep))
res = ep_send_events(ep, events, maxevents);
return res;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.
2. 关键函数:ep_send_events()
复制
static int ep_send_events(struct eventpoll *ep,
struct epoll_event __user *events,
int maxevents)
{
struct ep_send_events_data esed;
esed.maxevents = maxevents;
esed.events = events;
// 扫描就绪链表 rdllist
return ep_scan_ready_list(ep, ep_send_events_proc, &esed);
}
static int ep_send_events_proc(struct eventpoll *ep, struct list_head *head,
void *priv)
{
struct ep_send_events_data *esed = priv;
struct epitem *epi;
int eventcnt = 0;
// 遍历就绪链表
list_for_each_entry_safe(epi, tmp, head, rdllink) {
// 再次检查是否真的就绪(可能已经被处理)
revents = ep_item_poll(epi, NULL);
if (revents) {
// 拷贝到用户空间
if (__put_user(revents, &uevent->events) ||
__put_user(epi->event.data, &uevent->data)) {
return eventcnt ? eventcnt : -EFAULT;
}
eventcnt++;
uevent++;
// LT模式:保留在链表中
// ET模式:从链表移除
if (!(epi->event.events & EPOLLET)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
}
return eventcnt;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.
关键点:
- 只遍历rdllist就绪链表,不遍历红黑树
- 复杂度:O(就绪fd数量)
3. LT vs ET 模式的本质区别
水平触发(LT,Level Triggered):
- fd就绪后,会一直保留在就绪链表中
- epoll_wait会反复通知,直到数据被读完
边缘触发(ET,Edge Triggered):
- fd就绪后,只加入就绪链表一次
- epoll_wait只通知一次,必须一次性读完所有数据
实现上的区别:复制
// ep_send_events_proc 中
if (!(epi->event.events & EPOLLET)) {
// LT模式:把epitem重新加回就绪链表
list_add_tail(&epi->rdllink, &ep->rdllist);
} else {
// ET模式:从就绪链表移除
ep_pm_stay_awake(epi);
list_del_init(&epi->rdllink);
}1.2.3.4.5.6.7.8.9.
为什么ET模式性能更好?
- LT模式:每次epoll_wait都可能返回同一个fd(如果数据没读完)
- ET模式:只返回一次,减少无谓的epoll_wait调用
七、完整流程图:从添加fd到返回就绪事件
复制
┌─────────────────────────────────────────────────────────────┐
│ 用户态调用 epoll_ctl(ADD, fd) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 内核态:ep_insert() │
│ 1. 创建 epitem 节点 │
│ 2. 调用 fd->ops->poll(),注册回调 ep_poll_callback │
│ 3. 插入红黑树 │
│ 4. 如果fd当前就绪,加入就绪链表 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 数据结构状态 │
│ │
│ eventpoll │
│ ┌──────────┐ 红黑树 │
│ │ rbr │────→ [epitem:fd=5] │
│ │ rdllist │────→ NULL (空链表) │
│ └──────────┘ │
│ │
│ socket(fd=5) │
│ ┌──────────┐ │
│ │等待队列 │────→ [eppoll_entry(回调=ep_poll_callback)] │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 网卡收到数据,触发硬件中断 │
│ ↓ │
│ 内核网络层处理 │
│ ↓ │
│ 唤醒 socket 等待队列 │
│ ↓ │
│ 执行回调:ep_poll_callback() │
│ 1. 检查事件类型是否匹配 │
│ 2. 把 epitem(fd=5) 加入就绪链表 rdllist │
│ 3. 唤醒阻塞在 epoll_wait 的进程 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 数据结构状态 │
│ │
│ eventpoll │
│ ┌──────────┐ │
│ │ rdllist │────→ [epitem:fd=5] ⭐ (新加入) │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 用户态调用 epoll_wait() │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 内核态:ep_poll() │
│ 1. 检查 rdllist 是否为空 │
│ - 不为空:直接进入步骤4 │
│ - 为空:进入步骤2 │
│ 2. 把当前进程加入 ep->wq 等待队列 │
│ 3. 进程睡眠,等待被唤醒或超时 │
│ 4. 调用 ep_send_events(),扫描就绪链表 │
│ 5. 把就绪事件拷贝到用户空间 │
│ 6. 返回就绪fd数量 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 用户态收到就绪事件 │
│ events[0].fd = 5 │
│ events[0].events = EPOLLIN │
└─────────────────────────────────────────────────────────────┘1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.
八、性能对比总结:为什么epoll是O(1)
1. select/poll vs epoll 的本质区别
| 维度 | select/poll | epoll |
| 数据结构 | 数组 | 红黑树 + 就绪链表 |
| fd管理 | 每次调用都要传递全部fd | fd在内核中持久化存储 |
| 查找就绪fd | 遍历全部fd | 只遍历就绪链表 |
| 通知方式 | 主动轮询 | 事件驱动回调 |
| 内存拷贝 | 每次调用都拷贝 | 只在添加/删除时拷贝 |
| 时间复杂度 | O(n) | O(1) ~ O(就绪fd数) |
| fd数量限制 | select有1024限制 | 理论上无限制(受系统资源限制) |
2. epoll高性能的三大支柱
(1) 红黑树 — 高效管理海量fd
- 添加/删除/查找:O(log n)
- 避免了select/poll的全量遍历
(2) 就绪链表 — 只关心就绪的fd
- epoll_wait只遍历就绪链表
- 不管监听10个还是10万个fd,只处理就绪的那几个
(3) 回调机制 — 事件驱动,自动加入链表
- fd就绪时,内核自动把epitem加入就绪链表
- 避免了主动轮询的开销
3. 一个直观的类比
select/poll 就像:复制
你在超市找3个成熟的苹果,
店员让你把10000个苹果全搬出来挨个检查,
每次购物都得重复这个过程。1.2.3.
epoll 就像:复制
你告诉店员要监控这10000个苹果,
店员给每个苹果装了传感器,
苹果成熟时会自动发通知,
你只需要去取走成熟的那3个。1.2.3.4.
九、进阶:epoll的其他优化细节
1. 惊群问题的优化
惊群(Thundering Herd):多个进程/线程监听同一个epoll实例,当事件到来时,内核会唤醒所有等待的进程,但只有一个能处理,其他白白浪费CPU。
解决方案:Linux 4.5引入了EPOLLEXCLUSIVE标志。复制
// 只唤醒一个进程
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);1.2.3.
内核实现(在ep_poll()中):复制
// 添加进程到等待队列时
__add_wait_queue_exclusive(&ep->wq, &wait); // 注意 exclusive1.2.
原理:等待队列的独占标志,确保只唤醒一个进程。
2. overflow list的优化
问题:在epoll_wait扫描就绪链表时,如果又有新事件到来怎么办?
解决方案:使用overflow list(ep->ovflist)。复制
// ep_send_events() 中
ep->ovflist = NULL; // 激活overflow list
// 扫描rdllist的同时,新到的事件加入ovflist
// 扫描完成后,把ovflist的事件合并回rdllist1.2.3.4.5.6.
作用:避免在扫描过程中持有锁,提高并发性能。
3. oneshot模式
EPOLLONESHOT:事件触发一次后自动从就绪链表移除,避免重复触发。复制
ev.events = EPOLLIN | EPOLLONESHOT;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);1.2.
应用场景:多线程环境,确保同一个fd不会被多个线程同时处理。
十、实战:手写一个mini epoll服务器
理论讲完了,来点实战。下面是一个最精简的epoll服务器示例:复制
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#define MAX_EVENTS 10
#define PORT 8888
// 设置非阻塞
void setnonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int listen_fd, conn_fd, epoll_fd, nfds;
struct epoll_event ev, events[MAX_EVENTS];
struct sockaddr_in addr;
// 1. 创建监听socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
setnonblocking(listen_fd);
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 128);
// 2. 创建epoll实例
epoll_fd = epoll_create1(0);
// 3. 添加监听socket到epoll
ev.events = EPOLLIN | EPOLLET; // ET模式
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
// 4. 事件循环
while (1) {
// 等待事件(阻塞)
nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
// 处理就绪事件
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接
while ((conn_fd = accept(listen_fd, NULL, NULL)) > 0) {
setnonblocking(conn_fd);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
}
} else {
// 数据到达,ET模式必须一次性读完
char buf[1024];
int n;
while ((n = read(events[i].data.fd, buf, sizeof(buf))) > 0) {
write(events[i].data.fd, buf, n); // echo回去
}
if (n == 0) {
// 连接关闭
close(events[i].data.fd);
}
}
}
}
close(listen_fd);
close(epoll_fd);
return0;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.
关键点:
- ET模式配合非阻塞socket
- accept和read都要循环调用,直到返回EAGAIN
- epoll_wait只处理就绪的fd
十一、常见面试追问
(1) Q1: epoll的缺点是什么?
A:
- 只支持Linux,不可移植(FreeBSD用kqueue,Windows用IOCP)
- 不支持普通文件fd,只支持socket、pipe等
- ET模式编程复杂,容易出bug(必须一次性读完)
- 小规模连接场景不如poll,因为有额外的红黑树操作开销
(2) Q2: epoll适合什么场景?
A:
- ✅ 高并发长连接场景(如IM服务器、游戏服务器)
- ✅ 大量连接,但活跃连接少(如反向代理)
- ✅ 需要精确控制事件的场景(支持ET/LT模式)
- ❌ 不适合连接数少的场景(select/poll更简单)
- ❌ 不适合需要跨平台的场景
(3) Q3: epoll和io_uring的区别?
A:
- epoll:事件通知机制,告诉你”fd可读/可写了”,还需要用户态调用read/write
- io_uring:真正的异步IO,提交读写请求到内核队列,内核完成后通知你
- 性能:io_uring更强,但需要Linux 5.1+,且编程更复杂
(4) Q4: 为什么Nginx用epoll而不是io_uring?
A:
- io_uring是Linux 5.1引入的,太新,生产环境支持度不够
- epoll已经足够快,能满足绝大多数场景
- epoll更成熟稳定,坑已经填完了
- 未来Nginx可能会支持io_uring作为可选项
十二、总结:面试怎么回答
当面试官问”epoll为什么性能高”时,你可以这样回答:
epoll的高性能源于三大核心机制:
- 红黑树管理fd所有被监听的fd存储在内核的红黑树中,添加/删除/查找都是O(log n),避免了select/poll每次调用都要传递全量fd数组。
- 就绪链表内核维护一个就绪链表(rdllist),只有就绪的fd才会被加入。epoll_wait只遍历这个链表,而不是遍历所有fd,所以复杂度接近O(1)。
- 回调机制当fd就绪时(比如socket收到数据),内核会自动调用ep_poll_callback,把对应的epitem加入就绪链表。这是事件驱动的,不需要主动轮询。
本质区别:
- select/poll是主动轮询:每次都遍历所有fd
- epoll是被动通知:fd就绪时主动加入就绪链表
这就是为什么10万并发时,epoll比select快4000倍的原因。
如果面试官继续追问细节,你就可以展开讲红黑树、回调注册、LT/ET模式等。



暂无评论内容