从底层的数据结构、设计思想、完整工作流程入手,介绍 Linux 上的三种 IO 多路复用模型。


select

通过一个系统调用同时监控多个文件描述符(FD)的 IO 事件,实现单线程处理多连接的能力。

一、核心设计:用”位图”监控多个 FD

select 核心是通过 fd_set 位图结构管理需要监控的文件描述符,配合 select 系统调用让内核检查这些 fd 的状态。

关键数据结构:fd_set(文件描述符集合),fd_set 的第 3 位为 1,表示监控 FD=3 的事件。

二、代码和执行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
int main() {
// 1. 创建监听套接字(sockfd:服务器的"门口")
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sockfd, ...);
listen(sockfd, 10);

// 2. 初始化监控集合
fd_set readfds, tempfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
int maxfd = sockfd;

while (1) {
// 3. 复制总列表到临时列表(内核会修改临时列表)
tempfds = readfds;

// 4. 调用 select,阻塞等待事件就绪
int nready = select(maxfd + 1, &tempfds, NULL, NULL, NULL);

// 5. 处理新连接
if (FD_ISSET(sockfd, &tempfds)) {
int clientfd = accept(sockfd, ...);
FD_SET(clientfd, &readfds);
if (clientfd > maxfd) maxfd = clientfd;
}

// 6. 处理客户端数据
for (int i = sockfd + 1; i <= maxfd; i++) {
if (FD_ISSET(i, &tempfds)) {
char buf[1024];
int count = recv(i, buf, 1024, 0);

if (count == 0) {
close(i);
FD_CLR(i, &readfds);
} else {
send(i, buf, count, 0);
}
}
}
}
}

三、分步解析执行原理

阶段 1:初始化监控列表

  • FD_ZERO(&readfds):将 readfds 位图的所有位设为 0(清空监控列表)。
  • FD_SET(sockfd, &readfds):将 sockfd 对应的位设为 1,表示需要监控 sockfd 的事件(关注新连接)。
  • maxfd 的作用:select 需要知道最大 FD 值(maxfd + 1 作为第一个参数),内核会从 FD=0 检查到 maxfd,避免无意义的遍历。

阶段 2:调用 select 进入内核

1
int nready = select(maxfd + 1, &tempfds, NULL, NULL, NULL);

select 是阻塞系统调用,内核在此阶段完成以下工作:

  1. 参数解析:第一个参数 maxfd + 1 告诉内核需要检查的 FD 范围(0 到 maxfd);第二个参数 &tempfds 是需要监控的”读事件”集合;后三个参数为 NULL 表示不监控写事件、异常事件,且无限期阻塞等待。
  2. 内核遍历检查:内核遍历 tempfds 中所有位为 1 的 FD,逐个检查其状态——对 sockfd 检查是否有新连接,对 clientfd 检查接收缓冲区是否有数据。
  3. 标记就绪事件:内核会修改 tempfds 位图,清除未就绪 FD 对应的位,只保留就绪 FD 的位为 1。
  4. 唤醒线程:当至少有一个 FD 就绪时,内核唤醒等待的线程,返回就绪 FD 的数量(nready)。

阶段 3:处理就绪事件

线程被唤醒后,通过 FD_ISSET 检查 tempfds 中哪些 FD 就绪:

  1. 新连接处理:若 FD_ISSET(sockfd, &tempfds) 为真,调用 accept 生成 clientfd,并通过 FD_SET 将其加入 readfds。
  2. 客户端数据处理:遍历所有可能的客户端 FD,对就绪的 clientfd 调用 recv 读取数据,若客户端断开则 close 并从 readfds 中移除。

四、关键细节:为何需要复制 readfds 到 tempfds?

因为内核会修改传入的 tempfds(清除未就绪 FD 的位),导致其不再是完整的监控列表。而 readfds 保留了所有需要监控的 FD,每次循环复制 tempfds = readfds,是为了让内核下次能重新检查所有 FD。

五、局限性:为何高并发下效率低?

问题 说明
FD 数量上限 fd_set 大小固定(默认 FD_SETSIZE = 1024),最多监控 1024 个 FD
内核遍历开销 每次 select 调用,内核需遍历 0 到 maxfd 的所有 FD,时间复杂度 O(n)
用户态遍历开销 用户态需遍历所有 FD 才能找到就绪者(通过 FD_ISSET)
频繁内存拷贝 每次调用 select 都需将 fd_set 从用户态拷贝到内核态

