☰
Linux --从 poll 到 epoll多路转接
2026/10/27 2:00:07 网站建设 项目流程

为什么要多路转接?

一个服务器要同时处理多个客户端连接。最朴素的做法是:

  • 阻塞 IO + 多进程/多线程:每个连接一个进程/线程,简单但资源开销大,连接数一多就撑不住。

  • 非阻塞 IO + 轮询:不停问每个 fd "你好了吗",CPU 空转,效率低。

IO 多路转接的思路是:把一批 fd 交给内核,让内核帮我"盯着",哪个就绪了再告诉我,我再去处理。这样单线程就能管理大量连接,这就是 select / poll / epoll 的共同目标。

三者的演进关系可以概括为:

机制核心数据结构最大 fd 数每次调用是否拷贝全部 fd就绪查找方式
select三个位图 fd_set有上限(FD_SETSIZE,通常 1024)是遍历位图
pollpollfd 数组无上限(性能随数量下降)是遍历数组
epoll红黑树 + 就绪双链表无上限

poll:比 select 更顺手的"数组版"

接口

#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; /* 要监听的文件描述符 */ short events; /* 请求监听的事件(输入) */ short revents; /* 实际发生的事件(输出) */ };

参数说明:

  • fds:pollfd 结构数组,每个元素描述一个 fd 及关心的事件。

  • nfds:数组长度。

  • timeout:超时时间,单位毫秒。-1表示永久阻塞,0表示立即返回。

返回值:

  • < 0:出错;

  • = 0:等待超时;

  • > 0:有 fd 就绪,返回值是就绪的 fd 个数。

常用事件

事件含义可作输入可作输出
POLLIN普通数据/优先数据可读是是
POLLRDNORM普通数据可读是是
POLLRDBAND优先级带数据可读(Linux 不支持)是是
POLLPRI高优先级数据可读(如 TCP 带外数据)是是
POLLOUT数据可写是

一个最小示例:监控标准输入

#include <poll.h> #include <unistd.h> #include <stdio.h> int main() { struct pollfd poll_fd; poll_fd.fd = 0; // 标准输入 poll_fd.events = POLLIN; for (;;) { int ret = poll(&poll_fd, 1, 1000); if (ret < 0) { perror("poll"); continue; } if (ret == 0) { printf("poll timeout\n"); continue; } if (poll_fd.revents == POLLIN) { char buf[1024] = {0}; read(0, buf, sizeof(buf) - 1); printf("stdin:%s", buf); } } }

poll 的优缺点

优点:

  1. 接口更统一:select 用三个位图分别表示读、写、异常,poll 用一个 pollfd 数组统一描述,且events(输入)和revents(输出)分离,不需要像 select 那样每次调用前重新设置 fd_set。

  2. 没有最大数量限制:pollfd 数组可以动态扩容,不像 select 受 FD_SETSIZE 限制。

缺点:

  1. 返回后仍需轮询:poll 只告诉你"有几个 fd 就绪",具体是哪些,还得遍历整个数组检查revents。

  2. 每次调用都要全量拷贝:每次 poll 都要把整个 pollfd 数组从用户态拷贝到内核态,fd 越多拷贝开销越大。

  3. 效率随 fd 数量线性下降:大量连接中只有少量活跃时,绝大部分遍历都是无效的。

一句话总结 poll:比 select 好用,但没解决"全量拷贝 + 全量遍历"的根本问题。

epoll:为大批量句柄而生

poll 是什么

man 手册的原话是:epoll 是"为处理大批量句柄而作了改进的 poll"。它在 Linux 2.5.44 内核中被引入,被公认为 Linux 2.6 下性能最好的多路 IO 就绪通知方法。

epoll 的使用是三部曲:

  1. epoll_create:创建一个 epoll 句柄;

  2. epoll_ctl:把要监控的 fd 注册进去(增/删/改);

  3. epoll_wait:等待就绪事件。

三个系统调用

epoll_create
int epoll_create(int size);

创建一个 epoll 句柄。Linux 2.6.8 之后size参数被忽略(但必须传一个大于 0 的值)。用完之后必须close()关闭。

epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll 的事件注册函数。

与 select/poll "每次监听时告诉内核关心什么事件"不同,epoll 是先注册、后等待。

  • 第一个参数:epoll 句柄(epoll_create 的返回值);

  • 第二个参数:动作,三个宏之一:

    • EPOLL_CTL_ADD:注册新的 fd;

    • EPOLL_CTL_MOD:修改已注册 fd 的监听事件;

    • EPOLL_CTL_DEL:删除一个 fd;

  • 第三个参数:要监听的目标 fd;

  • 第四个参数:告诉内核监听什么事件。

struct epoll_event定义:

typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ } EPOLL_PACKED;

events常用宏:

宏含义
EPOLLIN对应 fd 可读(包括对端 socket 正常关闭)
EPOLLOUT对应 fd 可写
EPOLLPRI有紧急数据可读(带外数据)
EPOLLERRfd 发生错误
EPOLLHUPfd 被挂断
EPOLLET设置为边缘触发(ET)模式
EPOLLONESHOT只监听一次,之后需重新加入红黑树才能继续监听
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

