多路转接之poll/epoll

1. poll

1.1 函数接口

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明

  • fds:指向 pollfd 结构体数组的指针。每个 pollfd 结构体描述一个文件描述符和要监视的事件。

    • 结构体定义:

      struct pollfd {
          int fd;         // 文件描述符
          short events;   // 感兴趣的事件(如 POLLIN 表示可读)
          short revents;  // 实际发生的事件(由 poll 返回时填充)
      };
      
    • 如果fd设为-1,则什么都不干,否则进行监听。

  • nfdsfds 数组中的元素数量,即监视的文件描述符个数。

  • timeout:超时时间,单位为毫秒。

    • -1:无限等待,直到有事件发生。
    • 0:非阻塞模式,立即返回。
    • 正数:等待指定毫秒后超时返回。

常用事件(events/revents)

  • POLLIN:数据可读(如套接字收到数据)。
  • POLLRDNORM:普通数据可读。
  • POLLRDBAND:优先级带数据可读(Linux不支持)。
  • POLLPRI:高优先级数据可读,如部分TCP带外数据。
  • POLLOUT:可写(如套接字发送缓冲区空闲)。
  • POLLWRNORM:普通数据可写。
  • POLLWRBAND:优先级带数据可写(Linux不支持)。
  • POLLRDHUP:TCP连接被对方关闭,或对方关闭了写操作。
  • POLLERR:发生错误。
  • POLLHUP:挂起(如管道写端关闭)。
  • POLLNVAL:无效的文件描述符。

返回值

  • 成功:返回就绪的文件描述符数量(即 revents 非零的 pollfd 个数)。
  • 超时:返回0。
  • 错误:返回 -1,并设置 errno

1.2 poll的优点

  • pollfd 结构包含了要监视的 event 和发生的 event,不再使用 select“参数-值”传递的方式,接口使用比 select 更方便。
  • poll 并没有最大数量限制(但是数量过大后性能也是会下降)。

1.3 poll的缺点

poll中监听的文件描述符数目增多时:

  • 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。
  • 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中。
  • 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

1.4 poll示例

#include <iostream>
#include <unistd.h>
#include <poll.h>

using namespace std;

int main() {
    struct pollfd poll_fd;
    poll_fd.fd = 0;
    poll_fd.events = POLLIN;

    while (true) {
        cout << "> " << flush;
        timeval tv;
        tv.tv_sec = 1;
        tv.tv_usec = 0;
        int ret = poll(&poll_fd, 1, 1000);
        if (ret < 0) {
            cerr << "select error" << endl;
            continue;
        }
        else if (ret == 0) {
            cerr << "select timeout" << endl;
            continue;
        }
        else {
            string s;
            cin >> s;
            cout << "input: " << s << endl;
        }
    }

    return 0;
}

2. epoll

2.1 epoll初识

按照 man 手册的说法: 是为处理大批量句柄而作了改进的 poll。

它是在 2.5.44 内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44),它几乎具备了之前所说的一切优点, 被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法。

2.2 epoll的相关系统调用

使用下面接口时,需要包含头文件#include <sys/epoll.h>

2.2.1 epoll_create
int epoll_create(int size);

**功能:**创建一个epoll句柄。

参数:

  • size:建议的监听数量(Linux 2.6.8 后已忽略,但需 > 0)。

**返回值:**epoll 文件描述符(用于后续操作)。

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

**功能:**注册/修改/删除事件。

参数:

  • epfd:epoll 实例的文件描述符。
  • op:操作类型
    • EPOLL_CTL_ADD:添加新的fd到epfd中。
    • EPOLL_CTL_MOD:修改已经添加的监听事件。
    • EPOLL_CTL_DEL:删除epfd中的一个fd。
  • fd:要操作的文件描述符。
  • event:事件结构体

事件结构体:

struct epoll_event {
    uint32_t events;      // 事件类型(如 EPOLLIN)
    union {
        void *ptr;        // 用户数据指针
        int fd;           // 文件描述符
        uint32_t u32;
        uint64_t u64;
    } data;
};

events可以是下面几个宏的组合:

  • EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭)
  • EPOLLOUT : 表示对应的文件描述符可以写。
  • EPOLLPRI : 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
  • EPOLLERR : 表示对应的文件描述符发生错误。
  • EPOLLHUP : 表示对应的文件描述符被挂断。
  • EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于水触发(Level Triggered)来说的。
  • EPOLLONESHOT: 只监听一次事件, 当监听完这次事件之后,如果还需要继续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里。
