异步IO时代的颠覆者:深入探讨io_uring

大家好,我是深度Linux,今天我们将一同踏入 Linux 操作系统中一个令人兴奋的领域io_uring。在当今这个对计算性能和效率不断追求极致的时代,高效的输入输出(I/O)操作成为了决定系统整体表现的关键因素之一。

Linux作为广泛应用于服务器、云计算、嵌入式系统等众多领域的强大操作系统,一直在不断探索和创新 I/O 技术,以满足日益增长的性能需求。而 io_uring 正是 Linux 在异步 I/O 方面的一颗璀璨新星,它有望引领未来高效异步 I/O 的发展方向。那么,io_uring 究竟有何神奇之处?它是如何实现高效异步 I/O 的?又将为我们的系统带来哪些变革呢?让我们带着这些疑问,深入探索 io_uring 的奥秘,揭开它神秘的面纱,一同展望 Linux 中高效异步 I/O 的未来。

一、io_uring概述

io_uring 是一个Linux内核提供的高性能异步 I/O 框架,最初在 Linux 5.1 版本中引入。它的设计目标是解决传统的异步 I/O 模型(如epoll或者 POSIX AIO)在大规模 I/O 操作中效率不高的问题。

在传统的 Linux I/O 操作中,存在一些性能瓶颈。例如,系统调用的开销较大,同步 I/O 操作会导致线程在等待 I/O 完成时被阻塞,浪费了 CPU 资源。随着对高性能、高并发服务器和应用程序的需求不断增加,需要一种更高效的 I/O 处理机制。io_uring 应运而生,它是由 Jens Axboe 开发的,目的是为了解决这些传统 I/O 机制的效率问题。

1.1过往IO接口的缺陷

(1)同步IO接口

最原始的文件IO系统调用就是read,write。read系统调用从文件描述符所指代的打开文件中读取数据。write系统调用将数据写入一个已打开的文件中。在文件特定偏移处的IO是pread,pwrite。调用时可以指定位置进行文件IO操作,而非始于文件的当前偏移处,且他们不会改变文件的当前偏移量。

分散输入和集中输出(Scatter-Gather IO)是readv, writev,调用并非只对单个缓冲区进行读写操作,而是一次即可传输多个缓冲区的数据,免除了多次系统调用的开销,提高文件 I/O 的效率,特别是当需要读写多个连续或非连续的数据块时。

该机制使用一个数组iov定义了一组用来传输数据的缓冲区,一个整形数iovcnt指定iov的成员个数,其中,iov中的每个成员都是如下形式的数据结构。

struct iovec {
   void  *iov_base;    /* Starting address */
   size_t iov_len;     /* Number of bytes to transfer */
};

上述接口在读写IO时,系统调用会阻塞住等待,在数据读取或写入后才返回结果。同步导致的后果就是在阻塞的同时无法继续执行其他的操作,只能等待IO结果返回。存储场景中对性能的要求非常高,所以需要异步IO。

(2)异步IO接口:AIO

Linux 的异步 IO(AIO,Asynchronous I/O)是一种高级的文件 IO 模型,允许应用程序在发起 IO 操作后不必等待操作完成,而是可以继续执行其他任务。这与传统的同步 IO 模型不同,后者在 IO 操作完成之前会阻塞应用程序的执行。

1.2io_uring设计思路

(1)解决“系统调用开销大”的问题?

针对这个问题,考虑是否每次都需要系统调用。如果能将多次系统调用中的逻辑放到有限次数中来,就能将消耗降为常数时间复杂度。

(2)解决“拷贝开销大”的问题?

之所以在提交和完成事件中存在大量的内存拷贝,是因为应用程序和内核之间的通信需要拷贝数据,所以为了避免这个问题,需要重新考量应用与内核间的通信方式。我们发现,两者通信,不是必须要拷贝,通过现有技术,可以让应用与内核共享内存。

要实现核外与内核的零拷贝,最佳方式就是实现一块内存映射区域,两者共享一段内存,核外往这段内存写数据,然后通知内核使用这段内存数据,或者内核填写这段数据,核外使用这部分数据。因此,需要一对共享的ring buffer用于应用程序和内核之间的通信。

  • 一块用于核外传递数据给内核,一块是内核传递数据给核外,一方只读,一方只写。

  • 提交队列SQ(submission queue)中,应用是IO提交的生产者,内核是消费者。

  • 完成队列CQ(completion queue)中,内核是IO完成的生产者,应用是消费者。

  • 内核控制SQ ring的head和CQ ring的tail,应用程序控制SQ ring的tail和CQ ring的head

(3)解决“API不友好”的问题?

问题在于需要多个系统调用才能完成,考虑是否可以把多个系统调用合而为一。有时候,将多个类似的函数合并并通过参数区分不同的行为是更好的选择,而有时候可能需要将复杂的函数分解为更简单的部分来进行重构。

如果发现函数中的某一部分代码可以独立出来成为一个单独的函数,可以先进行这样的提炼,然后再考虑是否需要进一步使用参数化方法重构。

1.3io_uring实现原理