收集已就绪的事件。

  • events:用户态分配好的 epoll_event 数组,内核把就绪事件复制到这里。不能是空指针,内核只负责复制数据,不负责在用户态分配内存。

  • maxevents:告诉内核数组有多大,必须大于 0。

  • timeout:毫秒。0立即返回,-1永久阻塞。

返回值:成功时返回就绪的 fd 数目;0表示超时;< 0表示失败。

epoll 工作原理(重点)

理解 epoll 高性能的关键,在于内核里的这两个结构:

struct eventpoll { /* 红黑树的根节点,存储所有添加到 epoll 中的需要监控的事件 */ struct rb_root rbr; /* 双链表,存放将通过 epoll_wait 返回给用户的满足条件的事件 */ struct list_head rdlist; };

每个 epoll 对象都有一个独立的eventpoll结构体。工作流程如下:

  1. 注册:调用epoll_ctl(EPOLL_CTL_ADD)时,内核会为每个事件建立一个epitem结构体,并把它挂到红黑树上。红黑树的作用是高效识别重复添加,插入效率 O(log n)。

  2. 回调注册:每个添加到 epoll 的事件都会与设备(网卡)驱动建立回调关系。当事件真正发生时,驱动调用内核中的ep_poll_callback,它把对应的 epitem 加到rdlist就绪双链表中。

  3. 等待:epoll_wait检查时,只需看rdlist是否为空。非空就把就绪事件复制到用户态数组并返回数量。这个操作的时间复杂度是O(1)——不管注册了多少 fd,都只和"就绪的 fd 数量"有关。

struct epitem { struct rb_node rbn; // 红黑树节点 struct list_head rdllink; // 双向链表节点 struct epoll_filefd ffd; // 事件句柄信息 struct eventpoll *ep; // 指向所属的 eventpoll 对象 struct epoll_event event; // 期待发生的事件类型 };

用一张图概括:

用户态 内核态 ------ ------ epoll_create ────────► eventpoll { rbr(红黑树), rdlist(就绪链表) } epoll_ctl ADD ────────► epitem 插入红黑树,注册回调 ep_poll_callback │ 事件发生(网卡驱动) │ ep_poll_callback 把 epitem 挂到 rdlist │ epoll_wait ◄──────── 检查 rdlist,非空则拷贝就绪事件返回(O(1))

epoll 的优点(对照 select/poll 的缺点)

  1. 接口使用方便:虽然拆成三个函数,但不需要每次循环都重新设置关注的文件描述符,输入输出参数分离,用起来反而更清晰高效。

  2. 数据拷贝轻量:只在EPOLL_CTL_ADD时把 fd 结构拷贝到内核一次,不像 select/poll 每次循环都要全量拷贝。

  3. 事件回调机制:不靠遍历,而是靠回调把就绪 fd 加入就绪队列,epoll_wait直接访问就绪队列就知道哪些 fd 就绪,时间复杂度 O(1)。fd 数量再多,效率也不受影响。

  4. 没有数量限制:fd 数目无上限。

三者的对比总结

维度selectpollepoll
数据结构三个位图 fd_setpollfd 数组红黑树 + 就绪链表
最大 fd 数FD_SETSIZE(通常 1024)无上限无上限
fd 拷贝每次调用全量拷贝每次调用全量拷贝仅注册时拷贝一次
就绪查找遍历位图 O(n)遍历数组 O(n)直接取就绪链表 O(1)
触发模式LTLTLT / ET
适用场景少量连接少量连接大量连接、少量活跃

epoll 的两种工作模式:LT 与 ET

一个很生动的比喻:你妈喊你吃饭。

  • 水平触发(LT):你妈喊你一次你没动,她会继续喊第二次、第三次……

  • 边缘触发(ET):你妈喊你一次你没动,她就不管你了。

场景设定

假设:

  • 已把一个 TCP socket 添加到 epoll;

  • 对端写入了 2KB 数据;

  • epoll_wait返回,说明可读;

  • 你调用read只读了 1KB;

  • 继续调用epoll_wait……

水平触发 LT(Level Triggered)

epoll 的默认模式。

  • 检测到 socket 就绪时,可以不立刻处理,或者只处理一部分。

  • 上面的例子中,缓冲区还剩 1KB,第二次epoll_wait仍然会立刻返回并通知读就绪。

  • 直到缓冲区数据全部处理完,epoll_wait才不再立刻返回。

  • 支持阻塞读写和非阻塞读写。

边缘触发 ET(Edge Triggered)

在添加 socket 时带上EPOLLET标志,进入 ET 模式。

  • 检测到就绪时必须立刻处理。

  • 上面的例子中,只读了 1KB,剩下 1KB 在缓冲区,但第二次epoll_wait不会再返回了。

  • 也就是说,ET 模式下 fd 就绪后只有一次处理机会。

  • ET 性能比 LT 更高(epoll_wait返回次数少很多),Nginx 默认采用 ET。

  • 只支持非阻塞读写。

LT 与 ET 的取舍

