epoll与select的区别?
从效率上讲,上层调用select,内核会执行do_select,轮询遍历所有文件描述符,时间复杂度为o(n),随着描述符的增大,效率会越慢,而epoll的文件描述符由红黑树管理,监控描述符状态变化,就绪时通过ep_callback回调函数将描述符状态写进就绪队列,时间复杂度为o(1);
从资源消耗上讲,每次调用select,内核都会执行copy_to_user和copy_from_user在内核态和用户态来回拷贝,且没有缓存机制,每次调用都要来回拷贝一次,资源消耗很大,而调用epoll时,内核会通过mmap映射的方式把就绪队列映射在用户态,避免了用户态和内核态的重复交互,资源消耗上大大提供;
从局限性来讲,select采用数组管理文件描述符,有一定的数量上限,epoll采用红黑树管理,理论上没有上限,但是epoll仅限用于linux,而select可以跨平台使用,
还有就是触发模式不同,select仅可以水平触发,epoll具有水平触发和边缘触发双模式,但默认是水平触发。
如果你认为我的回答还不错,那么接下来这篇文章会带你全面认识IO多路复用,和底层实现机制
1. I/O 多路复用的核心目标
I/O 多路复用的核心目标是:在单线程中同时监控多个文件描述符(fd)的状态变化(如可读、可写、异常),避免为每个文件描述符创建独立线程,降低系统的资源消耗,减少上下文切换带来的开销。
2. Select 的机制与局限性
(1) 核心原理
Select 的工作机制通过一个固定大小的 fd_set
(位数组)来标记和存储待监控的文件描述符。当调用 select
时,用户空间会将 fd_set
数据结构全量拷贝到内核,内核通过轮询遍历这些 fd 来检查其状态(如是否可读、可写或发生异常)。在检查完成后,内核会修改 fd_set
来标记就绪的 fd,并通过 copy_to_user
将结果拷贝回用户态。
(2) 性能瓶颈
- 时间复杂度:由于
select
需要对每一个文件描述符进行逐一检查,时间复杂度为 O(n),当监控的文件描述符数量增加时,效率急剧下降。 - 资源消耗:每次调用
select
时都需要进行两次全量数据拷贝(用户态与内核态之间),这带来了较大的内存和 CPU 开销。 - 数量限制:
fd_set
的大小通常由FD_SETSIZE
限制(一般为 1024),无法灵活扩展,限制了其在高并发场景下的应用。
(3)select相关 API 总结
API | 作用 |
---|---|
select | 监控文件描述符的状态变化,检查哪些文件描述符就绪 |
FD_ZERO | 清空一个文件描述符集合 |
FD_SET | 将一个文件描述符加入到集合中 |
FD_CLR | 从文件描述符集合中删除一个文件描述符 |
FD_ISSET | 检查某个文件描述符是否就绪 |
FD_COPY | 复制文件描述符集合 |
struct timeval | 用于指定 select 函数的超时时间 |
(4) TCP 套接字的select示例
#define PORT 12345
int main() {
int server_fd, new_fd;
struct sockaddr_in server_addr;
char buffer[1024];
fd_set read_fds;
struct timeval timeout;
// 创建服务器套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
// 绑定套接字
if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1)
// 监听套接字
if (listen(server_fd, 3) == -1)
// 设置超时时间
timeout.tv_sec = 5;
timeout.tv_usec = 0;
// 初始化文件描述符集
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds); // 监控 server_fd
while (1) {
// 使用 select 监听文件描述符
fd_set temp_fds = read_fds; // 创建一个临时的文件描述符集合
select(server_fd + 1, &temp_fds, NULL, NULL, &timeout);
// 检查是否有新的连接请求
if (FD_ISSET(server_fd, &temp_fds)) {
new_fd = accept(server_fd, NULL, NULL);
// 将新的文件描述符添加到监控集合中
FD_SET(new_fd, &read_fds);
}
// 检查已连接的客户端是否有数据可读
for (int i = 0; i <= server_fd; i++) {
if (FD_ISSET(i, &temp_fds) && i != server_fd) {
int bytes_read = read(i, buffer, sizeof(buffer) - 1);
if (bytes_read == -1) {
perror("read failed");
} else if (bytes_read == 0) {
printf("Client disconnected.\n");
close(i); // 客户端断开连接
FD_CLR(i, &read_fds); // 从文件描述符集合中移除
} else {
buffer[bytes_read] = '\0'; // 确保字符串正确结束
printf("Received: %s\n", buffer);
}
}
(5)select
的流程可以详细总结为以下步骤:
-
初始化文件描述符集合:首先,通过
FD_ZERO
清空文件描述符集合,接着使用FD_SET
将需要监控的文件描述符添加到集合中。这些文件描述符可以是套接字、管道等。 -
设置超时时间:通过
struct timeval
设置超时时间(tv_sec
为秒数,tv_usec
为微秒数),如果不想设置超时,可以传递NULL
使select
一直阻塞直到有事件发生。 -
调用
select
函数:在调用select
时,传递文件描述符集合、指定监控的类型(可读、可写、异常)以及超时时间。select
会阻塞直到有文件描述符的状态发生变化(例如可读、可写或发生异常)。 -
检查
select
返回结果:select
返回后,检查返回值来确认哪些文件描述符的状态已经发生变化。返回值为 0 表示超时,负值表示错误,正值表示就绪的文件描述符个数。 -
通过
FD_ISSET
检查文件描述符是否就绪:对于每个监控的文件描述符,通过FD_ISSET
检查该文件描述符是否已经就绪。如果就绪,则可以执行相应的 I/O 操作。 -
处理就绪事件:一旦发现某个文件描述符就绪,进行相应的读写操作,或者处理异常。
3. Epoll 的优化与底层实现
(1) 核心组件
Epoll 引入了多个重要的优化点来解决 select
的性能瓶颈:
- 红黑树:用于管理所有待监控的文件描述符。红黑树的插入和删除操作的时间复杂度为 O(log n),支持高效地管理大量的文件描述符。
- 就绪队列(双向链表):用于存储已经就绪的文件描述符。事件触发时,内核会通过回调函数
ep_poll_callback
动态地将就绪的 fd 添加到就绪队列中。 - mmap 内存映射:通过用户态和内核态共享内存来避免数据的拷贝,从而实现零拷贝。
(2) 工作流程
Epoll 的工作流程如下:
- epoll_create:创建一个 epoll 实例,并初始化红黑树和就绪队列。
- epoll_ctl:通过
epoll_ctl
向红黑树中添加、删除或修改监控的 fd,同时注册回调函数。 - epoll_wait:用来检查就绪队列:
- 如果队列非空,直接返回已就绪的 fd(O(1) 时间复杂度)。
- 如果队列为空,则阻塞等待事件的触发。
(3) 触发模式
Epoll 提供两种触发模式:
- 水平触发(LT,默认模式):只要文件描述符处于就绪状态,每次
epoll_wait
调用都会返回该 fd。 - 边缘触发(ET):仅在文件描述符状态变化时触发一次,需要配合非阻塞 I/O 和循环读写来避免数据遗漏。
(4) 性能优势
- 时间复杂度:就绪事件的处理时间为 O(1),监控 fd 的管理为 O(log n)。
- 零拷贝机制:通过 mmap 共享内存来消除用户态与内核态之间的数据拷贝,提高了性能。
- 无数量限制:仅受限于系统的最大文件描述符数量(可通过
ulimit
调整),相比select
的固定限制,Epoll 在高并发下更具优势。
int main() {
int server_fd, new_fd, epoll_fd;
struct sockaddr_in server_addr;
char buffer[1024];
struct epoll_event ev, events[10];
// 创建服务器套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
// 绑定套接字
bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 监听套接字
listen(server_fd, 10);
// 创建 epoll 实例
epoll_fd = epoll_create1(0);
ev.events = EPOLLIN;
ev.data.fd = server_fd;
// 将 server_fd 添加到 epoll 实例
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);
while (1) {
// 等待事件发生
int n = epoll_wait(epoll_fd, events, 10, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == server_fd) {
// 新连接到来
new_fd = accept(server_fd, NULL, NULL);
ev.events = EPOLLIN;
ev.data.fd = new_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_fd, &ev);
} else {
// 读取客户端数据
int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer) - 1);
if (bytes_read == -1) {
perror("read failed");
} else if (bytes_read == 0) {
// 客户端断开连接
close(events[i].data.fd);
} else {
buffer[bytes_read] = '\0';
printf("Received: %s\n", buffer);
}
}
close(server_fd);
return 0;
}
-
创建套接字和绑定:
- 创建一个 TCP 套接字 (
server_fd
)。 - 设置服务器地址,绑定套接字到指定端口(
PORT 12345
),并开始监听。
- 创建一个 TCP 套接字 (
-
创建 epoll 实例:
- 使用
epoll_create1(0)
创建一个 epoll 实例,返回一个epoll_fd
,用于监控多个文件描述符的事件。
- 使用
-
将服务器套接字添加到 epoll 实例:
- 使用
epoll_ctl()
将服务器套接字(server_fd
)添加到 epoll 实例中,并设置其监控事件为EPOLLIN
(表示该文件描述符可读)。
- 使用
-
事件循环:
- 进入一个
while
循环,不断等待和处理事件。 - 使用
epoll_wait()
阻塞等待事件的发生,并返回就绪事件列表events
。
- 进入一个
-
处理新连接:
- 如果事件的文件描述符是
server_fd
(即有新连接到来),则调用accept()
接受连接,返回新连接的文件描述符(new_fd
)。 - 将新的文件描述符
new_fd
添加到 epoll 实例中,设置其事件为EPOLLIN
(表示可读)。
- 如果事件的文件描述符是
-
处理客户端数据:
- 对于其他就绪的文件描述符,表示已经连接的客户端有数据可读。
- 使用
read()
读取客户端发送的数据,处理后打印出来。如果客户端断开连接,关闭该文件描述符。
-
关闭套接字:
- 在程序结束时关闭
server_fd
。
- 在程序结束时关闭
4. Select 与 Epoll 的对比
特性 | Select | Epoll |
---|---|---|
数据结构 | 固定大小位数组(fd_set) | 红黑树 + 就绪队列 |
时间复杂度 | O(n) 轮询 | O(1) 就绪事件处理 + O(log n) 管理 |
内存拷贝 | 每次调用需要全量拷贝 fd_set | 通过 mmap 共享内存,零拷贝 |
最大 fd 数量 | 有限(通常为 1024) | 理论无上限 |
触发模式 | 仅支持水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
跨平台性 | 支持(POSIX 标准) | Linux 独占 |
5. 其他多路复用技术
(1) Poll
- 改进点:使用链表而非位数组,突破了
fd_set
的数量限制。 - 局限性:仍然需要 O(n) 的轮询和全量数据拷贝,因此其性能与
select
相似。
(2) Kqueue(FreeBSD/macOS)
- 设计思想:类似于 Epoll,采用事件驱动和回调机制来实现高效的文件描述符监控。
- 数据结构:使用二叉堆(如果量级较大,可能用红黑树进行优化)来管理文件描述符。
- 优势:支持更复杂的事件类型(如文件修改、信号通知等),比 Epoll 更适合一些特定场景。
6. Epoll 的底层实现细节
(1) 红黑树的作用
核心目标:高效管理海量文件描述符(fd)
-
动态增删的高效性
红黑树是内核中用于管理所有通过epoll_ctl
注册的 fd 的核心数据结构。每个 fd 对应一个epitem
结构体(存储 fd、事件类型、回调函数指针等信息)。- 插入/删除:
epoll_ctl(EPOLL_CTL_ADD/DEL)
操作时,内核以 fd 为键值,在红黑树中快速定位节点,时间复杂度为 O(log N),适合高并发场景(如百万连接)。 - 查找:当需要修改监听事件(如
EPOLLIN
→EPOLLOUT
)时,红黑树能快速找到对应节点。
- 插入/删除:
-
自平衡特性
红黑树通过颜色标记和旋转规则,始终保持近似平衡,避免退化为链表,确保操作效率稳定。
(2) 回调机制(ep_poll_callback)
核心目标:事件驱动的就绪队列更新
-
回调注册
当通过epoll_ctl
添加 fd 时,内核会为该 fd 绑定一个回调函数ep_poll_callback
,并将其挂载到设备的等待队列(如 socket 的sk_wq
)。这一过程由内核协议栈或驱动完成。 -
触发条件
当 fd 的状态变化(如 socket 收到数据、TCP 连接完成),底层硬件(网卡)会触发中断,内核协议栈处理完数据后,调用该 fd 的等待队列中的回调函数。
红黑树与回调的协作流程
示例:TCP 数据到达
- 网卡收到数据包,通过 DMA 写入内核内存,触发中断。
- 内核协议栈解析数据,将数据存入 socket 的接收缓冲区。
- 协议栈检查 socket 的等待队列,调用
ep_poll_callback
。 - 回调函数将对应的
epitem
添加到rdllist
,并唤醒epoll_wait
。 epoll_wait
从内核返回,用户程序直接从rdllist
获取就绪的 fd 列表。
(3) mmap 内存映射
核心目标:零拷贝(Zero-Copy)传递事件
-
传统问题
select/poll
需要将整个 fd 集合从用户态拷贝到内核态,返回时再将就绪状态拷贝回用户态,存在两次拷贝开销。 -
共享内存:通过
mmap
将内核的「就绪队列」映射到用户空间,用户程序可直接读取就绪事件,无需数据拷贝。- 事件传递:
epoll_wait
返回时,用户程序遍历events
数组(映射到内核的rdllist
),直接处理就绪 fd。
- 事件传递: