io_uring

Core Counts Grow, Clock Speeds Stay Constant. Meanwhile, I/O Continues to Increase in Speed.

io_uring

io_uring是linux 5.1引入的异步io接口,适合io密集型应用。其初衷是为了解决linux下异步io接口不完善且性能差的现状,用以替代linux aio接口(io_setup,io_submit,io_getevents)。现在io_uring已经支持socket和文件的读写,未来会支持更多场景。

当前linux aio接口,有很多限制:

  • 只支持O_DIRECT模式的异步io。开启O_DIRECT后,会绕过kernel's cache,并需要进行字节对齐。需要在用户程序自行管理缓存,以保证性能。一般仅用于数据库这一细分领域。
  • 不稳定的非阻塞机制。虽然接口是非阻塞,但仍有很多case会导致程序在提交io任务时阻塞,且这些场景很难预料。
  • 接口设计存在局限。每次io提交都需要至少两次系统调用(submit + wait),这不可避免会造成内存拷贝、系统中断、上下文切换。

社区在优化linux aio未果后,开始考虑做一套新的接口,并将一些设计原则纳入其中:

  • 易用。接口容易理解和使用。
  • 泛用。不仅局限在io的场景,网络等其他场景也可以复用这套新接口。
  • 足够强大。功能足够丰富,上层使用者不必在应用层实现一些逻辑来弥补接口的功能缺失。
  • 高性能。足够快,并节约资源(CPU)。避免不必要的拷贝、切换开销。
  • 灵活。提供灵活的扩展性,上层使用者可以通过接口控制接口底层的行为。

1 总览

与dpdk、spdk不同,io_uring作为内核的一部分,它并没有让程序绕过内核。它通过mmap的方式让用户程序和内核共享一块内存,并基于memory barrier在这块内存上实现了两个无锁环形队列: submission queue (sq) 和 completion queue (cq). sq用于用户程序向内核提交IO任务,内核执行完成的任务会放入cq,用户程序从cq获取结果。在提交任务和返回任务结果时,用户程序和内核共用环形队列中的数据,不再需要额外的数据拷贝;除此之外,io_uring还提供了两种轮询模式,可以避免提交任务时的系统调用,以及io完成后的中断通知。

libaio与io_uring性能对比
早期一份spdk与io_uring性能对比

一些概念:

  • sqring - submission queue ring: 用户程序向内核提交任务的无锁环形队列
  • cqring - completion queue ring: 内核向用户程序传递结果的无锁环形队列
  • sqe - submission queue event : sqring中的一项,用来描述一个任务
  • cqe - completion queue event : cqring中的一项,用来描述一个任务的结果
  • sqes - submission queue events : sqe数组
  • cqes - completion queue events : cqe数组

sqring和cqring本身没有提供锁等同步机制,从cqring中取出cqe,向sqring中放入sqe,都需要通过memory barrier来实现。

  • read_barrier(): 保证之前的写入操作对后续的读取操作都可见
  • write_barrier(): 保证后续的写操作一定排在之前的写操作之后

2 数据结构

2.1 cqe

用于描述一个任务的结果。作为响应,直觉上来讲,需要包含操作的返回码,还包含某种标识,将其与sq中的请求对应起来。

struct io_uring_cqe {
    __u64 user_data;
    __s32 res;
    __u32 flags;
};
  • user_data 拷贝自io_uring_sqe.user_data,linux内核不会修改这个字段。用户可以填入任何想填的内容,一般用来标识这个结果属于哪个请求
  • res 包含本次操作的返回码。比如,对于read/write类型的请求,成功时,该字段包含本次读写的字节数,失败时则为具体的错误码(比如-EIO)
  • flags 用于返回有关本次操作的一些元数据,目前尚未使

2.2 sqe

