io-uring学习总结
这两天学了一些关于io-uring的内容,但是要理解io-uring的性能为什么快,我觉得应该先了解传统的IO多路复用,也就是Select、Poll以及Epoll,然后才能明白为什么Io-uring比较快。
IO的基本流程
传统IO的基本流程一般都是应用程序也就是用户态下的代码发起IO请求,然后发生系统调用,系统由用户态转移到内核态,内核进行IO操作,得到结果之后把内容拷贝进用户空间,这里的开销主要在于上下文切换和IO本身的开销,因为IO相对于cpu运行的速度来说是非常慢的,而且磁盘运行速度也远低于内存,我们为了等待io就会浪费大量的cpu资源,因此出现了IO模型
IO模型的种类
在Linux下一般有如下五种IO模型
- blocking I/O
- nonblocking I/O
- I/O multiplexing (select 、poll、 epoll)
- signal driven I/O (SIGIO)
- asynchronous I/O (the POSIX aio_functions)
下面我们逐一介绍
blocking I/O
顾名思义,就是阻塞IO,在这个IO做完之前,程序必须等待数据返回,这对文件的I/O倒还好,如果是网络I/O的话就会浪费相当多的时间,因为对方不一定当时给你发数据,一直等待会造成cpu资源的浪费
nonblocking I/O
非阻塞式IO,当我们发起IO请求后,可以等一段时间发送一个syscall问问操作系统数据准备好了没有,但是这样的话会产生很多次上下文切换,产生许多不必要的开销,像文件系统I/O这种相对来说比较快的IO比阻塞式还多了一次syscall,反而得不偿失
I/O多路复用
I/O多路复用就是在非阻塞I/O的前提下,一次监视多个文件描述符,一次系统调用,系统会一次性告诉你完成的I/O操作,这里多路复用又分为三种模型,Select、Poll和Epoll
Select
机制简介:
- select 是一种较早的 I/O 多路复用机制,允许在单次系统调用中同时监视多个文件描述符。
- 用户需要传递三个文件描述符集合:分别对应读、写和异常事件。
- 内核会阻塞进程并监视这些文件描述符的状态变化,直到有文件描述符就绪或超时。
优缺点:
- 优点:
- 简单,几乎所有操作系统都支持。
- 适合监视文件描述符数量较少的场景。
- 缺点:
-文件描述符数量有限(通常 FD_SETSIZE 默认值为 1024,编译时可修改)。
-每次调用都需要将文件描述符集合从用户态复制到内核态,效率较低。
-内核采用线性扫描检查文件描述符状态,时间复杂度为 O(n)。
实现细节:
- 内核会修改传入的 fd_set,移除未就绪的文件描述符。
- 用户需要使用宏 FD_ISSET 检查每个文件描述符是否就绪。
Poll
Poll其实没什么可介绍的地方,因为Select能传递的文件描述符有限,满足不了大量的文件描述符,于是就有了Poll,但是原理几乎是一样的,只不过传递的是数组,Select传递的是位图
Epoll
机制简介:
epoll 是 Linux 内核提供的一种高效 I/O 多路复用机制,适合大量文件描述符的监控场景。
用户通过三个系统调用实现操作:
- epoll_create:创建一个 epoll 实例。
- epoll_ctl:向 epoll 实例中添加或删除文件描述符。
- epoll_wait:等待文件描述符事件。
文件描述符及其关心的事件会存储在内核中的红黑树中,监听状态变化。
优缺点:
- 优点:
- 使用事件驱动机制,无需每次传递所有文件描述符。
- 文件描述符存储在内核中,避免重复传递数据。
- 支持水平触发(LT)和边沿触发(ET)模式,灵活性更强。
- 高效:就绪文件描述符通过链表管理,避免线性扫描。
- 缺点:
- 仅支持 Linux 系统。
- 边沿触发模式下可能需要额外处理复杂的逻辑。
实现细节:
内核通过事件通知机制管理文件描述符的状态变化,效率较高。
epoll_wait 返回的结果只包含就绪的文件描述符,无需遍历所有文件描述符。
Epoll快速的原因主要是在于它改变了底层的机制,Select和Poll是在每次询问的时候,内核去看看有没有做好,这是一个O(n)的操作,而且返回之后用户还要手动遍历fd_set,看看都完成了吗,如果完成的比较好,那大部分的遍历都是浪费的,而Epoll则是在内核中把文件描述符集合用红黑树存储起来,这样的话查找是一个O(logn)的操作,每次有I/O操作完成时会触发中断,中断处理搜索epoll集合,存在的话就把这个描述符丢到返回队列里,等用户请求的时候直接把返回队列返回,这样的话用户也不需要遍历没有完成的事件,通过回调优化了速度
Signal-Driven I/O
这种信号驱动的I/O并不常见,从图片可以看到它第一次发起system call不会阻塞进程,kernel的数据就绪后会发送一个signal给进程。进程发起真正的IO操作。
这种I/O模型有点复杂,且存在一些限制。有兴趣请点击Signal-Driven I/O for Sockets查看。
异步I/O
异步I/O就是不会引起阻塞,其实上面的信号驱动I/O也算是一种异步I/O,不过不是完全的异步,因为Singal-Driven的I/O会阻塞当前的进程,你可以去运行别的进程,虽然不消耗资源,但是当前进程别的内容也做不了,而异步I/O不会阻塞当前的进程,你该做什么还可以继续做什么,需要异步框架的支持
工作机制:
- 异步 I/O 直接交由内核完成。用户进程发起 I/O 操作后立即返回,由内核在后台完成 I/O 数据的传输。
- 当 I/O 操作完成时,内核通知用户进程(例如通过回调函数、事件队列等)。
= 用户进程无需主动调用 read 或 write,数据的传输完全由内核负责。
特点:
- 完全异步:用户进程发起请求后无需关心具体的 I/O 执行过程。
- 通知机制:通常通过回调函数或事件机制通知用户 I/O 操作已完成。
优缺点:
- 优点:
- 真正的异步模型,用户进程完全无需等待或主动检查 I/O 状态。
- 高效,适合高并发场景。
- 缺点:
- 实现和使用相对复杂(如需要支持 AIO 或其他异步框架)。
- 对底层支持有依赖,比如 POSIX AIO 或其他异步 I/O 库。
io-uring
背景
传统的 I/O 模型(如 select、poll、epoll)都存在性能瓶颈,特别是在高并发和大量 I/O 请求的场景下,于是io-uring便应运而生.
传统I/O模型的性能瓶颈
- 频繁的用户态和内核态切换。
- I/O 操作本身的阻塞问题。
- 对文件描述符的线性扫描效率低下(select 和 poll)。
io-uring 的诞生:
io-uring 是 Linux 内核从 5.1 开始引入的新型 I/O 模型,旨在通过优化用户态与内核态的交互、支持完全异步操作,解决传统模型的性能瓶颈。
通过共享环形队列的方式,将 I/O 操作的效率提升到了一个新水平。
io-uring 的核心机制
- 环形队列:
- 提交队列(Submission Queue, SQ):用户进程通过共享内存直接将 I/O 请求写入队列中,内核从中读取并执行。
- 完成队列(Completion Queue, CQ):内核将完成的 I/O 操作结果写入完成队列,用户进程读取结果。
- 完全异步:
- 用户发起 I/O 请求后立即返回,由内核在后台完成 I/O 操作并通知用户。
- 支持链式操作:
- 允许一次性提交多个关联的 I/O 操作,内核以流水线方式处理,进一步减少交互次数。
相对于传统I/O多路复用的优势
io-uring主要是使用了共享内存来避免系统调用发生的上下文切换,进而优化了效率,不过相对于epoll来说,它执行的主要是事件驱动的I/O,而不是像epoll那样需要动态监视文件描述符集合,它主要的操作流程是这样:
- 显式注册文件I/O请求,io-uring会把它放入到SQ中,tail指针向后移动
- 内核读取SQ,进行I/O请求,head指针向后移动
- I/O请求完毕,内核收到中断,中断处理时把结果放入到CQ,中间没有发生系统调用
- 用户查看CQ,发现已经完成,移除CQ中已经完成的事件,移动CQ的head指针