Linux I/O多路复用:深入浅出poll与epoll

一、poll系统调用

poll是System V引入的I/O多路复用函数,它克服了select的一些限制(如文件描述符数量上限)。poll通过一个结构体数组来监视多个文件描述符的事件。

1. 函数原型

复制代码
#include <poll.h>

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

    • fds:指向struct pollfd数组的指针,每个元素描述一个待监视的文件描述符及其感兴趣的事件。

    • nfdsfds数组中的元素个数。

    • timeout:超时时间(毫秒)。

      • -1:阻塞直到有事件发生;

      • 0:立即返回,不阻塞;

      • >0:等待指定的毫秒数。

  • 返回值

    • 成功:返回就绪(有事件发生)的文件描述符个数。

    • 超时:返回0。

    • 出错:返回-1,并设置errno。

struct pollfd定义如下:

复制代码
struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 等待的事件掩码 */
    short revents;    /* 实际发生的事件掩码 */
};

eventsrevents是由以下标志按位或组成的位掩码:

  • POLLIN:有数据可读。

  • POLLOUT:可写数据。

  • POLLERR:发生错误(仅输出)。

  • POLLHUP:挂起(仅输出)。

  • 等等。

2. 使用示例

复制代码
struct pollfd fds[2];
fds[0].fd = sockfd;
fds[0].events = POLLIN;
fds[1].fd = STDIN_FILENO;
fds[1].events = POLLIN;

int ret = poll(fds, 2, 5000); // 等待5秒
if (ret > 0) {
    for (int i = 0; i < 2; i++) {
        if (fds[i].revents & POLLIN) {
            // 处理该文件描述符的读事件
        }
    }
} else if (ret == 0) {
    // 超时处理
} else {
    perror("poll");
}

3. poll的特点与局限性

  • 优点

    • 没有最大文件描述符数量的限制(基于链表存储,受系统内存约束)。

    • 接口相对简单,支持多种事件类型。

  • 缺点

    • 每次调用都需要将pollfd数组从用户态拷贝到内核态,当监视大量文件描述符时开销较大。

    • 内核检测到事件后,仍需遍历整个数组以查找哪些描述符就绪(线性扫描),时间复杂度O(n)。

    • 无法动态修改监视的描述符集合(需要重新组织数组并调用poll)。

    • 只能工作在水平触发模式(Level-Triggered, LT),即只要文件描述符处于就绪状态,每次poll都会报告该事件。

二、epoll:Linux特有的高性能I/O事件通知机制

epoll是Linux内核为处理大批量文件描述符而引入的增强版I/O多路复用接口,它解决了poll和select的性能瓶颈。epoll通过内核事件表、回调机制和内存映射等技术,实现了高效的I/O事件通知。

1. 核心函数

epoll提供三个系统调用:

(1) epoll_create
复制代码
#include <sys/epoll.h>

int epoll_create(int size);
  • 功能:创建一个epoll实例,返回一个指向内核事件表的文件描述符(称为epfd)。

  • 参数size提示内核事件表的大小(Linux 2.6.8之后被忽略,但必须大于0)。

  • 返回值:成功返回新的文件描述符,失败返回-1。

(2) epoll_ctl
复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 功能:对内核事件表进行控制:添加、修改或删除一个监视的文件描述符。

  • 参数

    • epfd:epoll实例的文件描述符。

    • op:操作类型,可取:

      • EPOLL_CTL_ADD:添加fd到事件表。

      • EPOLL_CTL_MOD:修改fd上已注册的事件。

      • EPOLL_CTL_DEL:从事件表中删除fd。

    • fd:要操作的文件描述符。

    • event:指向struct epoll_event的指针,描述感兴趣的事件和用户数据。

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_data_t data;     /* 用户数据 */
};

events可以是以下宏的按位或:

  • EPOLLIN:可读。

  • EPOLLOUT:可写。

  • EPOLLRDHUP:流套接字对端关闭连接。

  • EPOLLPRI:有紧急数据可读。

  • EPOLLERR:发生错误(自动设置,无需手动注册)。

  • EPOLLHUP:挂起(自动设置)。

  • EPOLLET:设置为边缘触发模式(Edge-Triggered, ET)。

  • EPOLLONESHOT:事件只触发一次,触发后需要重新注册。

(3) epoll_wait
复制代码
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 功能:等待事件表中的文件描述符产生事件。

  • 参数

    • epfd:epoll实例的文件描述符。

    • events:用户提供的数组,用于存放内核返回的就绪事件。

    • maxeventsevents数组的大小(即最多返回多少个事件)。

    • timeout:超时时间(毫秒),语义与poll相同。

  • 返回值:成功返回就绪事件个数;超时返回0;失败返回-1。

2. 使用示例(服务器监听socket)