异步IO时代的颠覆者:深入探讨io_uring_第1张图片

  • SQE:提交队列项,表示IO请求。

  • CQE:完成队列项,表示IO请求结果。

  • SQ:Submission Queue,提交队列,用于存储SQE的数组。

  • CQ:Completion Queue,完成队列,用于存储CQE的数组。

  • SQ Ring:SQ环形缓冲区,包含SQ,头部索引(head),尾部索引(tail),队列大小等信息。

  • CQ Ring:CQ环形缓冲区,包含SQ,头部索引(head),尾部索引(tail),队列大小等信息。

  • SQ线程:内核辅助线程,用于从SQ队列获取SQE,并提交给内核处理,并将IO请求结果生成CQE存储在CQ队列。

二、io_uring核心设计

2.1共享内存机制

io_uring 的共享内存机制是其高性能的关键所在。共享内存是指在用户态和内核态之间开辟一块共同可以访问的内存区域。对于 io_uring 来说,这块共享内存主要用于存储提交队列(SQ)和完成队列(CQ)。

在传统的 I/O 模型中,每次用户空间和内核空间进行数据交互(如提交一个 I/O 请求或者获取 I/O 完成的结果)都需要通过系统调用。系统调用会引起上下文切换,这是一个比较耗时的操作。而共享内存机制使得用户空间和内核空间可以直接在共享的内存区域中进行通信,减少了频繁的系统调用,从而大大提高了 I/O 操作的效率。

共享内存的操作方式

内存映射(mmap):用户空间和内核空间通过内存映射(mmap)的方式来访问共享内存。在初始化 io_uring 时,通过io_uring_setup系统调用创建共享内存区域,这个区域包含了提交队列、完成队列以及相关的控制结构。然后,用户空间通过 mmap 系统调用将这个共享内存区域映射到自己的地址空间中,这样用户空间就可以像访问普通内存一样访问共享内存中的提交队列和完成队列。

同步与异步操作:在共享内存的操作中,提交队列的操作主要是异步的。用户空间可以在任何时候填充提交队列项并提交 I/O 请求,而不需要等待之前的请求完成。完成队列的操作可以是异步的,也可以是同步的。异步操作时,用户空间可以通过注册事件通知(如使用epoll等机制)来获取完成队列的更新信息;同步操作时,用户空间可以通过轮询完成队列来获取 I/O 完成的结果。

共享内存的安全性和同步机制

安全性:虽然共享内存提供了高效的用户空间和内核空间通信方式,但也需要注意安全性。由于用户空间和内核空间都可以访问共享内存,所以必须确保数据的一致性和正确性。例如,在对提交队列和完成队列进行操作时,需要遵循一定的规则,防止数据的错误写入或读取。

同步机制:为了确保共享内存的正确使用,io_uring 采用了一些同步机制。在提交队列方面,有一个提交队列尾(SQ - Tail)指针,用于指示下一个可以填充提交队列项的位置。用户空间和内核空间通过对这个指针的操作来协调提交队列的填充和获取。在完成队列方面,也有类似的机制,如完成队列头(CQ - Head)指针,用于指示下一个未被读取的完成队列项的位置,以保证用户空间能够正确地获取 I/O 完成的结果。

2.2提交队列与完成队列

提交队列(SQ):它是一个环形缓冲区,位于共享内存中,用于用户空间向内核提交 I/O 请求。用户空间通过填充提交队列项(SQE)来描述 I/O 操作,如读或写操作的文件描述符、操作的起始位置、数据长度等信息。这些 SQE 按照一定的顺序排列在提交队列中。当用户空间准备好 I/O 请求后,通过调用相关的系统调用(如io_uring_enter),内核就可以从提交队列中获取这些请求并进行处理。

完成队列(CQ):同样是一个环形缓冲区,用于内核将完成的 I/O 请求结果返回给用户空间。当内核完成一个 I/O 请求后,会将该请求的结果信息(如操作是否成功、实际传输的数据量等)填充到完成队列项(CQE)中。用户空间可以通过轮询或者异步通知的方式获取完成队列中的结果,以了解 I/O 操作的完成情况。

2.3队列结构与操作

(1)提交队列结构(io_sq_ring)

内核中的表示:在 Linux 内核中,提交队列(SQ)由io_sq_ring结构来表示。这个结构包含了多个重要的字段,用于管理提交队列的状态和操作。

头部和尾部指针:其中有提交队列头部(sq_head)和尾部(sq_tail)指针。sq_head指向内核当前正在处理的提交队列项(SQE),而sq_tail则用于用户空间来指示下一个可以放置新 SQE 的位置。这种双指针的设计使得内核和用户空间能够在不同的节奏下操作提交队列。例如,用户空间可以不断地向队列尾部添加新的请求,而内核则从队列头部获取请求进行处理。

环形缓冲区属性:由于提交队列是一个环形缓冲区,其大小(sq_entries)是一个重要的属性。这个属性决定了提交队列能够容纳的 SQE 的最大数量。同时,还有一个标志位(sq_flags)用于表示队列的一些状态信息,如是否已满、是否有新的请求等。

关联的文件描述符(fd):io_sq_ring结构还通过文件描述符与用户空间的程序相关联。这个文件描述符是在初始化 io_uring 时通过io_uring_setup系统调用返回的,用户空间通过这个文件描述符来对提交队列进行操作,包括填充 SQE 和提交请求。

(2)提交队列项结构(io_uring_sqe)

操作类型字段:每个提交队列项(SQE)由io_uring_sqe结构表示,其中最重要的字段之一是操作类型(opcode)。这个字段指定了 I/O 操作的类型,如IORING_OP_READ表示读操作、IORING_OP_WRITE表示写操作等。不同的操作类型会使内核执行不同的 I/O 处理逻辑。

