简介
io_uring是2019年在linux新增的异步IO接口。
它的出现是为了替代linux的旧的异步IO接口aio。
它以高效率、适用面广碾压aio
io_uring可以访问direct io、buffer io和网络IO
它的性能近乎原生的内存操作
在磁盘访问方面,媲美spdk
在网络访问方面,以多倍的优势超越epoll
AIO存在的问题
- 只能访问direct io
- 不是所有路径都是异步的,例如如果需要元数据来执行IO,那么提交就会被阻塞等待;对于存储设备,有固定数量可用请求槽,如果没有可用的槽,则会发生阻塞
- 元数据开销大,每个IO提交都需要复制104字节的元数据,发生了不必要的内存拷贝。
- 需要两次系统调用才能完成提交与等待完成
io_uring整体架构图
描述:
- SQ与CQ是两个独立的消息队列
- SQ的生产者是用户态线程,消费者是用户态线程,用于提交事件给内核处理,例如读某个fd,写某个fd
- CQ的生产者是内核态线程,消费者是用户态线程,用于内核线程对提交的事件处理完后,把结果返回给用户态。例如把读某个fd的内容返回,通知用户态写某个fd完成
- SQ在用户态的内存与内核态的内存是使用mmap共享的一片内存
- CQ在用户态的内存与内核态的内存是使用mmap共享的一片内存
- SQ、CQ的head和tail,是原子变量,用户态与内核态对这两个原子变量的同步通过写屏障和读屏障来同步
读文件过程(以SQPOLL为例):
- 初始化io_uring环境
- 用户态线程获取文件fd
- 用户态线程在SQ队列写入一个元素,指明需要读某个文件,带上一个可以获取文件内容的内存地址
- 用户态线程对原子变量tail++
- 用户态此时可以做其他事情
- 内核态线程发现SQ的tail发生变化,获取SQ的提交,并对fd对应的设备发起读请求
- 内核态线程获取设备返回的内容并写在SQ对应的内存地址中,整理结果并写入CQ
- 内核态线程对CQ的tail++
- 用户态不断轮询CQ,发现CQ中存在需要处理的数据,则获取CQ的结果,head--,从而拿到文件的内容
写文件过程(以SQPOLL为例):
- 初始化io_uring环境
- 用户态线程获取文件fd
- 用户态线程在SQ队列写入一个元素,指明需要写某个文件,带上一个可以带内容的内存地址
- 用户态线程对原子变量tail++
- 用户态此时可以做其他事情
- 内核态线程发现SQ的tail发生变化,获取SQ的提交,并对fd对应的设备发起写请求
- 内核态线程完成对文件的写入后,整理结果并写入CQ
- 内核态线程对CQ的tail++
- 用户态不断轮询CQ,发现CQ中存在需要处理的数据,则获取CQ的结果,head--,得知写文件已经完成
可以看到,除了初始化io_uring发生了系统调用
read和write都是内存操作
SQ与CQ的设计,避免了用户态和内核态之间的内存拷贝、
唯一的开销是原子变量的同步
设计关键点
- 内存在用户态与内核态共享,读写不需要系统调用,避免不必要的系统调用
- 队列使用无锁原子队列,单生产单消费,内核与用户态之间只需要同步原子变量开销
- 内核线程,轮询用户态提交来的请求,并在处理后,写回返回队列,不需要用户态提交任务,减少系统调用
- 注册文件描述符,避免内核多次绑定与fd的关系,提高效率
- 用户态轮询,用户态在等待任务时,不陷入睡眠,而是通过轮询队列的状态,避免用户态频繁进行上下文切换,对高吞吐场景可以实现很高的性能
性能数据
Interface QD Polled Latency IOPS
--------------------------------------------------------------------------
io_uring 1 0 9.5usec 77K
io_uring 2 0 8.2usec 183K
io_uring 4 0 8.4usec 383K
io_uring 8 0 13.3usec 449K
libaio 1 0 9.7usec 74K
libaio 2 0 8.5usec 181K
libaio 4 0 8.5usec 373K
libaio 8 0 15.4usec 402K
io_uring 1 1 6.1usec 139K
io_uring 2 1 6.1usec 272K
io_uring 4 1 6.3usec 519K
io_uring 8 1 11.5usec 592K
spdk 1 1 6.1usec 151K
spdk 2 1 6.2usec 293K
spdk 4 1 6.7usec 536K
spdk 8 1 12.6usec 586K
数据结构与API
CQe:完成队列事件
struct io_uring_cqe {
__u64 user_data; //用户自定义数据,来自SQe
__s32 res; //返回的结果
__u32 flags; //保留字段
};
SQE:提交队列事件
struct io_uring_seq {
__u8 opcode; //操作码
__u8 flags;
__u16 ioprio; //优先级
__u32 fd;
__u64 off;
__u64 addr;
__u32 len;
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
__u32 sync_range_flags;
__u32 msg_flags;
};
__u64 user_data; //自定义数据
union {
__u16 buf_index;
__u64 __pad2[3];
};
};
CQ与SQ的提交
unsigned head;
head = cqring->head;
read_barrier();
// 头不等于尾,环未满
if (head != cqring->tail) {
struct io_uring_cqe *cqe;
unsigned index;
// 使用掩码来计算出正确的索引位置
index = head & (cqring->mask);
cqe = &cqing->cqes[index];
head++;
}
cqring->had = head;
write_barrier();
CQ、SQ是一个由数组组成的环形队列
通过变动head的索引来完成对队列的插入操作
CQ与SQ的消费
struct io_uring_seq *sqe;
unsigned tail, index;
tail = sqring->tail;
index = tail & (*sqring->ring_mask);
sqe = &sqring->specs[index];
init_io(sqe);
sqring->array[index] = index;
tail++
write_barrier();
sqring->tail = tail;
write_barrier();
CQ、SQ是一个由数组组成的环形队列
通过变动tail的索引来完成对队列的弹出操作
io_uring原生API
原生API只有三个,由于在写的过程中设计过多的体系结构知识,例如读写屏障,并不容易使用,所以下面只简单描述
-
io_uring_setup
初始化
-
io_uring_enter
提交SQ、等待CQ
-
io_uring_register
注册某个fd,后面则不需要再通过SQE来关注这个fd了
注册固定缓冲区(暂时还不知道有什么用)
SQE排序
如果多个sqe没有特别说明,在内核中是乱序执行的
但某些场景,为了保证数据的完整性,例如在一系列写操作后,然后调用fsync
io_uring支持将sqe的flags字段设置成IOSQE_IO_DRAIN,然后将sqe提交到内核中,io_uring可以保证在所有之前的请求完成前是不会执行这个sqe的
链式sqes
sqe还提供一钟链式执行的顺序,保证sqes按顺序一个一个的执行
通过设置IOSQE_IO_LINK、IO_SQE_LINK来完成
liburing
用于简化原生API
轮询IO(IOPOLL)
通过轮询,可以减少睡眠产生的上下文切换
对于低延时和IOPS高的应用来说,轮询IO可以提供机制的性能
通过注册IORING_SETUP_IOPOLL
使用轮询后,应用不能通过检查CQ尾部来检查完成事件,必须通过不断调用io_uring_enter来检查并获取完成事件
内核轮询(SQPOLL)
启动SQPOLL后,应用对SQ的提交不需要调用io_uring_enter来通知内核
SQPOLL会自动检测,从而可以减少系统调用
为了避免再非活跃期浪费过多的CPU,当内核线程空闲一段时间后,它会自动睡眠
用户态需要检测内核线程是否睡眠,而决定是否去wakeup内核线程
// 添加新的 sqe 条目
add_more_io();
// 如果可轮询并且线程已经睡眠,需要调用 io_uring_enter() 使内核发现一个新的 IO
if ((*sqring->flags) & IORING_SQ_NEED_wAKEUP)
io_uring_enter(ring_fd, to_submit, to_wait, IORING_ENTER_SQ_WAKEUP);
网友评论