一、先理解核心概念:IO 多路复用

IO 多路复用(I/O Multiplexing)是一种让单个进程 / 线程可以同时监听多个文件描述符(File Descriptor,FD)的技术,当其中某些 FD 就绪(可读 / 可写 / 异常)时,系统会通知进程去处理对应的 IO 操作。

通俗比喻:你是一个餐厅老板(进程),有多个餐桌(FD)。

  • 非多路复用:你站在 1 号桌等客人点餐,等完再去 2 号桌,效率极低;
  • 多路复用:你在前台(内核)盯着所有餐桌,哪个餐桌的客人举手(FD 就绪),你再过去服务,效率大幅提升。

它主要解决 “高并发场景下,单线程 / 进程处理大量 IO 请求” 的问题,是 Nginx、Redis 等高性能中间件的核心底层技术之一。

二、select/poll/epoll 对比

三者都是 IO 多路复用的实现,epoll 是 Linux 下的优化版本,select/poll 是跨平台的(但效率较低)。

特性selectpollepoll(Linux 2.6+)
FD 数量限制受限于FD_SETSIZE(默认 1024)无硬限制(受系统资源)无硬限制(仅受内存)
数据结构位图(fd_set)数组(pollfd 结构体)红黑树 + 就绪链表
效率轮询所有 FD(O (n))轮询所有 FD(O (n))只处理就绪 FD(O (1))
重复拷贝每次调用拷贝所有 FD 到内核每次调用拷贝所有 FD 到内核仅初始化时拷贝,后续复用
触发方式水平触发(LT)水平触发(LT)水平触发(LT)/ 边缘触发(ET)
关键术语解释:
  • 水平触发(LT):只要 FD 处于就绪状态,就会一直通知(比如数据没读完,会反复提醒),容错性高,新手友好;
  • 边缘触发(ET):仅当 FD 状态从 “未就绪” 变为 “就绪” 时通知一次,需要一次性读完 / 写完数据,效率更高,但编程要求更严格。

三、实际使用示例

1. select 示例(跨平台,以 Linux 为例)

c

运行

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8888
#define MAX_FD 1024

int main() {
    // 1. 创建监听socket
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket error");
        exit(1);
    }

    // 2. 绑定端口
    struct sockaddr_in serv_addr;
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = INADDR_ANY;
    serv_addr.sin_port = htons(PORT);
    if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind error");
        exit(1);
    }

    // 3. 开始监听
    if (listen(listen_fd, 5) < 0) {
        perror("listen error");
        exit(1);
    }

    fd_set read_fds, tmp_fds;
    FD_ZERO(&read_fds);       // 清空集合
    FD_SET(listen_fd, &read_fds);  // 将监听FD加入集合
    int max_fd = listen_fd;   // 记录最大FD(select需要)

    while (1) {
        tmp_fds = read_fds;   // select会修改集合,所以每次要拷贝
        // 4. 调用select,监听读事件
        int nfds = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);
        if (nfds < 0) {
            perror("select error");
            exit(1);
        }

        // 5. 遍历所有FD,检查哪个就绪
        for (int fd = 0; fd <= max_fd; fd++) {
            if (!FD_ISSET(fd, &tmp_fds)) continue;  // 该FD未就绪,跳过

            // 6. 监听FD就绪:有新连接
            if (fd == listen_fd) {
                struct sockaddr_in cli_addr;
                socklen_t cli_len = sizeof(cli_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
                if (conn_fd < 0) {
                    perror("accept error");
                    continue;
                }
                printf("new client: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
                FD_SET(conn_fd, &read_fds);  // 新连接FD加入监听集合
                if (conn_fd > max_fd) max_fd = conn_fd;  // 更新最大FD
            } 
            // 7. 普通FD就绪:有数据可读
            else {
                char buf[1024] = {0};
                int n = read(fd, buf, sizeof(buf)-1);
                if (n <= 0) {  // 客户端断开或读错误
                    close(fd);
                    FD_CLR(fd, &read_fds);  // 从集合中移除
                    printf("client %d closed\n", fd);
                } else {
                    printf("recv from %d: %s\n", fd, buf);
                    write(fd, buf, n);  // 回显数据
                }
            }
        }
    }

    close(listen_fd);
    return 0;
}

代码说明

  • fd_set 是 select 的核心数据结构(位图),FD_ZERO/SET/ISSET/CLR 是操作位图的宏;
  • select 第一个参数是最大FD+1,后面三个参数分别监听 “读 / 写 / 异常” 事件,最后一个参数是超时时间(NULL 表示阻塞);
  • 每次调用 select 前要拷贝read_fdstmp_fds,因为 select 会修改传入的集合,只保留就绪的 FD。