用于描述一个任务的请求信息。考虑到io_uring的泛用性,相比cqe,sqe会复杂些。

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 描述本次操作的类型(比如 IORING_OP_READV,IORING_OP_WRITEV
  • flags 用于传递一些控制接口行为的参数,以IOSQE_开头(比如IOSQE_IO_LINK
  • ioprio 指定本次请求的优先级
  • fd 要执行IO的文件描述符
  • off 执行IO操作的位置,fd文件内的偏移量
  • addr 指向iovec数组 或者 buffer的指针
  • len iovecs的iovcnt 或者 buffer的size
  • *_flags 各种操作的flags
  • user_data 传递到 io_uring_cqe.user_data 的数据
  • buf_index 在 fixed buffers 数组中的索引,详见 io_uring_register
  • __pad2 64bytes对齐

2.3 cqring

cqring比较简单,cqe直接放在cqring->cqes中,cqes这个数组即为环形队列的数组,cqring->head != cqring->tail即代表队列非空。当一个任务被完成时,kernel将cqe放在cqring的队尾,然后让cqring->tail++。应用程序则从cqring->head处取出事件,进行消费,消费完成后,更新cqring->head++cqring->headcqring->tail 是32bit的无符号整数,它们的值可以在[0,UINT32_MAX]范围内流动,再配合一个cqring->ring_mask来得到具体的index(cqring->head & cqring->ring_mask即为真实的index)。这也意味着数组的长度必须是2的n次方。

仅用于示意的结构定义:

struct io_uring_cqring {
    struct io_uring_cqe *cqes;
    __u32 *head;
    __u32 *tail;
    __u32 *ring_mask; // ring_entries - 1
    __u32 *ring_entries;
};

从cqring消费一个cqe的流程大概是这样:

auto head = cqring->head;
read_barrier();
if (head != cqring->tail) {
    struct io_uring_cqe *cqe;
    auto index = head & (cqring->ring_mask);
    cqe = cqring->cqes[index];
    // process cqe
    head++;
}
cqring->head = head;
write_barrier();

2.4 sqring

相比cqring直接使用cqes作为环形数组,sqring 引入了一个新的数组 sqring->array。sqe存放在sqring->sqes中,sqring->array中存储sqe在sqring->sqes中的index。sqring->array是环形队列的底层数组,sqring->sqes则用于存放数据。默认情况下,sqring->arraysqring->array的长度是一样的,一个sqe对应在两者中的index也是一样的,换句话说,相当于只有一个数组,就像不存在两个数组。那你可能想问,这样实现的意义是什么?

考虑这样一个场景,应用程序维护了自己的sqe数组,当需要提交io时,才将该数组其中部分的sqe提交到内核执行。如果sqring没有双数组的概念,这时,只能将应用程序的sqe数组中要提交任务的sqe逐一拷贝到sqring->sqes中,多了内存拷贝的开销。有了间接数组,应用程序可以直接将sqring->sqes指向用户维护的数组,这样提交任务时,只需要把sqes数组的index传递给sqring->array,即可完成提交,无需任何拷贝操作。

cqring返回cqe的顺序与任务放入sqring的顺序没有任何关系,两个队列是独立运行的。但cqe和sqe一定是一一对应的。

仅用于示意的结构定义:

struct io_uring_sqring {
    struct io_uring_sqe *sqes;
    __u32 *array;
    __u32 *head;
    __u32 *tail;
    __u32 *ring_mask; // ring_entries - 1
    __u32 *ring_entries;
}

向sqring放入一个sqe的流程大概是这样(默认情形):

struct io_uring_sqe *sqe;
auto tail = sqring->tail;
auto array_index = tail & (sqring->ring_mask);
auto sqes_index = array_index;
sqe = &sqring->sqes[seqs_index];

// fill sqe
init(sqe);

sqring->array[array_index] = sqes_index;
tail++;

write_barrier();
sqring->tail = tail;
write_barrier();

3 接口

3.1 io_uring_setup

初始化一个io_uring实例。

int io_uring_setup(unsigned int entries, struct io_uring_params *params);
  • entries 期望的sqring->sqes长度,必须为2的幂。默认情况,cqring->cqes的长度为sqring->sqes的两倍
  • params kernel会从这读取一部分参数,并填充一部分参数进来
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 由内核写入,sqring->sqes的实际最大长度
  • cq_entries 由内核写入,cqring->cqes的实际最大长度
  • flags 由用户程序写入,控制io_uring的行为
  • sq_thread_cpu 由用户程序写入,设置sq polling线程所在的cpu,需同时设置IORING_SETUP_SQ_AFF才生效,需要root权限,否则返回-EPERM
  • sq_thread_idle 由用户程序写入,设置sq polling线程在空闲多久后,陷入sleep,单位为ms,如果不设置,默认为1000ms
  • resv
  • sq_off 由内核写入,包含sqring各成员的内存地址偏移
  • cq_off 由内核写入,包含cqring各成员的内存地址偏移

io_uring_setup成功返回时,返回一个fd,用于访问这个io_uring实例。用户程序需要使用返回的fd配合下面的off来调用mmap,来映射对应内存到应用程序的内存空间。

#define IORING_OFF_SQ_RING 0ULL
#define IORING_OFF_CQ_RING 0x8000000ULL
#define IORING_OFF_SQES 0x10000000ULL
  • IORING_OFF_SQ_RING 映射sqring到程序内存空间
  • IORING_OFF_CQ_RING 映射cqring到程序内存空间
  • IORING_OFF_SQES 映射sqes数组到程序内存空间

通过mmap映射完成后,再使用params中内核填充的偏移量sq_offcq_off来访问具体的属性。

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;
};

应用程序可以定义自己的结构,将内存偏移转化为可以直接使用的指针,便于后续使用。

struct app_sq_ring {
    unsigned int *head;
    unsigned int *tail;
    unsigned int *ring_mask;
    unsigned int *ring_entries;
    unsigned int *flags;
    unsigned int *dropped;
    unsigned int *array;
};

struct app_sq_ring app_setup_sq_ring(int ring_fd, struct io_uring_params *p) {
    struct app_sq_ring sqring;
    void *ptr;
    ptr = mmap(NULL, p->sq_off.array + p->sq_entries * sizeof(__u32),
               PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
               ring_fd, IORING_OFF_SQ_RING);
    sqring->head = ptr + p->sq_off.head;
    sqring->tail = ptr + p->sq_off.tail;
    sqring->ring_mask = ptr + p->sq_off.ring_mask;
    sqring->ring_entries = ptr + p->sq_off.ring_entries;
    sqring->flags = ptr + p->sq_off.flags;
    sqring->dropped = ptr + p->sq_off.dropped;
    sqring->array = ptr + p->sq_off.array;
    return sqring;
}

3.2 io_uring_enter

默认情形下,提交任务的流程是

  1. 把sqe放入sqring
  2. 调用io_uring_enter通知内核
  3. 轮询cqring等待结果
    或者
    通过带IORING_ENTER_GETEVENTSmin_complete参数的io_uring_enter阻塞等待指定数目的任务完成,再去cqring中检查结果
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返回的fd
  • to_submit 本次有多少个sqe需要提交到内核
  • min_complete 要求内核至少等待min_complete个任务完成再返回
    需要flags设置IORING_ENTER_GETEVENTS才会进行等待
  • flags 可以传递一些flags控制接口行为,比如IORING_ENTER_GETEVENTS

3.2.1 一个操作等待一系列操作完成

存在一个操作,要等待前面一系列操作完成后,才能执行(比如多个 write 后接一个 fsync)的场景,可以通过设置IOSQE_IO_DRAINsqe->flags来实现;设置了该flag的sqe,会等待前面所有的sqe都完成后,才会执行。同时,在该sqe之后提交的sqe,也会等待这个sqe执行完之后,才会执行。这会对性能产生比较大的影响,可以通过分配一个新的io_uring实例来执行这类同步操作。

3.2.2 一系列有序的操作

当有一系列操作,需要有序执行时,可以通过设置IOSQE_IO_LINKsqe->flags来实现;设置了该flag的sqe会和下一个sqe形成一个链接,下一个sqe会在本sqe执行完之后才会执行。连续提交带IOSQE_IO_LINK的sqe可以形成一个链表,链表的尾节点是第一个不带IOSQE_IO_LINKflag的sqe。不同的链表间以及链表与普通单sqe间没有限定,可以并发被执行,只有链表内部的节点会按顺序执行。如果链表中某个操作执行失败,这个操作后的其他sqe都会被取消执行,返回 ECANCELED 错误码。

3.2.3 定时返回

可以通过设置IORING_OP_TIMEOUTio_uring_enter的flags参数,来使用定时功能。启用后,可以通过sqe->addrsqe->offset设置两个触发条件,任一条件满足后,就会返回一个cqe到用户程序。
可将sqe->addr设为一个struct timespec64的地址,来设置定时触发,当超时后,函数会返回。
可将sqe->offset设为期望完成的任务数,当有指定个任务完成后,函数会返回。

3.3 io_uring_register

一般情况,只用setup和enter两个系统调用就能完成任务。io_uring_register用于一些特定场景的性能优化。

int io_uring_register(unsigned int fd, unsigned int opcode,
                      void *arg, unsigned int nr_args);
  • fd io_uring_setup返回的fd
  • opcode registration的类型,对于文件,用IORING_REGISTER_FILES
  • arg 对于文件,指向一个fd数组,其中的fd为这个进程已经打开的文件
  • nr_args arg数组的长度

3.3.1 预注册文件

每次通过sqe向内核传递一个fd,内核都需要通过fd去进程描述符中找到对应的文件引用,完成该sqe处理后,则将该引用释放。对于高iops的场景,这个开销会拖慢请求的速度。通过register可以预先注册一组已经打开的文件。一旦注册成功,在通过sqe传递fd时,就可以在fd字段填入该文件在fd数组中的index,并设置IOSQE_FIXED_FILEsqe->flags。程序可以混合使用已注册的文件和未注册的文件。已注册的文件可以通过再次调用register接口,并传入IORING_UNREGISTER_FILES来取消注册,也可以等待io_uring实例销毁时,被自动释放。

3.3.2 预注册buffer

除了文件,也可以映射一系列fixed IO buffer。在设置了O_DIRECT的读写场景,内核需要在读写前进行page map,读写完成后,执行unmap。类似的,通过预先注册,来避免多次的map和unmap。在这个场景,opcode需要传入IORING_REGISTER_BUFFERSargstruct iovec的数组,nr_args为数组长度。一旦buffer注册成功,程序可以使用IORING_OP_READ_FIXEDIORING_OP_WRITE_FIXED来进行读写。提交任务时,sqe->addr需要指向某个buffer内的地址,sqe->len包含本次读写的长度(bytes)。

3.4 高级特性

3.4.1 io polling

这里先回顾,默认情形下,提交任务的流程,以及获取结果的方式:

  1. 把sqe放入sqring
  2. 调用io_uring_enter通知内核
  3. 可以轮询cqring等待结果
    或者
    通过带IORING_ENTER_GETEVENTSmin_complete参数的io_uring_enter阻塞等待指定数目的任务完成,再去cqring中检查结果

默认情形下,底层设备通过中断通知内核执行io_uring对应的处理函数,构造结果并封装成cqe丢到cqring中,用户程序可以通过轮询cqring获取结果。当开启了io polling之后,不再有中断触发回调这一过程,不可以通过轮询cqring获取结果,而是需要应用程序主动调用带IORING_ENTER_GETEVENTSmin_complete参数的io_uring_enter来下发轮询任务给内核,内核会轮询检查是否有结果产生,如果有,则将结果放入cqring。当有min_complete个结果返回后,函数会返回。这期间,用户程序会阻塞在io_uring_enter的调用上。这还有一个特殊用法,可以把min_complete设为0,这时,仅会触发内核进行一次结果检查,而不会轮询并阻塞住程序,函数会立刻返回。对于低延迟的存储设备,io polling可以获得很大的性能提升。

io_uring_setupparams->flags传入IORING_SETUP_IOPOLL可以开启io polling。只有部分opcode支持io polling模式: IORING_OP_READV,IORING_OP_WRITEV,IORING_OP_READ_FIXED,IORING_OP_WRITE_FIXED.通过io_uring_enter提交不支持io polling的任务,会返回-EINVAL

3.4.2 sq polling

默认情形下,我们每次提交任务,都需要调用一次io_uring_enter通知内核,这会导致中断和切换开销,通过sq polling可以避免这部分开销。开启sq polling之后,用户程序只需要将sqe放入sqring之后,不再需要调用io_uring_enter,内核侧会启动一个线程,主动去轮询sqring,并处理提交的sqe。

io_uring_setupparams->flags传入IORING_SETUP_SQPOLL可以开启sq polling。

同时,为了避免轮询线程占用cpu过多影响其他程序的执行,可以通过io_uring_setupparams->sq_thread_cpuparams->sq_thread_idle设置线程亲和的cpu和空闲超时事件。

  • sq_thread_cpu 由用户程序写入,设置sq polling线程所在的cpu,需同时设置IORING_SETUP_SQ_AFF才生效,需要root权限,否则返回-EPERM
  • sq_thread_idle 由用户程序写入,设置sq polling线程在空闲多久后,陷入sleep,单位为ms,如果不设置,默认为1000ms

当sq polling线程陷入sleep后,内核会设置IORING_SQ_NEED_WAKEUP到sqring的flags成员。当用户程序在提交sqe到sqring后,发现该flag被设置,则需要调用一次io_uring_enter并带上IORING_ENTER_SQ_WAKEUPflag.

4 使用

如前所说,直接使用io_uring接口,需要处理很多细节(比如io_uring实例的初始化和mmap,cqring和sqring的带memory barrier读写等)。作者基于io_uring封装了一个更上层的库liburing,屏蔽了很多细节。可以直接通过liburing来使用io_uring

参考:

推荐阅读更多精彩内容