poll select epoll

10 阅读8分钟

我们先来区分下以下概念:

  • POSIX(/ˈpɒz.ɪks/):Portable Operating System Interface,可移植操作系统接口。是“计算机操作系统接口的标准”的总称,可以理解为它是软件世界的“普通话”。为了保证不同操作系统之间能顺畅地互相‘听懂’对方说话(兼容性),POSIX 这个标准详细规定了三大块内容:API、命令行 shell、shell 命令。
  • Unix 是一种多用户、多任务的计算机操作系统‌,1969 年由美国贝尔实验室开发,广泛应用于服务器、工作站及超级计算机领域。
  • Unix-like 类Unix系统是指继承 Unix 设计风格演变而来的操作系统统称,遵循 POSIX 规范且不包含Unix源代码。
  • Linux 是一种免费使用和自由传播的“类Unix”操作系统。
  • Solaris 是由Sun Microsystems公司开发的计算机操作系统,最初基于BSD UNIX开发,后被认定为UNIX衍生版本之一。
  • BSD 伯克利软件套件(Berkeley Software Distribution,简称BSD)是Unix的衍生系统,“BSD”并不特指任何一个BSD衍生版本,而是类UNIX操作系统中的一个分支的总称。
  • FreeBSD 是基于 BSD、386BSD 和 4.4BSD 发展而来的类 UNIX 操作系统

select、poll、epoll overview

select、poll、epoll 都是 IO 多路复用技术。他们的目标一致:让单个线程能够同时监控成千上万个连接。Select 和 poll 是较旧的方法,具有 O(n)O(n) 的时间复杂度,而 epoll 是特定于 Linux 的高性能 API,通过仅监控活动描述符来提供扩展性。

简单了解 select 和 poll

select 是类 Unix 及 POSIX 兼容系统中的系统调用,用于监视文件描述符(FD)的状态变化,其增强版是 pselect。它和 Unix 系统的 poll 类似,同属传统 I/O 多路复用方案。

但是由于 C10k 问题,在高并发场景下,selectpoll 都被 kqueue(BSD/macOS)、/dev/poll(Solaris)、epoll(Linux)、I/O completion ports(Windows)代替了。

select 和 poll 存在的问题

  1. 每调用一次 select 或者 poll 方法,内核必须检查所有的文件描述符是否就绪。假设你有 1 万个连接,只有 1 个发了数据,poll 会把这 1 万个连接全部线性遍历一遍,确认谁就绪了。这种 O(n)O(n) 的时间复杂度让性能随着连接数增加而雪崩。
  2. 每次调用 select/poll,你都得把这 1 万个文件描述符从用户态拷贝到内核态,内核查完了再拷贝回来。这内存开销极其感人。
  3. select 默认限制只能监听 1024 个 FD(虽然能改,但性能会更烂), poll没有 1024 个 FD 的限制。

简单了解 epoll

epoll 是 Linux 内核的中用于可扩展IO事件通知机制的系统调用,首次在2002年10月发布的 Linux 2.5.45 版本中引入。它的功能是监视多个文件描述符,以查看是否有可以就绪的I/O。

它旨在取代旧的 POSIX selectpoll 系统调用。旧的系统调用需要在 O(n) 时间,而 epoll 需要 O(1) 时间。

epoll 类似于 FreeBSD 的 kqueue,因为它由一组用户空间函数组成,每个函数都有一个文件描述符参数,表示可配置的内核对象,它们协同操作。epoll使用红黑树(RB树)数据结构来跟踪当前正在监视的所有文件描述符。

epoll 的3个系统调用

epoll 包含3个系统调用:epoll_create、epoll_ctl、epoll_wait

  • epoll_create: 创建一个 epoll 实例,向内核申请一个 epoll 句柄(返回一个文件描述符),用于后续的事件管理。
  • epoll_ctl:向 epoll 实例中注册、修改或移除所关注的文件描述符及其感兴趣的事件(如可读、可写)。
  • epoll_wait:阻塞等待已注册的文件描述符上发生就绪事件,并返回就绪事件列表,供应用程序处理。

下面是一个简单的使用示例。

#include <stdio.h>
#include <sys/epoll.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define MAX_EVENTS 10
#define PORT 8080

// 工具函数:将 socket 设置为非阻塞
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}


