I/O多路复用是一种在单个线程中管理多个输入/输出通道(描述符)的技术,它允许程序同时监听多个输入流,并在有数据可读或可写时进行相应的处理,而不需要为每个通道创建一个独立的线程。
I/O多路复用是为了解决传统同步阻塞I/O模型中一个线程只能处理一个客户端连接的低效问题而引入的技术。在传统的BIO模型中,服务器在处理客户端请求时会阻塞在某个操作上,直到操作完成才能处理下一个请求。这种方式在高并发场景下会导致大量线程被占用,系统资源浪费严重。为了解决这个问题,NIO模型被引入,它允许一个线程轮流处理多个连接,但仍需不断轮询检查各个连接状态,这依然会浪费CPU资源。
I/O多路复用技术通过select、poll和epoll等系统调用来避免这种资源浪费。这些机制将多个I/O通道注册到一个事件管理器中,并通过阻塞方式等待事件的发生。当有事件发生时,线程被唤醒并处理具体事件,从而有效管理大量I/O通道,减少线程创建和销毁开销。
相关概念
描述符:文件描述符是操作系统为打开的文件分配的一个数字标识。在Unix和类Unix系统中,文件描述符可以用于表示任何类型的文件,包括网络套接字。
阻塞与非阻塞IO:阻塞I/O意味着调用会一直等待直到操作完成;而非阻塞I/O则会在没有数据可用时立即返回一个错误或特殊值。
事件驱动:I/O多路复用通常采用事件驱动模型,在这个模型中,程序等待多个事件中的任意一个发生,然后根据发生的事件来执行相应的处理逻辑。
select
select
允许程序同时监视多个文件描述符,等待它们中的一个或多个变得“就绪”,即可以无阻塞地进行读取、写入或发生异常。
select
调用的基本功能是:监视一组文件描述符,等待其中一个或多个文件描述符变得可读、可写或有异常发生。一旦某个文件描述符就绪,select
就会返回。
函数原型
#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
:指向timeval
结构体的指针,用于设置select
调用的超时时间。如果设置为NULL,select
将一直阻塞直到至少一个文件描述符就绪。
select()
的特点
- 有限的文件描述符:
select()
的一个限制是它只能监控最多FD_SETSIZE
个文件描述符,默认通常是 1024。 - 效率: 当监控的文件描述符数量增加时,效率会下降,因为它需要线性扫描所有的文件描述符来检查哪些已经准备好。
- 复制问题: 调用
select()
之前需要将描述符集合复制到本地变量中,而返回后需要再次复制回原来的集合。
poll
poll是继select之后推出的一个用于I/O多路复用的系统调用,旨在解决select的一些限制。
与select类似,poll允许一个进程监视多个文件描述符,等待它们中的任何一个变为可读或可写状态,然后再进行实际的I/O操作。
函数原型
在 C 语言中,poll()
函数的基本原型如下:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- fds: 是一个指向
struct pollfd
数组的指针,该数组包含要监视的文件描述符及其相关的事件。 - nfds: 是一个整数,表示监视的文件描述符的数量。
- timeout: 是一个整数,定义了
poll()
函数的超时时间(以毫秒为单位)。如果为负数,poll()
将无限期地等待,直到有一个描述符准备好或者发生错误。如果为 0,则poll()
会立即返回,即使没有描述符准备好。
struct pollfd
结构体用于存储文件描述符及其相关事件:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监视的事件 */
short revents; /* 已发生的事件 */
};
- fd: 文件描述符。
- events: 指定要监视的事件。常见的事件标志有:
POLLIN
: 描述符可读(有数据可读)。POLLPRI
: 有紧急数据可读。POLLOUT
: 描述符可写。POLLERR
: 发生错误。POLLHUP
: 描述符挂起。POLLNVAL
: 描述符无效。POLLREMOVE
: 描述符已被移除。
- revents: 返回时设置,表示已发生的事件。
运行原理
poll使用pollfd
结构体代替了select中的fd_set
位图。每个pollfd
结构体包含一个文件描述符、一个事件集和一个用于存储返回结果的revents
字段。
当调用poll时,内核会根据各文件描述符的状态,设置对应的revents
字段并返回就绪的文件描述符数量。这样,就无需像select那样在返回后遍历整个文件描述符集合来找出就绪的描述符。
poll()
的特点
- 无文件描述符限制:
poll()
不像select()
那样受到FD_SETSIZE
的限制,理论上它可以监视任意数量的文件描述符。 - 更灵活的事件模型:
poll()
支持更多的事件类型,例如紧急数据可用 (POLLPRI
) 和描述符挂起 (POLLHUP
)。 - 效率:
poll()
通常比select()
更高效,因为它不需要在每次调用时复制整个描述符集。 - 事件持久性:
poll()
中的事件不会被清除,这意味着如果应用程序没有处理某个事件,那么下一次调用poll()
时,该事件仍然会被报告。 - 文件描述符数量较多时性能下降:虽然
poll()
解决了select()
的文件描述符数量限制问题,但在文件描述符数量非常多的情况下,仍然需要在内核中遍历所有文件描述符来检查其状态,这会导致性能下降。 - 不支持真正的异步通知:
poll()
仍然需要主动查询文件描述符的状态,而非真正的事件驱动方式,这在高并发场景下可能不是最优选择。
epoll
epoll
是Linux 内核 2.6 版本引入的特有的I/O多路复用机制,它解决了 select
和 poll
在处理大量文件描述符时的一些性能问题。
epoll
提供了一种方法,允许程序监视多个文件描述符,并且只在实际发生事件时才通知应用程序。它使用一个事件表来跟踪每个文件描述符的状态,而不是像 select
和 poll
那样每次调用都需要传递整个文件描述符集合。
工作原理
epoll使用事件驱动机制,并采用红黑树和链表结合的数据结构来管理文件描述符,这样在文件描述符数量较多时依然能保持高性能。
epoll支持两种触发模式——水平触发(LT)和边缘触发(ET)。LT模式下,只要描述符状态符合条件就会触发,而ET模式下只有在状态发生变化时才触发。
主要函数
epoll
操作涉及以下三个系统调用:
epoll_create()
: 创建一个新的 epoll 文件描述符。epoll_ctl()
: 添加、修改或删除要监控的文件描述符。epoll_wait()
: 等待文件描述符上的 I/O 事件。
函数原型
#include <sys/epoll.h>
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_create
:size
:告知内核事件表预计要监听的事件数量。这个参数从Linux 2.6.8之后不再起实际作用,但必须大于0。
epoll_ctl
:epfd
:epoll_create
返回的epoll
文件描述符。op
:要执行的操作,可以是EPOLL_CTL_ADD
(添加)、EPOLL_CTL_MOD
(修改)、EPOLL_CTL_DEL
(删除)。fd
:要操作的文件描述符。event
:指向epoll_event
结构体的指针,用于描述监视的文件描述符和事件。
epoll_wait
:epfd
:epoll_create
返回的epoll
文件描述符。events
:指向epoll_event
结构体数组的指针,用于存放就绪的事件。maxevents
:events
数组可以存储的最大事件数量。timeout
:等待事件的超时时间(毫秒)。设置为-1表示无限期等待,设置为0表示立即返回。
epoll_event 结构体
查看代码
struct epoll_event {
uint32_t events; /* 描述事件类型 */
epoll_data_t data; /* 用户数据 */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events
:描述事件类型,可以是以下值的组合:EPOLLIN
: 描述符可读(有数据可读)。EPOLLOUT
: 描述符可写。EPOLLPRI
: 有紧急数据可读。EPOLLERR
: 错误事件。EPOLLHUP
: 描述符挂起。EPOLLET
: 边缘触发模式。EPOLLONESHOT
: 单次触发模式。EPOLLEXCLUSIVE
: 排他模式。
使用步骤
- 调用
epoll_create
创建一个epoll
实例。 - 调用
epoll_ctl
添加、修改或删除要监视的文件描述符。 - 调用
epoll_wait
等待文件描述符就绪。 - 处理就绪的文件描述符。
epoll()
的特点
- 高效处理大量文件描述符:
epoll
使用事件回调机制,即当描述符的状态发生变化时,内核会主动通知应用程序,这大大减少了不必要的上下文切换和内存拷贝。 - 边缘触发(ET)模式:仅在事件首次发生时被报告,避免了不必要的事件重复通知。
- 水平触发(LT)模式:与
select
和poll
类似,只要描述符还有未处理的事件,就会一直被报告,但通常性能更好。 - Linux特有:
epoll
只在Linux系统上可用,不是跨平台的。 - 边缘触发模式复杂度:边缘触发模式需要更复杂的逻辑来确保不会错过任何事件。
水平触发模式
在水平触发模式下,epoll
会持续报告一个文件描述符的事件,只要该描述符保持在准备就绪的状态。换句话说,只要描述符处于可读或可写状态,epoll_wait()
就会一直报告相应的事件。
当描述符变为可读时,epoll_wait()
会报告 EPOLLIN
事件,只要描述符保持可读状态,每次调用 epoll_wait()
都会报告此事件。
当描述符变为可写时,epoll_wait()
会报告 EPOLLOUT
事件,只要描述符保持可写状态,每次调用 epoll_wait()
都会报告此事件。
特点
- 持续报告:只要文件描述符可读或可写,就会持续报告
EPOLLIN
或EPOLLOUT
事件。 - 易于编程:因为事件会持续报告,所以应用程序不需要担心错过事件。
- 适用于大多数情况:对于大多数应用程序来说,LT 模式是默认选择,因为它的实现更为简单,不容易出错。
边缘触发模式
在边缘触发模式下,epoll
只会在文件描述符的状态从“未准备就绪”变化到“准备就绪”的那一刻报告事件。换句话说,只有在描述符的状态首次发生变化时,才会触发事件。
当描述符首次变为可读时,epoll_wait()
会报告 EPOLLIN
事件。一旦事件被触发,除非描述符再次变为可读(例如有新数据到达),否则不会再次报告此事件。
当描述符首次变为可写时,epoll_wait()
会报告 EPOLLOUT
事件。一旦事件被触发,除非描述符再次变为可写(例如缓冲区变得空闲),否则不会再次报告此事件。
特点
- 仅报告一次:一旦事件被触发,除非描述符再次发生变化,否则不会重复触发该事件。
- 更高的性能:由于事件只报告一次,因此可以减少不必要的上下文切换和内存拷贝,从而提高性能。
- 需要小心处理:应用程序必须确保在事件触发后完全处理所有可用数据,否则可能会错过数据。