2.2.3 epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

**功能:**等待事件就绪。

参数:

  • epfd:epoll 实例
  • events:输出参数,存储就绪事件的数组
  • maxevents:数组大小
  • timeout:超时时间(毫秒,-1 无限等待,0 非阻塞)

返回值:

  • **正数:**就绪事件数量。
  • **0:**超时。
  • **负数:**出错。

2.3 epoll 的工作原理

在这里插入图片描述

  • 当某一进程调用 epoll_create 方法时, Linux 内核会创建一个 eventpoll 结构体, 这个结构体中有两个成员与 epoll 的使用方式密切相关。

    struct eventpoll{
        ....
        /*红黑树的根节点, 这颗树中存储着所有添加到 epoll 中的需要监控的事件*/
        struct rb_root rbr;
        /*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/
        struct list_head rdlist;
        ....
    };
    
  • 每一个 epoll 对象都有一个独立的 eventpoll 结构体, 用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件。

  • 这些事件都会挂载在红黑树中, 如此, 重复添加的事件就可以通过红黑树而高效的识别出来。

  • 而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系, 也就是说,当响应的事件发生时会调用这个回调方法。

  • 这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdlist 双链表中。

  • 在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体。

    struct epitem{
        struct rb_node rbn;//红黑树节点
        struct list_head rdllink;//双向链表节点
        struct epoll_filefd ffd; //事件句柄信息
        struct eventpoll *ep; //指向其所属的 eventpoll 对象
        struct epoll_event event; //期待发生的事件类型
    }
    
  • 当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的rdlist 双链表中是否有 epitem 元素即可。

  • 如果 rdlist 不为空, 则把发生的事件复制到用户态, 同时将事件数量返回给用户,这个操作的时间复杂度是 O(1)

总结使用epoll的三部曲:

  • 调用epoll_create创建一个epoll句柄。
  • 调用epoll_ctl,将要监控的文件描述符进行注册。
  • 调用epoll_wait,等待文件描述符就绪。

2.5 epoll 的优点

  • 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开。
  • 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD将文件描述符结构拷贝到内核中,这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝)。
  • 事件回调机制:避免使用遍历:而是使用回调函数的方式:将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度 O(1). 即使文件描述符数目很多, 效率也不会受到影响。
  • 没有数量限制: 文件描述符数目无上限

2.6 epoll的工作方式

epoll 有 2 种工作方式:水平触发(LT)和边缘触发(ET)。

场景举例:

  1. 我们已经把一个 tcp socket 添加到 epoll 描述符。
  2. 这个时候 socket 的另一端被写入了 2KB 的数据。
  3. 调用 epoll_wait, 并且它会返回. 说明它已经准备好读取操作。
  4. 然后调用 read, 只读取了 1KB 的数据。
  5. 然后继续调用 epoll_wait…
2.6.1 水平触发 Level Triggered 工作模式
  • 当 epoll 检测到 socket 上事件就绪的时候, 可以不立刻进行处理,或者只处理一部分。
  • 如上面的例子,由于只读了 1K 数据,缓冲区中还剩 1K 数据,在第二次调用 epoll_wait 时,epoll_wait 仍然会立刻返回并通知 socket 读事件就绪。
  • 直到缓冲区上所有的数据都被处理完,epoll_wait 才不会立刻返回。
  • 支持阻塞读写和非阻塞读写。
2.6.2 边缘触发 Edge Triggered 工作模式
  • 当 epoll 检测到 socket 上事件就绪时,必须立刻处理。
  • 如上面的例子,虽然只读了 1K 的数据,缓冲区还剩 1K 的数据,在第二次调用epoll_wait 的时候,epoll_wait 不会再返回了。
  • 也就是说,ET 模式下,文件描述符上的事件就绪后,只有一次处理机会。
  • ET 的性能比 LT 性能更高(epoll_wait 返回的次数少了很多),Nginx 默认采用 ET 模式使用 epoll。
  • 只支持非阻塞的读写。

select 和 poll 其实也是工作在 LT 模式下,epoll 既可以支持 LT,也可以支持 ET。

2.6.3 对比LT和ET
  • LT 是 epoll 的默认行为。

  • 使用 ET 能够减少 epoll 触发的次数,但是代价就是强逼着程序员一次响应就绪过程中就把所有的数据都处理完。

  • 另外的,TCP连接中,ET 模式下,由于会一次把缓冲区中的效率读完,然后返回整个缓冲区的窗口大小给对方,让对方能够一次发送更多的数据,提高IO效率。

  • 相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比 LT 更高效一些,但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的。

  • 另一方面,ET的代码复杂度更高了。

2.6.4 理解ET模式和非阻塞文件描述符

使用 ET 模式的 epoll,需要将文件描述设置为非阻塞,这个不是接口上的要求,而是“工程实践”上的要求。

假设这样的场景:服务器接收到一个 10k 的请求,会向客户端返回一个应答数据,如果客户端收不到应答,不会发送第二个 10k 请求。

在这里插入图片描述

如果服务端写的代码是阻塞式的 read,并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的 9k 数据就会待在缓冲区中。

在这里插入图片描述

此时由于 epoll 是 ET 模式,并不会认为文件描述符读就绪,epoll_wait 就不会再次返回,剩下的 9k 数据会一直在缓冲区中,直到下一次客户端再给服务器写数据epoll_wait 才能返回。

所以,为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完),于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来。

而如果是 LT 没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪。

2.7 epoll 的使用场景

epoll 的高性能,是有一定的特定场景的,如果场景选择的不适宜,epoll 的性能可能适得其反。

  • 对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。

2.8 epoll 的惊群问题

惊群问题(Thundering Herd Problem)是指在多进程/多线程环境下,当多个进程/线程同时等待同一个事件(如socket可读)时,当事件发生时,操作系统会唤醒所有等待的进程/线程,但最终只有一个能成功处理该事件,其他进程/线程会被系统调用返回错误(如EAGAIN),造成不必要的上下文切换和CPU资源浪费。

多进程共享epoll示例:

// 父进程创建epoll实例
int epoll_fd = epoll_create1(0);

// 多个子进程共享同一个epoll_fd
for (int i = 0; i < 4; i++) {
    if (fork() == 0) {
        // 子进程监听相同epoll实例
        epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        // 处理事件...
    }
}
// 多个线程等待同一个epoll实例
void* worker_thread(void* arg) {
    int epoll_fd = *(int*)arg;
    struct epoll_event events[MAX_EVENTS];

    while (1) {
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        // 多个线程可能同时被唤醒
        for (int i = 0; i < n; i++) {
            // 处理事件...
        }
    }
}

惊群问题的影响:

  1. CPU资源浪费:不必要的进程/线程唤醒
  2. 性能下降:频繁的上下文切换
  3. 锁竞争加剧:多个进程/线程同时尝试获取资源锁
  4. 系统调用开销:大量EAGAIN错误返回

解决方案:

  1. 使用EPOLL_EXCLUSIVE标志(Linux 4.5+)

    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLEXCLUSIVE;
    ev.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
    
    • 原理:确保只有一个等待的进程/线程被唤醒

    • 适用场景:多进程环境

  2. Accept锁机制

    // 使用文件锁或互斥锁
    pthread_mutex_t accept_mutex = PTHREAD_MUTEX_INITIALIZER;
    
    void handle_connection(int epoll_fd) {
        pthread_mutex_lock(&accept_mutex);
        int conn_fd = accept(listen_fd, NULL, NULL);
        pthread_mutex_unlock(&accept_mutex);
    
        if (conn_fd > 0) {
            // 添加到epoll
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = conn_fd;
            epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
        }
    }
    
  3. 独立epoll实例

    // 每个进程/线程拥有独立的epoll实例
    for (int i = 0; i < num_workers; i++) {
        if (fork() == 0) {
            int epoll_fd = epoll_create1(0);
            // 每个进程只监听自己的socket
            // ...
        }
    }
    
  4. SO_REUSEPORT(Linux 3.9+)

    // 每个进程绑定到同一个端口
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
    
    // 多个进程独立accept
    bind(listen_fd, &addr, sizeof(addr));
    listen(listen_fd, backlog);
    
  5. 使用EPOLLONESHOT标志

    // 单次触发模式,处理完需重新注册
    ev.events = EPOLLIN | EPOLLONESHOT;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev);
    
    // 处理完后重新注册
    epoll_ctl(epoll_fd, EPOLL_CTL_MOD, fd, &ev);
    
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