IO
多路复用:复用一个线程,处理多个socket
中的事件。能够资源复用,防止创建多线程导致的上下文切换的开销。
五种I/O模式
IO 模型 | 工作方式 | 特点 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|---|
阻塞式IO | 应用程序发起IO 请求后,等待IO 操作完成 | 简单易用,但会阻塞进程 | 编程模型简单 | 效率低,无法处理高并发 | 简单的应用,IO 操作不频繁 |
非阻塞式IO | 应用程序发起IO 请求后,立即返回,通过轮询检查IO 操作是否完成 | 非阻塞,但需要频繁检查 | 可以处理更多的并发请求 | 效率不高,CPU 资源浪费 | 需要处理一定并发,但并发量不是特别高 |
IO 复用 | 应用程序使用select/poll/epoll 监听多个IO 事件,事件发生时通知应用程序 | 可以同时处理多个IO 请求 | 高效处理大量并发连接 | 编程复杂,需要管理多个文件描述符 | 高并发服务器,如Web服务器 |
信号驱动式IO | 应用程序发起IO 请求后,通过信号通知应用程序IO 操作完成 | 非阻塞,响应式 | 可以异步处理IO 事件 | 编程复杂,信号处理可能引入竞态条件 | 需要异步处理IO 事件的场景 |
异步IO | 应用程序发起IO请求后,系统完成IO 操作后通知应用程序 | 真正的非阻塞,无需轮询或信号 | 编程模型简单,效率高 | 实现复杂,资源消耗大 | 高并发、高性能要求的应用,如数据库 |
select→poll→epoll
进程通过一个系统调用函数从内核中获取多个事件。
获取网络事件的方式:在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态处理这些连接的请求。
特性/技术 | select | poll | epoll |
---|---|---|---|
描述 | 传统的多路复用机制,用于监听一组文件描述符。 | 对 select 的改进,用于监听多个文件描述符。 | Linux 提供的一种 I/O 事件通知机制。 |
文件描述符限制 | 受限于 FD_SETSIZE 宏定义,默认通常是 1024 。 | 没有文件描述符数量上的限制。 | 没有文件描述符数量上的限制,适合大规模并发连接。 |
工作方式 | 轮询文件描述符集合,检查是否有文件描述符就绪。 | 遍历指定的文件描述符数组,检查是否有就绪的 I/O 事件。 | 监视大量的文件描述符,并在文件描述符就绪时触发事件。 |
触发模式 | 仅支持水平触发。 | 仅支持水平触发。 | 支持水平触发(默认)和边缘触发(EPOLLET )。 |
性能 | 在文件描述符较少时性能可接受,连接数较大时效率下降。 | 性能优于 select ,在大量文件描述符上仍可能有性能问题。 | 较高的 I/O 效率,适用于大规模并发连接。 |
适用场景 | 较小规模的并发连接或简单的客户端程序。 | 中等规模的并发连接。 | 大规模并发连接,如高性能服务器。 |
优化 | 文件描述符限制⇒ 轮询⇒ | struct_pollfd 数组⇒ | - 事件驱动模型维护事件表 - 支持边缘触发 |
select/poll
select
需要遍历2
次文件描述符集合,拷贝2
次文件描述符集合。
select
多路复用实现方式:将已连接的Socket
都放在一个文件描述符集合,然后将文件描述符集合拷贝到内核里,由内核遍历文件描述符检查是否有网络事件,当检查到可读可写后,标记该socket
为可读可写,然后再将这个文件描述符集合拷贝到用户态,用户态遍历找到可读可写Socket
。
select
采用固定长度的BisMap
表示文件描述符集合,受内核中FD_SETSIZE
限制。poll
使用动态数组替代,以链表形式来组织。
由于select
和poll
都是采用线性结构存储集合,都需要时间遍历找到可读可写的Socket
,同时也需要在用户态和内核态间拷贝。
epoll
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...);
/**
* @brief 创建一个epoll实例,用于I/O多路复用。
* @param size 可选的初始大小,用于指定epoll实例可以管理的文件描述符数量。
* @return 返回新创建的epoll文件描述符,或在出错时返回-1。
*/
int epfd = epoll_create(/* size */);
/**
* @brief 将一个socket添加到epoll实例中进行监听。
* @param epfd epoll文件描述符。
* @param op 指定要执行的操作,如EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)或EPOLL_CTL_DEL(删除)。
* @param fd 要监听的socket文件描述符。
* @param event 指向struct epoll_event的指针,包含监听事件的类型和数据。
* @return 成功时返回0,失败时返回-1。
*/
epoll_ctl(epfd, EPOLL_CTL_ADD, s, ...);
/**
* @brief 无限循环,持续监听和处理I/O事件。
*/
while(1) {
/**
* @brief 等待I/O事件的发生。
* @param epfd epoll文件描述符。
* @param events 指向epoll_event数组的指针,用于存储就绪的事件。
* @param maxevents 指定events数组的最大长度。
* @param timeout 指定等待事件的最长时间(毫秒),或0表示立即返回,或-1表示无限等待。
* @return 返回就绪的事件数量,或在出错时返回-1。
*/
int n = epoll_wait(epfd, ...);
/**
* @brief 遍历所有就绪的socket,处理它们的I/O事件。
*/
for(int i = 0; i < n; ++i) {
// 处理接收到数据的socket
}
}
epoll
通过两个方面优化解决select/poll
的问题。
- 红黑树保存待检测的文件描述符:
epoll
使用红黑树来跟踪进程所有待检测的文件描述符,通过epoll_ctl()
将需要监控的socket
加入到内核的红黑树中。 - 就绪链表:
epoll
采用事件驱动机制,当事件发生时,通过回调函数将其加入到就绪事件列表,用户通过调用epoll_wait()
得到有事件发生的文件描述符个数,不再需要轮询整个集合。
拷贝开销
- 每次调用
select
,都需要将文件描述符集合从用户态拷贝到内核态,这个开销在文件描述符很多时会很大。 epoll
保证了每个文件描述符在整个过程中只会拷贝一次,因此不会有拷贝开销的增加。
遍历开销
- 每次调用
select
,需要在内核遍历传递进来的所有文件描述符,即使只有很少的文件描述符就绪。 epoll
只需要轮询一次文件描述符集合,然后查看就绪链表中有没有就绪的文件描述符即可,避免了遍历所有文件描述符的开销。
文件描述符数量限制
select
有一个固定的限制,它支持的文件描述符数量较小,默认情况下是1024
。epoll
没有这个限制,它所支持的文件描述符上限取决于系统最大可以打开的文件数目,可使用ulimit -a
查看。
epoll 水平与边缘触发
特性/触发模式 | 水平触发(LT) | 边缘触发(ET) |
---|---|---|
定义 | 只要文件描述符准备好,就会通知。 | 只在文件描述符状态首次变化时通知一次。 |
触发条件 | 有数据可读或可写时触发,即使已通知过。 | 仅在状态从“未准备”变为就绪时触发。 |
通知频率 | 持续通知,直到read 所有数据。 | epoll_wait() 只通知一次,需一次性将内核缓冲区数据读取完成。 |
配置方式 | 默认模式,无需特殊标志。 | 需要设置 EPOLLET 标志。 |
I/O 方式 | 可以使用阻塞或非阻塞 I/O 。 | 不知道能读多少数据,所以需要循环读写数据,防止进程阻塞必须使用非阻塞 I/O 。 |
应用场景 | 数据量大且需要多次读写的场景。 | 减少系统调用,需要快速响应状态变化且避免错过事件的场景。 |
零拷贝
传统 IO
的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4
次上下文切换,和 4
次数据拷贝,其中 2
次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA
完成,另外 2
次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU
完成的。
零拷贝(Zero-copy) 技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU
来搬运数据,所有的数据都是通过 DMA
来进行传输的。
零拷贝技术的文件传输方式通过一次系统调用sendfile
方法合并了磁盘读取和网络发送两个操作,减少了 2 次上下文切换和数据拷贝次数。
只需要
2
次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2
次的数据拷贝过程,都不需要通过 CPU
,2
次都是由 DMA
来搬运。
零拷贝技术可以把文件传输的性能提高至少一倍以上。