2. poll 示例(解决 select 的 FD 数量限制)

c

运行

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8888
#define MAX_POLL_FD 10240  // 无硬限制,可设更大

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); exit(1); }

    struct sockaddr_in serv_addr = {0};
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = INADDR_ANY;
    serv_addr.sin_port = htons(PORT);
    if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) { perror("bind"); exit(1); }
    if (listen(listen_fd, 5) < 0) { perror("listen"); exit(1); }

    // 1. 定义pollfd数组(核心区别:替代select的位图)
    struct pollfd fds[MAX_POLL_FD] = {0};
    fds[listen_fd].fd = listen_fd;
    fds[listen_fd].events = POLLIN;  // 监听读事件
    int max_index = listen_fd;       // 记录最大索引

    while (1) {
        // 2. 调用poll,监听事件
        int nfds = poll(fds, max_index + 1, -1);  // -1表示阻塞
        if (nfds < 0) { perror("poll"); exit(1); }

        // 3. 遍历检查就绪FD
        for (int i = 0; i <= max_index; i++) {
            if (fds[i].revents == 0) continue;  // 无事件

            // 4. 监听FD就绪:新连接
            if (i == listen_fd) {
                struct sockaddr_in cli_addr;
                socklen_t cli_len = sizeof(cli_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
                if (conn_fd < 0) { perror("accept"); continue; }
                printf("new client: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
                fds[conn_fd].fd = conn_fd;
                fds[conn_fd].events = POLLIN;  // 监听读事件
                if (conn_fd > max_index) max_index = conn_fd;
            }
            // 5. 普通FD就绪:读数据
            else if (fds[i].revents & POLLIN) {
                char buf[1024] = {0};
                int n = read(i, buf, sizeof(buf)-1);
                if (n <= 0) {
                    close(i);
                    fds[i].fd = -1;  // 标记为无效
                    printf("client %d closed\n", i);
                } else {
                    printf("recv from %d: %s\n", i, buf);
                    write(i, buf, n);
                }
            }
        }
    }

    close(listen_fd);
    return 0;
}

代码说明

  • pollfd 结构体包含fd(文件描述符)、events(要监听的事件)、revents(实际发生的事件);
  • poll 没有 FD 数量的硬限制(仅受数组大小和系统资源),无需像 select 那样传max_fd+1,而是传数组长度;
  • 核心事件:POLLIN(可读)、POLLOUT(可写)、POLLERR(异常)。
3. epoll 示例(Linux 高性能首选)

c

运行

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>

#define PORT 8888
#define MAX_EVENTS 10240  // 每次最多处理的就绪事件数

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); exit(1); }

    // 端口复用(避免重启报错)
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in serv_addr = {0};
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = INADDR_ANY;
    serv_addr.sin_port = htons(PORT);
    if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) { perror("bind"); exit(1); }
    if (listen(listen_fd, 5) < 0) { perror("listen"); exit(1); }

    // 1. 创建epoll实例(核心:epoll_fd是句柄)
    int epoll_fd = epoll_create1(0);  // 参数0即可,旧版epoll_create需传大小(无实际意义)
    if (epoll_fd < 0) { perror("epoll_create1"); exit(1); }

    // 2. 将监听FD加入epoll实例
    struct epoll_event ev, events[MAX_EVENTS];
    ev.events = EPOLLIN;          // 监听读事件(LT模式,默认)
    ev.data.fd = listen_fd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) {
        perror("epoll_ctl add listen_fd");
        exit(1);
    }

    while (1) {
        // 3. 等待事件就绪(核心:只返回就绪的FD,无需轮询所有)
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);  // -1阻塞
        if (nfds < 0) { perror("epoll_wait"); exit(1); }

        // 4. 遍历就绪事件
        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;

            // 5. 新连接
            if (fd == listen_fd) {
                struct sockaddr_in cli_addr;
                socklen_t cli_len = sizeof(cli_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
                if (conn_fd < 0) { perror("accept"); continue; }
                printf("new client: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));

                // 6. 将新连接FD加入epoll(ET模式示例:EPOLLIN | EPOLLET)
                ev.events = EPOLLIN | EPOLLET;  // 边缘触发
                ev.data.fd = conn_fd;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) < 0) {
                    perror("epoll_ctl add conn_fd");
                    close(conn_fd);
                }
            }
            // 7. 数据可读
            else if (events[i].events & EPOLLIN) {
                char buf[1024] = {0};
                int n = read(fd, buf, sizeof(buf)-1);
                if (n <= 0) {
                    // 客户端断开,从epoll移除
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                    printf("client %d closed\n", fd);
                } else {
                    printf("recv from %d: %s\n", fd, buf);
                    write(fd, buf, n);
                }
            }
        }
    }

    close(listen_fd);
    close(epoll_fd);
    return 0;
}