文件描述符相关字段:SQE 中包含用于指定 I/O 操作对应的文件描述符(fd)的字段。这个文件描述符告诉内核要对哪个文件或套接字等进行 I/O 操作。同时,还有一些与文件描述符相关的属性字段,如flags,用于指定一些特殊的操作要求,如是否采用非阻塞模式等。

数据缓冲区相关字段:对于读和写操作,SQE 中会有字段指定数据缓冲区的位置(addr)和大小(len)。addr指向用户空间中存储数据的缓冲区地址,len则表示要读取或写入的数据长度。这些字段确保内核能够正确地将数据从指定的位置读取或写入。

用户数据字段(user_data):这个字段允许用户空间在提交 I/O 请求时传递一些自定义的数据。这些数据可以是一个简单的整数或者一个指向更复杂数据结构的指针。在内核完成 I/O 操作后,这些用户数据会原封不动地返回在完成队列项(CQE)中,用户空间可以通过这个字段来识别不同的 I/O 请求或者传递一些额外的上下文信息。

(3)操作流程

①填充提交队列项(SQE):用户空间首先需要填充 SQE。这包括设置操作类型、文件描述符、数据缓冲区信息以及用户数据等字段。用户空间可以根据具体的 I/O 需求来构建多个 SQE,这些 SQE 可以是针对同一个文件描述符的不同操作,也可以是针对多个不同文件描述符的操作。

②将 SQE 放入提交队列:在填充好 SQE 后,用户空间需要将其放入提交队列。这是通过更新提交队列的尾部指针(sq_tail)来实现的。用户空间会检查队列是否已满(通过比较sq_tail和sq_entries等属性),如果队列未满,则将 SQE 放入队列中适当的位置,然后更新sq_tail指针,以指示下一个可以放置新 SQE 的位置。

③提交请求(通过 io_uring_enter):仅仅将 SQE 放入提交队列还不够,用户空间还需要通过io_uring_enter系统调用向内核提交请求。这个系统调用会告诉内核有新的 I/O 请求等待处理。在io_uring_enter调用中,用户空间可以指定要提交的请求数量、期望获取的完成结果数量等参数,从而触发内核开始从提交队列头部(sq_head)获取 SQE 并执行对应的 I/O 操作。

④完成队列操作:在内核完成 I/O 操作后,会将结果放入完成队列(CQ)中的完成队列项(CQE)。用户空间可以通过轮询完成队列或者使用异步事件通知机制(如结合epoll)来获取完成队列中的结果。通过检查 CQE 的各个字段,用户空间可以了解 I/O 操作的完成状态、实际完成的数据量等信息,并且可以根据 CQE 中的用户数据字段来识别对应的 I/O 请求。

三、io_uring系统API

io_uring 是 Linux 5.1 引入的异步 IO 接口,适合 IO 密集型应用。其初衷是为了解决 Linux 下异步 IO 接口不完善且性能差的现状,用以替代 Linux AIO 接口。

io_uring 的实现主要在 fs/io_uring.c 中,仅使用了三个系统调用 API:

  • io_uring_setup:用于设置 io_uring 的上下文。用户通过该函数初始化一个 io_uring 的上下文,返回一个文件描述符 fd,并将 io_uring 支持的功能及各个数据结构在 fd 中的偏移保存在参数中。用户根据偏移量通过 mmap 将 fd 映射到内存,获取到一段用户和内核共享的内存区域,其中有 io_uring 的上下文、SQ_Ring、CQ_Ring 以及一块专门用来存放 SQ Entry 的区域。

  • io_uring_enter:用于提交和获取完成任务。默认模式下,用户可以通过这个系统调用通知内核新请求已经提交到 SQ 中,也可以使用同一个 io_uring_enter 系统调用等待 I/O 请求完成。io_uring_enter 支持中断模式和轮询模式。用户也可以自己轮询 CQ 等待 I/O 请求完成,而不使用任何系统调用。此外,io_uring 也可以使用一个内核线程轮询 SQ,这样在整个 I/O 操作中不会使用任何系统调用。

  • io_uring_register:用于注册内核用户共享缓冲区(通过 mmap)。用户和内核通过提交和完成队列进行任务的提交和获取。

io_uring 作为一种异步 IO 框架,其高性能依赖于多个方面。例如用户态和内核态共享提交队列和完成队列,用户态支持 Polling 模式,不依赖硬件的中断,通过调用 IORING_ENTER_GETEVENTS 不断轮询收割完成事件。内核态也支持 Polling 模式,IO 提交和收割可以 offload 给 Kernel,且提交和完成不需要经过系统调用。在 DirectIO 下可以提前注册用户态内存地址,减小地址映射的开销。

(1)io_uring_setup 的作用

io_uring_setup 主要用于初始化和配置 io_uring 环境。在 Linux 系统中,通过调用 io_uring_setup 函数,会在用户空间和内核空间之间创建一块共享的内存区域,用于消息传递。这个共享内存区域分为三个部分,分别是提交队列(SQ)、完成队列(CQ)以及提交队列项数组(SQEs)。这样的设计使得应用程序和内核能够高效地进行异步 I/O 操作。

io_uring_setup 的核心功能之一是初始化 io_uring 结构体。它允许用户指定 io_uring 的入口数目,即同时处理的 I/O 事件数目。通过这个参数,用户可以根据自己的应用场景和系统资源来调整 io_uring 的处理能力。