int main() {
    // 1. 创建监听 socket (对应 Reactor 中的 Acceptor 监听的基础)
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(listen_fd);

    struct sockaddr_in addr = { 
        .sin_family = AF_INET, 
        .sin_port = htons(PORT), 
        .sin_addr.s_addr = INADDR_ANY 
    };
    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, 5);

    // 2. 创建 epoll 实例 (创建红黑树和就绪链表)
    int epoll_fd = epoll_create1(0); 

    // 3. 将监听 socket 加入 epoll (丢入红黑树)
    struct epoll_event event, events[MAX_EVENTS];
    event.events = EPOLLIN; // 监听读事件
    event.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event);

    printf("Reactor 引擎已启动,监听端口: %d...\n", PORT);

    while (1) {
        // 4. 等待事件发生 (对应 Reactor 的事件分发核心)
        // 此处 $O(1)$ 效率,仅返回活跃的连接 
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 如果是监听 socket 有变动,说明有新连接 (Acceptor 工作)
                int conn_fd = accept(listen_fd, NULL, NULL);
                event.events = EPOLLIN | EPOLLET; // 使用 ET (边缘触发) 模式 
                event.data.fd = conn_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &event);
                printf("新客到访 (FD: %d)\n", conn_fd);
            } else {
                // 如果是普通连接,说明有数据发来 (Handler 工作)
                char buf[1024];
                read(events[i].data.fd, buf, sizeof(buf));
                printf("收到消息: %s\n", buf);
            }
        }
    }
    close(listen_fd);
    return 0;
}

  1. 调用 socket 方法时,内核创建了一系列 socket 相关的对象。最终会返回一个句柄(按照上面的代码是 listen_fd ),通过这个句柄我们可以访问到这个 socket 对象。(socket对象中有一个等待队列,用于记录“哪些进程或模块在等待这个socket的事件”)
  2. 在用户进程调用 epoll_create 的时候,内核会创建一个 struct eventpoll 的内核对象,并且返回一个 epoll 句柄(或者说文件描述符,按照上面的代码是记为 epoll_fd ),我们以后就可以通过这个句柄来访问 eventpoll 对象。 eventpoll 中有几个关键成员:① wq:等待队列链表 ② rbr:红黑树 ③ rdllist:就绪的描述符的链表。
    struct eventpoll {
        wait_queue_head_t wq;
        struct list_head rdllist;
        struct rb_root rbr;
        //...
    }
    
  3. 使用 epoll_ctl 注册每一个 socket 的时候,内核会做下面三件事情:
    ① 分配一个红黑树节点对象 epitem(epitem 结构中有一个等待队列)
    ② 将等待项添加到 socket 的等待队列中,并注册一个回调函数。 这步的目的是建立事件通知机制。(这里等待项指的是 wait_queue_entry,注册的回调函数是 ep_poll_callback)
    ③ 将 epitem 插入 eventpoll 对象的红黑树
    // epitem 的数据结构
    struct epitem {
        struct rb_node rbn;       // 红黑树节点
        struct epoll_filefd ffd;  // socket 文件描述符信息
        struct eventpoll *ep;     // 所归属的 eventpoll 对象
        struct list_head pwqlist; // 等待队列
    }
    
    // "等待队列项"
    struct wait_queue_entry {
        unsigned int flags;
        void *private;          // 这里指向 epitem!
        wait_queue_func_t func; // 回调函数指针 → ep_poll_callback
        struct list_head entry; // 链表节点
    };
    
    一些注意点:
    • 等待队列是 socket 的:每个socket都有自己的等待队列,类似每个快递站有自己的通知名单
    • 添加的是"等待项":epoll添加的是一个包含回调函数指针和数据指针的等待项
    • 回调是通用的:所有socket都使用同一个ep_poll_callback,但通过private指针知道是为哪个epitem服务
    • 事件驱动核心:内核在有事件时主动调用回调,而不是进程去轮询检查
  4. 使用 epoll_wait 阻塞进程等待事件发生。 调用 epoll_wait 时,如果就绪链表为空,进程进入睡眠状态,当网络数据到达、socket 状态变化等,内核回调 ep_poll_callback,将对应的 epitem 加入就绪链表,并唤醒在 epoll_wait中睡眠的进程。被唤醒的进程从就绪链表中批量取出事件,复制到用户空间的 events 数组。

为什么 epoll 快呢?

  1. 红黑树 (Red-Black Tree):内核用红黑树管理所有连接。增加、删除连接的复杂度是 O(logn)O(\log n),非常稳定。
  2. 回调机制 (Callback):内核不再死等遍历。当网卡收到数据,会触发一个中断,内核直接把对应的 FD 挂到“就绪链表”里。
  3. 就绪链表 (Ready List):epoll_wait 返回的只有“确实有事”的连接。处理效率是 O(1)O(1),连接数从 1 千增加到 1 百万,性能几乎不下降。

epoll 的触发模式

epoll 提供了两种触发模式: ET 和 LT 模式。在 ET 模式中,调用 epoll_wait 只会在新事件进入epoll实例中的时候才会返回(即如果你没一次性把数据全部读走,剩余的数据不会再次触发通知)。而在 LT 模式中,只要条件满足了, epoll_wait 就会返回(只要文件描述符处于“可读 / 可写”状态,epoll_wait()就会不断返回,即使你没把数据一次性读完,下次调用 epoll_wait()仍然会立刻通知你)。

ET 模式(edge-triggered mode):边缘触发、边沿触发
LT 模式(level-triggered mode):水平触发、电平触发、条件触发

LT 模式是默认模式,它的鲁棒性(容错性)极强,编程模型简单,适合绝大多数通用场景。Redis、Java NIO/Netty、Node.js 使用 LT 模式。

ET 模式对开发者要求极高,但它能显著减少 epoll_wait 的调用次数,被公认为追求极致吞吐量的首选。Nginx、Go 网络调度器 使用 ET 模式。


参考:

  1. en.wikipedia.org/wiki/Select…
  2. en.wikipedia.org/wiki/Epoll
  3. jvns.ca/blog/2017/0…
  4. 张彦飞《深入理解Linux网络》
  5. 《The Linux Programming Interface》