代码说明

  • epoll_create1:创建 epoll 实例,返回的epoll_fd是操作句柄;
  • epoll_ctl:控制 epoll 实例(EPOLL_CTL_ADD添加 FD、EPOLL_CTL_DEL删除 FD、EPOLL_CTL_MOD修改事件);
  • epoll_wait:等待就绪事件,返回就绪的事件数,events数组存放就绪的事件;
  • ET 模式需注意:必须一次性读完数据(可循环 read 直到返回EAGAIN),否则后续不会再通知。

总结

  1. IO 多路复用核心:单线程 / 进程监听多个 FD,仅处理就绪的 IO,提升高并发场景下的效率;
  2. select/poll:跨平台但效率低(轮询所有 FD),select 有 FD 数量限制,poll 无硬限制;
  3. epoll:Linux 专属高性能方案(O (1) 效率),支持 LT/ET 触发,是生产环境(如 Nginx)的首选。

四、epoll_wait 函数基础定义

epoll_wait 是 epoll 机制中获取就绪事件的核心函数,它会阻塞等待 epoll 实例中的文件描述符(FD)就绪,然后将就绪事件返回到用户态的数组中。

1. 函数原型(头文件:<sys/epoll.h>

c

运行

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
2. 逐个解析参数(重点)
参数名类型含义与使用注意事项
epfdintepoll 实例的句柄(由 epoll_create1() 返回),必须是有效的、未关闭的句柄
eventsstruct epoll_event*输出参数:指向用户提前分配的 epoll_event 数组,内核会把就绪事件填充到这个数组中
maxeventsint指定 events 数组的最大长度(即本次最多能接收的就绪事件数),必须 > 0
timeoutint超时时间(单位:毫秒),有三种取值:① -1:永久阻塞,直到有事件就绪才返回;② 0:非阻塞,立即返回(不管有无事件);③ >0:阻塞指定毫秒数,超时后返回 0
3. 返回值解析(必须重点处理)
返回值含义与处理方式
> 0成功,返回就绪的事件数量(≤ maxevents),只需遍历前 N 个事件即可
0超时(仅当 timeout > 0 时可能出现),无任何事件就绪,可重试调用
-1失败,需检查 errno:① EINTR:被信号中断(非真正错误,建议重新调用);② EBADFepfd 不是有效的 epoll 句柄;③ EINVALepfd 不是 epoll 句柄,或 maxevents ≤ 0

五、epoll_wait 使用关键注意事项

  1. 遍历范围只到 nfds:不要遍历整个 events 数组(比如 for (int i=0; i<MAX_EVENTS; i++)),只需遍历前 nfds 个元素 —— 因为只有这部分是内核填充的就绪事件,这是 epoll 比 select/poll 高效的核心原因。

  2. 处理 EINTR 中断:当程序收到信号(如 Ctrl+C、定时器信号)时,epoll_wait 会返回 -1errno=EINTR,这不是错误,只需重新调用 epoll_wait 即可,不要直接退出程序。

  3. timeout 的合理选择

    • 服务端常驻程序:用 -1(永久阻塞),减少空轮询;
    • 需要定期执行其他逻辑(如清理超时连接):用 >0(比如 1000ms),超时后执行清理逻辑再重试;
    • 非阻塞测试:用 0,立即返回当前就绪事件。
  4. events 数组大小设置:无需和监听的 FD 数量一致,建议设为 1024/4096 等(根据业务并发量),内核会自动适配,即使有更多 FD 就绪,epoll_wait 也只会返回 maxevents 个,剩余的会在下一次调用时返回。

  5. 必须处理 EPOLLERR/EPOLLHUP:这两个事件无需手动监听,内核会自动返回,若不处理会导致 FD 泄露 —— 比如客户端断开后,FD 仍在 epoll 中,占用资源。

总结

  1. epoll_wait 是获取 epoll 就绪事件的核心,参数需注意 maxevents>0timeout 的三种取值,返回值要区分 “成功 / 超时 / 错误”;
  2. 遍历就绪事件时仅处理前 nfds 个,是 epoll 高效的关键,且必须处理 EINTR 中断;
  3. 务必处理 EPOLLERR/EPOLLHUP 事件,避免 FD 泄露,同时根据业务场景选择合适的 timeout

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