此外,io_uring_setup 返回一个新的文件描述符,这个文件描述符将用于后续的 I/O 操作。用户可以通过这个文件描述符来与内核进行交互,提交任务和获取任务的完成状态。

(2)io_uring_enter 的功能

io_uring_enter 主要用于提交 I/O 事件并等待其完成。它接受多个参数,包括 io_uring 文件描述符、要提交的 I/O 事件数量、函数返回前必须完成的最小事件数量、用于控制函数行为的标志位以及指向信号集的指针。

通过 io_uring_enter,应用程序可以将准备好的 I/O 任务提交到内核进行处理。内核会将这些任务放入提交队列(SQ)中,并在适当的时候进行处理。同时,内核会将完成的任务放入完成队列(CQ)中。

io_uring_enter 的一个重要作用是减少对用户态线程的阻塞。在传统的 I/O 操作中,应用程序往往需要等待 I/O 操作完成才能继续执行其他任务,这会导致应用程序的并发度降低。而通过 io_uring_enter,应用程序可以提交任务后继续执行其他任务,无需等待所有任务完成,从而提高了应用程序的并发度。

此外,io_uring_enter 还可以根据不同的标志位来控制函数的行为。例如,可以设置标志位来指定只获取完成的事件、唤醒线程或者等待任务完成等。

(3)io_uring_register 的用途

io_uring_register 主要用于注册内核用户共享缓冲区,如文件描述符、缓冲区等。通过这个函数,应用程序可以将需要进行 I/O 操作的资源注册到 io_uring 中,以便内核能够更好地管理和处理这些资源。

具体来说,io_uring_register 接受四个参数,分别是 io_uring 文件描述符、指定注册操作的类型、指向要注册的数据的指针以及指定 arg 指针指向的数据的大小或数量。

注册操作的类型可以包括注册文件描述符、注册缓冲区等。例如,当应用程序需要对一个文件进行异步 I/O 操作时,可以使用 io_uring_register 将该文件的文件描述符注册到 io_uring 中。这样,内核在处理 I/O 任务时就可以直接访问这个文件描述符,提高 I/O 操作的效率。

此外,io_uring_register 还可以用于注册用户空间和内核空间的共享缓冲区。通过将缓冲区注册到 io_uring 中,应用程序可以直接在用户空间和内核空间之间进行数据交换,避免了数据的拷贝,提高了 I/O 操作的性能。

四、io_uring实现过程

4.1初始化过程

io_uring 通过系统调用 io_uring_setup 进行初始化。用户通过 io_uring_setup 初始化一个 io_uring 的上下文,该函数返回一个文件描述符,并将 io_uring 支持的功能及各个数据结构在该文件描述符中的偏移保存在一个参数结构体中。用户根据偏移量通过内存映射(mmap)将文件描述符映射到内存,获取到一段用户和内核共享的内存区域。这块区域中有 io_uring 的上下文、提交队列(SQ)、完成队列(CQ)以及一块专门用来存放提交队列条目的区域(SQE area)。

在初始化时,如果没有指定第一个参数(队列条目数),内核默认会分配一定数量的提交队列条目(SQE)和两倍数量的完成队列条目(CQE)。初始化后,用户和内核通过共享内存进行 I/O 请求的提交和完成结果的获取。

①io_uring_setup 系统调用

io_uring_setup是 io_uring 初始化的核心系统调用。它的主要作用是创建和配置 io_uring 实例所需的资源,包括提交队列(SQ)、完成队列(CQ)以及提交队列项(SQE)相关的内存区域。

这个系统调用接受一个参数,用于指定提交队列的大小(即提交队列项的数量)。这个参数决定了可以同时提交的 I/O 请求的最大数量。例如,如果设置为 100,那么最多可以同时向内核提交 100 个 I/O 请求。

另外,它返回一个文件描述符(fd),这个 fd 是后续操作的关键。通过这个文件描述符,用户空间可以与内核空间进行交互,访问和操作 io_uring 相关的资源。

②内存区域创建与划分

共享内存布局:在io_uring_setup调用后,内核会创建一块共享内存区域。这块区域主要分为三部分:提交队列(SQ)、完成队列(CQ)和提交队列项数组(SQEs)。提交队列和完成队列是环形缓冲区结构,它们用于用户空间和内核空间之间的 I/O 请求提交和完成结果反馈。提交队列项数组则用于存储具体的 I/O 请求信息,每个提交队列项包含了诸如 I/O 操作类型(读、写等)、文件描述符、数据缓冲区地址等详细信息。

内存映射(mmap)操作:用户空间程序通过mmap系统调用将这块共享内存区域映射到自己的地址空间中。这样,用户空间就可以像访问普通内存一样访问提交队列、完成队列和提交队列项数组。这是 io_uring 实现高效 I/O 操作的关键步骤之一,因为通过共享内存和内存映射,减少了传统 I/O 操作中频繁的系统调用和上下文切换。

③初始化相关的数据结构和参数

提交队列(SQ)初始化:在内核中,提交队列(由io_sq_ring结构表示)的相关参数会被初始化。例如,提交队列头部(sq_head)和尾部(sq_tail)指针会被设置为初始值,用于后续跟踪 I/O 请求的提交和处理情况。同时,提交队列的大小(sq_entries)和标志位(sq_flags)等参数也会被确定,这些参数决定了提交队列的容量和一些操作特性。

