前言
最近在看Rocksdb中关于异步I/O与预读的代码,有点不太理解的是如果linux内核版本不够高的话,Rocksdb异步I/O会完全回退到同步的I/O操作,而不是尝试采用linux的另一种aio的方式来达到异步。同时之前或多或少接触过io_uring的知识,这是正好有机会做个总结,和aio对比一下。内容总共分为两篇,这一篇主要用图解的方式讲明aio的实现原理和流程。
异步I/O
有关同步与异步I/O的对比以及常见的5种I/O模型,会在另外一篇文章中讲述,在这里就简单说明。异步I/O是指当一个进程发起I/O请求之后,不等待结果,而是由内核在I/O请求完成之后通过信号或者回调的方式通知进程。
在经典的计算机存储结构中,设备的存取速度自上而下由快到慢。数据流从最上层的寄存器(L1/2/3 cache)开始,到达内存DRAM,进而到达持久化磁盘。同时我们也知道处理器的运行速度远远快过内存的速度,更远远快于从磁盘上存取的数据的速度(暂时不去考虑近年来不断涌现的新的存储介质,例如SSD/NVMe盘,持久化内存等)。于是,当处理器发出一次I/O请求时,总是会存在一个等待磁盘将数据返回给处理器的时间差。为了不让CPU在这段时间进行忙等,就有了中断的操作,让CPU先去处理其他的事情,在磁盘数据准备完成之后再通过中断的方式通知CPU。有关中断的详细讲解也可以等我后续更新的另一篇文章。
Linux aio
Linux下的aio接口是Linux内核支持的原生异步I/O接口,完全不同于一些利用线程模拟异步的第三方aio库。aio接口最适合的场景是通过O_DIRECT绕过page cache访问块存储设备,例如磁盘或者存储阵列。
调用流程
io_setup
创建一个异步I/O请求io_submit
向内核的I/O任务队列中提交一个I/O请求。内核会在后台进行调度处理任务队列中的I/O任务,然后在执行后将结果存储在I/O任务中。- 进程通过
io_getevents
获取I/O请求状态,如果返回失败则请求还没有完成,否则返回请求结果。
实现原理
异步I/O上下文的表示
在Linux内核中,异步I/O上下文由kioctx
结构体表示。
struct kioctx {
atomic_t users; // 引用计数器
int dead; // 是否已经关闭
struct mm_struct *mm; // 对应的内存管理对象
unsigned long user_id; // 唯一的ID,用于标识当前上下文, 返回给用户
struct kioctx *next;
wait_queue_head_t wait; // 等待队列
spinlock_t ctx_lock; // 锁
int reqs_active; // 正在进行的异步IO请求数
struct list_head active_reqs; // 正在进行的异步IO请求对象
struct list_head run_list;
unsigned max_reqs; // 最大IO请求数
struct aio_ring_info ring_info; // 环形缓冲区
struct work_struct wq;
};
在kioctx的成员变量中,比较重要的是用来表示I/O任务队列环的wait
,用来保存I/O操作结果的环形缓冲区ring_info
,以及保存所有正在处理的I/O请求的链表active_reqs
。整体的结构如下图所示。
可以看到,active_reqs
是由一个一个的kiocb
结构体组成。先看它的定义,
struct kiocb {
...
struct file *ki_filp;
struct kioctx *ki_ctx;
...
struct list_head ki_list;
__u64 ki_user_data;
loff_t ki_pos;
...
};
ki_flip
:异步I/O操作的文件对象。ki_ctx
:异步I/O的上下文ki_list
:这个很好理解,维护链表构成的指针ki_user_data
:用户data指针ki_pos
:异步I/O文件offset
至于aio_ring_info
,其相当于环形缓冲区的一个metadata区域。同样先看定义,
struct aio_ring_info {
unsigned long mmap_base; // 环形缓冲区的虚拟内存地址
unsigned long mmap_size; // 环形缓冲区的大小
struct page **ring_pages; // 环形缓冲区所使用的内存页数组
spinlock_t ring_lock; // 保护环形缓冲区的自旋锁
long nr_pages; // 环形缓冲区所占用的内存页数
unsigned nr, tail;
#define AIO_RING_PAGES 8
struct page *internal_pages[AIO_RING_PAGES];
};
struct aio_ring {
unsigned id;
unsigned nr; // 环形缓冲区可容纳的 io_event 数
unsigned head; // 环形缓冲区的开始位置
unsigned tail; // 环形缓冲区的结束位置
...
};
环形缓冲区的作用是存储I/O请求的执行结果,通过head以及tail之前的相互运算来判断是否为空。这里的aio_ring_info
相当于环形缓冲区的元数据,真正的缓冲区数据需要对ring_pages
调用kmap_atomic()
来建立虚拟内存的映射来获取。注意这里有一个优化,如果ring buffer的大小不大于 8 个内存页,那么ring_pages
字段就指向internal_pages
字段。这样的好处是避免了后续再去调用kmap_atomic()
来建立虚拟内存的映射的开销。
介绍完涉及到的数据结构,下面我们分步来看aio的每一个系统调用都做了些什么。
- 设置异步I/O上下文
- 这一步最关键的调用是
ioctx_alloc()
,它负责分配一个异步I/O上下文,也就是kioctx,然后初始化其中的active_reqs以及ring buffer。
- 这一步最关键的调用是
- 提交异步I/O请求
lookup_ioctx()
:获取异步I/O上下文。copy_from_user(iocb)
:将用户指定的iocb从内核态拷贝到用户态。io_submit_one()
:提交异步I/O请求。aio_get_req()
:创建一个I/O请求对象,也就是kiocb
,设置该结构体相应域的值,然后放入active_req
中。aio_read()/aio_write()
:这里就是具体的aio读写的调用了,具体的实现取决于具体接入的文件系统。aio_complete()
:在I/O请求完成之后,内核调用该函数将结果放入ring buffer中,见下文。kmap_atomic()
:对ring buffer中的ring_pages
建立虚拟内存的映射,构建aio_ring
。aio_ring_event()
:在ring buffer中获取一个空闲的io_event保存I/O操作的结果。- 设置event各个域的值。
put_aio_ring_event()
:将设置好的io_event放入ring buffer中。kunmap_atomic()
:解除虚拟内存映射。
- 获取异步I/O请求结果
read_events()
:获取下一个可以拿到的io_event。aio_read_evt()
:在循环中寻找环形缓冲区中的下一个可以拿到的结果,如果为空就退出。aio_ring_event()
:根据环形缓冲区当前head指针指向的io_event,将结果存到要返回的io_event中。
小结
由此我们就已经分析完整个Linux aio的结构框架了,包括相关的数据结构(kioctx, kiocb, io_event, aio_ring_info, aio_ring, iocb等等),以及各个步骤调用的详细原理。再结合下面这个完整的流程图,可以更好的理解。
io_uring
io_uring是Linux内核进入5.0时代之后带来的异步I/O框架。下一篇就来讲讲io_uring。