  • LT 是默认行为,代码简单,不容易丢数据。

  • ET 减少触发次数,但逼迫程序员在一次就绪响应中把数据全部处理完,代码复杂度更高。

  • 如果在 LT 下也能做到每次就绪都立刻处理、不让就绪被重复提示,其实性能和 ET 是一样的。ET 的优势主要体现在"少返回几次"上。

为什么 ET 必须搭配非阻塞 fd?

这不是接口的硬性要求,而是工程实践上的要求。一个非常典型的场景:

服务器收到一个 10KB 的请求,要向客户端返回应答。如果客户端收不到应答,不会发送第二个 10KB 请求。

假设服务端用阻塞式 read,且一次只读 1KB:

  1. 第一次epoll_wait返回可读,read 读了 1KB,缓冲区还剩 9KB;

  2. 由于是 ET 模式,内核不认为 fd 仍然"新就绪",epoll_wait不再返回;

  3. 剩下的 9KB 一直躺在缓冲区里;

  4. 但服务器逻辑是"读完 10KB 才给客户端响应";

  5. 客户端没收到响应,就不会发下一个请求;

  6. 没有新数据到来,epoll_wait永远不返回,死锁。

于是形成死循环式的互相等待:

  • 客户端:你要把 10KB 都读完给我响应,我才发下一个数据啊!

  • 服务器:客户端你快再发一个数据,我才能把剩下 9KB 读出来啊!

关键矛盾:ET 只给一次处理机会,但你用阻塞 read 又不敢一次读干净(因为阻塞 read 在没数据时会睡死),于是只能读一部分,剩下的数据再也等不到通知。

解决方案:用非阻塞 + 循环读的方式,保证一次把缓冲区里的数据读干净(读到EAGAIN/EWOULDBLOCK为止),这样就不依赖"下一次事件通知"。

而 LT 模式没有这个问题:只要缓冲区还有数据没读完,epoll_wait就会继续返回读就绪,程序总有机会把剩余数据读走。

结论:ET + 非阻塞是标配组合。ET 负责"少通知",非阻塞负责"一次读干净"。

epoll 的使用场景

epoll 的高性能是有特定场景的,选错场景可能适得其反。

  • 适合:多连接,且多连接中只有一部分比较活跃。典型如处理上万客户端的互联网 APP 入口服务器。

  • 不适合:系统内部服务器之间通信、只有少数几个连接的场景。这种场景用 epoll 并不合适,select/poll 甚至阻塞 IO 可能更简单。

一句话:epoll 赢在"连接多、活跃少",不是所有场景的无脑最优解。

epoll 中的惊群问题

epoll 中的惊群问题,简单说就是:多个进程或线程同时等在同一个 epoll 实例上,一个事件到来时,内核把“所有人”都叫醒了,但最终只有一个能真正干活,其他人白醒一场。

为什么会发生?

当多个进程通过fork继承了同一个监听 socket,并各自把该 socket 加入自己的 epoll 实例时,这个监听 socket 的等待队列上就挂了多个 epoll 实例。

默认情况下,这个等待队列是非排他的。一旦新连接到达,内核会唤醒等待队列上的所有epoll 实例,进而唤醒所有阻塞在epoll_wait上的进程。结果就是:所有进程都被调度起来,抢着去accept,但只有一个成功,其余进程拿不到连接,只能重新回去睡。CPU 时间就这么浪费在了无效的唤醒和上下文切换上。

怎么解决?

主要有三种思路,从内核到应用层都有方案:

1. 内核方案:EPOLLEXCLUSIVE(Linux 4.5+)
在epoll_ctl添加监听 fd 时带上EPOLLEXCLUSIVE标志。这样当事件发生时,内核只唤醒等待队列上的第一个(或少数几个)有线程在等的 epoll 实例,而不是全部。

2. 内核方案:SO_REUSEPORT
让每个 worker 进程自己bind同一个端口,各自拥有独立的监听 socket和 epoll 实例。内核在收到新连接时,负责把它均衡地分给其中一个 socket。因为大家不再共享同一个等待队列,从根源上就没有“抢同一个事件”的问题了。这是目前比较推荐的方案。

3. 应用层方案:Nginx 的 accept_mutex
这是 Nginx 早期的经典做法。用一个进程间的互斥锁,保证同一时刻只有一个 worker把监听 fd 加入自己的 epoll 里去等 accept。拿到锁的进程处理完 accept 事件(或超时)后释放锁,下一个进程再拿锁去监听。相当于在应用层把“并发抢”改成了“轮流等”,避免了无效唤醒。

在LT(水平触发)模式下,即使用了EPOLLEXCLUSIVE,也可能存在负载不均衡的问题:一个进程可能反复被唤醒并独占处理事件,其他进程一直闲着。而在ET(边缘触发)模式下,虽然惊群本身被缓解,但多个进程循环accept时,也可能出现“A 进程把连接都 accept 走了,B 进程被唤醒后却无活可干”的类惊群现象。所以实际工程中,往往结合SO_REUSEPORT或精细的锁策略来达到更好的均衡效果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询