I/O 多路复用完全指南 —— select、poll、epoll

I/O 多路复用完全指南 ------ select、poll、epoll

一句话总结:I/O 多路复用就是"一个保安盯多个门"------让单个进程/线程同时监控多个 I/O 通道,有数据来了再处理,没有就歇着,避免了一个通道配一个人的资源浪费。


目录

  1. [为什么需要 I/O 多路复用?](#为什么需要 I/O 多路复用? "#%E4%B8%80%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-io-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8")
  2. [I/O 多路复用概述](#I/O 多路复用概述 "#%E4%BA%8Cio-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8%E6%A6%82%E8%BF%B0")
  3. [select 方案详解](#select 方案详解 "#%E4%B8%89select-%E6%96%B9%E6%A1%88%E8%AF%A6%E8%A7%A3")
  4. [poll 方案详解](#poll 方案详解 "#%E5%9B%9Bpoll-%E6%96%B9%E6%A1%88%E8%AF%A6%E8%A7%A3")
  5. [epoll 方案详解](#epoll 方案详解 "#%E4%BA%94epoll-%E6%96%B9%E6%A1%88%E8%AF%A6%E8%A7%A3")
  6. 核心数据结构对比
  7. 知识对比表
  8. 总结与选型建议

一、为什么需要 I/O 多路复用?

1.1 传统方案的困境

在没有多路复用 I/O 之前,处理多个 I/O 通道只有两种方式:

方案一:阻塞 I/O + 多进程/多线程

每个进程/线程处理一路 I/O,来了连接就创建一个新进程/线程去处理。

c 复制代码
// 阻塞 I/O + 多线程的经典模式(伪代码)
while (1) {
    int client_fd = accept(server_fd, ...);  // 阻塞等待连接
    pthread_create(&tid, NULL, handle_client, &client_fd);  // 每个连接一个线程
}
  • 类比:就像银行柜台,来一个客户就开一个窗口。客户多了,大厅全是窗口,成本暴增。
  • 缺点:客户端越多,需要创建的进程/线程越多,内存开销巨大。每个线程默认栈空间约 8MB,10000 个连接就需要 80GB 内存,显然不可行。

方案二:非阻塞 I/O + 轮询

单个进程不断遍历所有连接,尝试读写。

c 复制代码
// 非阻塞 I/O 轮询(伪代码)
for (int i = 0; i < n; i++) {
    int ret = read(fds[i], buf, sizeof(buf));  // 非阻塞模式
    if (ret > 0) 处理数据;
    // 否则立即返回,继续检查下一个
}
  • 类比:就像老师每隔 1 秒点一次名,问每个同学"作业写完了吗?",即使 90% 的人还没写,也要一个一个问。
  • 缺点:CPU 空转,大量时间浪费在"检查是否就绪"上,消耗巨大。

1.2 多路复用的核心思想

一句话:内核做"保安"------应用程序把要监控的文件描述符告诉内核,内核负责盯着,有数据了再通知。这样应用程序就不用自己一个个去问了。

基本流程

  1. 应用程序将要监控的文件描述符注册到内核
  2. 内核监控这些描述符的 I/O 事件
  3. 当有描述符就绪(可读/可写),内核通知应用程序
  4. 应用程序只处理就绪的描述符,无需遍历所有

二、I/O 多路复用概述

2.1 定义

I/O 多路复用(I/O Multiplexing)本质上是通过复用一个进程来处理多个 I/O 请求的技术。它让内核来监控多个文件描述符是否可以执行 I/O 操作,如果有就绪的描述符,将结果告知用户进程,用户进程再进行实际的 I/O 操作。

2.2 Linux 下的三种方案

方案 核心数据结构 时间复杂度 文件描述符上限
select 位图(fd_set) O(n) 1024
poll pollfd 数组 + 内核链表 O(n) 无限制(受内存限制)
epoll 红黑树 + 就绪链表 + 回调机制 O(1) 无限制(受内存限制)

一句话对比

  • select 是老式人工巡检 ------ 拿着一张 1024 格的签到表,每次从头到尾扫一遍
  • poll 是升级版人工巡检 ------ 签到表不限长度了,但还得从头扫到尾
  • epoll 是智能传感器系统 ------ 谁有事谁主动报告,没事不打扰

三、select 方案详解

一句话总结:select 像是一个只能管 1024 个座位的剧场管理员,每次查票都要把全场从头到尾检查一遍,即使只有 3 个人来了。

3.1 基本原理

select 通过单进程创建一个文件描述符集合(fd_set),将需要监控的文件描述符添加到这个集合中,由内核负责监控这些描述符是否可以进行读写操作。

flowchart LR subgraph 用户空间 A[应用程序] --> B[设置 fd_set<br/>将关心的 fd 加入集合] B --> C[调用 select] C --> D{select 返回} D -->|有就绪| E[遍历结果集合<br/>处理就绪的 fd] D -->|超时| F[超时处理] D -->|出错| G[错误处理] end subgraph 内核空间 H[将 fd_set 拷贝到内核] --> I[遍历所有 fd<br/>检查是否就绪] I -->|有就绪 fd| J[标记结果集合中的对应位] I -->|无就绪 fd| K{超时?} K -->|是| L[返回 0] K -->|否| M[继续遍历轮询] end C <--> H J --> E L --> F

3.2 select 函数详解

函数原型

c 复制代码
#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

参数说明

参数 含义
nfds 最大文件描述符值 + 1
readfds 监控可读的文件描述符集合
writefds 监控可写的文件描述符集合
exceptfds 监控异常的文件描述符集合
timeout 超时时间结构体指针

返回值

  • > 0:就绪的文件描述符数量
  • = 0:超时,没有文件描述符就绪
  • = -1:出错,并设置 errno

操作文件描述符集合的宏

功能
FD_ZERO(fd_set *set) 清空集合
FD_SET(int fd, fd_set *set) 将 fd 添加到集合
FD_CLR(int fd, fd_set *set) 将 fd 从集合中删除
FD_ISSET(int fd, fd_set *set) 判断 fd 是否在集合中

3.3 应用示例

示例:使用 select 监控标准输入

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>

int main(void)
{
    int ret;
    int maxfd = 0;
    fd_set readfds, tmpfds;
    struct timeval tv = {3, 0}, tmp_tv;
    char buffer[64] = {0};

    FD_ZERO(&readfds);
    FD_SET(0, &readfds);  // 监控标准输入(fd=0)

    for (;;) {
        tmp_tv = tv;      // 每次循环重新赋值(Linux 上 select 会修改 timeout)
        tmpfds = readfds; // 每次循环重新复制集合,因为 select 会修改

        ret = select(maxfd + 1, &tmpfds, NULL, NULL, &tmp_tv);
        if (ret == -1) {
            perror("[ERROR] select(): ");
            exit(EXIT_FAILURE);
        } else if (ret == 0) {
            printf("Timeout.\n");
        } else if (ret > 0) {
            if (FD_ISSET(0, &tmpfds)) {
                fgets(buffer, sizeof(buffer), stdin);
                // 去除末尾换行符
                buffer[strcspn(buffer, "\n")] = '\0';
                printf("buffer : %s\n", buffer);
                // 输入 "quit" 时退出程序
                if (strcmp(buffer, "quit") == 0) {
                    printf("Bye!\n");
                    break;
                }
            }
        }
    }

    return 0;
}

执行结果

erlang 复制代码
$ ./select_demo
(等待 3 秒,无输入)
Timeout.
(等待 3 秒,无输入)
Timeout.
hello world       ← 用户在键盘输入 "hello world" 并回车
buffer : hello world
(等待 3 秒,无输入)
Timeout.
quit              ← 输入 "quit" 退出程序
buffer : quit
Bye!

⚠️ 核心注意点 :select 会修改传入的 fd_set (将就绪结果写回集合),且在 Linux 上还会修改 timeout 值(反映剩余时间),所以每次调用前必须重新复制集合和超时时间。这是 select 最容易被忽视的陷阱。

3.4 select 的底层原理

3.4.1 fd_set ------ 位图数据结构

select 使用 fd_set 类型来管理文件描述符集合,其内核定义如下:

c 复制代码
typedef long int __fd_mask;

#define __NFDBITS     (8 * (int) sizeof(__fd_mask))  // 64 位系统下为 64
#define __FD_SETSIZE  1024

typedef struct {
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];  // 1024 / 64 = 16
} fd_set;

在 64 位系统中:

  • __fd_masklong int,大小为 8 字节(64 位)
  • __NFDBITS = 64(每个元素用 64 个 bit 表示 64 个文件描述符)
  • 数组大小 = 1024 / 64 = 16 个元素
  • 总共:16 × 64 = 1024 个 bit,对应 1024 个文件描述符

位图存储原理

  • 数组不直接存储文件描述符的值,而是用某一位来表示某个文件描述符是否需要监控
  • 每个元素 64 bit,对应 64 个 fd;__fds_bits[i] 的第 j bit 代表 fd = i × 64 + j
  • fd = 60:60/64=0__fds_bits[0]60%64=60 → 第 60 bit,设为 1
  • fd = 64:64/64=1__fds_bits[1]64%64=0 → 第 0 bit,设为 1
flowchart LR subgraph fd_set内存布局 A[&#34;__fds_bits[0]<br/>bit0~bit63<br/>对应fd 0~63&#34;] B[&#34;__fds_bits[1]<br/>bit0~bit63<br/>对应fd 64~127&#34;] C[&#34;...&#34;] D[&#34;__fds_bits[15]<br/>bit0~bit63<br/>对应fd 960~1023&#34;] end E[&#34;fd=5<br/>5÷64=0余5<br/>→ bits[0]的bit5=1&#34;] --> A F[&#34;fd=60<br/>60÷64=0余60<br/>→ bits[0]的bit60=1&#34;] --> A G[&#34;fd=64<br/>64÷64=1余0<br/>→ bits[1]的bit0=1&#34;] --> B H[&#34;fd=100<br/>100÷64=1余36<br/>→ bits[1]的bit36=1&#34;] --> B I[&#34;fd=1023<br/>1023÷64=15余63<br/>→ bits[15]的bit63=1&#34;] --> D style A fill:#e1f5fe style B fill:#e1f5fe style C fill:#e1f5fe style D fill:#e1f5fe

fd 到数组位置的映射关系

fd 数组索引 (fd ÷ 64) 元素内偏移 (fd % 64) 实际存储位置
0 0 0 bits[0] 的第 0 bit
5 0 5 bits[0] 的第 5 bit
60 0 60 bits[0] 的第 60 bit
63 0 63 bits[0] 的第 63 bit
64 1 0 bits[1] 的第 0 bit
100 1 36 bits[1] 的第 36 bit
127 1 63 bits[1] 的第 63 bit
128 2 0 bits[2] 的第 0 bit
1023 15 63 bits[15] 的第 63 bit

💡 记忆口诀bits[i] 的第 j bit = 文件描述符 i × 64 + j 。bit 从 0 开始编号,所以 fd = 64 落在 bits[1] 的 bit 0,不要被"第 64 个"的直觉误导了。
结论 :select 最大只能监控 1024 个文件描述符 ,这是由 fd_set 结构的硬编码决定的。

3.4.2 select 在内核中的执行流程
flowchart TD Start[应用层调用 select] --> CopyIn[将 fd_set 从用户空间<br/>拷贝到内核空间] CopyIn --> Alloc[分配 6 个集合空间<br/>in/out/ex/res_in/res_out/res_ex] Alloc --> Check[do_select 函数<br/>开始遍历轮询] Check --> Loop{遍历所有 fd} Loop -->|有 fd 就绪| Mark[标记结果集合对应位<br/>retval++] Loop -->|无 fd 就绪| Continue[继续检查下一个] Mark --> Done{遍历完成?} Continue --> Done Done -->|有就绪 fd| Return[返回就绪数量<br/>并将结果拷贝回用户空间] Done -->|无就绪 fd| Wait[进程进入休眠<br/>等待超时或信号唤醒] Wait --> Timeout{超时?} Timeout -->|是| ReturnZero[返回 0] Timeout -->|否| Recheck[被唤醒后重新检查] Recheck --> Check

核心源码(精简)

c 复制代码
// sys_select → core_sys_select → do_select

int do_select(int n, fd_set_bits *fds, struct timespec *end_time)
{
    for (;;) {
        // 遍历所有 bitmap
        for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
            // ... 检查每位对应的 fd 是否就绪
            // 如果该 fd 有 poll 函数,调用它检测状态
            mask = (*f_op->poll)(f.file, wait);
            
            if ((mask & POLLIN_SET) && (in & bit)) {
                res_in |= bit;   // 标记就绪
                retval++;
            }
        }
        
        if (retval || timed_out || signal_pending(current))
            break;
        
        // 没有就绪的 fd,进程休眠
        poll_schedule_timeout(...);
    }
    return retval;
}

3.5 select 的缺点

  1. 每次调用都要将整个 fd_set 从用户空间拷贝到内核空间------fd 越多,拷贝开销越大
  2. 每次都要遍历所有文件描述符------O(n) 的时间复杂度,fd 越多越慢
  3. 最大支持 1024 个文件描述符------硬编码限制
  4. 会修改传入的 fd_set------每次调用前必须重新赋值

3.6 社会类比

类比:老式人工签到会

想象一个 1000 人的大会场。主席台要统计"谁到了"。

  • select 的方式:工作人员拿着一块只能写 1024 个名字的白板,每隔 5 分钟就在会场里跑一圈,一个个对名字。下次点名还得重新写一遍白板。
  • 痛点:白板容量有限(1024 限制),每次都要全场跑(遍历所有 fd),每次点名都要重写白板(重新拷贝 fd_set)。

四、poll 方案详解

一句话总结:poll 把 select 的"1024 格签到板"换成了"不限长度的签到表",但还是要从头到尾一个个检查。

4.1 poll 与 select 的核心区别

对比维度 select poll
数据结构 位图(fd_set) pollfd 结构体数组
文件描述符上限 1024(硬编码) 无限制(受内存限制)
请求与就绪分开 ❌ 在同一集合中 ✅ 用 events 和 revents 分开
跨平台性 所有主流平台(Windows、Linux、macOS) 主流 Linux/Unix 系统(POSIX)

poll 最大的改进是:

  1. struct pollfd 结构体管理文件描述符,不再受 1024 限制
  2. 将请求事件(events)和就绪事件(revents)分离,无需每次重新赋值

4.2 poll 函数详解

函数原型

c 复制代码
#include <poll.h>

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

参数说明

参数 含义
fds struct pollfd 结构体数组指针
nfds fds 数组的元素个数
timeout 超时时间(毫秒)

struct pollfd 结构体

c 复制代码
struct pollfd {
    int   fd;       // 文件描述符
    short events;   // 监控的事件(由应用程序设置)
    short revents;  // 返回的就绪事件(由内核设置)
};

常用事件

事件 含义
POLLIN 普通数据可读
POLLOUT 普通数据可写
POLLERR 发生错误
POLLRDNORM 普通数据可读(同 POLLIN)
POLLHUP 挂起(连接断开)

返回值

  • > 0:就绪的文件描述符数量
  • = 0:超时,没有文件描述符就绪
  • = -1:出错,并设置 errno

4.3 应用示例

示例:使用 poll 监控标准输入

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>

int main(void)
{
    int ret;
    struct pollfd pfds;
    char buffer[64] = {0};

    pfds.fd = 0;          // 监控标准输入
    pfds.events = POLLIN; // 监控可读事件

    for (;;) {
        // 监控 1 个文件描述符,超时 1 秒
        ret = poll(&pfds, 1, 1000);
        
        if (ret == -1) {
            perror("[ERROR] poll(): ");
            exit(EXIT_FAILURE);
        } else if (ret == 0) {
            printf("Timeout.\n");
        } else if (ret > 0) {
            if (pfds.revents & POLLIN) {
                fgets(buffer, sizeof(buffer), stdin);
                // 去除末尾换行符
                buffer[strcspn(buffer, "\n")] = '\0';
                printf("buffer : %s\n", buffer);
                // 输入 "quit" 时退出程序
                if (strcmp(buffer, "quit") == 0) {
                    printf("Bye!\n");
                    break;
                }
            }
        }
    }

    return 0;
}

执行结果

erlang 复制代码
$ ./poll_demo
(等待 1 秒,无输入)
Timeout.
(等待 1 秒,无输入)
Timeout.
Hello Poll!       ← 用户在键盘输入
buffer : Hello Poll!
(等待 1 秒,无输入)
Timeout.
quit              ← 输入 "quit" 退出
buffer : quit
Bye!

与 select 对比

  • ✅ 无需每次重新复制集合(events 不会被修改)
  • ✅ 没有 1024 的限制
  • ❌ 底层仍然是 O(n) 的轮询

4.4 poll 的底层原理

flowchart TD subgraph 用户空间 A[应用程序定义 pollfd 数组] --> B[调用 poll 函数] end subgraph 内核空间 C[sys_poll 系统调用] --> D[do_sys_poll 函数] D --> E[分配栈空间<br/>struct poll_list] E --> F{256字节栈空间<br/>是否足够?} F -->|不够| G[kmalloc 分配<br/>最大 PAGE 大小] F -->|足够| H[直接使用栈空间] G --> I[将 pollfd 数组<br/>从用户空间拷贝到内核] H --> I I --> J[do_poll 函数<br/>遍历所有 pollfd] J --> K[do_pollfd 检查每个 fd] K --> L{fd 就绪?} L -->|是| M[设置 revents, count++] L -->|否| N[继续下一个] M --> O{所有 fd 检查完?} N --> O O -->|有就绪| P[返回就绪数量] O -->|无就绪| Q[进程休眠等待] end B <--> C P --> R[将 revents 写回<br/>用户空间 pollfd 数组] Q --> S[超时/信号唤醒<br/>重新检查] S --> J

内核中的 poll_list 链表结构

c 复制代码
struct poll_list {
    struct poll_list *next;       // 链表下一节点
    int              len;         // 本节点 pollfd 数量
    struct pollfd    entries[0];  // 柔性数组,存放 pollfd
};

poll 在内核中使用链表来组织 pollfd 数组,突破了 select 的 1024 限制。栈空间预先分配 256 字节(约 32 个 pollfd),不够时用 kmalloc 动态分配,每次分配最多一个内存页(PAGE_SIZE)。

4.5 社会类比

类比:升级版签到会

还是那个 1000 人的会场。poll 的方式是:

  • 把签到表从"1024 格白板"换成"不限长度的电子表格"
  • 表格上每个人有"应到"(events)和"实到"(revents)两个格子
  • 但是,点名方式没变------工作人员还得从头跑到尾一个个看

改进 :签到表不限容量了(无 1024 限制),也不用每次重写整张表(events/revents 分离) 没变:还是 O(n) 的体力活------人越多,点名越慢


五、epoll 方案详解

一句话总结:epoll 像智能门禁系统------谁刷卡谁进来,不用保安站在门口一个个问"你刷卡了吗?"

5.1 epoll 的革命性改进

epoll 针对 select/poll 的三大痛点做了根本性改进:

select/poll 的痛点 epoll 的解决方案
每次调用都要拷贝 fd 集合到内核 通过 epoll_ctl 注册,只需拷贝一次
每次都要轮询所有 fd(O(n)) 使用回调机制,只处理就绪的 fd(O(1))
需要遍历整个集合来找就绪的 fd 维护一个就绪链表,直接取走

5.2 epoll 的核心 API

epoll 提供三个核心函数:

函数 功能 类比
epoll_create 创建 epoll 实例 搭建一个"监控中心"
epoll_ctl 控制 epoll 实例(增/删/改) 告诉监控中心"盯住谁"
epoll_wait 等待事件发生 问监控中心"谁有动静了?"
5.2.1 epoll_create
c 复制代码
#include <sys/epoll.h>

int epoll_create(int size);
  • 功能:创建 epoll 实例,分配核心数据结构
  • 参数 size:从 Linux 2.6.8 开始,size 参数被忽略,只需填一个大于 0 的值
  • 返回值:成功返回 epoll 文件描述符,失败返回 -1
5.2.2 epoll_ctl
c 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

op 操作命令字

命令 含义 说明
EPOLL_CTL_ADD 添加 将 fd 添加到 epoll 实例(加入到红黑树)
EPOLL_CTL_MOD 修改 修改 fd 关联的监控事件
EPOLL_CTL_DEL 删除 从 epoll 实例中删除 fd

struct epoll_event 结构体

c 复制代码
typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;   // epoll 事件(EPOLLIN, EPOLLOUT, EPOLLET 等)
    epoll_data_t data;     // 用户数据(通常存 fd)
};

常用事件

事件 含义
EPOLLIN 读事件有效
EPOLLOUT 写事件有效
EPOLLET 边缘触发模式(Edge Triggered)
EPOLLERR 发生错误
EPOLLHUP 挂起(连接断开)
5.2.3 epoll_wait
c 复制代码
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
  • 功能:等待文件描述符关联的事件发生
  • 参数
    • epfd:epoll 实例
    • events:存储就绪事件的数组
    • maxevents:最多返回的事件数
    • timeout:超时时间(毫秒)
  • 返回值
    • > 0:就绪的文件描述符数量
    • = 0:超时
    • = -1:出错

5.3 应用示例

示例:使用 epoll 监控标准输入

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>

#define MAXEVENTS 10

int main(void)
{
    int epfd, ret;
    struct epoll_event ev;
    struct epoll_event ret_ev[MAXEVENTS];
    char buffer[64] = {0};

    // 1. 创建 epoll 实例
    epfd = epoll_create(1);
    if (epfd == -1) {
        perror("[ERROR] epoll_create(): ");
        exit(EXIT_FAILURE);
    }
    printf("epfd = %d\n", epfd);  // 输出 epoll 文件描述符

    // 2. 将标准输入添加到 epoll 实例
    ev.data.fd = 0;          // 存储要监控的 fd
    ev.events = EPOLLIN;     // 监控可读事件
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &ev);
    if (ret == -1) {
        perror("[ERROR] epoll_ctl(): ");
        exit(EXIT_FAILURE);
    }

    // 3. 循环等待事件
    for (;;) {
        ret = epoll_wait(epfd, ret_ev, MAXEVENTS, 1000);
        if (ret == -1) {
            perror("[ERROR] epoll_wait(): ");
            exit(EXIT_FAILURE);
        } else if (ret == 0) {
            printf("Timeout.\n");
        } else if (ret > 0) {
            // 遍历就绪事件数组(ret 为就绪数量)
            for (int i = 0; i < ret; i++) {
                // 检查就绪的 fd 是否是我们关心的标准输入
                if (ret_ev[i].data.fd == 0 && (ret_ev[i].events & EPOLLIN)) {
                    fgets(buffer, sizeof(buffer), stdin);
                    // 去除末尾换行符
                    buffer[strcspn(buffer, "\n")] = '\0';
                    printf("buffer : %s\n", buffer);
                    // 输入 "quit" 时退出程序
                    if (strcmp(buffer, "quit") == 0) {
                        printf("Bye!\n");
                        goto out;  // 跳出外层循环
                    }
                }
            }
        }
    }
out:
    close(epfd);  // 关闭 epoll 实例
    return 0;
}

执行结果

erlang 复制代码
$ ./epoll_demo
epfd = 3
(等待 1 秒,无输入)
Timeout.
(等待 1 秒,无输入)
Timeout.
Hello Epoll!      ← 用户在键盘输入
buffer : Hello Epoll!
(等待 1 秒,无输入)
Timeout.
quit              ← 输入 "quit" 退出
buffer : quit
Bye!

5.4 epoll 的底层原理

5.4.1 核心数据结构

epoll 在内核中维护了两个关键数据结构:

flowchart TD subgraph struct_eventpoll[&#34;struct eventpoll(epoll 实例)&#34;] A[wq: 等待队列] --- B[poll_wait: poll 等待队列] B --- C[rdllist: 就绪链表<br/>存储就绪的 epitem] C --- D[rbr: 红黑树根节点<br/>管理所有注册的 fd] end subgraph 红黑树节点[&#34;struct epitem(红黑树节点)&#34;] E[rdllink: 链表指针] --- F[ffd: 文件描述符信息] F --- G[event: 监控事件] G --- H[pwqlist: 等待队列链表] end D --- E C --- E style struct_eventpoll fill:#fff3e0 style 红黑树节点 fill:#e8f5e9

epoll 实例的核心结构(内核源码):

c 复制代码
struct eventpoll {
    wait_queue_head_t wq;         // 等待队列(epoll_wait 中的进程在此休眠)
    wait_queue_head_t poll_wait;  // poll 等待队列
    struct list_head rdllist;     // 就绪链表(已就绪的文件描述符在此)
    struct rb_root rbr;           // 红黑树根节点(管理所有注册的 fd)
};

红黑树节点(每个注册的 fd 对应一个)

c 复制代码
struct epitem {
    struct list_head rdllink;     // 就绪链表链接指针
    struct epitem *next;          // 用于 overflow 链表
    struct epoll_filefd ffd;      // 文件描述符信息
    struct epoll_event event;     // 注册的监控事件
};
5.4.2 三大核心流程
flowchart TD subgraph epoll_create[&#34;epoll_create 流程&#34;] A1[分配 struct eventpoll 空间] --> A2[初始化等待队列和红黑树] A2 --> A3[创建匿名文件<br/>返回文件描述符] end subgraph epoll_ctl[&#34;epoll_ctl 流程&#34;] B1{操作类型} -->|EPOLL_CTL_ADD| B2[分配 struct epitem] B2 --> B3[将 epitem 插入红黑树] B3 --> B4[注册回调函数 ep_poll_callback<br/>到设备驱动等待队列] B1 -->|EPOLL_CTL_DEL| B5[从红黑树删除节点] B5 --> B6[移除回调函数] B1 -->|EPOLL_CTL_MOD| B7[修改红黑树节点<br/>的监控事件] end subgraph epoll_wait[&#34;epoll_wait 流程&#34;] C1{rdllist 就绪链表<br/>是否为空?} C1 -->|不为空| C2[将就绪事件拷贝到<br/>用户空间 events 数组] C2 --> C3[返回就绪数量] C1 -->|为空| C4[进程加入等待队列<br/>wq, 进程休眠] C4 --> C5{被唤醒?} C5 -->|回调触发| C6[检查 rdllist] C5 -->|超时| C7[返回 0] C5 -->|信号| C8[返回 -EINTR] C6 --> C2 end style epoll_create fill:#e3f2fd style epoll_ctl fill:#fce4ec style epoll_wait fill:#f3e5f5

最关键的设计------回调机制

sequenceDiagram participant App as 应用程序 participant Epoll as epoll 实例 participant Kernel as 内核监控 participant Driver as 设备驱动 App->>Epoll: epoll_ctl(EPOLL_CTL_ADD, fd) Epoll->>Kernel: ep_insert() Kernel->>Driver: ep_ptable_queue_proc() Driver->>Driver: 注册 ep_poll_callback<br/>到驱动的等待队列 App->>Epoll: epoll_wait() ← 先调用,阻塞等待 Epoll->>Kernel: 检查 rdllist,为空则进程休眠 Note over Driver: 数据到来时... Driver->>Kernel: 触发 ep_poll_callback Kernel->>Kernel: 将就绪的 epitem<br/>加入 rdllist 链表 Kernel->>Epoll: 唤醒在 wq 上等待的进程 Epoll->>App: 返回就绪的 fd<br/>(直接从 rdllist 取,无需遍历)

回调函数 ep_poll_callback 的核心逻辑

c 复制代码
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
    struct epitem *epi = ep_item_from_wait(wait);
    struct eventpoll *ep = epi->ep;

    // 检查事件是否匹配
    if (key && !((unsigned long)key & epi->event.events))
        goto out_unlock;

    // 最重要的事情:将就绪 fd 添加到就绪链表
    if (!ep_is_linked(&epi->rdllink)) {
        list_add_tail(&epi->rdllink, &ep->rdllist);
    }

    // 如果有进程在 epoll_wait 中休眠,唤醒它
    if (waitqueue_active(&ep->wq))
        wake_up_locked(&ep->wq);

    return 1;
}

核心思想:epoll 不是主动去问"谁就绪了",而是让设备驱动在数据到达时主动调用回调函数,把就绪的 fd 放入就绪链表。这就是 O(1) 性能的根源。

5.5 LT 模式 vs ET 模式

epoll 支持两种触发模式,这是另一个关键概念:

特性 LT(水平触发) ET(边缘触发)
触发条件 只要缓冲区有数据,每次都通知 只有状态变化时才通知一次
默认模式 ✅ 默认 需要设置 EPOLLET 标志
编程难度 简单 较复杂
性能 一般 更高
使用建议 适合新手、简单场景 适合高性能服务器

类比

  • LT(水平触发) = 家中烟雾报警器------只要还有烟,就一直响
  • ET(边缘触发) = 门铃------只在你按下的那一刻响一次,不重复提醒

5.6 社会类比

类比:现代化医院叫号系统

想象一家大医院:

  • select 方案:护士每隔 10 分钟在所有诊室间跑一圈,记录"谁看完了"(就绪),回到前台翻她那只能记 1024 人的小本本。每次跑完,本本还要重新写。

  • poll 方案:护士换了一个不限页数的点名册,但还是得跑圈。

  • epoll 方案:每个诊室装了一个呼叫铃(回调机制)。病人看完病,医生按铃,护士站的大屏幕(就绪链表)立刻显示"3 号诊室空出来了"。护士只处理按铃的诊室,不用跑圈。诊室再多也不影响效率。


六、核心数据结构对比

6.1 select 的内核数据结构

flowchart LR subgraph fd_set_结构[&#34;fd_set(位图)&#34;] A[&#34;bits[0]: 64bit → fd 0~63&#34;] B[&#34;bits[1]: 64bit → fd 64~127&#34;] C[&#34;...&#34;] D[&#34;bits[15]: 64bit → fd 960~1023&#34;] end subgraph fd_set_bits[&#34;fd_set_bits(6 个集合指针)&#34;] E[&#34;in → 输入读集合&#34;] F[&#34;out → 输入写集合&#34;] G[&#34;ex → 输入异常集合&#34;] H[&#34;res_in → 结果读集合&#34;] I[&#34;res_out → 结果写集合&#34;] J[&#34;res_ex → 结果异常集合&#34;] end fd_set_结构 --> fd_set_bits

6.2 poll 的内核数据结构

flowchart LR subgraph 用户空间 A[&#34;pollfd 数组<br/>[fd, events, revents]&#34;] end subgraph 内核空间 B[&#34;struct poll_list<br/>链表节点&#34;] C[&#34;struct poll_list<br/>链表节点&#34;] D[&#34;...&#34;] B ---> C ---> D B1[&#34;entries[0~N]<br/>pollfd 数组&#34;] C1[&#34;entries[0~N]<br/>pollfd 数组&#34;] end A -->|copy_from_user| B1 A -->|copy_from_user| C1

6.3 epoll 的内核数据结构

flowchart TB subgraph 用户空间 U[epoll_create<br/>返回 epfd] end subgraph 内核空间 EP[&#34;struct eventpoll<br/>(epoll 实例)&#34;] subgraph RB[&#34;红黑树(rbr)&#34;] direction LR N1[&#34;epitem<br/>fd=5&#34;] --> N2[&#34;epitem<br/>fd=20&#34;] N1 --> N3[&#34;epitem<br/>fd=10&#34;] end subgraph RL[&#34;就绪链表(rdllist)&#34;] direction LR L1[&#34;epitem<br/>fd=5&#34;] --> L2[&#34;epitem<br/>fd=20&#34;] end EP --> RB EP --> RL end U --> EP

七、知识对比表

7.1 基本特性对比

对比维度 select poll epoll
底层数据结构 位图(fd_set) pollfd 数组 + 内核链表 红黑树 + 就绪链表
时间复杂度(监控) O(n) O(n) O(1)
时间复杂度(就绪返回) O(n) O(n) O(1)✅
最大 fd 数 1024(硬编码) 无限制(受内存限制) 无限制(受内存限制)
是否支持回调机制
每次是否需要拷贝集合 ✅ 每次都要拷贝 ✅ 每次都要拷贝 (但 events 无需重置) ✅ 只需注册时拷贝一次

7.2 使用细节对比

使用注意点 select poll epoll
是否需要重新设置集合 ✅ 每次必须重新赋值 ❌ events 不会被修改 ❌ 注册一次即可
就绪 fd 的获取方式 遍历整个集合检查 FD_ISSET 遍历整个数组检查 revents 直接读取就绪事件数组
超时精度 微秒(timeval) 毫秒 毫秒
跨平台支持 Windows、Linux、macOS 主流 Linux/Unix Linux 2.6+
触发模式 仅水平触发(LT) 仅水平触发 支持 LT 和 ET 两种模式

7.3 性能对比

场景 select poll epoll
少量连接(< 100) ✅ 可用 ✅ 可用 ✅ 可用
中等连接(100~1000) ⚠️ 性能下降 ⚠️ 性能下降 ✅ 性能优
海量连接(> 1000) ❌ 不可用(1024 限制) ❌ 性能急剧下降 ✅ 性能稳定
连接频繁增删场景 ❌ 必须重新设置整个集合 ❌ 必须重新设置整个数组 ✅ 通过 epoll_ctl 动态调整
CPU 占用 高(空轮询) 高(空轮询) 低(回调驱动)

7.4 典型应用场景

场景 推荐方案 原因
嵌入式系统、fd 少且固定 select 简单、跨平台、够用
普通桌面应用、中等规模 fd poll 无 1024 限制,API 更友好
高并发网络服务器(Nginx、Redis) epoll 海量连接、高性能、O(1)
需要跨平台(如 Windows + Linux) select / poll epoll 仅限 Linux
需要边缘触发模式 epoll 唯一支持 ET 的方案
实时性要求高的场景 epoll(ET 模式) 事件驱动,响应最快

7.5 各方案的"槽点"总结

方案 最受诟病的问题
select "1024 上限太抠门" + "每次都要重新赋值"
poll "虽然不限人数了,但还得一个个问"(O(n) 轮询)
epoll "Linux only,Windows 不带你玩"

八、总结与选型建议

8.1 一句话概括三兄弟

方案 一句话总结
select "1024 个座的小剧场管理员,每次查票跑全场"
poll "不限座位的大体育场管理员,但还得跑全场"
epoll "智能感应系统------谁有事谁按铃,没事不打扰"

8.2 演进路线

flowchart LR A[&#34;select<br/>位图 + 轮询<br/>1024 限制&#34;] -->|&#34;解决 1024 限制&#34;| B[&#34;poll<br/>链表 + 轮询<br/>无数量限制&#34;] B -->|&#34;解决 O(n) 轮询&#34;| C[&#34;epoll<br/>红黑树 + 回调<br/>O(1) 性能&#34;] C -->|&#34;更高性能&#34;| D[&#34;io_uring<br/>异步 I/O 框架<br/>Linux 5.1+&#34;]

8.3 选型决策树

flowchart TD Q1{运行在 Linux 上?} Q1 -->|否| Q2{连接的 fd 数量<br/>是否超过 1024?} Q1 -->|是| Q3{是否是高性能<br/>网络服务器?} Q2 -->|否| S1[select] Q2 -->|是| S2[poll] Q3 -->|是| S3[epoll ET 模式] Q3 -->|否| Q4{开发效率 vs 性能} Q4 -->|开发效率优先| S4[poll / epoll LT] Q4 -->|性能优先| S3 style S1 fill:#e3f2fd style S2 fill:#fce4ec style S3 fill:#e8f5e9 style S4 fill:#f3e5f5

8.4 关键记忆点

一条发展主线:从 select→poll→epoll 的演进,本质是**"被动轮询 → 主动通知"**的变革。

一个核心指标:时间复杂度从 O(n) → O(1),这是 epoll 能支撑高并发的根本原因。

一个最重要结论:如果没有 epoll,就没有今天互联网上千万级的并发连接,也就没有 Nginx、Redis、Node.js 这些高性能基石。


本文档基于 Linux 内核源码分析整理,示例代码可在任何 Linux 环境下编译运行。 编译命令示例:gcc -o select_demo select_demo.c

相关推荐
进阶的猪1 小时前
Linux 内核移植过程学习
linux·学习
问简3 小时前
【共享盘】ubuntu、windows
linux·windows·ubuntu
小生不才yz3 小时前
Shell脚本精读 · S13-01 | POSIX sh 与 Bash:语法对照表
linux
阿拉斯攀登4 小时前
平台设备驱动:platform bus 与设备树
linux·嵌入式硬件·嵌入式·linux驱动·字符设备驱动
阿拉斯攀登4 小时前
并发与同步:自旋锁、信号量、互斥锁
linux·网络·嵌入式硬件·linux驱动·字符设备驱动
网络小白不怕黑4 小时前
10.邮件服务器搭建并脚本实现监控httpd状态
linux·运维·服务器
Carlos_Xu5 小时前
Linux基础-基础命令
linux
风曦Kisaki5 小时前
# Linux实战笔记-HIS项目实战Day01:版本控制与Git基础
linux·笔记·git
Nontee6 小时前
Set 这个东西,跟我一开始想的不太一样
java·linux·windows·microsoft