当我们进行一个系统调用,用户层的应用程序调用内核,它在内核空间中复制数据。在内核完成执行之后,它将结果复制回用户空间缓冲区。然后它返回。在这段时间内,系统调用仍然被阻塞
那么如何解决多次复制和同步问题呢
由此便引出了io_uring
io_uring是2019年Linux5.1内核首次引入的高性能异步IO框架,能显著带来加速IO密集型应用的性能
但相比正确使用的AIO可能最多只有5%的提升
io_uring来自于Jens Axboe的观察——随着设备越来越快,中断驱动效率已经低于轮训模式。io_uring基本逻辑与AIO一致,提供两个接口,一个将IO请求提交到内核,一个从内核接收完成时间
相比于libaio/KAIO主要特性在于
与glibc的POSIX AIO相比,io_uring具有以下优点:
direct IO就是无缓存IO,不会经过OS Cache。通常用于数据库,因为数据库有自己的cache
每个io_uring都有提交、完成两个环形队列,在内核和应用之间共享。其中CQ其实包含了cqe,而SQ中值其实是包含保存SQE项真实数据的指针
额外的SQEs是为了方便通过环形缓冲区提交内存上不连续的请求。SQ和CQ中每个节点保存的都是SQEs数组的索引,而不是实际的请求,实际的请求只保存在SQEs数组中。这样在提交请求时,就可以批量提交一组SQEs上不连续的请求。
SQE包含
CQE包含
这两个队列都是单生产者、单消费者,size是2的幂。并且提供了无锁接口,内部使用内存屏障做同步
使用方式
工作模式
中断驱动模式
默认模式。可通过io_uring_enter()提交IO请求,然后检查CQ状态判断是否完成
轮询模式。
需要文件系统和块设备支持。相比中断驱动,延迟更低,但可能会消耗更多CPU资源
内核轮询模式。
创建内核线程执行SQ轮询。当前应用更新 SQ ring 并填充一个新的 sqe,内核线程 sqthread 会自动完成提交,这样应用无需每次调用 io_uring_enter() 系统调用来提交 IO。应用可通过 IORING_SETUP_SQ_AFF
和 sq_thread_cpu 绑定特定的 CPU。 同时,为了节省无 IO 场景的 CPU 开销,该内核线程会在一段时间空闲后自动睡眠。应用在下发新的 IO 时,通过 IORING_ENTER_SQ_WAKEUP
唤醒该内核线程,用户态可以通过 sqring 的 flags 变量获取 SQ 线程的状态。
中断是硬件机制,可以理解为中断是处理器对外开放的实时受控接口。可以看作是硬件轮询,也就是CPU会通过读取外部信号来判断CPU的下一步状态
轮询是一种CPU如何提供周边设备服务的方式
初始化
// 创建拥有entries个请求的SQ(entries个)、CQ(2*entries个),通过mmap将内存区域映射到用户空间,并且返回文件描述符用于后续操作
//
int io_uring_setup(u32 entries, struct io_uring_params *p);
struct io_uring_params {
// 输出参数。告诉应用该ring支持多少sqe
__u32 sq_entries;
// 输出参数。告诉应用该ring CQ有多大
__u32 cq_entries;
// 用来设置当前整个io_uring标志。决定是否启动sq_thread,是否采用iopoll模式等等
__u32 flags;
// 指定io_sq_thread内核线程CPU
__u32 sq_thread_cpu;
// 指定io_sq_thread内核线程idle时间
__u32 sq_thread_idle;
// 描述当前内核支持特性
__u32 features;
__u32 wq_fd;
// 保留字段
__u32 resv[3];
// 内核和用户态约定,分别描述SQ、CQ指针在mmap中的offset
struct io_sqring_offsets sq_off;
struct io_cqring_offsets cq_off;
};
/*
* io_uring_params->features flags
*/
// 一次mmap完成SQ、CQ内存映射
#define IORING_FEAT_SINGLE_MMAP (1U << 0)
#define IORING_FEAT_NODROP (1U << 1)
#define IORING_FEAT_SUBMIT_STABLE (1U << 2)
#define IORING_FEAT_RW_CUR_POS (1U << 3)
#define IORING_FEAT_CUR_PERSONALITY (1U << 4)
#define IORING_FEAT_FAST_POLL (1U << 5)
#define IORING_FEAT_POLL_32BITS (1U << 6)
IO提交
// 初始化完成之后,可以使用sq队列添加io请求。当请求都加入sq后,就可以提交io请求公司内核
// 具体的实现是找到一个空闲的SQE,根据请求设置SQE,并将这个SQE的索引放到SQ中。SQ是一个典型的ring buffer,有head,tail两个成员,如果head == tail,意味着队列为空。SQE设置完成后,需要修改SQ的tail,以表示向ring buffer中插入了一个请求。
int io_uring_enter(unsigned int fd, // io_uring_setup返回的fd
unsigned int to_submit, // 提交待消费数量
unsigned int min_complete, // 若已完成io数量小于min_complete,请求会堵塞
unsigned int flags,
sigset_t *sig);
注册
// 注册文件或用户缓冲区,允许内核获取内部数据结构的长期引用,或创建应用程序内存的长期映射,大大减少了per-I/O开销。
// 将fd中数据结构映射到共享内存,从而进一步减少用户IO提交到uring队列开销
int io_uring_register(unsigned int fd, unsigned int opcode,
void *arg, unsigned int nr_args);
#include "liburing.h"
#define QD 4 // io_uring 队列长度
int main(int argc, char *argv[]) {
int i, fd, pending, done;
void *buf;
// 1. 初始化io_uring
struct io_uring ring;
// QD 队列长度
// &ring io_ring实例
// 0 flags,代表使用中断驱动模式
ret = io_uring_queue_init(QD, &ring, 0);
// 2. 打开输入文件,注意这里指定了 O_DIRECT flag,内核轮询模式需要这个 flag
fd = open(argv[1], O_RDONLY | O_DIRECT);
struct stat sb;
fstat(fd, &sb); // 获取文件信息,例如文件长度,后面会用到
// 3. 初始化 4 个读缓冲区
ssize_t fsize = 0; // 程序的最大读取长度
struct iovec *iovecs = calloc(QD, sizeof(struct iovec));
for (i = 0; i < QD; i++) {
if (posix_memalign(&buf, 4096, 4096))
return 1;
iovecs[i].iov_base = buf; // 起始地址
iovecs[i].iov_len = 4096; // 缓冲区大小
fsize += 4096;
}
// 4. 准备4个sqe读请求,并将读取数据写入读缓冲区
struct io_uring_sqe *sqe;
offset = 0;
i = 0;
do {
// 获取可用的sqe
sqe = io_uring_get_sqe(&ring);
// sqe 用该sqe准备一个待提交的read操作
// fd 从fd打开的文件读取数据
// &iovecs[i] iovec地址,读到的数据写入iovec缓存区
// 1 iovec数量
// offset 读取操作的开始偏移量
io_uring_prep_readv(sqe, fd, &iovecs[i], 1, offset);
// 更新偏移量
offset += iovecs[i].iov_len;
i++;
if (offset>sb.st_size)
{
break;
}
}while (1);
// 5. 提交sqe读请求
ret = io_uring_submit(&ring); // 4个SQE一次提交,返回提交成功的SQE数量
if (ret < 0) {
fprintf(stderr, "io_uring_submit: %s\n", strerror(-ret));
return 1;
} else if (ret != i) {
fprintf(stderr, "io_uring_submit submitted less %d\n", ret);
return 1;
}
// 6. 等待读请求完成
struct io_uring_cqe *cqe;
done = 0;
pending = ret;
fsize = 0;
for (i = 0; i < pending; i++)
{
io_uring_wait_cqe(&ring,&cqe);
done++;
if (cqe->res!=4096 && cqe->res+fsize!=sb.st_size)
{
fprintf(stderr, "ret=%d, wanted 4096\n", cqe->res);
}
fsize += cqe->res;
io_uring_cqe_seen(&ring, cqe); // 更新 io_uring 实例的完成队列
}
// 7. 打印统计信息
printf("Submitted=%d, completed=%d, bytes=%lu\n", pending, done, (unsigned long) fsize);
// 8. 清理工作
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
在高IOPS时频繁建立、取消内存映射会造成比较大的开销,因此可以将iovec数组提前注册到iouring实例,建立相关内存映射。只有当主动取消注册或 io_uring 实例销毁时,才会取消内存映射。