前言
我们先总结Linux一些常用的IO:
- BIO(阻塞同步IO):read()、write()、accept()。这种阻塞式IO随着设备的更迭、程序的复杂,可能会不适用。
- NIO(非阻塞同步IO):epoll()、poll()、select()。应用程序调用这些函数时不会阻塞,而是会立即返回一个已经ready的文件描述符列表,但是这种方式只支持网络套接字和管道。
- AIO(非阻塞异步IO):io_setup()、io_submit()、io_getevents()。但是Linux AIO有些许问题:只支持O_DIRECT文件,也就是数据库应用。并且虽说是非阻塞,但是又可能有很多原因导致它阻塞:如果执行 I/O 需要元数据,提交将阻塞等待该元数据。对于存储设备,有固定数量的请求槽可用。如果这些槽当前都在使用中,提交将阻塞等待其中一个变得可用。这些不确定性意味着依赖于提交总是异步的应用程序仍然被迫卸载那部分工作。
所以为了应对业务需求,弥补AIO的不足,io_uring就出世了。首先它本身在系统中调用上下文就只用往队列里面放请求,仅此而已,因此就不会阻塞;其次它支持任何类型的IO,不仅局限于O_DIRECT文件;此外它的灵活性和可扩展性也较高。
原理
话不多说,咱们直接看原理。上图:
一个io_uring就是一对在共享内存中的环形队列。看这张图咱们就已经能清楚地了解io_uring的两个特点:共享内存、环形队列。初始AIO的设计,效率和可扩展性都明显受到了 AIO 必须做的多次拷贝的伤害,所以拷贝不可取,利用共享内存零拷贝;环形队列也是一种高效的数据结构,io_uring利用两个环形队列+共享内存这种灵活的机制来实现内核态与用户态的通信,减少变态,也就能大大提高性能。
数据结构
接下来我们详细讨论一下io_uring的数据结构:提交队列、完成队列、请求队列。
提交队列(SQ):它不仅需要描述比完成事件更多的信息,而且设计目标是让 io_uring 能够扩展以适应未来的请求类型。
struct io_uring_sqe {
__u8 opcode;//描述这个特定请求的操作码
__u8 flags;//包含跨命令类型的通用修饰标志
__u16 ioprio;//此请求的优先级。对于正常的读写,它遵循 ioprio_set(2) 系统调用中概述的定义
__s32 fd;
__u64 off;//包含操作应该发生的位置的偏移量
__u64 addr;//应该执行 I/O 的地址
__u32 len;
union {//特定于 op-code 的标志的联合
__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):完成队列很简单。它需要携带有关操作结果的信息,以及一些将该完成事件链接回它起源请求的方式。
struct io_uring_cqe {
__u64 user_data;//从最初的请求提交中传递过来,可以包含应用程序需要识别所述请求的任何信息。
//一个常见的用例是让它成为原始请求的指针。比如在SQE中传递的指针。
__s32 res;
__u32 flags;
};
请求队列。请求项的存储并不在提交队列中,也不再完成队列中,事实是真正的IO请求保存在一个基于数组结构的环形队列中,也就是说,提交队列与完成队列真正掌握的其实是指针或者编号,这样一来,任务提交,完成后我们又避免了拷贝,而是直接将提交队列中已经完成的IO的实际地址赋值给完成队列,效率又大大提高。
一旦 SQE 被内核消费,应用程序就可以自由地重用那个 SQE 条目。即使内核还没有完全完成给定的 SQE 也是如此。如果内核在条目被消费后需要访问它,它会制作一个稳定的副本。为什么会发生这种情况并不重要,但它对应用程序有一个重要的副作用。通常,应用程序会请求一个给定大小的环,并且假设这个大小直接对应于应用程序可以在内核中挂起的请求数量。然而,由于 SQE 的生命周期只是实际提交它的那段时间,因此应用程序可以推动比 SQ 环大小指示的挂起请求计数更高。应用程序必须注意不要这样做,否则可能会冒着溢出 CQ 环的风险。默认情况下,CQ 环的大小是 SQ 环的两倍。这为应用程序在管理这方面提供了一定的灵活性,但它并没有完全消除这样做的需要。如果应用程序违反了这个限制,它将作为 CQ 环中的溢出条件被跟踪。
API
io_uring的系统调用有三个:io_uring_setup()、io_uring_register()、io_uring_enter()。
第一个io_uring_setup()是一个用于设置 io_uring 实例的系统调用:设置上下文,这个系统调用创建一个SQ和CQ,SQ 和 CQ 在应用和内核之间共享,避免了在初始化和完成 I/O 时(initiating and completing I/O)拷贝数据
int io_uring_setup(unsigned entries, struct io_uring_params *params);
应用程序必须为这个 io_uring 实例提供一个期望的条目数量,以及与之相关的一组参数。entries 表示将与这个 io_uring 实例关联的SQES 的数量。它必须是 2 的幂,在 1..4096(包括两者)的范围内。params 结构由内核读取和写入,它被定义为:
struct io_uring_params {
__u32 sq_entries;
__u32 cq_entries;
__u32 flags;
__u32 sq_thread_cpu;
__u32 sq_thread_idle;
__u32 resv[5];
struct io_sqring_offsets sq_off;
struct io_cqring_offsets cq_off;
};
sq_entries 将由内核填写,让应用程序知道这个环支持多少 sqe 条目。同样对于 cqe 条目,cq_entries 成员告诉应用程序 CQ 环有多大。在成功调用 io_uring_setup(2) 后,内核将返回一个文件描述符,用于引用这个 io_uring 实例。应用随后可以将这个 fd 传给 mmap(2) 系统调用,来 map the submission and completion queues 或者传给 io_uring_register() io_uring_enter() 。
第二个io_uring_register()注册用于异步IO的文件或用户缓冲区:使内核能长时间持有对该文件在内核内部的数据结构引用或创建应用内存的长期映射, 这个操作只会在注册时执行一次,而不是每个 I/O 请求都会处理。
int io_uring_register(unsigned int fd, unsigned int opcode,
void *arg, unsigned int nr_args);
第三个io_uring_enter()用于初始化和完成(initiate and complete)I/O,使用共享的 SQ 和 CQ。 单次调用同时执行:提交新的 I/O 请求&&等待 I/O 完成。
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,sigset_t sig);
fd 是环文件描述符,如 io_uring_setup(2) 返回的。to_submit 告诉内核有多达那个数量的 sqes 准备被消费和提交,而 min_complete 请求内核等待完成那个数量的请求。拥有一个单一的调用可用于提交和等待请求完成意味着应用程序可以单次系统调用来提交和等待请求完成。flags 包含修改调用行为的标志,也就是设置工作模式。
工作模式
- 中断驱动模式(默认模式):可通过 io_uring_enter() 提交 I/O 请求,然后直接检查 CQ 状态判断是否完成。
- 轮询模式:繁忙等待IO完成,而不是通过异步 IRQ(Interrupt Request)接收通知。这种模式需要文件系统和块设备(block device)支持轮询功能。 相比中断驱动方式,这种方式延迟更低, 但可能会消耗更多 CPU 资源。目前,只有指定了 O_DIRECT flag 打开的文件描述符,才能使用这种模式。当一个读 或写请求提交给轮询上下文之后,应用必须调用
io_uring_enter()
来轮询 CQ 队列,判断请求是否已经完成。对一个 io_uring 实例来说,不支持混合使用轮询和非轮询模式。 - 内核轮询模式:这种模式中,会 创建一个内核线程(kernel thread)来执行 SQ 的轮询工作。使用这种模式的 io_uring 实例, 应用无需切到到内核态 就能触发(issue)I/O 操作。通过 SQ 来提交 SQE,以及监控 CQ 的完成状态,应用无需任何系统调用,就能提交和收割 I/O。如果内核线程的空闲时间超过了用户的配置值,它会通知应用,然后进入 idle 状态。这种情况下,应用必须调用
io_uring_enter()
来唤醒内核线程。如果 I/O 一直很繁忙,内核线程是不会 sleep 的。