完成队列(CQ)初始化:完成队列(由io_cq_ring结构表示)也会进行类似的初始化操作。完成队列头部(cq_head)和尾部(cq_tail)指针用于跟踪已完成 I/O 请求的结果反馈。完成队列的大小(cq_entries)等参数也会被设置,以确定其能够容纳的完成队列项数量。

提交队列项(SQE)初始化:对于提交队列项数组中的每个元素(由io_uring_sqe结构表示),一些默认值会被设置。例如,操作类型(opcode)等字段会被初始化为未指定状态,等待用户空间填充具体的 I/O 操作信息。

④功能能力和参数返回

功能支持信息:在初始化过程中,io_uring_setup还会返回关于 io_uring 实例所支持功能的信息。这些信息可以告诉用户空间程序这个 io_uring 实例能够支持哪些类型的 I/O 操作、是否支持某些高级特性(如异步通知等)。

偏移量等参数返回:同时,它会返回提交队列、完成队列以及提交队列项数组在共享内存区域中的偏移量等参数。用户空间可以根据这些偏移量准确地定位和访问各个数据结构,从而正确地进行 I/O 请求的提交和完成结果的获取。这些偏移量参数确保了用户空间和内核空间在共享内存区域中的操作能够准确无误地进行,是 io_uring 初始化过程中的重要信息传递机制。

4.2IO提交和收割

(1)IO 提交

填充提交队列项(SQE):用户空间首先要做的是填充提交队列项(SQE)。SQE 是一个包含详细 I/O 操作信息的结构体,它的字段包括操作类型(如读、写、打开文件等)、操作对应的文件描述符、数据缓冲区的地址、操作的数据长度等。例如,若要进行文件读取操作,就需要在 SQE 中指定文件描述符、读取数据存储的缓冲区地址和期望读取的数据长度。这些信息可以根据具体的 I/O 任务需求来填充,用户可以同时准备多个 SQE,用于对同一个文件描述符进行不同操作,或者对多个不同文件描述符进行操作。

将 SQE 放入提交队列(SQ):当 SQE 填充完成后,需要将其放入提交队列(SQ)。SQ 是一个环形缓冲区,位于用户态和内核态共享的内存区域。用户空间通过更新提交队列的尾部指针(sq_tail)来实现将 SQE 放入队列的操作。在放入之前,会检查队列是否已满。判断方式是比较sq_tail和提交队列的大小属性(sq_entries)。如果队列未满,就将 SQE 放入队列中适当的位置,然后更新sq_tail指针,以指示下一个可以放置新 SQE 的位置。

提交 IO 请求(通过 io_uring_enter):仅仅将 SQE 放入提交队列还不够,用户空间还需要通过io_uring_enter系统调用向内核提交请求。这个系统调用会触发内核从提交队列头部(sq_head)获取 SQE 并开始执行对应的 I/O 操作。io_uring_enter系统调用接受多个参数,包括 io_uring 的文件描述符、要提交的 I/O 请求数量、期望获取的完成结果数量等。通过这些参数,用户可以精确控制 I/O 请求的提交和完成结果的获取方式。例如,可以设置只提交一定数量的请求,或者要求在返回之前必须有一定数量的请求完成。

(2)IO 收割(获取完成的 I/O 结果)

轮询完成队列(CQ):内核在完成 I/O 请求后,会将结果信息填充到完成队列(CQ)中的完成队列项(CQE)。用户空间可以通过轮询完成队列来获取 I/O 完成的结果。CQ 也是一个环形缓冲区,每个 CQE 包含了对应的 I/O 请求的完成状态(如成功或失败)、实际完成的操作信息(如实际读取或写入的数据长度)、以及与该 I/O 请求相关的一些标识信息(如与提交队列项中的某些标识相对应,用于匹配请求和结果)。

用户空间在轮询时,会检查完成队列头部(cq_head)和尾部(cq_tail)指针的位置变化,来判断是否有新的 I/O 请求结果可以获取。当cq_head不等于cq_tail时,说明有完成的 I/O 请求结果等待读取。

异步事件通知机制(结合 epoll 等):除了轮询方式,用户空间还可以通过异步事件通知机制来获取完成队列的更新信息。例如,结合epoll等机制,当完成队列中有新的 I/O 结果时,会触发相应的事件通知。

这种方式避免了频繁轮询带来的性能开销,用户空间可以在收到事件通知后再去获取完成队列中的结果,从而更高效地利用系统资源。当收到通知后,按照与轮询类似的方式,通过检查 CQE 的各个字段来了解 I/O 操作的完成状态和实际完成情况等信息。

五、io_uring特点与优势

5.1高性能特点

⑴异步操作

io_uring 允许应用程序异步地提交 I/O 请求。这意味着应用程序可以在提交 I/O 请求后,不必等待请求完成,就能够继续执行其他任务。例如,在一个网络服务器中,服务器可以同时提交多个客户端的读或写请求,然后在这些请求处理期间去处理其他客户端连接或者业务逻辑,大大提高了系统的并发处理能力。

与传统的同步 I/O 相比,异步操作减少了线程等待 I/O 完成的时间,使得 CPU 资源能够更高效地被利用。在处理大量 I/O 操作时,这种优势尤为明显,能够有效避免线程因为等待 I/O 而长时间阻塞,从而提升系统的整体吞吐量。

