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_mask 是 long 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

相关推荐
夜听莺儿鸣7 小时前
502-003_Linux 中断与异常(一):从Linux角度理解中断与异常
linux·中断与异常·linux中断
杨云龙UP8 小时前
TDengine 3.4.2.8 Community 三节点三副本生产集群部署实战(DNode/MNode/taosAdapter/Explorer)
大数据·linux·运维·数据库·tdengine·时序库
向成科技8 小时前
XC3576H工控主板|深度适配Ubuntu 26.04 LTS,释放边缘AI与工业开发新潜能
linux·人工智能·ubuntu·机器人·硬件·主板·边缘ai
Fcy6489 小时前
Linux下 进程间关系与守护进程
linux·运维·服务器·守护进程
码农小韩10 小时前
Linux驱动理论(二)——Linux字符设备驱动
linux·嵌入式软件开发·linux操作系统·linux应用开发·linux驱动理论
well061210 小时前
Linux粘滞位与Makefile机制深度解析
linux·运维·服务器
再写一行代码就下班10 小时前
linux sh脚本在windows修改导致无法使用解决方式
java·linux·centos
额额额对了10 小时前
SPI通信
linux·c语言·汇编·单片机·嵌入式硬件·arm
子木HAPPY阳VIP10 小时前
Ubuntu 关闭防火墙操作步骤
linux·运维·ubuntu
王振超wzc11 小时前
嵌入式开发环境搭建--WM软件安装,Ubuntu操作系统安装
linux·运维·ubuntu