poll

poll 是对 select 的改进型 IO 多路复用技术,核心解决了 select 的”文件描述符数量上限”问题,优化了事件管理方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int main() {
// 1. 创建监听套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sockfd, ...);
listen(sockfd, 10);

// 2. 初始化 poll 监控数组
struct pollfd fds[1024] = {0};
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;
int maxfd = sockfd;

while (1) {
// 3. 调用 poll,阻塞等待事件就绪
int nready = poll(fds, 1024, -1);

// 4. 处理新连接
if (fds[sockfd].revents & POLLIN) {
int clientfd = accept(sockfd, ...);
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd > maxfd) maxfd = clientfd;
}

// 5. 处理已连接客户端的数据
for (int i = sockfd + 1; i <= maxfd; i++) {
if (fds[i].revents & POLLIN) {
char buf[1024] = {0};
int count = recv(i, buf, 1024, 0);

if (count == 0) {
close(i);
fds[i].fd = -1;
fds[i].events = 0;
} else {
send(i, buf, count, 0);
}
}
}
}
}

执行原理:三步实现动态监控多连接

阶段一:用户态准备——用数组声明监控目标

poll 最核心的改进是用 struct pollfd 替代 select 的 fd_set 位图,从数据结构上突破了 fd 数量限制。

1
2
3
4
5
struct pollfd {
int fd; // 要监控的文件描述符
short events; // 用户关注的事件(如 POLLIN 表示关注读事件)
short revents; // 内核返回的实际发生的事件(由内核填充)
};
  • fd:存储要监控的目标。若 fd = -1,poll 会忽略该条目(用于标记已关闭的连接)。
  • events:用户设置的关注事件,如 POLLIN(读事件)、POLLOUT(写事件)。
  • revents:内核检测到的实际事件,调用 poll 后由内核填充。

阶段二:内核态处理——遍历数组检查事件

调用 poll(fds, 1024, -1) 后,程序进入内核态:

  1. 接收监控列表:内核将用户态的 fds 数组拷贝到内核态。
  2. 阻塞等待:如果没有任何事件就绪,内核会让当前线程休眠。
  3. 遍历检查:内核遍历 fds 数组中所有 fd != -1 的条目,逐个检查每个 fd 的 events 事件是否就绪。
  4. 标记就绪事件:对就绪的 fd,内核将实际发生的事件写入其 revents 字段。
  5. 返回结果:内核统计就绪事件的总数量(nready),将修改后的 fds 数组拷贝回用户态,唤醒线程。

阶段三:用户态响应——处理内核标记的就绪事件

线程被唤醒后,通过遍历 fds 数组中 revents 字段,处理两类就绪事件:

  1. 新连接处理:若 fds[sockfd].revents & POLLIN 为真,调用 accept 生成 clientfd,将其加入 fds 数组。
  2. 客户端数据处理:遍历所有客户端 fd,对就绪的 clientfd 调用 recv 读取数据。若 recv 返回 0,关闭 fd 并将 fds[i].fd 设为 -1。

与 select 的核心差异

维度 select(位图) poll(数组)
fd 数量 固定上限(默认 1024) 无固定上限(仅受数组大小和内存限制)
事件管理 需用 FD_SET 重复设置(内核会修改原集合) events 与 revents 分离,无需重复设置
遍历效率 需遍历 0~maxfd 所有 fd(包括未监控的) 仅遍历 fds 数组中 fd != -1 的条目

局限性:为何高并发仍不够用?

尽管 poll 解决了 select 的 fd 数量限制,但仍存在轮询机制的固有缺陷:

  • O(n) 时间复杂度:内核每次需遍历所有监控的 fd,fd 数量越多,遍历耗时越长。
  • 内存拷贝开销:每次调用 poll 需将整个 fds 数组从用户态拷贝到内核态。

poll 适合中等规模并发(1000~10000 连接),且需要跨平台兼容性的场景。


epoll

epoll 是 Linux 系统中性能最优的 IO 多路复用技术,尤其适合高并发场景。其核心优势在于事件驱动机制和高效的内核数据结构,彻底解决了 select/poll 的轮询效率问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
int main() {
// 1. 创建监听套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sockfd, ...);
listen(sockfd, 10);

// 2. 初始化 epoll 实例
int epollfd = epoll_create(1);

// 3. 向 epoll 注册监听套接字
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);