⑵事件通知

它支持高效的事件通知机制。当 I/O 请求完成后,内核可以通过多种方式通知用户空间,如通过信号、文件描述符事件(例如结合epoll机制)等。这种通知机制使得应用程序能够及时获取 I/O 完成的信息,以便快速进行后续处理。

以epoll为例,通过将 io_uring 的完成队列与epoll关联,当有 I/O 完成事件时,epoll会立即通知应用程序,应用程序可以直接从完成队列中获取结果并处理。这种方式避免了应用程序频繁地轮询完成队列,减少了不必要的 CPU 开销。

⑶零拷贝传输

io_uring 在某些情况下能够实现零拷贝传输。在传统的 I/O 操作中,数据通常需要在用户空间和内核空间之间进行多次拷贝,这会带来额外的性能开销。而 io_uring 通过共享内存等机制,使得数据可以直接从磁盘或网络等设备传输到用户空间指定的缓冲区,或者反之。

例如,在文件读取操作中,如果文件数据存储在磁盘上,使用 io_uring 可以将磁盘数据直接读取到用户指定的内存缓冲区,避免了中间的数据拷贝过程,从而加快了数据传输速度,对于大文件传输或者高带宽网络应用等场景,这种优势能够显著提升性能。

⑷多功能性

io_uring 支持多种类型的 I/O 操作,包括但不限于文件 I/O(如读、写、打开、关闭文件等)、网络 I/O(如套接字的读写操作)、异步通知操作等。这种多功能性使得它可以广泛应用于各种不同类型的应用程序中。

无论是处理本地文件系统的高性能存储应用,还是构建高并发的网络服务器,io_uring 都能够提供有效的 I/O 处理支持。例如,一个数据库管理系统可以利用 io_uring 的多功能性,同时处理数据文件的读写和网络客户端的连接请求,提升系统的整体性能。

5.2对比传统机制的优势

⑴减少系统调用

传统的 I/O 操作通常需要频繁地进行系统调用。每次系统调用都会引起用户态和内核态之间的上下文切换,这是一个比较耗时的过程。而 io_uring 通过共享内存的提交队列和完成队列,使得用户空间和内核空间之间的交互更加高效。

应用程序可以在共享内存区域中批量准备 I/O 请求并提交,减少了单个 I/O 请求对应的系统调用次数。例如,在进行大量文件读取操作时,使用 io_uring 可以将多个读取请求一次性提交,而不是像传统方式那样为每个读取请求都进行系统调用,从而显著减少了上下文切换的开销。

⑵避免数据拷贝

如前文所述,传统 I/O 往往涉及多次数据在用户空间和内核空间之间的拷贝。io_uring 利用共享内存和直接内存访问等技术,在很多情况下能够避免这种不必要的数据拷贝。

以网络 I/O 为例,在传统的套接字读写操作中,数据可能需要从内核网络缓冲区拷贝到用户空间缓冲区,再进行处理。而 io_uring 可以通过合理的缓冲区规划和共享内存机制,使得数据能够直接在网络设备和用户空间之间传输,减少了数据传输的延迟和 CPU 开销。

⑶支持多种 I/O 操作顺序

传统的 I/O 模型通常对 I/O 操作顺序有比较严格的要求,例如同步 I/O 操作需要按照提交的顺序依次完成。而 io_uring 更加灵活,它支持乱序处理 I/O 请求。

内核可以根据 I/O 设备的状态和性能等因素,灵活地决定 I/O 请求的处理顺序,以达到最优的性能。例如,在处理多个文件读取请求时,如果某个文件的数据存储在高速磁盘区域,内核可以优先处理该文件的读取请求,而不必严格按照提交的顺序进行,这种灵活性有助于进一步提升系统的 I/O 效率。

六、io_uring的应用场景

⑴高并发服务器

网络服务器(如 Web 服务器、应用服务器):在高并发的网络服务器场景中,io_uring 可以显著提升性能。以 Web 服务器为例,当同时处理大量客户端的 HTTP 请求时,这些请求通常涉及到大量的文件读取(如 HTML 文件、CSS 文件、JavaScript 文件等)和网络数据发送。

传统的 I/O 处理方式可能会导致线程在等待文件读取或者网络 I/O 完成时被阻塞,限制了服务器的并发处理能力。而 io_uring 的异步操作特性允许服务器快速地提交这些 I/O 请求,然后继续处理其他客户端请求。例如,当一个客户端请求一个网页时,服务器可以使用 io_uring 异步地读取网页相关的多个文件,同时接收其他客户端的请求,提高了服务器的吞吐量和响应速度。

数据库服务器:对于数据库服务器,io_uring 同样有着重要的应用价值。数据库操作涉及大量的磁盘 I/O(如数据文件的读取和写入、索引文件的更新等)和网络 I/O(用于接收客户端查询请求和返回结果)。

使用 io_uring,数据库服务器可以异步地提交磁盘 I/O 请求,减少线程等待磁盘操作的时间。同时,在处理网络连接方面,也可以提高效率。例如,在处理多个客户端的并发查询时,数据库服务器可以利用 io_uring 同时读取不同查询所需的数据文件,然后异步地将结果返回给客户端,优化了数据库的整体性能,降低查询延迟。

⑵低延迟应用