复制代码
int epfd = epoll_create(1);
struct epoll_event ev, events[10];
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev);

while (1) {
    int nfds = epoll_wait(epfd, events, 10, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == listen_sock) {
            // 处理新连接
            int conn = accept(listen_sock, ...);
            ev.events = EPOLLIN | EPOLLET; // 边缘触发
            ev.data.fd = conn;
            epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
        } else {
            // 处理已连接套接字的读写
        }
    }
}

3. epoll的工作模式:水平触发与边缘触发

  • 水平触发(LT,Level-Triggered):默认模式。当文件描述符就绪时,epoll_wait会返回该事件,如果程序没有一次性处理完所有数据,下一次调用epoll_wait会再次报告该事件,直到数据被处理完。这种模式编程简单,不容易遗漏事件,但可能重复触发。

  • 边缘触发(ET,Edge-Triggered) :需要设置EPOLLET标志。当文件描述符从未就绪变为就绪时,epoll_wait仅返回一次该事件。如果程序没有处理完所有数据,后续不再通知,除非描述符再次出现新的状态变化。ET模式要求程序员必须一次性将数据全部读取或写入(通常使用非阻塞I/O循环处理),否则可能造成数据丢失或饥饿。ET模式效率更高,减少了epoll的重复触发次数,但编程复杂度较高。

4. epoll的优势

  • 无文件描述符数量上限:epoll监视的描述符数量只受系统内存限制。

  • 事件驱动,避免线性扫描:内核通过回调机制将就绪的描述符加入就绪队列,epoll_wait直接返回就绪队列,时间复杂度O(1)(仅返回就绪个数)。

  • 内存映射减少拷贝:epoll使用mmap在内核和用户空间共享事件表,避免了用户态到内核态的数据拷贝(事件注册时仍需拷贝,但相比poll的每次全量拷贝要少)。

  • 支持边缘触发,在高并发场景下可进一步减少系统调用次数。

  • 可修改监视事件:通过epoll_ctl动态添加、删除、修改监视的描述符,无需重新构建整个集合。

三、select、poll、epoll对比总结

特性 select poll epoll
底层数据结构 位数组(fd_set) pollfd数组(链表) 红黑树+就绪链表
最大连接数 有限(通常1024) 无上限(受内存限制) 无上限(受内存限制)
事件集合拷贝 每次调用都从用户态拷贝到内核态 每次调用都从用户态拷贝到内核态 使用epoll_ctl注册,通过mmap共享,减少拷贝
查找就绪描述符方式 线性遍历所有fd 线性遍历所有fd 直接返回就绪队列,无需遍历
工作模式 仅LT 仅LT 支持LT和ET
修改监视集 需要重新构造fd_set并重调select 需要重新组织pollfd数组并重调poll 使用epoll_ctl动态增删改,无需重建
时间复杂度(获取就绪fd) O(n) O(n) O(1)(就绪个数)
可移植性 广泛支持(POSIX) 广泛支持(POSIX) Linux特有

四、适用场景建议

  • select:适用于连接数较少(<1024)且对可移植性要求高的场景,代码简单。

  • poll:相比select没有最大连接数限制,但仍有线性遍历开销,适合中等规模的连接数(几千以内)。

  • epoll:高并发服务器(如C10K问题)的首选,尤其是当连接数巨大且活动连接比例较低时,ET模式能最大化性能。但需注意epoll是Linux专属,跨平台需考虑替代方案(如libevent、libuv等封装库)。

相关推荐
雾岛听蓝2 小时前
Linux文件系统:从硬件到软硬链接
linux·经验分享·笔记
HalvmånEver2 小时前
Linux:初始网络(上)
linux·网络·学习·通信
REDcker2 小时前
Linux C++ 内存泄漏排查分析手册
java·linux·c++
切糕师学AI2 小时前
Kubernetes Operator 详解
运维·分布式·云原生·容器·kubernetes·自动化·运维自动化
Hello World . .2 小时前
Linux:网络编程-基于HTTP协议的天气预报查询系统开发详解
linux·网络·http
哈哈很哈哈2 小时前
逻辑回归Logistic Regression
算法·机器学习·逻辑回归
软件资深者2 小时前
macOS Tahoe 26.3.1 ISO 虚拟机专用镜像:win系统/ESXi 服务器装苹果系统,改个后缀就能用
运维·服务器·macos·镜像·虚拟机
甄心爱学习2 小时前
【极大似然估计/最大化后验】为什么逻辑回归要使用交叉熵损失函数
算法·机器学习·逻辑回归
艾莉丝努力练剑3 小时前
【Linux进程间通信:共享内存】为什么共享内存的 key 值由用户设置
java·linux·运维·服务器·开发语言·数据库·mysql