io_uring介绍
最近看到liburing的文档,感觉对iouring的很多的原理和使用方式有一个比较大概的了解,对于想知道这套接口到底做了什么能比较好的入门,而且liburing这套lib的代码也非常容易懂,下面是自己看文档的笔记.
1. io_uring的起源
Linux之前其实已经有一套异步io的接口,但是这套接口有着几个比较明显的限制:
最最大的限制在于: 只有使用
O_DIRECT模式下面才能支持异步访问,因为只能支持O_DIRECT的话,那么就不能使用缓存和需要做内存对齐的限制,所以导致这个接口并不是很通用;依然会blocking; 即使你满足了所有关于这个接口的限制,依然存在多种方式会导致blocking; 比如meta-data需要被执行io的话,那么提交这个过程将会blocking;又或者当底层存储设备的slot都在被使用的过程中,也会导致blocking,直到最终一个slot变得可用;看到描述的感觉就是不可控,而且对于异步编程来说,blocking是一个很重大的问题,因为会导致你的整个异步框架问题;
API 不是很好;这边主要说的是性能问题,有太多的内核到userspace的copy;
为何选择重新设计一套接口而不是优化之前的aio的接口呢?
当时内核组的想法是提升aio性能和复用原先的接口,原因是
改善和优化现存接口会由于提供新的接口;用户采用一个新的接口是需要很长很长的时间
兼容原先接口可以兼容原先使用老接口的应用,从这个成眠来说减少了接入的工作量
但是最终发现要修复和改进目前的状态,一方面会导致整个接口变得无比复杂,也不能到达所谓的预期,于是就推到重来;
新设计的目标
更加容易被使用,更加难被误用;
有更加广泛的使用场景;主要是说这套接口能使用场景更加广泛,除了文件io还能支持类似的block设备或者网络接口等等;这套接口需要对未来有足够的开放性;
更丰富的扩展性;这边主要是的是需要对兼容目前已有的使用方式,能让用户一套接口实现目前已有的所有;防止用户需要为了不同的功能用两套不同的接口;
更丰富的功能;这边主要是的是需要对兼容目前已有的使用方式,能让用户一套接口实现目前已有的所有;防止用户需要为了不同的功能用两套不同的接口;
更加好的性能;
可伸缩性;可能是能随着底层设备的伸缩,接口本身能支持到线性扩展的能力;
2. 正式进入IO_URING
io_uring虽然有那么多的目标,但是第一优先级在于性能,而性能的最重要的点在于kernel-user之间的内存copy,要去除这个copy的话,就需要共享内存,但是同时需要避免所谓的并发问题的;最终找到了一个数据结构能得到要求:ring-buffer, 应该就是环形队列的;
SPSC: 单生产者单消费者;
通过
memory-barrier的方式来保证并发安全;在这边极力的避免所谓的lock的操作,lock对性能影响太大;
这边一定要注意的是单消费单生产者的这个前提,不然会觉得后面的实现貌似不thread-safe; 假设我们用writeIndex和readIndex来表示写和读的索引,可能会产生线程安全的问题主要在于判断队列full或者empty,这个通过比较writeIndex和readIndex的方式来进行,而对这两个变量的访问可以通过memory-barrier的方式来保护;
对于目前的异步接口,有两个最为重要的操作;分别为: 提交请求和与消费这个请求关联的完成事件;对于提交事件来说,application是生产者,而kernel是消费者;对于消费完成事件的话,kernel是生产者,application是消费者;因此这个新的接口会有两个ring-buffer
SQ: 提交队列
CQ: 完成队列
2.1 数据结构
// complete event struct
struct io_uring_cqe {
__u64 user_data;
__s32 res;
__u32 flags;
};
user_data: 一个指针,从提交请求那边带过来的,可以带各种信心,内核是不会去碰这个数据的;res: 本次request的最终结果,就是正常system call的结果,和之前read/write的一样;flags: 用来带与这个操作的元信息,但是目前还没用;
相比于完成event的数据结构,request的数据结构会更加复杂很多;
struct io_uring_sqe {
__u8 opcode;
__u8 flags;
__u16 ioprio;
__s32 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];
};
};
opcode: 本次request的操作类型,比如IOURING_OP_READV表示向量读flags: 一些特殊的配置,通过flags来进行设置ioprio:操作优先级,可以通过ioprio_set的方式来进行设置fd: 本次请求关联的文件句柄off:offsetaddr: 读写最终的地址,比如buffer的地址,又或者如果opcode设置成IOURING_OP_READV的话,那么addr是就是数组首地址;len:配合addr一起事情,比如读取或者写入的长度;union:需要配合特殊的opcode来使用;user_data:和之前完成event的field对应的;buf_index:也是高端使用;
2.2 内核和用户空间的通信 - ring-buffer with no lock
cqr: completion queue ringbuffer
完成队列kernel负责生产,而producer负责消费,整体的size是一个32bits的整数,这个方法的好处是:可以充分利用所有的空间,不需要去设置所谓的full flag来表示空间满的问题;劣势是size的长度必须是2的指数次的大小;
环形队列在没有特殊的flag的情况下是如何判断队列满或者空呢? * tailer:表示之前写入的位置 * head: 表示第一个可读的位置; 在push的过程中,判断tailer+1 == head: 如果相同,说明队列满; 在pop的过程中,判断head==tailer: 如果相同,说明队列空; 这种适合SPSC这种场景下面是一段伪码,用来描述消费这个ring的过程:
unsigned head; head = cqring→head; read_barrier(); if (head != cqring→tail) { struct io_uring_cqe *cqe; unsigned index; index = head & (cqring→mask); cqe = &cqring→cqes[index]; /* process completed cqe here */ ... /* we've now consumed this entry */ head++; } cqring→head = head; write_barrier();memory-barrier主要是用来保证在多线程情况下,各个线程看到指令可排序;它并不用来解决并发问题,它主要是来解决内存load和store的顺序问题;
获得head
判断队列是否为空
消费entry
修改head
ring-cqe[]是一个共享内存数组,连续的; 这个与sqe ring 有点不一样;
sqr: subimssion queue ringbuffer
sqr与cqr的最大区别点在于:cqr是一个连续的数据,并且cqe就是这个数组的entry;sqr本质上有两个数组,也就是两个ring;主要的目的在于获得sqe,并且填充sqe,最后提交sqe之间的过程是分开的的;一个索引ring主要是为了对真正提交的sqe,这个ring也是给kernel来看的,在不主动提交io请求之前,也就是获得sqe并且在填充sqe过程中,索引的ring的tail是不会变化的,内核也是不会知道有数据需要被消费的;这样从用户层面来说可以按照业务的要求提交多个io请求;
这个设计我一开始很奇怪,但是文档上给出的理由是:这种方式比较灵活;
Some applications may embed request units inside internal data structures, and this allows\ them the flexibility to do so while retaining the ability to submit multiple sqes in one operation. That in turns allows for\ easier conversion of said applications to the io_uring interface.
一个op中包含了多个sqe操作;使用这种方式的话会更加灵活,没有顺序的限制;
struct io_uring_sqe *sqe; unsigned tail, index; tail = sqring→tail; index = tail & (*sqring→ring_mask); sqe = &sqring→sqes[index]; /* this call fills in the sqe entries for this IO */ init_io(sqe); /* fill the sqe index into the SQ ring array */ sqring→array[index] = index; tail++; write_barrier(); sqring→tail = tail; write_barrier();
完成事件到达的速度是无序的,和提交到内核的是没有关系的,如果需要支持提交事件的依赖的话,有一些高级功能iouring也支持;
2.3 io_uring提供的接口
1. 初始化iouring的实例
int io_uring_setup(unsigned entries, struct io_uring_params *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;
};
entries: 用来指定sqe的个数,可以认为是提交队列的长度,cqe的长度是sqe的两倍; 要求为2的指数倍params: 一些传给内核的参数;这个参数比较特殊,是有用户和kernel两边来读写的;说明kernel会通过这个参数返回一些数据sq_entries: 个数cq_entries: 同上sq_off和cq_off:这两个参数很重要,会返回一些内核中ring的内存信息,而user可以通过mmap系统调用的方式来进行无缝对接,这样跟后面的所有的访问可以做到无内核态和用户态的内存copy,这个一段公共内存;
struct io_sqring_offsets {
__u32 head; /* offset of ring head */
__u32 tail; /* offset of ring tail */
__u32 ring_mask; /* ring mask value */
__u32 ring_entries; /* entries in ring */
__u32 flags; /* ring flags */
__u32 dropped; /* number of sqes not submitted */
__u32 array; /* sqe index array */
__u32 resv1;
__u64 resv2;
};
通过这些参数我们就可以对iouring中的sqe的ring和cqe的ring来进行操作的;下面这段是libiouring对这个参数的使用:
static int io_uring_mmap(int fd, struct io_uring_params *p,
struct io_uring_sq *sq, struct io_uring_cq *cq)
{
size_t size;
int ret;
size = sizeof(struct io_uring_cqe);
if (p->flags & IORING_SETUP_CQE32)
size += sizeof(struct io_uring_cqe);
sq->ring_sz = p->sq_off.array + p->sq_entries * sizeof(unsigned);
cq->ring_sz = p->cq_off.cqes + p->cq_entries * size;
if (p->features & IORING_FEAT_SINGLE_MMAP) {
if (cq->ring_sz > sq->ring_sz)
sq->ring_sz = cq->ring_sz;
cq->ring_sz = sq->ring_sz;
}
// array index
sq->ring_ptr = __sys_mmap(0, sq->ring_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, fd,
IORING_OFF_SQ_RING);
if (IS_ERR(sq->ring_ptr))
return PTR_ERR(sq->ring_ptr);
if (p->features & IORING_FEAT_SINGLE_MMAP) {
cq->ring_ptr = sq->ring_ptr;
} else {
cq->ring_ptr = __sys_mmap(0, cq->ring_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, fd,
IORING_OFF_CQ_RING);
if (IS_ERR(cq->ring_ptr)) {
ret = PTR_ERR(cq->ring_ptr);
cq->ring_ptr = NULL;
goto err;
}
}
size = sizeof(struct io_uring_sqe);
if (p->flags & IORING_SETUP_SQE128)
size += 64;
//真正存储sqe的
sq->sqes = __sys_mmap(0, size * p->sq_entries, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, fd, IORING_OFF_SQES);
if (IS_ERR(sq->sqes)) {
ret = PTR_ERR(sq->sqes);
err:
io_uring_unmap_rings(sq, cq);
return ret;
}
io_uring_setup_ring_pointers(p, sq, cq);
return 0;
}
array:这个表示array在整个区域的偏移量;上面的mmap映射是用来访问sqe的内存结构;通过这种方式,其实我们就可以实现之前的ring-buffer;可以看到两次mmap的最后一个参数是不一样的,用来表示从ring_fd的不同位置;
#define IORING_OFF_SQ_RING 0ULL
#define IORING_OFF_CQ_RING 0x8000000ULL
#define IORING_OFF_SQES 0x10000000ULL
#define IORING_OFF_PBUF_RING 0x80000000ULL
#define IORING_OFF_PBUF_SHIFT 16
#define IORING_OFF_MMAP_MASK 0xf8000000ULL
看到上面的真实实现之后,最终在用户层构建两个sq ring和cq ring,那么后面用户态的访问就可以继续这两个数据结构来进行;
2. 提交和消费事件
int io_uring_enter(unsigned int fd, unsigned int to_submit,unsigned int min_complete, unsigned int flags,sigset_t sig);
// to_submit: 表示有几个请求已经提交给你了,内核你可以消费了
// min_complete: 表示我需要等待几个完成事件
// flags: 可以设置一些行为
// IORING_ENTER_GETEVENTS: 表示内核会主动等待有min_complete event完成; 如果想要等待完成事件,这个flag需要设置
这个系统调用有两种作用,分别为:
提交io请求
消费已经完成的event
用户消费cqe的方式有两种,一种是主动等待,也就是io_uring_enter中主动等待,另外一种是去检查cqring即可, 没必要主动等待;
3. 其他
初始化ioring实例的时候flag:
IOSQE_IO_DRAIN: 表示在它之前的所有io请求都操作完,才操作它;主要是顺序性;但是这对性能影响很大的,因为有一定的依赖关系,请求与请求之间不能并行进行操作,即使io本身请求无关联;DRAIN表示排空,意思就是排空之前所有的io请求;
IOSQE_IO_LINK:DRAIN这个关系太强了点,尤其是不相关的io都被关联起来的,有的时候可能只需要几个io之前有依赖关系,这个时候就可以用LINK这个标识,来关联几个IO操作;对于这类的IO如果返回的结果是-ECANCELED就表示在中间可能出现了一些错误,从而中断了整个chain;从第一个IORING_OP_TIMEOUT: 并非是一个真实的数据操作的,而是一个超时,做了做定时器使用的;如果设置了flag的话,那么addr是timeout的参数,offset是cq 完成的个数,两种触发机制;
2.4 memory ordering
memory_order_relaxed: 最轻松的内存序,不提供任何同步语义。操作可以以任意顺序执行,没有对其他线程的可见性保证。memory_order_acquire: 用于获取操作(读取操作),确保对其他线程的读取和写入操作的可见性。该操作前的读取操作不能被重排序到该操作之后,确保所需的数据的可见性。memory_order_release: 用于释放操作(写入操作),确保对其他线程的写入操作的可见性。该操作后的写入操作不能被重排序到该操作之前,确保对其他线程的修改的可见性。memory_order_acq_rel: 同时具有获取和释放操作的特性,即确保前面的读取操作不会与后面的写入操作重排序,并确保后面的写入操作不会与前面的读取操作重排序。memory_order_seq_cst: 顺序一致性内存序,提供最强的内存顺序保证。所有操作都按照全局顺序执行,并且提供了对其他线程操作的最严格的可见性保证
目前我的理解是,memory ordering主要是为了解决单线程情况下的指令排序的问题,虽然说编译器或者cpu在保障单线程结果不影响的情况下进行排序来加速整个效率,但是它保证不了多线程情况下的这样的排序依然是对的;所以需要程序员自己去衡量;而memory ordering本质上不是为了解决多线程的线程安全问题,保证的在单线程的读写操作的顺序是可被保证的;比如当我读到a=1的时候,一定能保证b=2,因为在单线程中,我通过memory ordering保证了这个顺序,那么其他的线程看到a=1的时候就可以保证b一定等于2;当然前提是其他线程也要用memory ordering的方式去读;*
1: sqe→opcode = IORING_OP_READV;
2: sqe→fd = fd;
3: sqe→off = 0;
4: sqe→addr = &iovec;
5: sqe→len = 1;
6: sqe→user_data = some_value;
write_barrier();/* ensure previous writes are seen before tail write */
7: sqring→tail = sqring→tail + 1;
write_barrier();/* ensure tail write is seen */
write_barrier:保证这之后的写操作不会被调整到write_barrier之前,这样保证有什么用呢,这样保证当tail在变化的时候,sqe已经初始化好了;后面的write_barrier,保证这之后的指令都能看到这次修改;read_barrier: 保证之前的读操作不是被调整到read_barrier这个之后;这样的好处是,当我读到数据是某一个特定值的时候,那么就表示关于这个特定值的假设是成立的;
3. liburing
提供这个库的主要目的:
减少setup iouring的代码
提供更加简单的api
3.1 主要api
设置iouring实例
struct io_uring ring; io_uring_queue_init(ENTRIES, &ring, 0); io_uring_queue_exit(&ring);包含了iouring的实例 + mmap的映射之类的,都帮我们完成
提交和消费
struct io_uring_sqe sqe; struct io_uring_cqe cqe; /* get an sqe and fill in a READV operation */ sqe = io_uring_get_sqe(&ring); io_uring_prep_readv(sqe, fd, &iovec, 1, offset); /* tell the kernel we have an sqe ready for consumption */ io_uring_submit(&ring); /* wait for the sqe to complete */ io_uring_wait_cqe(&ring, &cqe); /* read and process cqe event */ app_handle_cqe(cqe); io_uring_cqe_seen(&ring, cqe);io_uring_cqe_seen:主要是说某一个sqe已经完成的,可以减少飞行模式的sqe;也会更改cq的head的位置,所以必须在application操作完成之后才能提交完成;有点类似于kafkaio_uring_peek_cqe: 不需要等待,非阻塞的
3.2 高端功能
注册文件句柄
正常情况下,每次向内核提交io的request的时候,内核都会去检索与之相关的文件,当io结束的之后,这个drop掉这个引用;这样每次都进行这样的操作,在一些高性能的场景中是很难被接受的,所以io_uring提供了另外的system call的接口,可以预先注册一些文件句柄到io_uring中,这样就省的每次都关联和关闭的操作;
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args); /** fd: iouring实例的文件句柄 opcode: 操作类型IORING_REGISTER_FILES arg: 一个文件句柄的数组 nr_args:个数; */如何使用呢?在后期的使用的过程中,通过array index的方式来替换真的fd提交给iouring(
sqe→fd), 然后通过IOSQE_FIXED_FILE设置在flag删除,这样iouring会自动在数组中找自己要的fd,并且也不会都drop;有两种会关闭这个资源:iouring实例被free掉
主动调用
io_uring_register,并且op_code设置IORING_UNREGISTER_FILES,可以把已经注册的fd给取消掉;还有可以将buffer固定下来,这样的好处是不需要每次都通过mmap把application的buffer到内核空间,可以通过
IORING_REGISTER_BUFFERS的方式来进行设置,每次提交sqe的时候用IORING_OP_READ_FIXED和IORING_OP_WRITE_FIXED的方式来告诉内核,可以用固定的buffer,当然前提是所有的读写不能操作固定buffer的长度;
POLLED IO: 这个很重要的模式;
正常的io,包括网络或者文件之类io请求,操作系统cpu不会主动去询问当前的io 请求是否完成,都是通过
soft/hard interrupt来实现的,而cpu会有相关的中断程序来处理这些中断,并且通知对应的应用程序请求已经完成了,可以继续处理;其实这个方式挺好的,可以让出cpu,让cpu去做其他的事情;整体的缺点就是可能会有一定的延迟,需要通过整个中断处理过程来触发的,有的时候cpu进入不能被中断的时候就需要等待,有一定的延迟的;而且当中断太多的时候,也会降低cpu的整体的效率,需要不断的去处理中断处理程序;所以如果为了降低延迟,提高整体的io性能的话,iouring提供了IORING_SETUP_IOPOLL的flag,这个flag的作用是: 应用程序不会再去检查cq ring的tail,因为之前正常的no-polled io的使用方式是通过去检查cq ring tail的方式来得到是否有完成的event,但是当你开启了polled io之后,将不会同步硬件完成event的通知,也就可能说cq ring 可能是不会再被改变了;代替的就是应用程序必须不断的的调用io_uring_enter来询问是否有完成的event;关于这个标识必须在初始化iouring的时候就进行设置,后期是修改不了的;
KERNEL SIDE POLLING: 这也是一个很重要的模式之前提到,提交io请求需要最终调用
io_uring_enter的系统调用的;也就是告诉内核目前有数据了,可以消费了;为了追求高性能的,iouring还提供了不需要主动提交事件的方式,因为当你修改tail之后其实已经表示了你提交了一个io事件,提交这个过程中application主动push的过程,虽然最终还是需要内核去操作,但是application会做一些事情,而不是仅仅提交这些io请求;开启了
IORING_SETUP_SQPOLL这个之后,内核会启动消费内核线程,主动去轮训 sq ring,application不需要去submit这个请求,这样整个性能的提升是会有的,起码对io_uring_enter的系统调用可以省去,这个消耗也是可观的;而且可以通过
IORING_SETUP_SQ_AFF去设定这个内核线程在哪些cpu核心上进行;为了避免浪费太多的无效的cpu,因为轮训本身是比较消耗cpu的;所以如果在一定周期内内核线程空闲的话,会被自动sleep的,用户这段需要通过调用IORING_SQ_NEED_WAKEUP的flag去唤醒这个线程的;if ((*sqring→flags) & IORING_SQ_NEED_WAKEUP) io_uring_enter(ring_fd, to_submit, to_wait, IORING_ENTER_SQ_WAKEUP);应用段的代码可能为了防止内核线程sleep,不处理请求,可以设定一个周期或者关心上一次提交请求的距离,如果很久了,那就提交一个唤醒操作,这样保证能正常处理请求;