高频交易系统:在金融领域的高频交易系统中,对延迟的要求极高。每一个微小的延迟都可能导致交易机会的丧失或者交易成本的增加。这些系统需要快速地处理大量的网络消息(如市场行情数据接收、交易指令发送)和磁盘数据读写(如交易记录存储、历史数据查询)。

io_uring 的异步和零拷贝特性可以在这种场景中发挥巨大作用。通过异步提交 I/O 请求,系统可以在等待网络数据接收或者磁盘数据读取的同时,处理其他事务。零拷贝特性可以减少数据传输的时间,确保市场行情数据能够以最快的速度从网络缓冲区传输到交易处理模块,或者交易指令能够快速地存储到磁盘上,从而降低交易延迟,提高交易系统的竞争力。

实时游戏服务器:对于实时游戏服务器,需要快速地处理玩家的操作请求并更新游戏状态。这些操作涉及到网络 I/O(接收玩家的操作指令、发送游戏场景更新数据)和可能的磁盘 I/O(如保存玩家游戏进度等)。

io_uring 的事件通知机制和异步操作可以让游戏服务器及时地接收玩家的操作请求,快速提交相关的 I/O 请求,并且在 I/O 完成后迅速将更新后的游戏状态发送给玩家。例如,当玩家在游戏中进行移动操作时,服务器可以使用 io_uring 异步地读取和更新游戏地图数据,同时接收其他玩家的操作请求,保证游戏的实时性和流畅性。

⑶大文件传输与存储系统

文件备份与恢复系统:在文件备份和恢复系统中,经常需要处理大文件的读写操作。io_uring 的零拷贝特性可以大大提高文件传输的效率。当备份文件时,系统可以直接将文件数据从源存储设备(如硬盘)通过共享内存区域传输到备份存储设备,避免了中间多次的数据拷贝。

同时,io_uring 的异步操作允许系统在传输大文件的过程中,同时处理其他文件的备份或者恢复任务。例如,在企业级的数据中心,使用 io_uring 的备份系统可以同时备份多个服务器上的大量文件,提高备份的速度和效率。

分布式存储系统:在分布式存储系统中,涉及到大量的数据读写操作,这些操作跨越多个存储节点。io_uring 可以用于优化节点之间的数据传输和本地存储设备的 I/O 操作。

例如,在一个分布式文件系统中,当客户端请求读取一个存储在多个节点上的大文件时,系统可以使用 io_uring 在各个节点上异步地读取文件片段,然后将这些片段组合起来返回给客户端。在存储数据时,也可以利用 io_uring 的异步和零拷贝特性,快速地将数据写入到各个存储节点的本地设备中,提高分布式存储系统的性能。

七、代码实践

 #include #include #include #include #include  #define EVENT_ACCEPT 0#define EVENT_READ 1#define EVENT_WRITE 2 struct conn_info{  int fd;  int event;}; int init_server(unsigned short port){   int sockfd = socket(AF_INET, SOCK_STREAM, 0);  struct sockaddr_in serveraddr;  memset(&serveraddr, 0, sizeof(struct sockaddr_in));  serveraddr.sin_family = AF_INET;  serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);  serveraddr.sin_port = htons(port);   if (-1 == bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr)))  {    perror("bind");    return -1;  }   listen(sockfd, 10);   return sockfd;} #define ENTRIES_LENGTH 1024#define BUFFER_LENGTH 1024 int set_event_recv(struct io_uring *ring, int sockfd,           void *buf, size_t len, int flags){   struct io_uring_sqe *sqe = io_uring_get_sqe(ring);   struct conn_info accept_info = {    .fd = sockfd,    .event = EVENT_READ,  };   io_uring_prep_recv(sqe, sockfd, buf, len, flags);  memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int set_event_send(struct io_uring *ring, int sockfd,           void *buf, size_t len, int flags){   struct io_uring_sqe *sqe = io_uring_get_sqe(ring);   struct conn_info accept_info = {    .fd = sockfd,    .event = EVENT_WRITE,  };   io_uring_prep_send(sqe, sockfd, buf, len, flags);  memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr,           socklen_t *addrlen, int flags){   struct io_uring_sqe *sqe = io_uring_get_sqe(ring);   struct conn_info accept_info = {    .fd = sockfd,    .event = EVENT_ACCEPT,  };   io_uring_prep_accept(sqe, sockfd, (struct sockaddr *)addr, addrlen, flags);  memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int main(int argc, char *argv[]){   unsigned short port = 9999;  int sockfd = init_server(port);   struct io_uring_params params;  memset(¶ms, 0, sizeof(params));   struct io_uring ring;  io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms); #if 0  struct sockaddr_in clientaddr;    socklen_t len = sizeof(clientaddr);  accept(sockfd, (struct sockaddr*)&clientaddr, &len);#else   struct sockaddr_in clientaddr;  socklen_t len = sizeof(clientaddr);  set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); #endif   char buffer[BUFFER_LENGTH] = {0};   while (1)  {     io_uring_submit(&ring);     struct io_uring_cqe *cqe;    io_uring_wait_cqe(&ring, &cqe);     struct io_uring_cqe *cqes[128];    int nready = io_uring_peek_batch_cqe(&ring, cqes, 128); // epoll_wait     int i = 0;    for (i = 0; i < nready; i++)    {       struct io_uring_cqe *entries = cqes[i];      struct conn_info result;      memcpy(&result, &entries->user_data, sizeof(struct conn_info));       if (result.event == EVENT_ACCEPT)      {         set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0);        // printf("set_event_accept\n"); //         int connfd = entries->res;         set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0);      }      else if (result.event == EVENT_READ)      { //         int ret = entries->res;        // printf("set_event_recv ret: %d, %s\n", ret, buffer); //         if (ret == 0)        {          close(result.fd);        }        else if (ret > 0)        {          set_event_send(&ring, result.fd, buffer, ret, 0);        }      }      else if (result.event == EVENT_WRITE)      {        //         int ret = entries->res;        // printf("set_event_send ret: %d, %s\n", ret, buffer);         set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);      }    }     io_uring_cq_advance(&ring, nready);  }}

