Efficient IO with io_uring
原文 https://kernel.dk/io_uring.pdf
Efficient IO with io_uring(io_uring的高效IO)
io_uring的最新Linux IO interface介绍,并将其与现有产品进行比较。 我们将探讨其存在的原因,它的内部工作原理以及用户可见的interface。 本文将不涉及有关特定命令等的详细信息,因为那只会复制man pages中的信息。 相反,我们将尝试介绍io_uring及其工作原理,使读者对它如何相互联系有更深入的了解。 就是说,本文和man pages之间会有一些重叠。
1.0 Introduction(介绍)
在Linux中有许多方式可以执行基于文件的IO。 最古老和最基本的是read(2)和write(2)系统调用。 后来用pread(2)和pwrite(2)版本增强了这些功能,允许传入offset,再后来我们使用了preadv(2)和pwritev(2),它们是前者基于vector的版本。 由于还不够,Linux还具有preadv2(2)和pwritev2(2)系统调用,它们进一步扩展了API以允许modifier flags(修饰符标志)。
除了这些系统调用的各种差异之外,它们具有synchronous interfaces的共同特征。
这意味着当数据准备就绪(或写入)时,系统调用return。 对于某些用例而言,它不是最佳的,并且需要一个asynchronous interface。 POSIX具有aio_read(3)和aio_write(3)来满足该需求,但是,这些实现通常乏善可陈,并且性能很差。
Linux确实有一个原生async IO interface,简称为aio。 不幸的是,它受到许多限制:
1、最大的限制无疑是它仅支持O_DIRECT(或 un-buffered)访问的 async IO。 由于O_DIRECT的限制(绕过cache和size/alignment限制),这使得原生aio interface在大多数情况下都不可行。 对于普通(buffered)IO,interface以synchronous方式运行。
2、即使您满足了async IO的所有限制,但有时并非如此。 IO submission最终有多种方式阻止async IO-如果需要meta data来执行IO,则submission将阻塞等待。 对于 storage devices,有固定数量的request slots。 如果这些slots当前已全部使用,则submission将被阻止,等待一个可用。 这些不确定性意味着依赖submission的应用程序即使处于async状态仍被迫使用sync。
3、API不友好。 每个IO submission最终需要copy 64 + 8个字节,每个completion copy 32个字节。 那是104字节的memory copy,对于IO来说应该是零copy。 这取决于您的IO大小。 暴露的completion event ring buffer通过使completions速度变慢而妨碍执行,并且很难(不可能吗?)从应用程序正确使用。 IO始终需要两个系统调用(submit + wait-for-completion),这会严重的变慢。
多年来,为消除上述第一个限制做出了各种努力(我在2010年也曾尝试这么做),但没有成功。 从效率方面,低于10微秒延迟和高IOPS的设备来看,该interface的问题开始显现。 对于这些类型的设备,缓慢的和不确定的submission等待时间是一个大问题。 除此之外,由于上述限制,可以肯定地说原生Linux aio没有很多用例。
此外,事实上, "normal" applications对aio没有用处,这意味着Linux仍然缺少其所需功能的interface。 applications或libraries没有理由继续创建private IO offload thread pools来获得像样的async IO,尤其是当可以在内核中更高效地完成时。
2.0 Improving the status quo(改善现状)
最初在改善aio interface上,工作进展相当长。 选择此方向有多种原因:
1、如果扩展和改进现有interface,将比提供一个新interface更好。 采用新interface需要花费时间,并且要审核和批准新interface是一项长期艰巨的任务。
2、一般而言,这项工作要少得多。 作为开发人员,您总是希望以最少的工作量来完成最大的任务。 扩展现有interface在现有test infrastructure方面为您带来许多优势。
现有的aio interface由三个主要系统调用组成:一个用于设置aio context的系统调用(io_setup(2)),一个用于提交IO的系统调用(io_submit(2)),以及一个用于获取或等待IO完成的系统调用(io_getevents(2))。 由于需要对多个这些系统调用进行行为更改,因此我们需要添加新的系统调用以传递此信息。 这样就创建了指向同一代码的多个入口点,以及其他位置的快捷方式。 最终结果在代码复杂性和可维护性方面不是很漂亮,并且最终只能解决上一节中突显的缺陷之一。 最重要的是,它实际上使其中之一变得更糟,因为现在API的理解和使用变得更加复杂。
尽管从头开始总是很难,但是很明显,我们需要全新的东西。 使我们能够提供所有要点的东西。 我们需要它具有高性能和可伸缩性,同时又要易于使用并具有现有interface所缺乏的功能。
3.0 New interface design goals(新interface设计目标)
尽管从头开始并不是一个容易的决定。 按重要性从高到低的顺序,主要设计目标是:
易于使用,难以滥用。 任何 user/application可见interface都应以此为主要目标。 该interface应易于理解并且使用方便。
可扩展。虽然我的背景主要与存储相关,但我希望interface可用于的不仅仅是面向block的 IO。这意味着未来可能会支持networking和non-block storage interfaces。如果你正在创建一个全新的interface,它应该是(或者至少尝试)面向未来的某种形式。
功能丰富。 Linux aio满足applications的子集。 我不想创建另一个仅涵盖某些applications需求或需要applications一次又一次地重新创建相同功能的interface(例如IO thread pools)。
效率。 虽然存储IO大部分仍是基于block的,因此大小至少为512b或4kb,但对于某些applications,这些大小下的效率仍然至关重要。 就每个请求的开销而言,新interface必须高效,这一点很重要。
可扩展性。 尽管效率和低延迟很重要,但在峰值端提供最佳性能也至关重要。 特别是对于存储,我们一直在努力提供可扩展的基础架构。 一个新的interface应该使我们能够一直将这种可扩展性展现给applications。
上述某些目标似乎互斥。 高效且可扩展的interface通常很难使用,更重要的是,很难正确使用。 既丰富又高效的功能也很难实现。 但是,这些是我们设定的目标。
4.0 Enter io_uring
尽管设计目标很高,但最初的设计还是以效率为中心。 效率不是事后才能想到的,它必须从一开始就进行设计-一旦固定了interface,以后就无法将其淘汰。 我知道我既不需要submissions或completion events的任何memory copy,也不需要内存间接访问。 在以前基于aio的设计,aio 必须处理 IO 的这两方面,这明显损害了效率和可伸缩性。
由于不需要copy,因此很明显kernel和applications必须优雅地共享定义IO本身和completion event的结构。 如果您认为共享得那么远,那么将共享数据也驻留在applications和kernel之间的内存中,这是一个自然的扩展。一旦实现了这种飞跃,必须以某种方式管理两者之间的同步。applications不能与kernel共享锁定而不调用系统调用,系统调用肯定会降低我们与kernel通信的速率。这与效率目标相左。一个满足我们需求的数据结构是single producer和single consumer ring buffer。使用shared ring buffer,我们可以消除applications和kernel之间共享锁定的需要,从而避免使用一些内存排序和障碍。
与async interface相关联的基本操作有两个:提交请求的操作以及与完成请求有关的事件。 对于提交IO,applications是生产者,kernel是消费者。 对于完成而言,情况恰恰相反-在这里,kernel会生成完成事件,而applications会使用它们。 因此,我们需要一对环以在applications和kernel之间提供有效的通信通道。 这对环是新interface io_uring的核心。 它们被适当地命名为提交队列(submission queue SQ)和完成队列(completion queue CQ),并构成新interface的基础。
4.1 DATA STRUCTURES
有了适当的通信基础之后,该看一下定义用于描述request和completion event的数据结构了。 completion方是直截了当的。 它需要携带与操作结果有关的信息,以及将completion情况链接回其来源request。 对于io_uring,选择的布局如下:
struct io_uring_cqe {
__u64 user_data;
__s32 res;
__u32 flags;
};
io_uring name应该可以识别,并且_cqe后缀指的是Completion Queue Event。 对于本文的其余部分,通常仅称为cqe。cqe包含一个user_data字段。 该字段从initial request submission中携带,并且包含applications识别所述请求所需的任何信息。
一种常见的用例是使其成为原始请求的指针。 内核不会涉及到该字段,它只是直接从submission到completion event。res保留请求的结果。 可以将其视为系统调用的返回值。 对于正常的read/write操作,这类似于read(2)或write(2)的返回值。 对于成功的操作,它将包含传输的字节数。 如果发生故障,它将包含负值。 例如,如果发生I/O错误,则res将包含-EIO。 最后,flags成员可以携带与此操作有关的元数据。 截至目前,该字段尚未使用。
request type的定义更为复杂。 它不仅需要描述比completion event更多的信息,而且它的设计目标是io_uring可扩展的request types。 我们想到的如下:
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];
};
};
类似于completion event,submission方结构称为Submission Queue Entry,简称 sqe。它包含一个opcode字段,用于描述此特定请求的操作代码(op-code)。其中一个这样的操作IORING_OP_READV,即vectored read。flags包含命令类型中常见的修饰符标志。稍后我们将在高级用例部分中介绍这个问题。ioprio 是此请求的优先级。对于正常的read/writes,这遵循了为 ioprio_set(2)系统调用概述的定义。fd 是与请求关联的文件描述符,并且off保留操作应发生的offset。如果opcode描述传输数据的操作,则addr 包含该操作执行 IO 地址。如果该操作是某种vectored read/write,这将是指向结构 iovec 数组的指针,例如 preadv(2) 使用。对于non-vectored IO 传输,addr 必须直接包含地址。这将传递到 len 中,该字节计数可以是non-vectored IO 传输,也可以是 addr 为vectors IO 传输描述的多个vectors。
接下来是特定于opcode的flags的并集。 例如,对于提到的vectored read ( IORING_OP_READV ),这些flags遵循为preadv2(2)系统调用所描述的flags
。 user_data在opcode之间是通用的,并且未被内核使用。 当为此请求发布completion event时,将其简单地复制到completion event cqe中。 buf_index将在高级用例部分中描述。最后,在structure的末尾有一些填充。 这样做的目的是确保sqe在内存中以64 bytes大小很好地对齐,也用于将来可能需要包含更多数据来描述请求。我想到了一些用例-一种是key/value存储命令集,另一种用于端到端数据保护,其中应用程序针对要写入的数据传递预先计算的校验和 。
4.2 COMMUNICATION CHANNEL
通过描述的structures,让我们更详细地介绍ring的工作原理。 即使我们具有submission和completion side,这是对称的,但两者之间的indexing却有所不同。 像上一节一样,让我们从不太复杂的completion ring开始。
cqes被组织成一个数组,支持该数组的内存可以被内核和应用程序看到和修改。 但是,由于cqe是由内核生成的,因此只有内核实际上在修改cqe entries。 通信由 ring buffer管理。 每当内核将新event发布到CQ ring时,它都会更新与之关联的tail。 当应用程序使用entries时,它将更新head。 因此,如果tail与head不同,则应用程序知道它有一个或多个event可供使用。 ring counters本身是free flowing 32位整数,并且在completed的events数超过ring的容量时依赖于natural wrapping。 这种方法的一个优点是,我们可以利用ring的完整大小,而不必在一边管理"ring is full" flag,这会使ring的管理变得复杂。因此,ring也必须是2的幂。
为了找到event的index,应用程序必须使用ring的大小掩码来屏蔽当前的 tail index。 通常如下所示:
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();
ring→cqes []是io_uring_cqe structures的共享数组。 在接下来的部分中,我们将深入探讨如何设置和管理共享内存(以及io_uring实例本身)以及magic read 和 write barrier calls在这里所做的内部细节。
对于submission side,角色是相反的。 该应用程序是一个更新tail的应用程序,内核消耗head(和更新)entries。 一个重要的区别是,尽管CQ ring直接索引共享的cqes数组,但是submission side在它们之间具有一个间接数组。 因此,submission side ring buffer是此数组的索引,而该数组又包含sqes的索引。 最初,这看起来可能很奇怪并且令人困惑,但是背后有一些原因。 某些应用程序可能将请求单元嵌入内部数据结构中,这使它们可以灵活地执行此操作,同时保留了在一个操作中submit multiple sqes的能力。 继而允许将所述应用程序更容易地转换为io_uring接口。
添加 sqe 供内核使用,基本上与从内核获取 cqe 正好相反。 典型的示例类似:
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();
与CQ ring side一样,稍后将说明read和write barriers。 上面是一个简化的示例,它假定SQ ring当前为空,或者至少它有空间可以再输入一个。
一旦内核消耗了sqe,应用程序就可以自由地重用该sqe entry。 即使对于给定的sqe内核尚未完全完成的情况也是如此。 如果内核在使用完entry后确实需要访问它,则它将制作一个稳定的副本。 为什么会发生这种情况并不一定很重要,但是它会对应用程序产生重要的副作用。 通常,应用程序会要求给定大小的ring,并且可能的假设是该大小直接对应于应用程序在内核中可能有多少个待处理的请求。 但是,由于sqe生存期仅是其实际submission的生存期,因此应用程序可能会驱动比SQ ring大小所指示的更高的挂起请求计数。 应用程序必须注意不要这样做,否则可能会导致CQ ring溢出的风险。 默认情况下,CQ ring的大小是SQ ring的两倍。 这为应用程序在管理上提供了一定的灵活性,但是并不能完全消除这样做的需要。 如果应用程序确实违反了此限制,则会在CQ ring中将其作为溢出条件进行跟踪。 稍后会有更多详细信息。
Completion events可以按任何顺序到达,在请求submission和关联completion之间没有顺序。 SQ ring和CQ ring彼此独立运行。 但是,Completion events将始终与给定的submission请求相对应。 因此,completion events将始终与特定的submission请求相关联。
5.0 io_uring interface
就像aio一样,io_uring具有与之关联的许多系统调用,这些系统调用定义了其操作。 第一个是用于设置io_uring实例的系统调用:
int io_uring_setup(unsigned entries, struct io_uring_params *params);
application必须为此io_uring实例提供所需数量的entries,以及与之关联的一组参数。 entries表示将与此io_uring实例关联的平方数。 它必须是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将由内核填充,让application知道此ring支持多少个sqe entries。
同样,对于sqe entries,cq_entries成员告诉应用程序CQ ring的大小。 除sq_off和cq_off字段是对通过io_uring设置基本通信所必需的字段之外,其余结构的讨论将推迟到高级用例部分。
成功调用io_uring_setup(2)时,内核将返回一个文件描述符,该文件描述符用于引用此io_uring实例。 这是sq_off和cq_off结构派上用场的地方。 假定sqe和cqe结构由内核和应用程序共享,则应用程序需要一种方法来访问该内存。 这是通过mmap(2)将其放入应用程序存储空间来完成的。 该应用程序使用sq_off成员找出各种环成员的offsets。 io_sqring_offsets结构如下:
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;
};
要访问此内存,应用程序必须使用io_uring文件描述符以及与SQ环关联的内存offsets来调用mmap(2)。 io_uring API定义了以下供应用程序使用的mmap offsets:
#define IORING_OFF_SQ_RING 0ULL
#define IORING_OFF_CQ_RING 0x8000000ULL
#define IORING_OFF_SQES 0x10000000ULL
其中IORING_OFF_SQ_RING用于将SQ ring映射到应用程序存储空间,IORING_OFF_CQ_RING用于CQ ring,最后使用IORING_OFF_SQES映射sqe数组。 对于CQ ring,cqes数组是CQ ring本身的一部分。 由于SQ ring是sqe数组中值的索引,因此必须由应用程序单独映射sqe数组。
应用程序将定义包含这些offset的自己的结构。 一个示例可能如下所示:
struct app_sq_ring {
unsigned *head;
unsigned *tail;
unsigned *ring_mask;
unsigned *ring_entries;
unsigned *flags;
unsigned *dropped;
unsigned *array;
};
因此,典型的setup案例如下所示:
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);
sring→head = ptr + p→sq_off.head;
sring→tail = ptr + p→sq_off.tail;
sring→ring_mask = ptr + p→sq_off.ring_mask;
sring→ring_entries = ptr + p→sq_off.ring_entries;
sring→flags = ptr + p→sq_off.flags;
sring→dropped = ptr + p→sq_off.dropped;
sring→array = ptr + p→sq_off.array;
return sring;
}
使用IORING_OFF_CQ_RING和io_cqring_offsets cq_off成员定义的偏移量,CQ ring与此类似地映射。最后,使用IORING_OFF_SQES偏移量映射sqe数组。由于这主要是可以在应用程序之间重用的样板代码,因此liburing library interface提供了一组helpers程序,以简单的方式完成设置和内存映射。有关详细信息,请参见io_uring library部分。 一旦完成所有这些操作,应用程序就可以通过io_uring实例进行通信了。
应用程序还需要一种方法来告诉内核,它现在已经产生了使用它的请求。 这是通过另一个系统调用完成的:
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t sig);
fd指的是ring文件描述符,由io_uring_setup(2)返回。 to_submit告诉内核,准备使用和提交的sqes数量已达到上限,而min_complete则请求内核等待该数量的请求完成。 使单个调用可用于提交和等待完成都意味着该应用程序可以通过单个系统调用来提交和等待请求完成。 flags包含修改调用行为的标志。 最重要的是:
#define IORING_ENTER_GETEVENTS (1U << 0)
如果在flags中设置了IORING_ENTER_GETEVENTS,则内核将主动等待min_complete事件可用。
精明的读者可能想知道我们是否需要此标志,如果我们还有min_complete的话。在某些情况下,区分很重要,稍后将介绍。目前,如果您希望等待完成,则必须设置IORING_ENTER_GETEVENTS。
基本上涵盖了io_uring的基本API。 io_uring_setup(2)将创建给定大小的io_uring实例。
通过该设置,应用程序可以开始填写sqes并使用io_uring_enter(2)提交。 可以通过相同的调用等待完成,也可以在以后的时间分别完成。 除非应用程序想要等待完成操作进入,否则它也可以只检查cq环尾以了解任何事件的可用性。 内核将直接修改CQ环尾,因此应用程序可以使用完成操作,而不必调用设置了IORING_ENTER_GETEVENTS的io_uring_enter(2)。
有关可用命令的类型以及如何使用它们,请参见io_uring_enter(2)手册页。
5.1 SQE ORDERING
通常sqes是独立使用的,这意味着执行一次不会影响环中后续sqe entries的执行顺序。 这使操作具有完全的灵活性,并使它们能够并行执行和完成,以实现最高的效率和性能。 可能需要排序的一种用例是数据完整性写入。 一个常见的示例是一系列写入,然后是fsync/fdatasync。 只要我们允许写入以任何顺序完成,我们就只关心在所有写入完成后执行数据同步。
应用程序通常将其转换为写入并等待操作,然后在基础存储确认所有写入后才发出同步。
io_uring支持耗尽提交侧队列,直到所有先前的完成都完成为止。 这允许应用程序将上述同步操作排队,并且知道它不会在所有先前的命令完成之前启动。 这可以通过在sqe flags字段中设置IOSQE_IO_DRAIN来完成。 请注意,这会使整个提交队列停滞。 根据特定应用程序使用io_uring的方式,这可能会引入比预期更大的管道气泡。 如果这些类型的消耗操作很常见,则应用程序可以仅针对完整性写入使用独立的io_uring上下文,以允许更好地同时执行不相关的命令。
5.2 LINKED SQES
虽然IOSQE_IO_DRAIN包括完整的流水线屏障,但是io_uring还支持更精细的sqe序列控制。
链接的sqes提供了一种方法来描述较大提交环中一系列sqes之间的依赖关系,其中每个sqe的执行取决于前一个sque的成功完成。 此类用例的示例可能包括一系列必须按顺序执行的写操作,或者可能是类似复制的操作,在该操作中,从一个文件中读取数据后再写入另一个文件,同时共享两个sque的缓冲区。 要使用此功能,应用程序必须在sqe标志字段中设置IOSQE_IO_LINK。 如果已设置,则下一个sqe将不会在成功完成前一个sqe之前启动。 如果先前的sqe尚未完全完成,则链会断开,并使用-ECANCELED作为错误代码来取消链接的sqe。 在这种情况下,完全完成是指请求的完全成功完成。
任何错误或可能较短的读/写操作都将中止链,请求必须完全完成。
只要在flags字段中设置了IOSQE_IO_LINK,链接的squre链就会继续。 因此,将链定义为从设置了IOSQE_IO_LINK的第一个sqe开始,到没有设置它的第一个后续sqe结束。
支持任意长链。
链条独立于提交环中的其他平方执行。 链是独立的执行单元,多个链可以相互并行执行和完成。 这包括不属于任何链条的平方尺。
5.3 TIMEOUT COMMANDS
io_uring支持的大多数命令都直接处理数据,例如直接执行read/write操作或间接执行fsync样式命令,但timeout命令则有所不同。 IORING_OP_TIMEOUT无需处理数据,而是帮助操纵完成环上的等待。 超时命令支持两种不同的触发器类型,可以在一个命令中一起使用。 一种触发类型是经典超时,调用方传入的结构时间规格(变化)具有非零seconds/nanoseconds值。 为了保持32位和64位应用程序与内核空间之间的兼容性,使用的类型必须具有以下格式:
struct __kernel_timespec {
int64_t tv_sec;
long long tv_nsec;
};
在某个时候,用户空间应该具有适合此描述的结构timespec64。 在此之前,必须使用上述类型。 如果需要超时,sqe addr字段必须指向此类型的结构。 经过指定的时间后,超时命令将完成。
第二种触发类型是完成计数。 如果使用,则完成计数值应填写到sqe的offset字段中。 自超时命令排队以来,一旦完成指定次数的完成,超时命令将完成。
您可以在一个超时命令中指定两个触发事件。 如果同时将超时排队,则要触发的第一个条件将生成超时完成事件。 发布超时完成事件时,所有完成服务的等待者都将被唤醒,无论他们是否满足要求的完成数量。
7.0 liburing library
有了io_uring的内部细节,现在您将放心地了解到有一种更简单的方法可以执行上述操作。 liburing library有两个目的:
消除了用于设置io_uring实例的样板代码。
为基本用例提供简化的API。
后者确保应用程序完全不必担心内存障碍,也不必自己进行任何ring buffer管理。 这使该API更加易于使用和理解,并且消除了理解其工作原理的所有细节。 如果我们只是专注于提供基于liburing的示例,那么这篇文章可能会短得多。 此外,liburing目前的重点是减少样板代码并为标准用例提供基本帮助。 一些更高级的功能尚无法通过liburing获得。 但是,这并不意味着您不能将两者混在一起。 它们都在相同的结构上运行。 通常鼓励应用程序使用liburing,即使它们使用的是原始接口。
7.1 LIBURING IO_URING SETUP
让我们从一个例子开始。 liburing提供了以下helper来完成相同的任务
struct io_uring ring;
io_uring_queue_init(ENTRIES, &ring, 0);
io_uring structure同时包含SQ和CQ ring的信息,并且io_uring_queue_init(3)调用为您处理所有setup逻辑。 对于此特定示例,我们将为flags参数传递0。 使用io_uring实例完成应用程序后,它只需调用:
io_uring_queue_exit(&ring);
拆掉它。 与应用程序分配的其他资源类似,一旦应用程序退出,内核将自动获取它们。 对于应用程序可能已创建的任何io_uring实例,也是如此。
7.2 LIBURING SUBMISSION AND COMPLETION
一个非常基本的用例是提交请求,然后等待它完成。 使用liburing helpers,看起来像这样:
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_wait_cqe(3)的最后一次调用将返回我们刚刚提交的 sqe 的完成事件,前提是您没有其他 sqes 在flight中。如果这样做,完成事件可能是为另一个 sqe。
如果应用程序只希望查看完成情况,而不是等待事件可用,则io_uring_peek_cqe(3) 这样做。对于这两个用例,一旦完成此完成事件应用程序必须调用 io_uring_cqe_seen(3)。否则,对 io_uring_peek_cqe(3) 或 io_uring_wait_cqe(3) 的重复呼叫将继续返回相同的事件。这种拆分是必要的,以避免内核可能覆盖现有的完成,甚至在应用程序完成它之前。io_uring_cqe_seen(3)递增 CQ 环头,使内核能够在同一插槽中填充新事件。
填写sqe有很多helpers程序,io_uring_prep_readv(3)只是一个示例。 我鼓励应用程序始终尽可能地利用提供的helpers程序的优势。
liburing library仍处于起步阶段,并且正在不断开发。
8.0 Advanced use cases and features
上面的示例和用例适用于各种类型的IO,例如基于O_DIRECT文件的IO,缓冲的IO,套接字IO等。 无需特别注意以确保它们的操作或异步性质。 但是,io_uring确实提供了应用程序需要选择的许多功能。 以下小节将描述其中的大多数。
8.1 FIXED FILES AND BUFFERS
每次将文件描述符填充到sqe中并提交给内核时,内核必须检索对该文件的引用。 IO完成后,将再次删除文件引用。 由于此文件引用的原子性,对于高IOPS工作负载,这可能会明显变慢。 为了缓解此问题,io_uring提供了一种为io_uring实例预注册文件集的方法。 这是通过第三个系统调用完成的:
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg,
unsigned int nr_args);
fd是io_uring实例ring文件描述符,而opcode则是正在完成的注册类型。 要注册文件集,必须使用IORING_REGISTER_FILES。 然后,arg必须指向应用程序已经打开的文件描述符数组,并且nr_args必须包含该数组的大小。 io_uring_register(2) 成功完成文件集注册后,应用程序可以通过将数组中的文件描述符的索引(而不是实际的文件描述符)分配给 sqe→fd 字段,并在 sqe→flags 字段中设置 IOSQE_FIXED_FILE 将其标记为文件集 fd 来使用这些文件。 即使通过将 sqe→fd 设置为未注册的 fd 而不是在标志中设置"未注册",应用程序也可以自由地继续使用 IOSQE_FIXED_FILE未注册的文件。 当文件实例被拆掉时,io_uring文件集会自动释放,或者可以通过在操作代码中的 IORING_UNREGISTER_FILES 为 io_uring_register(2)手动完成。
也可以注册一组固定的IO缓冲区。 使用O_DIRECT时,内核必须先将应用程序页面映射到内核,然后才能对它们执行IO,然后在完成IO之后取消映射这些页面。 这可能是昂贵的操作。 如果应用程序重用IO缓冲区,则可以进行一次映射和取消映射,而不必执行每个IO操作。 要为IO注册一组固定的缓冲区,必须使用IORING_REGISTER_BUFFERS的操作码调用io_uring_register(2)。 然后,args必须包含一个struct iovec数组,并用每个iovec的地址和长度填充它们。 nr_args必须包含iovec数组的大小。 成功注册缓冲区后,应用程序可以使用IORING_OP_READ_FIXED和IORING_OP_WRITE_FIXED在这些缓冲区之间执行IO。 使用这些固定的操作码时,ske→addr必须包含一个位于这些缓冲区之一内的地址,而sqe→len必须包含请求的长度(以字节为单位)。 该应用程序可能会注册比任何给定IO操作大的缓冲区,将固定的读/写仅作为单个固定缓冲区的子集是完全合法的。
8.2 POLLED IO
对于追求最低延迟的应用程序,io_uring提供了对轮询的文件IO的支持。 在这种情况下,轮询是指执行IO而不依赖硬件中断来发出完成事件的信号。 轮询IO时,应用程序将反复向硬件驱动程序询问所提交的IO请求的状态。 这与非轮询式IO不同,非轮询式IO在这种情况下,应用程序通常会进入睡眠状态,等待硬件中断作为其唤醒源。 对于延迟很短的设备,轮询可以显着提高性能。 对于非常高的IOPS应用程序也是如此,因为高中断率使非轮询负载具有更高的开销。 轮询时有意义的边界号,无论是在等待时间还是总体IOPS速率方面,都取决于应用程序,IO设备和计算机的功能。
要利用IO轮询,必须在传递给io_uring_setup(2)系统调用或io_uring_queue_init(3) liburing library helper的标志中设置IORING_SETUP_IOPOLL。 使用轮询时,应用程序将无法再检查CQ环尾是否有完成功能,因为不会有自动触发的异步硬件完成事件。 相反,应用程序必须通过调用io_uring_enter(2)并设置IORING_ENTER_GETEVENTS并将min_complete设置为所需的事件数来主动查找并获得这些事件。 设置IORING_ENTER_GETEVENTS并将min_complete设置为0是合法的。对于轮询的IO,这要求内核仅检查驱动程序端的完成事件,而不是不断循环这样做。
在IORING_SETUP_IOPOLL中注册的io_uring实例上只能使用对轮询完成有意义的操作码。 这些包括任何读/写命令:IORING_OP_READV,IORING_OP_WRITEV,IORING_OP_READ_FIXED,IORING_OP_WRITE_FIXED。 在已注册用于轮询的io_uring实例上发布不可轮询的操作码是非法的。 这样做将导致io_uring_enter(2)返回-EINVAL。 其背后的原因是内核无法知道设置了IORING_ENTER_GETEVENTS的io_uring_enter(2)调用是否可以安全地等待事件,或者是否应该主动轮询事件。
8.3 KERNEL SIDE POLLING
尽管io_uring通常可以通过较少的系统调用来发出和完成更多请求,因此效率更高,但是在某些情况下,我们可以通过进一步减少执行IO所需的系统调用次数来提高效率。 这样的功能之一就是内核端轮询。 启用该功能后,应用程序不再需要调用io_uring_enter(2)来提交IO。 当应用程序更新SQ环并填写新的sqe时,内核端将自动注意到一个或多个新条目并提交。 这是通过特定于io_uring的内核线程完成的。
要使用此功能,必须将io_uring实例注册到特定于io_uring_params标志成员的IORING_SETUP_SQPOLL,或传递给io_uring_queue_init(2)。 此外,如果应用程序希望将此线程限制为特定的CPU,也可以通过标记IORING_SETUP_SQ_AFF并将io_uring_params sq_thread_cpu设置为所需的CPU来完成。 请注意,使用IORING_SETUP_SQPOLL设置io_uring实例是一项特权操作。 如果用户没有足够的特权,则io_uring_queue_init(3)将失败,并显示-EPERM。
为了避免在io_uring实例处于非活动状态时浪费过多的CPU,内核侧线程将在空闲一段时间后自动进入睡眠状态。 发生这种情况时,线程将在SQ环形标志成员中设置IORING_SQ_NEED_WAKEUP。 设置该值后,应用程序将无法依赖内核自动查找新条目,然后必须设置IORING_ENTER_SQ_WAKEUP来调用io_uring_enter(2)。 应用程序端逻辑通常看起来像这样:
/* fills in new sqe entries */
add_more_io();
/*
* need to call io_uring_enter() to make the kernel notice the new IO
* if polled and the thread is now sleeping.
*/
if ((*sqring→flags) & IORING_SQ_NEED_WAKEUP)
io_uring_enter(ring_fd, to_submit, to_wait, IORING_ENTER_SQ_WAKEUP);
只要应用程序继续驱动IO,就永远不会设置IORING_SQ_NEED_WAKEUP,并且我们可以有效地执行IO,而无需执行单个系统调用。 但是,重要的是在应用程序中始终保持与上面类似的逻辑,以防线程进入睡眠状态。 可以通过设置io_uring_params sq_thread_idle成员来配置空闲之前的特定宽限期。 该值以毫秒为单位。 如果未设置此成员,则内核默认为空闲时间一秒钟,然后将线程置于睡眠状态。
对于“normal” IRQ驱动的IO,可以通过直接在应用程序中查看CQ环来找到完成事件。 如果io_uring实例是使用IORING_SETUP_IOPOLL设置的,则内核线程也将负责完成收获。 因此,对于这两种情况,除非应用程序希望等待IO发生,否则它只需查看CQ环就可以找到完成事件。
9.0 Performance
最后,io_uring达到了为其设定的设计目标。 我们在内核和应用程序之间有一个非常有效的传递机制,以两个不同的环的形式。 尽管原始接口需要小心谨慎以在应用程序中正确使用,但主要的复杂之处实际上是需要显式的内存排序原语。 在发布和处理事件的提交和完成方面,这些仅保留一些细节,并且通常在整个应用程序中遵循相同的模式。 随着释放接口的不断成熟,我希望大多数应用程序都可以使用那里提供的API感到满意。
尽管本说明的目的不是要详细介绍io_uring的已实现性能和可伸缩性,但本节将简要介绍该领域中的一些成功经验。 有关更多详细信息,请参见[1]。 请注意,由于在等式的块方面进行了进一步的改进,因此这些结果有些过时了。 例如,在我的测试箱上,io_uring的每核峰值性能现在约为1700K 4k IOPS,而不是1620K。 请注意,这些值没有太多绝对含义,它们在衡量相对改进方面非常有用。 现在,通过使用io_uring,我们将继续发现更低的延迟和更高的峰值性能,因为应用程序和内核之间的通信机制不再是瓶颈。
9.1 RAW PERFORMANCE
有很多方法可以查看界面的原始性能。 大多数测试也将涉及内核的其他部分。 一个这样的示例就是上面部分中的数字,我们在其中通过从块设备或文件中随机读取来衡量性能。 为了获得最佳性能,io_uring通过轮询可以帮助我们达到170万个4k IOPS。 aio的性能要比608K低得多。 这里的比较不太公平,因为aio不支持轮询的IO。 如果我们禁用轮询,则io_uring可以为(否则)相同的测试用例驱动约1.2M IOPS。 那时aio的局限性很明显,对于相同的工作负载,ioio驱动的IOPS量是原来的两倍。
io_uring也支持no-op命令,该命令主要用于检查接口的原始吞吐量。
根据所使用的系统,观察到从每秒12M消息(我的笔记本电脑)到每秒20M消息(用于其他引用结果的测试框)之间的任何位置。 实际结果会因特定的测试用例而有很大不同,并且主要受必须执行的系统调用的数量限制。 否则,原始接口受内存限制,并且提交和完成消息在内存中较小且呈线性,因此每秒获得的消息速率可能会很高。
9.2 BUFFERED ASYNC PERFORMANCE
我之前提到过,内核中缓冲的aio实现可能比在用户空间中实现的效率更高。 主要原因与缓存数据与未缓存数据有关。 在进行缓冲IO时,应用程序通常严重依赖内核页面缓存来获得良好的性能。 用户空间应用程序无法知道是否要缓存下一步要查询的数据。 它可以查询这些信息,但是这需要更多的系统调用,并且答案本质上总是很简单-从现在开始到现在几秒钟之内缓存的内容就不那么多了。 因此,具有IO线程池的应用程序始终必须将请求退回至异步上下文,从而导致至少两个上下文切换。 如果请求的数据已经在页面缓存中,则会导致性能急剧下降。
io_uring像处理其他可能阻塞应用程序的资源一样处理这种情况。 更重要的是,对于不会阻塞的操作,将以内联方式提供数据。 这使得io_uring对于页面高速缓存中已经存在的IO而言,与常规同步接口一样有效。 IO提交调用返回后,应用程序将在CQ环中已经有一个完成事件等待着它,并且数据已经被复制了。
总结:
1、read、write与pread、pwrite区别,pread、和pwrite允许传入offset
pread、pwrite与preadv、pwritev区别?