while (1) {
// 4. 等待就绪事件
struct epoll_event events[1024];
int nready = epoll_wait(epollfd, events, 1024, -1);

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

// 新连接到来
if (connfd == sockfd) {
int clientfd = accept(sockfd, ...);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, clientfd, &ev);
}
// 客户端发数据
else if (events[i].events & EPOLLIN) {
char buf[1024] = {0};
int count = recv(connfd, buf, 1024, 0);

if (count == 0) {
close(connfd);
epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
} else {
send(connfd, buf, count, 0);
}
}
}
}
}

核心原理:内核红黑树 + 就绪链表

epoll 的高效源于内核维护的两个关键数据结构,以及”事件驱动”的工作模式:

1. 内核核心数据结构

  • 红黑树:存储所有被监控的文件描述符(fd)及其事件。支持高效的 fd 添加(EPOLL_CTL_ADD)、删除(EPOLL_CTL_DEL)和修改(EPOLL_CTL_MOD)操作,时间复杂度 O(log n)。由 epoll_create 初始化时创建。
  • 就绪链表:存储已经就绪的 fd 及其事件。当 fd 事件就绪时,内核会自动将其从红黑树移到就绪链表,无需用户态遍历。

执行原理:四步实现高效事件监控

阶段一:创建 epoll 实例(epoll_create)

1
int epollfd = epoll_create(1);

内核会分配一个 epoll 实例,包含红黑树和就绪链表,返回文件描述符 epollfd 用于后续操作。参数 1 是历史遗留值,只需大于 0 即可。

阶段二:注册监控事件(epoll_ctl)

1
epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);

epoll_ctl 是 epoll 的事件管理接口:

  • EPOLL_CTL_ADD:将 sockfd 和关注的事件插入红黑树。
  • EPOLL_CTL_DEL:从红黑树中移除指定 fd(如客户端断开时)。
  • EPOLL_CTL_MOD:更新 fd 关注的事件(如从 EPOLLIN 改为 EPOLLOUT)。

关键优化:仅在 epoll_ctl 操作时,fd 信息会从用户态拷贝到内核态(一次性拷贝),后续无需重复拷贝。

阶段三:等待就绪事件(epoll_wait)

1
int nready = epoll_wait(epollfd, events, 1024, -1);
  • 若就绪链表为空,当前线程进入休眠(不占用 CPU),直到有事件就绪。
  • 若就绪链表非空,内核将链表中的事件拷贝到用户态的 events 数组(仅拷贝就绪的 fd)。
  • 返回就绪事件的数量 nready。

效率核心:无需像 select/poll 那样遍历所有监控的 fd,直接从就绪链表取结果,时间复杂度 O(1)。

阶段四:处理就绪事件

遍历 events 数组,根据 fd 类型处理:

  1. 新连接事件(sockfd 就绪):调用 accept 生成 clientfd,再通过 epoll_ctl 添加到红黑树。
  2. 客户端数据事件(clientfd 就绪):调用 recv 读取数据;若客户端断开,close 并从红黑树删除。

与 select/poll 的本质区别

特性 select/poll epoll
事件通知方式 轮询(遍历所有监控的 fd) 事件驱动(内核主动将就绪 fd 加入链表)
时间复杂度 O(n)(n 为监控的 fd 总数) O(1)(直接取就绪链表结果)
内存拷贝 每次调用拷贝所有监控对象 仅 epoll_ctl 时拷贝一次
fd 数量限制 select 有上限(1024) 无上限(仅受系统内存限制)
触发模式 仅水平触发 支持水平触发(LT)和边缘触发(ET)

边缘触发(ET):更高效率的选择

epoll 支持两种触发模式:

  • 水平触发(LT,默认):只要 fd 有未处理数据,epoll_wait 就会持续通知(与 select/poll 行为一致,编程简单)。
  • 边缘触发(ET):仅在 fd 状态变化时通知一次(如数据从无到有时),需一次性读完所有数据(配合非阻塞 IO),避免重复通知,效率更高。

总结:epoll 为何适合高并发?

epoll 通过”红黑树管理监控对象”+”就绪链表直接返回结果”的设计,彻底解决了 select/poll 的两大瓶颈:

  1. 无需遍历所有监控的 fd(从 O(n) 到 O(1))
  2. 减少内存拷贝(仅注册时拷贝,而非每次调用)

这使得 epoll 能轻松支撑十万甚至百万级并发连接,成为 Nginx、Redis 等高性能服务器的核心技术。在 Linux 环境下,高并发场景首选 epoll 是业界共识。