①服务器初始化

int init_server(unsigned short port)
{
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);
	struct sockaddr_in serveraddr;
	memset(&serveraddr, 0, sizeof(struct sockaddr_in));
	serveraddr.sin_family = AF_INET;
	serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
	serveraddr.sin_port = htons(port);
 
	if (-1 == bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr)))
	{
		perror("bind");
		return -1;
	}
 
	listen(sockfd, 10);
	return sockfd;
}

该函数初始化了一个 TCP 服务器套接字,用于监听客户端连接请求。socket、bind 和 listen 是常规的服务器初始化步骤,将服务器绑定到指定的端口,并使其开始监听客户端连接。

②io_uring 环境初始化

struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
 
struct io_uring ring;
io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms);

io_uring_queue_init_params 函数初始化了一个 io_uring 实例,这个实例将用于管理所有的异步 I/O 操作,ENTRIES_LENGTH 定义了提交队列和完成队列的大小,表示可以同时处理的最大 I/O 操作数量。

③设置 accept 事件

struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0);

set_event_accept 函数将一个 accept 操作添加到 io_uring 的提交队列中。这个操作用于接受客户端连接请求。这一步是服务器启动时的初始操作,它告诉 io_uring 开始监听并处理客户端连接。

④主循环:提交操作和处理完成事件

while (1)
{
	io_uring_submit(&ring);
	struct io_uring_cqe *cqe;
	io_uring_wait_cqe(&ring, &cqe);
 
	struct io_uring_cqe *cqes[128];
	int nready = io_uring_peek_batch_cqe(&ring, cqes, 128);
  • io_uring_submit:将之前添加到提交队列中的所有操作提交给内核,由内核异步执行这些操作。

  • io_uring_wait_cqe:等待至少一个操作完成,这是一个阻塞调用。

  • io_uring_peek_batch_cqe:批量获取已经完成的操作结果,nready 表示完成的操作数量。

⑤处理完成的事件

for (i = 0; i < nready; i++)
{
	struct io_uring_cqe *entries = cqes[i];
	struct conn_info result;
	memcpy(&result, &entries->user_data, sizeof(struct conn_info));
 
	if (result.event == EVENT_ACCEPT)
	{
		set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0);
		int connfd = entries->res;
		set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0);
	}
	else if (result.event == EVENT_READ)
	{
		int ret = entries->res;
		if (ret == 0)
		{
			close(result.fd);
		}
		else if (ret > 0)
		{
			set_event_send(&ring, result.fd, buffer, ret, 0);
		}
	}
	else if (result.event == EVENT_WRITE)
	{
		int ret = entries->res;
		set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);
	}
}

EVENT_ACCEPT:处理 accept 事件。当一个新的客户端连接到来时,io_uring 完成队列会返回 EVENT_ACCEPT 事件,表示一个新的连接已经建立。此时,服务器会:重新设置 accept 事件,继续监听新的客户端连接。获取新连接的文件描述符 connfd,并设置一个 recv 事件来准备接收数据。

EVENT_READ:处理 recv 事件。当从客户端接收到数据时,io_uring 返回 EVENT_READ 事件。如果接收到的数据长度大于0,则会设置一个 send 事件来将数据发送回客户端。如果 ret == 0,说明客户端关闭了连接,则关闭文件描述符。

EVENT_WRITE:处理 send 事件。当数据成功发送给客户端后,io_uring 返回 EVENT_WRITE 事件。此时,服务器会再次设置一个 recv 事件,准备接收更多数据。

⑥完成队列的推进

io_uring_cq_advance(&ring, nready);

这个函数通知 io_uring,你已经处理完了 nready 个完成队列条目(CQE)。io_uring 可以释放这些 CQE 供后续操作使用。

⑦总结

io_uring 的作用:在这个示例中,io_uring 被用来高效地处理网络 I/O 操作。通过异步提交和处理 accept、recv、send 操作,服务器能够高效处理多个并发连接,而无需阻塞等待每个I/O操作完成。

异步模型:io_uring 提供了一种低延迟、高并发的异步 I/O 处理方式。操作在提交后由内核异步执行,完成后再由应用程序查询并处理结果。这种方式大大减少了系统调用的开销,提高了程序的并发处理能力。

关键点:

  • 提交操作:使用 io_uring_prep_* 函数准备操作,并提交给内核处理。

  • 等待完成:使用 io_uring_wait_cqe 等方法等待操作完成,并获取结果。

  • 处理结果:根据完成队列中的事件类型(如 EVENT_ACCEPT、EVENT_READ、EVENT_WRITE)进行相应的处理和后续操作。

你可能感兴趣的:(C/C++全栈开发,Linux进程管理,linux,C/C++,异步IO)