Linux --从 poll 到 epoll多路转接

为什么要多路转接?

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

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

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

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

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

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

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

接口

cpp 复制代码
#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 数据可写 是

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

cpp 复制代码
#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
cpp 复制代码
int epoll_create(int size);

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

epoll_ctl
cpp 复制代码
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 定义:

cpp 复制代码
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 有紧急数据可读(带外数据)
EPOLLERR fd 发生错误
EPOLLHUP fd 被挂断
EPOLLET 设置为边缘触发(ET)模式
EPOLLONESHOT 只监听一次,之后需重新加入红黑树才能继续监听
epoll_wait
cpp 复制代码
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 高性能的关键,在于内核里的这两个结构:

cpp 复制代码
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 数量"有关。

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

用一张图概括:

cpp 复制代码
用户态                          内核态
------                          ------
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 数目无上限。

三者的对比总结

维度 select poll epoll
数据结构 三个位图 fd_set pollfd 数组 红黑树 + 就绪链表
最大 fd 数 FD_SETSIZE(通常 1024) 无上限 无上限
fd 拷贝 每次调用全量拷贝 每次调用全量拷贝 仅注册时拷贝一次
就绪查找 遍历位图 O(n) 遍历数组 O(n) 直接取就绪链表 O(1)
触发模式 LT LT LT / 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 或精细的锁策略来达到更好的均衡效果。

相关推荐
青瓦梦滋1 小时前
WebSocket协议
linux·服务器·网络·c++·websocket·网络协议·http
阿狸猿1 小时前
论零信任安全架构在信息系统中的应用
1024程序员节
桌面运维家2 小时前
旧电脑跑不动课程软件,VDI云桌面怎样接着用?
运维·服务器·虚拟化·pve·vdi·云桌面
zhang133830890752 小时前
传统机械三杯式风速传感器易磨损冻堵?CG-88金属超声波风速风向传感器给出免运维方案
运维·服务器·网络·人工智能·自动化
念何架构之路2 小时前
Go reflect 场景实战
1024程序员节
知已难寻2 小时前
第 2 篇 · 云的地基:服务器、存储、网络三大基础设施
运维·服务器·网络·云原生·云计算·负载均衡·1024程序员节
初阳7852 小时前
【基于Qt5仿音乐播放器(六)】——细节打磨与总结
qt·1024程序员节
信誓旦旦的程序猿4 小时前
【Python量化实战宝典 #26】止损与回撤控制手册:移动止损、最大回撤(Python 可跑)
python·1024程序员节·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
2601_949847754 小时前
bcrypt.js 跨端三方库仓颉适配全流程
linux·运维·ubuntu