IO多路复用是一种同时监控多个IO操作的机制,允许进程同时等待多个文件描述符就绪。在Linux系统中,主要有select、poll和epoll三种IO多路复用机制。
1. select
特点:
- 能监控的文件描述符数量有限制(通常是1024)
- 每次调用都需要将fd集合从用户态拷贝到内核态
- 每次调用都需要在内核遍历传递进来的所有fd
- 时间复杂度O(n)
使用:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 工作流程
+----------------------------------------------+
| 应用程序 |
+----------------------------------------------+
|
| 1. 初始化fd_set
v
+----------------------------------------------+
| fd_set readfds, writefds, exceptfds; |
| FD_ZERO(&readfds); |
| FD_ZERO(&writefds); |
| FD_ZERO(&exceptfds); |
+----------------------------------------------+
|
| 2. 设置感兴趣的文件描述符
v
+----------------------------------------------+
| for (int i = 0; i < num_fds; i++) { |
| FD_SET(fds[i], &readfds); |
| FD_SET(fds[i], &writefds); |
| FD_SET(fds[i], &exceptfds); |
| } |
+----------------------------------------------+
|
| 3. 设置最大文件描述符和超时
v
+----------------------------------------------+
| int max_fd = /* 最大的文件描述符 + 1 */; |
| struct timeval timeout; |
| timeout.tv_sec = /* 秒 */; |
| timeout.tv_usec = /* 微秒 */; |
+----------------------------------------------+
|
| 4. 调用select()
v
+----------------------------------------------+
| int ret = select(max_fd, |
| &readfds, |
| &writefds, |
| &exceptfds, |
| &timeout); |
+----------------------------------------------+
|
| 5. 进入内核空间
v
+----------------------------------------------+
| 内核空间 |
| +----------------------------------------+ |
| | 复制fd_set和timeout到内核 | |
| +----------------------------------------+ |
| | |
| v |
| +----------------------------------------+ |
| | 遍历所有文件描述符 | |
| | +------------------------------------+| |
| | | for (fd = 0; fd < max_fd; fd++) { || |
| | | if (FD_ISSET(fd, &readfds)) || |
| | | 检查fd是否可读 || |
| | | if (FD_ISSET(fd, &writefds)) || |
| | | 检查fd是否可写 || |
| | | if (FD_ISSET(fd, &exceptfds)) || |
| | | 检查fd是否有异常 || |
| | | } || |
| | +------------------------------------+| |
| +----------------------------------------+ |
| | |
| | 6. 如果没有就绪的fd |
| v |
| +----------------------------------------+ |
| | 等待事件或超时 | |
| | +------------------------------------+| |
| | | while (没有就绪的fd && 未超时) { || |
| | | 睡眠或等待中断 || |
| | | } || |
| | +------------------------------------+| |
| +----------------------------------------+ |
| | |
| | 7. 有fd就绪或超时 |
| v |
| +----------------------------------------+ |
| | 更新fd_set,清除未就绪的fd | |
| +----------------------------------------+ |
| | |
| | 8. 返回用户空间 |
| v |
+----------------------------------------------+
| select() 函数返回 |
| 返回值: 就绪的文件描述符数量 |
+----------------------------------------------+
|
| 9. 检查返回值
v
+----------------------------------------------+
| if (ret > 0) { |
| // 有文件描述符就绪 |
| for (int fd = 0; fd < max_fd; fd++) { |
| if (FD_ISSET(fd, &readfds)) { |
| // fd 可读 |
| read(fd, ...); |
| } |
| if (FD_ISSET(fd, &writefds)) { |
| // fd 可写 |
| write(fd, ...); |
| } |
| if (FD_ISSET(fd, &exceptfds)) { |
| // fd 有异常 |
| // 处理异常... |
| } |
| } |
| } else if (ret == 0) { |
| // 超时,没有文件描述符就绪 |
| } else { |
| // 出错 |
| perror("select"); |
| } |
+----------------------------------------------+
|
| 10. 重置fd_set
v
+----------------------------------------------+
| FD_ZERO(&readfds); |
| FD_ZERO(&writefds); |
| FD_ZERO(&exceptfds); |
| // 重新设置感兴趣的文件描述符... |
+----------------------------------------------+
|
| 11. 继续下一次循环或结束
v
+----------------------------------------------+
| 继续或退出程序 |
+----------------------------------------------+
- 初始化fd_set结构
- 设置感兴趣的文件描述符
- 设置最大文件描述符和超时时间
- 调用select()函数
- 进入内核空间,复制fd_set和timeout
- 内核遍历所有文件描述符,检查它们的状态
- 如果没有就绪的文件描述符,等待事件或超时
- 更新fd_set,清除未就绪的文件描述符
- 返回用户空间
- 应用程序检查select()的返回值
- 遍历fd_set,处理就绪的文件描述符
- 重置fd_set,准备下一次调用
这个流程图详细展示了select的工作原理,包括用户空间和内核空间的操作,以及如何处理就绪的文件描述符。它还展示了select在每次调用时需要重新设置fd_set的特点,这是select相对于其他I/O多路复用方法的一个主要缺点。
工作流程总结
-
准备阶段:
- 应用程序创建三个fd_set结构(readfds, writefds, exceptfds),用于表示要监视的读、写和异常事件的文件描述符集合。
- 使用FD_SET()宏将需要监视的文件描述符添加到相应的fd_set中。
-
调用select:
- 应用程序调用select函数,传入最大文件描述符值+1、三个fd_set和超时时间。
- select将fd_set从用户空间拷贝到内核空间。
-
内核处理:
- 内核遍历所有fd_set中的文件描述符,检查它们的状态。
- 如果没有就绪的文件描述符且未超时,进程会被阻塞。
-
返回结果:
- 当有文件描述符就绪或超时时,select返回。
- 内核修改fd_set,只保留就绪的文件描述符,并将结果拷贝回用户空间。
-
处理结果:
- 应用程序遍历fd_set,使用FD_ISSET()宏检查哪些文件描述符已就绪。
- 对就绪的文件描述符进行相应的I/O操作。
2. poll
特点:
- 没有最大文件描述符数量的限制
- 每次调用都需要将fd集合从用户态拷贝到内核态
- 每次调用都需要在内核遍历传递进来的所有fd
- 时间复杂度O(n)
使用:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll 工作流程
+----------------------------------------------+
| 应用程序 |
+----------------------------------------------+
|
| 1. 准备pollfd结构数组
v
+----------------------------------------------+
| struct pollfd fds[N]; |
| for (int i = 0; i < N; i++) { |
| fds[i].fd = /* 文件描述符 */; |
| fds[i].events = /* 感兴趣的事件 */; |
| fds[i].revents = 0; |
| } |
+----------------------------------------------+
|
| 2. 设置超时时间
v
+----------------------------------------------+
| int timeout = /* 毫秒 */; |
+----------------------------------------------+
|
| 3. 调用poll()
v
+----------------------------------------------+
| int ret = poll(fds, N, timeout); |
+----------------------------------------------+
|
| 4. 进入内核空间
v
+----------------------------------------------+
| 内核空间 |
| +----------------------------------------+ |
| | 复制pollfd数组到内核 | |
| +----------------------------------------+ |
| | |
| v |
| +----------------------------------------+ |
| | 遍历所有文件描述符 | |
| | +------------------------------------+| |
| | | for (int i = 0; i < N; i++) { || |
| | | 检查 fds[i].fd 的状态 || |
| | | if (就绪) { || |
| | | 设置 fds[i].revents || |
| | | } || |
| | | } || |
| | +------------------------------------+| |
| +----------------------------------------+ |
| | |
| | 5. 如果没有就绪的fd |
| v |
| +----------------------------------------+ |
| | 等待事件或超时 | |
| | +------------------------------------+| |
| | | while (没有就绪的fd && 未超时) { || |
| | | 睡眠或等待中断 || |
| | | } || |
| | +------------------------------------+| |
| +----------------------------------------+ |
| | |
| | 6. 有fd就绪或超时 |
| v |
| +----------------------------------------+ |
| | 更新pollfd数组的revents字段 | |
| +----------------------------------------+ |
| | |
| | 7. 返回用户空间 |
| v |
+----------------------------------------------+
| poll() 函数返回 |
| 返回值: 就绪的文件描述符数量 |
+----------------------------------------------+
|
| 8. 检查返回值
v
+----------------------------------------------+
| if (ret > 0) { |
| // 有文件描述符就绪 |
| for (int i = 0; i < N; i++) { |
| if (fds[i].revents != 0) { |
| // 处理就绪的文件描述符 |
| if (fds[i].revents & POLLIN) { |
| // 可读 |
| read(fds[i].fd, ...); |
| } |
| if (fds[i].revents & POLLOUT) { |
| // 可写 |
| write(fds[i].fd, ...); |
| } |
| // 处理其他事件... |
| } |
| } |
| } else if (ret == 0) { |
| // 超时,没有文件描述符就绪 |
| } else { |
| // 出错 |
| perror("poll"); |
| } |
+----------------------------------------------+
|
| 9. 重置pollfd数组
v
+----------------------------------------------+
| for (int i = 0; i < N; i++) { |
| fds[i].revents = 0; |
| } |
+----------------------------------------------+
|
| 10. 继续下一次循环或结束
v
+----------------------------------------------+
| 继续或退出程序 |
+----------------------------------------------+
流程图展示了poll的完整工作流程,包括:
- 应用程序准备pollfd结构数组
- 设置超时时间
- 调用poll()函数
- 进入内核空间,复制pollfd数组
- 内核遍历所有文件描述符,检查它们的状态
- 如果没有就绪的文件描述符,等待事件或超时
- 更新pollfd数组的revents字段
- 返回用户空间
- 应用程序检查poll()的返回值
- 遍历pollfd数组,处理就绪的文件描述符
- 重置pollfd数组,准备下一次调用
这个流程图详细展示了poll的工作原理,包括用户空间和内核空间的操作,以及如何处理就绪的文件描述符。
工作流程总结
-
准备阶段:
- 应用程序创建pollfd结构数组,每个pollfd包含文件描述符、请求的事件和返回的事件。
-
调用poll:
- 应用程序调用poll函数,传入pollfd数组、数组长度和超时时间。
- poll将pollfd数组从用户空间拷贝到内核空间。
-
内核处理:
- 内核遍历pollfd数组中的所有文件描述符,检查它们的状态。
- 如果没有就绪的文件描述符且未超时,进程会被阻塞。
-
返回结果:
- 当有文件描述符就绪或超时时,poll返回。
- 内核修改pollfd数组中的revents字段,标记就绪的事件,并将结果拷贝回用户空间。
-
处理结果:
- 应用程序遍历pollfd数组,检查revents字段以确定哪些文件描述符已就绪。
- 对就绪的文件描述符进行相应的I/O操作。
3. epoll
特点:
- 能监控的文件描述符数量无限制
- 使用事件驱动机制,只有活跃的fd才会调用回调函数
- 内核使用红黑树来跟踪所有待检测的fd,时间复杂度O(1)
- 支持边缘触发(ET)和水平触发(LT)两种模式
使用:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll 工作流程
+----------------------------------------------+
| 应用程序 |
+----------------------------------------------+
|
| 1. 创建epoll实例
v
+----------------------------------------------+
| int epfd = epoll_create(size); |
+----------------------------------------------+
|
| 2. 准备epoll_event结构
v
+----------------------------------------------+
| struct epoll_event ev; |
| ev.events = EPOLLIN | EPOLLET; /* 事件类型*/|
| ev.data.fd = fd; /* 关联的文件描述符 */ |
+----------------------------------------------+
|
| 3. 注册事件到epoll实例
v
+----------------------------------------------+
| epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); |
+----------------------------------------------+
|
| 4. 等待事件发生
v
+----------------------------------------------+
| struct epoll_event events[MAX_EVENTS]; |
| int nfds = epoll_wait(epfd, events, |
| MAX_EVENTS, -1); |
+----------------------------------------------+
|
| 5. 进入内核空间
v
+----------------------------------------------+
| 内核空间 |
| +----------------------------------------+ |
| | 将文件描述符fd注册到epfd | |
| +----------------------------------------+ |
| | |
| v |
| +----------------------------------------+ |
| | 等待fd上的事件发生 | |
| | +------------------------------------+| |
| | | 使用红黑树管理fd || |
| | | 使用就绪列表跟踪就绪事件 || |
| | +------------------------------------+| |
| +----------------------------------------+ |
| | |
| | 6. 事件发生 |
| v |
| +----------------------------------------+ |
| | 将就绪的事件放入就绪列表 | |
| +----------------------------------------+ |
| | |
| | 7. 返回用户空间 |
| v |
+----------------------------------------------+
| epoll_wait() 返回 |
| 返回值: 就绪的事件数量 |
+----------------------------------------------+
|
| 8. 处理就绪的事件
v
+----------------------------------------------+
| for (int n = 0; n < nfds; ++n) { |
| if (events[n].events & EPOLLIN) { |
| // 处理读事件 |
| } |
| if (events[n].events & EPOLLOUT) { |
| // 处理写事件 |
| } |
| // 处理其他事件... |
| } |
+----------------------------------------------+
|
| 9. 循环等待更多事件或结束
v
+----------------------------------------------+
| 继续等待或退出程序 |
+----------------------------------------------+
epoll的详细工作流程:
- 应用程序创建epoll实例。
- 准备epoll_event结构,设置感兴趣的事件类型和关联的文件描述符。
- 使用epoll_ctl将事件注册到epoll实例。
- 调用epoll_wait等待事件发生,可以指定超时时间或无限等待。
- 进入内核空间,内核处理事件注册和等待逻辑。
- 当事件发生时,内核将就绪的事件放入就绪列表。
- epoll_wait返回到用户空间,返回就绪的事件数量。
- 应用程序遍历返回的事件数组,根据事件类型进行相应处理。
- 应用程序可以循环调用epoll_wait等待更多事件,或者在适当的时候结束程序。
这个流程图详细展示了epoll的工作原理,包括用户空间和内核空间的操作,以及如何处理就绪的事件。epoll通过使用红黑树管理所有监视的文件描述符和就绪列表跟踪就绪事件,提供了高效的事件通知机制。
工作流程总结
-
创建epoll实例:
- 应用程序调用epoll_create创建一个epoll实例,返回一个文件描述符(epfd)。
-
注册文件描述符:
- 使用epoll_ctl添加要监视的文件描述符到epoll实例。
- 指定每个文件描述符感兴趣的事件类型(如EPOLLIN、EPOLLOUT等)。
-
等待事件:
- 应用程序调用epoll_wait,传入epoll实例的文件描述符、用于接收就绪事件的数组、最大事件数和超时时间。
- 如果没有就绪的事件且未超时,进程会被阻塞。
-
内核处理:
- 内核检查epoll实例中注册的文件描述符。
- 当有事件发生时,内核将就绪的文件描述符添加到就绪列表中。
-
返回结果:
- 当有事件就绪或超时时,epoll_wait返回。
- 内核将就绪事件的信息复制到用户空间提供的事件数组中。
-
处理结果:
- 应用程序遍历返回的事件数组,直接处理就绪的文件描述符。
- 不需要像select和poll那样遍历所有被监视的文件描述符。
epoll 工作模式
epoll
是 Linux 下一种高效的事件通知机制,用于处理大量并发的 socket 连接。它提供了两种工作模式:水平触发(Level Triggered,LT)和边缘触发(Edge Triggered,ET)。这两种模式定义了文件描述符就绪事件被 epoll
捕获和通知给应用程序的方式。
水平触发(Level Triggered,LT)
-
特点:当被监视的文件描述符上有可进行的操作时,
epoll
会通知应用程序,而且只要这种状态持续存在,epoll_wait
就会重复通知该事件。这意味着,如果应用程序没有处理该事件(例如,读取操作没有读取数据直到返回EAGAIN),epoll_wait
会再次返回该事件。 -
适用场景:LT 模式比较容易理解和使用,适用于大多数应用场景。它对文件描述符的读写操作没有特殊要求。
边缘触发(Edge Triggered,ET)
-
特点:只有当被监视的文件描述符的状态发生变化时(例如,从非就绪变为就绪),
epoll
才会通知应用程序一次。这意味着,应用程序必须一次性处理完所有事件(例如,读取操作需要一直读取数据直到返回EAGAIN),因为只要文件描述符保持在同一状态,epoll_wait
不会再次通知该事件。 -
适用场景:ET 模式能够减少事件通知的次数,提高应用程序处理大量并发连接的效率。它特别适用于高性能网络服务器。但是,使用ET模式需要更加小心地编写代码,确保正确处理所有数据。
模式选择
-
默认模式:
epoll
默认使用水平触发(LT)模式。 -
设置ET模式:可以在调用
epoll_ctl
添加事件时,通过设置EPOLLET
标志来启用边缘触发模式。
注意事项
-
在ET模式下,由于事件只会通知一次,应用程序必须使用非阻塞I/O操作来避免在读写操作时阻塞,确保能够处理所有数据。
-
在多线程环境中使用ET模式时,需要特别注意同步和并发控制,避免多个线程同时操作同一个文件描述符导致的竞态条件。
-
无论是LT模式还是ET模式,正确地处理
EAGAIN
错误都是非常重要的,它表示当前没有更多的数据可以读取或写入。
通过选择合适的工作模式,epoll
能够为不同的应用场景提供高效的事件处理能力。
总结
select
、poll
和 epoll
都是 I/O 多路复用的机制,用于同时监控多个文件描述符。以下是它们的主要区别:
-
文件描述符数量限制
- select: 受限于 FD_SETSIZE,通常为 1024
- poll: 无固定限制
- epoll: 无固定限制
-
工作机制
- select: 轮询机制,每次调用都要扫描全部监视的文件描述符
- poll: 轮询机制,与 select 类似
- epoll: 事件驱动机制,只有活跃的文件描述符才会调用回调函数
-
数据结构
- select: 使用 fd_set 位图结构
- poll: 使用 pollfd 结构数组
- epoll: 使用红黑树存储文件描述符
-
效率
- select/poll: O(n),n 为文件描述符数量
- epoll: O(1)
-
触发方式
- select/poll: 水平触发
- epoll: 支持水平触发和边缘触发
-
内存拷贝
- select/poll: 每次调用都需要将 fd 集合从用户态拷贝到内核态
- epoll: 通过 mmap 减少内存拷贝
-
系统调用次数
- select/poll: 每次操作都需要系统调用
- epoll: 执行 epoll_ctl 时才需要系统调用
-
可移植性
- select: 几乎所有平台都支持
- poll: 大多数 Unix 系统支持
- epoll: Linux 特有
-
适用场景
- select/poll: 适用于连接数较少且连接都十分活跃的场景
- epoll: 适用于连接数较多但大多数连接不活跃的场景
-
API 复杂度
- select/poll: 相对简单
- epoll: 相对复杂,但提供更多的控制和灵活性
-
性能随 fd 数量增加的变化
- select/poll: 性能随 fd 数量增加而线性下降
- epoll: 性能基本不受 fd 数量影响
总的来说,epoll 在处理大量并发连接时性能最佳,特别是在大量连接中只有少部分活跃的情况下。但 select 和 poll 在可移植性和简单性方面仍有其优势。