深入解剖io_uring:Linux异步IO的终极武器

在 Linux 系统的世界里,I/O 操作的效率,始终是左右系统性能的关键因素。从一开始简单的阻塞式 I/O,到后来的非阻塞 I/O,再到 I/O 多路复用技术的诞生,每一次技术变革,都在不断突破 I/O 性能的瓶颈。在众多 I/O 技术中,epoll 曾是高性能 I/O 的代表,它以事件驱动模式,高效地处理着大量并发连接,在 Nginx、Redis 等众多知名项目中扮演着重要角色。但随着数据量的爆炸式增长,以及应用场景日益复杂,epoll 在面对某些极端高并发场景时,也渐渐显露出局限性。

就在这时候,io_uring 强势登场。它作为 Linux 内核异步 I/O 领域的革新者,一出现便备受关注。io_uring 的目标,是打破传统异步 I/O 模型的性能束缚,以颠覆性的设计理念,为 Linux 异步 I/O 领域带来全新变革。

那么,io_uring 究竟有着怎样的独特之处,能让开发者们对它寄予厚望?它与 epoll 相比,又在哪些方面实现了重大突破?现在,就让我们一起深入探究,开启这场从 epoll 到 io_uring 的技术探索之旅 。

Part1.Linux I/O 的前世今生

在 Linux 系统的发展历程中,I/O 模型的演进是提升系统性能和效率的关键因素。从早期简单的阻塞式 I/O 到如今复杂高效的 io_uring,每一次变革都解决了特定场景下的性能瓶颈问题。

1.1阻塞式 I/O(Blocking I/O)

阻塞式 I/O 是最基础、最直观的 I/O 模型。在这种模型下,当应用程序执行 I/O 操作(如 read 或 write)时,进程会被阻塞,直到 I/O 操作完成。例如,当从文件中读取数据时,如果数据尚未准备好,进程就会一直等待,期间无法执行其他任务。这就好比你去餐厅点餐,然后一直在餐桌旁等待食物上桌,在等待的过程中什么也做不了。

阻塞式 I/O 的优点是编程简单,逻辑清晰,但是在处理大量并发请求时,由于每个请求都可能阻塞进程,导致系统的并发处理能力极低,资源利用率也不高。在高并发的 Web 服务器场景中,如果使用阻塞式 I/O,每一个客户端连接都需要一个独立的线程来处理,当并发连接数增多时,线程资源将被大量消耗,系统性能会急剧下降。

1.2非阻塞式 I/O(Non - blocking I/O)

为了解决阻塞式 I/O 的问题,非阻塞式 I/O 应运而生。在非阻塞式 I/O 模型中,当应用程序执行 I/O 操作时,如果数据尚未准备好,系统不会阻塞进程,而是立即返回一个错误(如 EWOULDBLOCK 或 EAGAIN)。应用程序可以继续执行其他任务,然后通过轮询的方式再次尝试 I/O 操作,直到数据准备好。

这就像你在餐厅点餐时,服务员告诉你需要等待一段时间,你可以先去做其他事情,然后时不时回来询问食物是否准备好了。非阻塞式 I/O 提高了系统的并发处理能力,进程在等待 I/O 操作的过程中可以执行其他任务,但是频繁的轮询会消耗大量的 CPU 资源,增加了系统的开销。而且非阻塞式 I/O 的编程复杂度较高,需要处理更多的错误和状态判断。

1.3 I/O 多路复用(I/O Multiplexing)

I/O 多路复用是在非阻塞式 I/O 的基础上进一步发展而来的,它允许一个进程同时监视多个 I/O 描述符(如文件描述符、套接字等),当其中任何一个描述符就绪(即有数据可读或可写)时,进程就可以对其进行处理。常见的 I/O 多路复用技术有 select、poll 和 epoll。以 select 为例,应用程序通过调用 select 函数,将需要监视的 I/O 描述符集合传递给内核,内核会监视这些描述符的状态,当有描述符就绪时,select 函数返回,应用程序再对就绪的描述符进行 I/O 操作。

这就好比你在餐厅同时点了多道菜,你只需要等待服务员一次性通知你哪些菜已经准备好了,然后去取相应的菜,而不需要每道菜都单独询问。I/O 多路复用大大提高了系统的并发处理能力,减少了线程资源的消耗,但是它也存在一些问题,如 select 和 poll 的性能会随着监视的描述符数量增加而下降,epoll 虽然性能较好,但在高并发场景下,大量的事件处理也可能成为性能瓶颈。

1.4传统 I/O 模型的局限性

传统的 I/O 模型,无论是阻塞式 I/O、非阻塞式 I/O 还是 I/O 多路复用,在面对现代应用程序对高性能、高并发的需求时,都存在一定的局限性。它们的主要问题在于:

系统调用开销大:每次 I/O 操作都需要进行系统调用,从用户态切换到内核态,这会带来一定的开销。在高并发场景下,频繁的系统调用会消耗大量的 CPU 资源。数据拷贝次数多:在数据传输过程中,数据往往需要在用户空间和内核空间之间多次拷贝,这不仅增加了数据传输的时间,也消耗了系统资源。异步处理能力有限:虽然非阻塞式 I/O 和 I/O 多路复用在一定程度上实现了异步处理,但它们的异步程度还不够彻底,仍然需要应用程序主动轮询或等待事件通知,无法充分发挥硬件的性能。

为了突破这些局限性,Linux 内核引入了 io_uring,它代表了一种全新的异步 I/O 模型,为开发者提供了更高效、更强大的 I/O 处理能力。

Part2.io_uring 是什么

2.1定义与起源

io_uring 是 Linux 内核提供的高性能异步 I/O 框架,它在 Linux 5.1 版本中被引入,由 Jens Axboe 开发 。在 io_uring 出现之前,传统的异步 I/O 模型,如 epoll 或者 POSIX AIO,在大规模 I/O 操作中效率较低,存在系统调用开销大、数据拷贝次数多、异步处理能力有限等问题。为了解决这些问题,io_uring 应运而生,它的出现为 Linux 异步 I/O 领域带来了新的解决方案,旨在提供更高效、更强大的 I/O 处理能力。

2.2设计目标与特点

统一网络和磁盘异步 I/O:在 io_uring 之前,Linux 的网络 I/O 和磁盘 I/O 使用不同的机制,这给开发者带来了很大的不便。io_uring 的设计目标之一就是统一网络和磁盘异步 I/O,使得开发者可以使用统一的接口来处理不同类型的 I/O 操作。这就像一个万能的工具,无论你是处理网络数据的传输,还是磁盘文件的读写,都可以使用 io_uring 这个工具,而不需要在不同的工具之间切换。

提供统一完善的异步 API:它提供了一套统一且完善的异步 API,简化了异步 I/O 编程。在传统的 I/O 模型中,开发者可能需要使用多个不同的函数和系统调用来实现异步 I/O,而且这些接口可能并不统一,容易出错。io_uring 将这些复杂的操作封装成了简单易用的 API,开发者只需要调用这些 API,就可以轻松地实现异步 I/O 操作,降低了编程的难度和出错的概率。

支持异步、轮询、无锁、零拷贝:io_uring 支持异步操作,应用程序在发起 I/O 请求后不必等待操作完成,可以继续执行其他任务,提高了系统的并发处理能力;它还支持轮询模式,不依赖硬件的中断,通过调用 IORING_ENTER_GETEVENTS 不断轮询收割完成事件,减少了中断开销;同时,io_uring 采用了无锁设计,避免了锁竞争带来的性能损耗;在数据传输过程中,io_uring 支持零拷贝技术,减少了数据在用户空间和内核空间之间的拷贝次数,提高了数据传输的效率。例如,在一个文件传输的场景中,使用 io_uring 可以大大减少数据拷贝的时间,提高文件传输的速度。

2.3io_uring设计思路

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

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

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

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

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

一块用于核外传递数据给内核,一块是内核传递数据给核外,一方只读,一方只写。提交队列SQ(submission queue)中,应用是IO提交的生产者,内核是消费者。完成队列CQ(completion queue)中,内核是IO完成的生产者,应用是消费者。内核控制SQ ring的head和CQ ring的tail,应用程序控制SQ ring的tail和CQ ring的head3.解决“API不友好”的问题?

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

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

Part3.io_uring原理剖析

3.1核心概念

1.环形缓冲区

io_uring 的核心是两个环形缓冲区:提交队列(Submission Queue,SQ)和完成队列(Completion Queue,CQ)。这两个队列在内核态和用户态之间共享,通过内存映射(mmap)的方式实现。

提交队列(SQ)用于存放用户程序提交的 I/O 请求。当用户程序需要进行 I/O 操作时,它会创建一个提交队列条目(Submission Queue Entry,SQE),并将其放入 SQ 中。每个 SQE 包含了 I/O 操作的详细信息,如操作类型(读、写等)、文件描述符、缓冲区地址、数据长度等。

完成队列(CQ)用于存放内核完成 I/O 操作后的结果。当内核完成一个 I/O 操作后,会将对应的完成队列条目(Completion Queue Entry,CQE)放入 CQ 中。CQE 包含了 I/O 操作的返回值(如读取或写入的字节数、错误码等)以及用户在 SQE 中设置的用户数据。

环形缓冲区的工作方式基于生产者 - 消费者模型。用户程序是 SQ 的生产者,内核是 SQ 的消费者;内核是 CQ 的生产者,用户程序是 CQ 的消费者。通过这种方式,io_uring 实现了用户态和内核态之间高效的通信,减少了系统调用的次数和数据拷贝的开销。例如,在传统的 I/O 模型中,每次 I/O 操作都需要进行系统调用,从用户态切换到内核态,而 io_uring 通过共享的环形缓冲区,用户程序可以直接将 I/O 请求放入 SQ,内核从 SQ 中获取请求并处理,处理完成后将结果放入 CQ,用户程序再从 CQ 中获取结果,避免了频繁的系统调用和上下文切换。

2.异步 I/O 操作

io_uring 的异步 I/O 操作机制是其高性能的关键之一。在传统的 I/O 模型中,当应用程序发起 I/O 请求后,通常需要等待 I/O 操作完成才能继续执行其他任务,这期间应用程序会被阻塞。而在 io_uring 中,用户程序提交 I/O 请求后,无需等待操作完成,就可以继续执行其他任务。

当用户程序将 I/O 请求写入提交队列(SQ)后,内核会异步地处理这些请求。内核会根据请求的类型和参数,执行相应的 I/O 操作,如从磁盘读取数据或向网络发送数据。在 I/O 操作执行过程中,用户程序可以继续执行其他代码,不会被阻塞。当 I/O 操作完成后,内核会将操作结果写入完成队列(CQ),并通过事件通知机制(如 epoll)通知用户程序。用户程序可以通过轮询 CQ 或等待事件通知的方式,获取 I/O 操作的结果,并进行后续处理。这种异步操作方式使得应用程序能够充分利用 CPU 资源,提高了系统的并发处理能力。

例如,在一个文件服务器中,当有多个客户端同时请求读取文件时,使用 io_uring 可以让服务器在处理一个客户端的 I/O 请求时,同时处理其他客户端的请求,而不需要等待每个 I/O 请求都完成后再处理下一个,大大提高了服务器的响应速度和吞吐量。

3.批量操作与更多操作支持

io_uring 支持批量提交和处理 I/O 请求,这进一步提升了其性能。用户程序可以一次性将多个 I/O 请求写入提交队列(SQ),然后通过一次系统调用(如 io_uring_enter)通知内核处理这些请求。内核会批量处理这些请求,并将结果批量写入完成队列(CQ)。这种批量操作方式减少了系统调用的次数和上下文切换的开销,提高了 I/O 操作的效率。例如,在处理大量文件读写操作时,使用批量操作可以显著减少系统调用的开销,提高文件读写的速度。

此外,io_uring 支持的操作类型非常丰富,不仅包括传统的文件 I/O 操作(如 read、write、open、close 等),还支持网络相关的系统调用,如 send、recv、accept、connect 等。这使得开发者可以使用 io_uring 来构建高性能的网络服务器和应用程序。在开发一个高并发的 Web 服务器时,可以使用 io_uring 来处理客户端的连接请求、数据接收和发送等操作,充分发挥其高性能和异步处理的优势。io_uring 还支持一些其他的系统调用,如文件系统的操作(如 fsync、fdatasync 等),为开发者提供了更强大的功能和更灵活的编程方式。

3.2工作原理

1.提交队列(SQ)工作流程

用户程序在进行 I/O 操作时,首先会与 io_uring 进行交互,将 I/O 请求写入提交队列(SQ)。具体步骤如下:

获取空闲的 SQE:用户程序通过调用 io_uring_get_sqe 函数,从提交队列中获取一个空闲的提交队列条目(SQE)。这个过程类似于从一个空闲资源池中获取一个资源,每个 SQE 都可以看作是一个承载 I/O 请求的 “容器”。设置请求参数:获取到 SQE 后,用户程序会根据 I/O 操作的具体需求,设置 SQE 的各个参数。这些参数包括操作码(opcode),用于指定 I/O 操作的类型,如读操作(IORING_OP_READ)或写操作(IORING_OP_WRITE);文件描述符(fd),指向要进行 I/O 操作的文件或套接字;偏移量(off),指定从文件或套接字的哪个位置开始执行 I/O 操作;缓冲区地址(addr),指向用户空间中用于存放读取数据或提供要写入数据的缓冲区;数据长度(len),指定要读取或写入的数据量等。还可以设置一些其他的标志位和用户自定义数据,以便在 I/O 操作完成后进行相关的处理。将 SQE 索引放入 SQ:设置好 SQE 的参数后,用户程序会将该 SQE 的索引放入提交队列(SQ)中,并更新 SQ 的尾指针(tail)。这就像是将一个装满请求信息的 “包裹” 放入一个环形的传送带上,尾指针则表示传送带上最后一个 “包裹” 的位置。通过这种方式,用户程序向内核表明有新的 I/O 请求需要处理。2.完成队列(CQ)工作流程

当内核完成 I/O 操作后,会将操作结果写入完成队列(CQ),用户程序从 CQ 中获取结果并进行处理,具体流程如下:

内核写入 CQE:内核在完成 I/O 操作后,会创建一个完成队列条目(CQE),并将 I/O 操作的结果信息填充到 CQE 中。这些结果信息包括操作的返回值(res),如果操作成功,res 表示实际传输的字节数;如果操作失败,res 表示错误码(通常是一个负值,其绝对值对应具体的错误类型)。CQE 中还包含用户在提交 I/O 请求时设置的用户数据(user_data),以便用户程序在获取结果时能够识别该结果对应的是哪个 I/O 请求。内核将 CQE 放入完成队列(CQ)中,并更新 CQ 的尾指针(tail),表示有新的完成事件可供用户程序处理。用户程序获取 CQE:用户程序可以通过调用 io_uring_wait_cqe 函数来阻塞等待,直到 CQ 中有新的 CQE 可供处理;也可以通过调用 io_uring_peek_cqe 函数进行非阻塞地检查,看是否有新的 CQE。当获取到 CQE 后,用户程序可以根据 CQE 中的结果信息进行相应的处理。如果 I/O 操作成功,用户程序可以处理读取到的数据或确认写入操作已成功完成;如果 I/O 操作失败,用户程序可以根据错误码进行错误处理,如重试操作或向用户报告错误信息。标记 CQE 为已处理:用户程序处理完 CQE 后,需要调用 io_uring_cqe_seen 函数,将 CQ 的尾指针向前移动,标记该 CQE 已被处理,以便后续可以接收新的完成事件。这就像是在处理完一个任务后,将任务标记为已完成,以便系统可以继续处理其他新的任务。3.内核与用户态交互

内核和用户态之间通过共享内存的环形缓冲区(即提交队列 SQ 和完成队列 CQ)进行交互,这种交互方式极大地减少了系统调用和上下文切换的开销,提高了 I/O 操作的效率,其原理如下:

共享内存映射:在初始化 io_uring 时,通过内存映射(mmap)机制,将提交队列(SQ)和完成队列(CQ)映射到用户空间和内核空间。这样,用户程序和内核都可以直接访问这两个队列,而不需要通过传统的系统调用方式进行数据传递。这就好比在用户空间和内核空间之间建立了一条 “高速公路”,数据可以直接在两者之间快速传输,而不需要经过复杂的 “收费站”(系统调用)。减少系统调用:在传统的 I/O 模型中,每次 I/O 操作都需要进行多次系统调用,从用户态切换到内核态,然后再切换回用户态。而在 io_uring 中,用户程序将 I/O 请求写入 SQ 和从 CQ 获取结果,都可以在用户态完成,不需要频繁地进行系统调用。只有在需要通知内核处理 SQ 中的请求时(如调用 io_uring_enter 函数),才会进行一次系统调用,大大减少了系统调用的次数。减少上下文切换:上下文切换是指当操作系统从一个进程或线程切换到另一个进程或线程时,需要保存当前进程或线程的状态信息,并恢复下一个进程或线程的状态信息。在传统 I/O 模型中,频繁的系统调用会导致大量的上下文切换,消耗 CPU 资源。而 io_uring 通过共享内存的方式,减少了系统调用的次数,也就相应地减少了上下文切换的次数,使得 CPU 可以更专注于执行实际的 I/O 操作和用户程序的逻辑代码。

3.3系统调用详解

io_uring的实现仅仅使用了三个syscall:io_uring_setup, io_uring_enter和io_uring_register。

这几个系统调用接口都在io_uring.c文件中:

1.io_uring_setup

io_uring_setup 是用于初始化 io_uring 环境的系统调用。在使用 io_uring 进行异步 I/O 操作之前,首先需要调用 io_uring_setup 来创建一个 io_uring 实例。它接受两个参数,第一个参数是期望的提交队列(SQ)的大小,即队列中可以容纳的 I/O 请求数量;第二个参数是一个指向 io_uring_params 结构体的指针,该结构体用于返回 io_uring 实例的相关参数,如实际分配的 SQ 和完成队列(CQ)的大小、队列的偏移量等信息。

在调用 io_uring_setup 时,内核会为 io_uring 实例分配所需的内存空间,包括 SQ、CQ 以及相关的控制结构。同时,内核还会创建一些内部数据结构,用于管理和调度 I/O 请求。如果初始化成功,io_uring_setup 会返回一个文件描述符,这个文件描述符用于标识创建的 io_uring 实例,后续的 io_uring 系统调用(如 io_uring_enter、io_uring_register)将通过这个文件描述符来操作该 io_uring 实例。若初始化失败,函数将返回一个负数,表示相应的错误代码。

io_uring_setup():

复制
SYSCALL_DEFINE2(io_uring_setup, u32, entries, struct io_uring_params __user *, params) { return io_uring_setup(entries, params); }1.2.3.4.5.
功能:用于初始化和配置 io_uring 。应用用途:在使用 io_uring 之前,首先需要调用此接口初始化一个 io_uring 环,并设置其参数。2.io_uring_enter

io_uring_enter 是用于提交和等待 I/O 操作的系统调用。它的主要作用是将应用程序准备好的 I/O 请求提交给内核,并可以选择等待这些操作完成。io_uring_enter 接受多个参数,其中包括 io_uring_setup 返回的文件描述符,用于指定要操作的 io_uring 实例;to_submit 参数表示要提交的 I/O 请求的数量,即从提交队列(SQ)中取出并提交给内核的 SQE 的数量;min_complete 参数指定了内核在返回之前必须等待完成的 I/O 操作的最小数量;flags 参数则用于控制 io_uring_enter 的行为,例如可以设置是否等待 I/O 操作完成、是否获取完成的 I/O 事件等。当调用 io_uring_enter 时,如果 to_submit 参数大于 0,内核会从 SQ 中取出相应数量的 SQE,并将这些 I/O 请求提交到内核中进行处理。

同时,如果设置了等待 I/O 操作完成的标志,内核会阻塞等待,直到至少有 min_complete 个 I/O 操作完成,然后将这些完成的操作结果放入完成队列(CQ)中。应用程序可以通过检查 CQ 来获取这些完成的 I/O 请求的结果。通过 io_uring_enter,应用程序可以灵活地控制 I/O 请求的提交和等待策略,提高 I/O 操作的效率和灵活性。

io_uring_enter():

复制
SYSCALL_DEFINE6(io_uring_enter, unsigned int, fd, u32, to_submit, u32, min_complete, u32, flags, const void __user *, argp, size_t, argsz)1.2.3.
功能:用于提交和处理异步 I/O 操作。应用用途:在向 io_uring 环中提交 I/O 操作后,通过调用此接口触发内核处理这些操作,并获取完成的操作结果。3.io_uring_register

io_uring_register 用于注册文件描述符或事件文件描述符到 io_uring 实例中,以便在后续的 I/O 操作中使用。它接受四个参数,第一个参数是 io_uring_setup 返回的文件描述符,用于指定要注册到的 io_uring 实例;第二个参数 opcode 表示注册的类型,例如可以是 IORING_REGISTER_FILES(注册文件描述符集合)、IORING_REGISTER_BUFFERS(注册内存缓冲区)、IORING_REGISTER_EVENTFD(注册 eventfd 用于通知完成事件)等;

第三个参数 arg 是一个指针,根据 opcode 的类型不同,它指向不同的内容,如注册文件描述符时,arg 指向一个包含文件描述符的数组;注册缓冲区时,arg 指向一个描述缓冲区的结构体数组;第四个参数 nr_args 表示 arg 所指向的数组的长度。通过 io_uring_register 注册文件描述符或缓冲区等资源后,内核在处理 I/O 请求时,可以直接访问这些预先注册的资源,而无需每次都重新设置相关信息,从而提高了 I/O 操作的效率。例如,在进行大量文件读写操作时,预先注册文件描述符可以避免每次提交 I/O 请求时都进行文件描述符的查找和验证,减少了系统开销,提升了 I/O 性能。

io_uring_register():

复制
SYSCALL_DEFINE4(io_uring_register, unsigned int, fd, unsigned int, opcode, void __user *, arg, unsigned int, nr_args)1.2.
功能:用于注册文件描述符、缓冲区、事件文件描述符等资源到 io_uring 环中。应用用途:在进行 I/O 操作之前,需要将相关的资源注册到 io_uring 环中,以便进行后续的异步 I/O 操作。

3.4工作流程深度剖析

1.创建 io_uring 对象

使用 io_uring 进行异步 I/O 操作的第一步是创建 io_uring 对象。内核提供了io_uring_setup系统调用来初始化一个io_uring实例,创建SQ、CQ和SQ Array,entries参数表示的是SQ和SQArray的大小,CQ的大小默认是2 * entries。params参数既是输入参数,也是输出参数。

该函数返回一个file descriptor,并将io_uring支持的功能、以及各个数据结构在fd中的偏移量存入params。用户根据偏移量将fd通过mmap内存映射得到一块内核用户共享的内存区域。这块内存区域中,有io_uring的上下文信息:SQ信息、CQ信息和SQ Array信息。

复制
int io_uring_setup(int entries, struct io_uring_params *params);1.

这通过调用 io_uring_setup 系统调用来完成。在调用 io_uring_setup 时,用户需要指定提交队列(SQ)的大小,即期望的 I/O 请求队列长度。内核会根据这个请求,为 io_uring 对象分配必要的内存空间,包括提交队列(SQ)、完成队列(CQ)以及相关的控制结构。内核会创建一个 io_ring_ctx 结构体对象,用于管理 io_uring 的上下文信息。

同时,还会创建一个 io_urings 结构体对象,该对象包含了 SQ 和 CQ 的具体实现,如队列的头部索引(head)、尾部索引(tail)、队列大小等信息。在创建过程中,内核会初始化 SQ 和 CQ 的所有队列项(SQE 和 CQE),并设置好相关的指针和标志位。如果用户在调用 io_uring_setup 时设置了 IORING_SETUP_SQPOLL 标志位,内核还会创建一个 SQ 线程,用于从 SQ 队列中获取 I/O 请求并提交给内核处理。

创建完成后,io_uring_setup 会返回一个文件描述符,这个文件描述符是后续操作 io_uring 对象的关键标识,通过它可以进行 I/O 请求的提交、注册文件描述符等操作。

2.准备 I/O 请求

在创建 io_uring 对象后,需要准备具体的 I/O 请求。这通常通过 io_uring_prep_XXX 系列函数来完成,这些函数用于准备不同类型的 I/O 请求,如 io_uring_prep_read 用于准备读取操作,io_uring_prep_write 用于准备写入操作,io_uring_prep_accept 用于准备异步接受连接操作等。

以 io_uring_prep_read 为例,它接受多个参数,包括指向提交队列项(SQE)的指针、目标文件描述符、读取数据的缓冲区地址、读取的字节数以及文件中的偏移量等。函数会根据这些参数,将 I/O 请求的相关信息填充到 SQE 结构体中,包括设置操作类型(如 IORING_OP_READ)、目标文件描述符、缓冲区地址、数据长度、偏移量等字段。

除了基本的 I/O 操作参数外,还可以设置一些额外的标志位和选项,如 I/O 操作的优先级、是否使用直接 I/O 等,以满足不同的应用需求。通过这些函数,应用程序可以灵活地构建各种类型的 I/O 请求,并将其准备好以便提交到内核中进行处理。

3.提交 I/O 请求

当 I/O 请求准备好后,需要将其提交到内核中执行。这通过调用 io_uring_submit 函数(内部调用 io_uring_enter 系统调用)来实现。在提交 I/O 请求时,首先应用程序会将准备好的 SQE 添加到提交队列(SQ)中。SQ 是一个环形缓冲区,应用程序通过操作 SQ Ring 中的 tail 指针来将 SQE 放入队列。具体来说,应用程序会将 tail 指向的 SQE 填充为准备好的 I/O 请求信息,然后将 tail 指针递增,指向下一个空闲的 SQE 位置。在填充 SQE 时,需要注意按照 SQE 结构体的定义,正确设置各项字段,确保 I/O 请求的信息准确无误。

默认情况下,使用 io_uring 提交 I/O 请求需要:

从SQ Arrary中找到一个空闲的SQE;根据具体的I/O请求设置该SQE;将SQE的数组索引放到SQ中;调用系统调用io_uring_enter提交SQ中的I/O请求。

当所有要提交的 I/O 请求都添加到 SQ 中后,调用 io_uring_submit 函数,该函数会触发 io_uring_enter 系统调用,将 SQ 中的 I/O 请求提交给内核。内核接收到请求后,会从 SQ 中获取 SQE,并根据 SQE 中的信息执行相应的 I/O 操作。在这个过程中,由于 SQ 是用户态和内核态共享的内存区域,避免了数据的多次拷贝和额外的系统调用开销,提高了 I/O 请求提交的效率。

4.等待 IO 请求完成

提交 I/O 请求后,应用程序可以选择等待请求完成。等待 I/O 请求完成有两种主要方式。一种是使用 io_uring_wait_cqe 函数,该函数会阻塞调用线程,直到至少有一个 I/O 请求完成,并返回完成的完成队列项(CQE)。当调用 io_uring_wait_cqe 时,它会检查完成队列(CQ)中是否有新完成的 I/O 请求。如果没有,线程会进入阻塞状态,直到内核将完成的 I/O 请求结果放入 CQ 中。一旦有新的 CQE 可用,io_uring_wait_cqe 会返回该 CQE,应用程序可以通过 CQE 获取 I/O 操作的结果。

另一种方式是使用 io_uring_peek_batch_cqe 函数,它是非阻塞的,用于检查 CQ 中是否有已经完成的 I/O 请求。如果有,它会返回已完成的 CQE 列表,应用程序可以根据返回的 CQE 进行相应的处理;如果没有完成的请求,函数会立即返回,应用程序可以继续执行其他任务,然后在适当的时候再次调用该函数检查 CQ。这两种方式为应用程序提供了灵活的等待策略,使其可以根据自身的业务需求和性能要求,选择合适的方式来处理 I/O 请求的完成事件。

5.获取 IO 请求结果

当 I/O 请求完成后,应用程序需要从完成队列(CQ)中获取结果。这可以通过 io_uring_peek_cqe 函数来实现。io_uring_peek_cqe 函数用于从 CQ 中获取一个完成的 CQE,而不将其从队列中移除。应用程序获取到 CQE 后,可以根据 CQE 中的信息来处理完成的 I/O 请求。CQE 中包含了丰富的信息,如 I/O 操作的返回值、状态码、用户自定义数据等。例如,对于文件读取操作,CQE 中的返回值表示实际读取的字节数,状态码用于指示操作是否成功,若操作失败,状态码会包含具体的错误信息。

应用程序可以根据这些信息进行相应的处理,如读取数据并进行后续的业务逻辑处理,或者在操作失败时进行错误处理,如记录错误日志、重新尝试 I/O 操作等。在获取 CQE 后,应用程序通常会根据 I/O 操作的类型和结果,执行相应的业务逻辑,以实现应用程序的功能需求。

6.释放 IO 请求结果

在获取并处理完 IO 请求结果后,需要释放该结果,以便内核可以继续使用完成队列(CQ)。这通过调用 io_uring_cqe_seen 函数来实现。io_uring_cqe_seen 函数的作用是标记一个完成的 CQE 已经被处理,它会将 CQ Ring 中的 head 指针递增,指向下一个未处理的 CQE。通过这种方式,内核可以知道哪些 CQE 已经被应用程序处理,从而可以继续向 CQ 中放入新的完成结果。

在释放 IO 请求结果时,需要注意确保已经完成了对 CQE 中信息的处理,避免在释放后再次访问已释放的 CQE。同时,及时释放 CQE 也有助于提高系统的性能和资源利用率,避免 CQ 队列被占用过多而影响后续 I/O 请求结果的存储和处理。通过正确地释放 IO 请求结果,保证了 io_uring 的工作流程能够持续高效地运行,为应用程序提供稳定的异步 I/O 服务。

Part4.io_uring 应用实例

4.1 io_uring应用场景

1.在高性能网络服务中的应用

在高性能网络服务领域,io_uring 展现出了强大的优势,能够显著提升网络服务的并发处理能力和性能。以 Web 服务器和代理服务器为例,它们通常需要处理大量的并发连接和数据传输。

在传统的 Web 服务器中,如使用基于 epoll 的 I/O 模型,虽然可以通过事件驱动的方式处理多个连接,但在高并发情况下,仍然存在一定的局限性。例如,当有大量客户端同时请求访问网页时,epoll 需要不断地轮询文件描述符,检查是否有新的事件发生,这会消耗大量的 CPU 资源。而且,每次数据传输都可能涉及多次系统调用和数据拷贝,导致效率低下。

而引入 io_uring 后,Web 服务器的性能得到了大幅提升。io_uring 的异步 I/O 特性使得服务器在处理 I/O 请求时,无需阻塞等待操作完成,可以立即处理其他请求,从而大大提高了并发处理能力。在处理静态文件请求时,服务器可以使用 io_uring 一次性提交多个文件读取请求,内核在后台异步地处理这些请求,并将结果放入完成队列。服务器从完成队列中获取结果后,直接将数据发送给客户端,减少了数据传输的延迟。io_uring 支持的零拷贝技术也减少了数据在用户空间和内核空间之间的拷贝次数,提高了数据传输的效率。

对于代理服务器来说,io_uring 同样具有重要意义。代理服务器需要在客户端和目标服务器之间转发数据,对数据传输的效率和并发处理能力要求极高。使用 io_uring,代理服务器可以更高效地处理大量的并发连接,减少数据转发的延迟。在处理 HTTP 代理请求时,代理服务器可以利用 io_uring 的异步 I/O 和批量操作特性,同时处理多个客户端的请求,快速地从目标服务器获取数据并转发给客户端,提升了代理服务的性能和响应速度。

2.在数据库系统中的应用

在数据库系统中,I/O 操作是影响性能的关键因素之一。数据库系统需要频繁地进行数据的读写、索引的更新等操作,这些操作都涉及大量的 I/O。io_uring 为数据库系统提供了高效的 I/O 支持,对提升数据库性能起到了重要作用。

以关系型数据库 PostgreSQL 为例,在传统的 I/O 模型下,当进行数据写入操作时,需要将数据从用户空间拷贝到内核空间,然后再写入磁盘。这个过程涉及多次系统调用和数据拷贝,会消耗大量的时间和资源。而且,在高并发写入的情况下,由于锁竞争等问题,会导致写入性能下降。

而采用 io_uring 后,PostgreSQL 可以利用其异步 I/O 和批量操作特性,提高数据写入的效率。数据库可以一次性提交多个写入请求,内核异步地处理这些请求,减少了等待时间。io_uring 的零拷贝技术也减少了数据拷贝的开销,提高了写入性能。在数据读取方面,io_uring 同样可以提高效率。当查询数据时,数据库可以通过 io_uring 异步地从磁盘读取数据,在读取数据的同时,数据库可以继续处理其他任务,如解析查询语句、优化查询计划等,提高了数据库的整体响应速度。

对于一些新兴的分布式数据库,如 TiDB,io_uring 的优势更加明显。分布式数据库需要处理大量的分布式存储节点之间的数据传输和同步,对 I/O 的性能和可靠性要求极高。io_uring 的高效异步 I/O 和批量操作能力,可以帮助分布式数据库更好地处理这些复杂的 I/O 操作,提高系统的扩展性和性能。在数据同步过程中,io_uring 可以实现高效的数据传输,减少数据同步的延迟,保证分布式数据库的数据一致性和可用性。

3.在大规模文件系统操作中的应用

在大规模文件系统操作场景中,如存储服务和分布式文件系统,io_uring 也展现出了独特的优势。这些场景通常需要处理大量的文件读写、存储和管理操作,对 I/O 性能的要求非常高。

以存储服务为例,无论是对象存储还是块存储,都需要频繁地进行文件的读写操作。在传统的 I/O 模型下,当处理大量文件请求时,系统调用开销和数据拷贝开销会成为性能瓶颈。例如,在对象存储服务中,当用户上传或下载大量文件时,传统的 I/O 模型可能会导致响应时间过长,用户体验不佳。

而引入 io_uring 后,存储服务可以利用其异步 I/O 和批量操作特性,大大提高文件处理的效率。在处理文件上传时,存储服务可以使用 io_uring 一次性提交多个写入请求,内核异步地将数据写入存储设备,减少了用户等待时间。在文件下载时,io_uring 可以实现高效的文件读取,快速地将数据传输给用户。io_uring 的零拷贝技术也减少了数据传输过程中的开销,提高了存储服务的性能和吞吐量。

对于分布式文件系统,如 Ceph,io_uring 的应用可以提升整个文件系统的性能和可靠性。分布式文件系统需要处理多个存储节点之间的数据分布和读写操作,对 I/O 的并发处理能力和数据一致性要求很高。io_uring 的异步 I/O 和批量操作能力,可以帮助分布式文件系统更好地管理和调度 I/O 请求,提高数据读写的效率。在处理大规模文件的读写时,io_uring 可以实现高效的数据传输和并行处理,减少文件操作的延迟,提升分布式文件系统的整体性能。

4.2 io_uring案例分析

1.简单文件读写案例

⑴代码实现

复制
#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <string.h> #include <unistd.h> #include <sys/ioctl.h> #include <linux/io_uring.h> int main() { struct io_uring ring; struct io_uring_sqe *sqe; struct io_uring_cqe *cqe; int fd, ret; // 打开文件 fd = open("example.txt", O_RDONLY); if (fd < 0) { perror("Failed to open file"); return 1; } // 初始化io_uring io_uring_queue_init(8, &ring, 0); // 获取一个提交队列条目 sqe = io_uring_get_sqe(&ring); if (!sqe) { fprintf(stderr, "Could not get sqe\n"); return 1; } // 准备异步读操作 char *buf = malloc(1024); io_uring_prep_read(sqe, fd, buf, 1024, 0); // 提交请求 io_uring_submit(&ring); // 等待完成 ret = io_uring_wait_cqe(&ring, &cqe); if (ret < 0) { perror("io_uring_wait_cqe"); return 1; } // 检查结果 if (cqe->res < 0) { fprintf(stderr, "Async read failed: %s\n", strerror(-cqe->res)); } else { printf("Read %d bytes: %s\n", cqe->res, buf); } // 释放资源 io_uring_cqe_seen(&ring, cqe); io_uring_queue_exit(&ring); close(fd); free(buf); return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.

代码解读

文件打开:fd = open("example.txt", O_RDONLY); 这行代码使用 open 函数打开名为 example.txt 的文件,以只读模式(O_RDONLY)打开。如果打开失败,open 函数会返回一个负数,并通过 perror 函数打印错误信息,然后程序返回错误代码 1。

io_uring 初始化:io_uring_queue_init(8, &ring, 0); 这行代码用于初始化 io_uring 实例。其中,第一个参数 8 表示提交队列(SQ)和完成队列(CQ)的大小,即队列中可以容纳的 I/O 请求数量;第二个参数 &ring 是指向 io_uring 结构体的指针,用于存储初始化后的 io_uring 实例;第三个参数 0 表示使用默认的初始化标志。

获取提交队列条目:sqe = io_uring_get_sqe(&ring); 从 io_uring 的提交队列中获取一个提交队列项(SQE)。如果获取失败,io_uring_get_sqe 函数会返回 NULL,程序会打印错误信息并返回错误代码 1。

准备异步读操作:

复制
char *buf = malloc(1024); //分配 1024 字节的内存空间,用于存储读取的文件数据。1.

io_uring_prep_read(sqe, fd, buf, 1024, 0); 使用 io_uring_prep_read 函数准备一个异步读操作。它接受五个参数,第一个参数 sqe 是之前获取的提交队列项;第二个参数 fd 是要读取的文件描述符;第三个参数 buf 是用于存储读取数据的缓冲区;第四个参数 1024 表示要读取的字节数;第五个参数 0 表示从文件的起始位置开始读取。

提交请求:io_uring_submit(&ring); 将准备好的 I/O 请求提交到内核中执行。这个函数会触发 io_uring_enter 系统调用,将提交队列中的请求提交给内核。

等待完成:ret = io_uring_wait_cqe(&ring, &cqe); 等待 I/O 操作完成。这个函数会阻塞调用线程,直到至少有一个 I/O 请求完成,并返回完成的完成队列项(CQE)。如果等待过程中出现错误,io_uring_wait_cqe 函数会返回一个负数,程序会通过 perror 函数打印错误信息并返回错误代码 1。

检查结果:

复制
if (cqe->res < 0) 检查 I/O 操作的结果。如果 cqe->res 小于 0,表示操作失败,通过 fprintf 函数打印错误信息。1.

else 分支表示操作成功,打印实际读取的字节数和读取到的数据。

释放资源:

复制
io_uring_cqe_seen(&ring, cqe); /* 知内核已经处理完一个完成事件, 释放相关资源。这通过将完成队列的头部指针递增来实现,以便内核可以继续使用完成队列。*/1.2.

io_uring_queue_exit(&ring); 释放 io_uring 实例所占用的资源,包括提交队列和完成队列等。

close(fd); 关闭之前打开的文件。

free(buf); 释放之前分配的内存缓冲区。

2.网络编程案例(TCP 服务器)

⑴代码实现

复制
#include <stdio.h> #include <unistd.h> #include <string.h> #include <sys/socket.h> #include <netinet/in.h> #include <liburing.h> #define ENTRIES_LENGTH 4096 #define MAX_CONNECTIONS 1024 #define BUFFER_LENGTH 1024 char buf_table[MAX_CONNECTIONS][BUFFER_LENGTH] = {0}; enum { READ, WRITE, ACCEPT, }; struct conninfo { int connfd; int type; }; void set_read_event(struct io_uring *ring, int fd, void *buf, size_t len, int flags) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); io_uring_prep_recv(sqe, fd, buf, len, flags); struct conninfo ci = {.connfd = fd,.type = READ}; memcpy(&sqe->user_data, &ci, sizeof(struct conninfo)); } void set_write_event(struct io_uring *ring, int fd, const void *buf, size_t len, int flags) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); io_uring_prep_send(sqe, fd, buf, len, flags); struct conninfo ci = {.connfd = fd,.type = WRITE}; memcpy(&sqe->user_data, &ci, sizeof(struct conninfo)); } void set_accept_event(struct io_uring *ring, int fd, struct sockaddr *cliaddr, socklen_t *clilen, unsigned flags) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); io_uring_prep_accept(sqe, fd, cliaddr, clilen, flags); struct conninfo ci = {.connfd = fd,.type = ACCEPT}; memcpy(&sqe->user_data, &ci, sizeof(struct conninfo)); } int main() { int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd == -1) return -1; struct sockaddr_in servaddr, clientaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(9999); if (-1 == bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr))) { return -2; } listen(listenfd, 10); struct io_uring_params params; memset(¶ms, 0, sizeof(params)); struct io_uring ring; memset(&ring, 0, sizeof(ring)); /*初始化params 和 ring*/ io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms); socklen_t clilen = sizeof(clientaddr); set_accept_event(&ring, listenfd, (struct sockaddr *)&clientaddr, &clilen, 0); while (1) { struct io_uring_cqe *cqe; io_uring_submit(&ring); int ret = io_uring_wait_cqe(&ring, &cqe); struct io_uring_cqe *cqes[10]; int cqecount = io_uring_peek_batch_cqe(&ring, cqes, 10); unsigned count = 0; for (int i = 0; i < cqecount; i++) { cqe = cqes[i]; count++; struct conninfo ci; memcpy(&ci, &cqe->user_data, sizeof(ci)); if (ci.type == ACCEPT) { int connfd = cqe->res; char *buffer = buf_table[connfd]; set_read_event(&ring, connfd, buffer, 1024, 0); set_accept_event(&ring, listenfd, (struct sockaddr *)&clientaddr, &clilen, 0); } else if (ci.type == READ) { int bytes_read = cqe->res; if (bytes_read == 0) { close(ci.connfd); } else if (bytes_read < 0) { close(ci.connfd); printf("client %d disconnected!\n", ci.connfd); } else { char *buffer = buf_table[ci.connfd]; set_write_event(&ring, ci.connfd, buffer, bytes_read, 0); } } else if (ci.type == WRITE) { char *buffer = buf_table[ci.connfd]; set_read_event(&ring, ci.connfd, buffer, 1024, 0); } } io_uring_cq_advance(&ring, count); } return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.

⑵代码解读

创建监听套接字:int listenfd = socket(AF_INET, SOCK_STREAM, 0); 使用 socket 函数创建一个 TCP 套接字,AF_INET 表示使用 IPv4 协议,SOCK_STREAM 表示使用流式套接字(即 TCP 协议),0 表示默认协议。如果创建失败,socket 函数会返回 -1,程序返回 -1。

绑定地址和端口:

填充服务器地址结构体 servaddr,包括地址族(AF_INET)、IP 地址(INADDR_ANY 表示绑定到所有可用的网络接口)和端口号(9999)。

if (-1 == bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr))) 使用 bind 函数将创建的套接字绑定到指定的地址和端口。如果绑定失败,bind 函数返回 -1,程序返回 -2。

监听连接:listen(listenfd, 10); 使用 listen 函数开始监听套接字,第二个参数 10 表示最大连接数,即允许同时存在的未处理连接请求的最大数量。

初始化 io_uring:

复制
struct io_uring_params params; 和 struct io_uring ring; 分别定义了 io_uring 的参数结构体和实例结构体。 memset(¶ms, 0, sizeof(params)); 和 memset(&ring, 0, sizeof(ring)); 初始化这两个结构体的内容为 0。1.2.

io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params); 使用 io_uring_queue_init_params 函数初始化 io_uring 实例,ENTRIES_LENGTH 表示提交队列和完成队列的大小,&ring 是指向 io_uring 实例的指针,&params 是指向参数结构体的指针。

设置接受连接事件:set_accept_event(&ring, listenfd, (struct sockaddr *)&clientaddr, &clilen, 0); 调用 set_accept_event 函数设置一个接受连接的异步事件。在这个函数中,首先获取一个提交队列项(SQE),然后使用 io_uring_prep_accept 函数准备接受连接的请求,将相关信息(如监听套接字、客户端地址、地址长度等)填充到 SQE 中,并将自定义的连接信息结构体 conninfo 复制到 SQE 的用户数据区域,用于标识该请求的类型和相关连接信息。

事件循环处理:

while (1) 进入一个无限循环,用于持续处理 I/O 事件。io_uring_submit(&ring); 提交准备好的 I/O 请求到内核。int ret = io_uring_wait_cqe(&ring, &cqe); 等待 I/O 操作完成,获取完成的完成队列项(CQE)。struct io_uring_cqe *cqes[10]; 和 int cqecount = io_uring_peek_batch_cqe(&ring, cqes, 10); 使用 io_uring_peek_batch_cqe 函数尝试批量获取完成的 CQE,最多获取 10 个。

遍历获取到的 CQE:

struct conninfo ci; 和 memcpy(&ci, &cqe->user_data, sizeof(ci)); 从 CQE 的用户数据区域复制之前设置的连接信息结构体 conninfo。

根据连接信息中的类型(ci.type)进行不同的处理:

如果是 ACCEPT 类型,表示有新的连接请求被接受。获取新的连接描述符 connfd,设置读取事件,准备从新连接中读取数据,并再次设置接受连接事件,以便继续接受新的连接请求。如果是 READ 类型,表示有数据可读。根据读取的字节数进行处理,如果读取到的字节数为 0,表示客户端断开连接,关闭连接;如果读取失败(字节数小于 0),也关闭连接并打印断开连接的信息;如果读取成功,设置写入事件,将读取到的数据回显给客户端。如果是 WRITE 类型,表示数据写入完成,设置读取事件,准备从客户端读取下一次的数据。

io_uring_cq_advance(&ring, count); 告知内核已经处理完 count 个完成事件,通过将完成队列的头部指针递增 count 个位置,以便内核可以继续使用完成队列。

4.3性能对比测试

1.测试环境与方法

测试环境搭建:在一台配备 Intel (R) Xeon (R) CPU E5 - 2682 v4 @ 2.50GHz 处理器、16GB 内存、运行 Linux 5.10 内核的服务器上进行测试。使用的存储设备为 NVMe SSD,以确保 I/O 性能不受磁盘性能的过多限制。测试机器的网络配置为千兆以太网,以保证网络传输的稳定性。

2.测试方法设计

针对文件读写场景,使用 fio 工具进行测试。分别设置不同的 I/O 模式,包括阻塞 I/O、非阻塞 I/O、epoll 以及 io_uring。对于每种模式,进行多次测试,每次测试设置不同的文件大小(如 1MB、10MB、100MB)和 I/O 操作类型(如随机读、顺序读、随机写、顺序写)。在每次测试中,fio 工具会按照设定的参数进行 I/O 操作,并记录操作的时间、吞吐量等性能指标。例如,在随机读测试中,fio 会随机读取文件中的数据块,并统计单位时间内读取的数据量。

在网络编程场景下,搭建一个简单的 echo 服务器模型,分别使用 epoll 和 io_uring 实现。客户端通过多线程模拟大量并发连接,向服务器发送数据并接收服务器回显的数据。在测试过程中,逐渐增加并发连接数,从 100 个连接开始,每次增加 100 个,直到达到 1000 个连接。使用 iperf 等工具测量不同并发连接数下的 QPS(每秒查询率)、延迟等性能指标。iperf 工具会在客户端和服务器之间建立 TCP 连接,发送一定量的数据,并记录数据传输的速率、延迟等信息。

3.测试结果分析

文件读写性能:在小文件(1MB)读写测试中,阻塞 I/O 由于线程阻塞等待 I/O 操作完成,导致其吞吐量最低,平均吞吐量约为 50MB/s。非阻塞 I/O 虽然避免了线程阻塞,但频繁的轮询使得 CPU 利用率较高,且由于 I/O 操作的碎片化,其吞吐量也不高,平均约为 80MB/s。epoll 在处理多个文件描述符的 I/O 事件时,通过高效的事件通知机制,提高了 I/O 操作的效率,平均吞吐量达到 120MB/s。

Part5.io_uring与其他 I/O 模型对比

5.1与阻塞 I/O 对比

阻塞 I/O 是最基础的 I/O 模型,当应用程序执行 I/O 操作(如 read 或 write)时,线程会被阻塞,直到 I/O 操作完成。在从磁盘读取文件时,若数据尚未准备好,线程就会一直等待,期间无法执行其他任务。这就好比一个人在餐厅点餐,必须坐在餐桌旁等待食物上桌,期间什么其他事情都做不了。阻塞 I/O 的优点是编程简单,逻辑清晰,但在高并发场景下,由于每个 I/O 请求都可能阻塞线程,导致系统的并发处理能力极低,资源利用率也不高。

而 io_uring 采用异步 I/O 机制,用户程序提交 I/O 请求后,无需等待操作完成,就可以继续执行其他任务。当 I/O 操作完成后,内核会将结果放入完成队列(CQ),并通过事件通知机制通知用户程序。这种方式大大提高了系统的并发处理能力,线程在等待 I/O 操作的过程中可以充分利用 CPU 资源执行其他任务。在一个高并发的 Web 服务器中,使用阻塞 I/O 时,每个客户端连接都需要一个独立的线程来处理,当并发连接数增多时,线程资源将被大量消耗,系统性能会急剧下降;而使用 io_uring,服务器可以同时处理多个客户端的 I/O 请求,无需为每个请求创建单独的线程,提高了资源利用率和系统的并发处理能力。

5.2与非阻塞 I/O 对比

非阻塞 I/O 允许应用程序在 I/O 操作未完成时立即返回,线程不会被阻塞。当应用程序执行 I/O 操作时,如果数据尚未准备好,系统会立即返回一个错误(如 EWOULDBLOCK 或 EAGAIN),应用程序可以继续执行其他任务,然后通过轮询的方式再次尝试 I/O 操作,直到数据准备好。这就像在餐厅点餐时,服务员告知需要等待一段时间,你可以先去做其他事情,然后时不时回来询问食物是否准备好了。非阻塞 I/O 提高了系统的并发处理能力,但频繁的轮询会消耗大量的 CPU 资源,增加了系统的开销,而且编程复杂度较高,需要处理更多的错误和状态判断。

io_uring 虽然也是异步 I/O 模型,但与非阻塞 I/O 有很大的不同。io_uring 通过提交队列(SQ)和完成队列(CQ)实现了高效的异步 I/O 操作,减少了系统调用和上下文切换的开销。用户程序只需将 I/O 请求写入 SQ,内核会异步处理这些请求,并将结果写入 CQ,用户程序从 CQ 中获取结果,无需频繁轮询。在处理大量 I/O 请求时,非阻塞 I/O 的轮询操作会导致 CPU 使用率急剧上升,而 io_uring 通过异步机制和事件通知,大大减少了 CPU 的消耗,提高了系统的性能和效率。io_uring 还支持批量操作和更多的系统调用,功能更加丰富和强大。

5.3与 epoll 对比

epoll 是 Linux 下常用的 I/O 多路复用技术,它允许一个进程同时监视多个 I/O 描述符,当其中任何一个描述符就绪(即有数据可读或可写)时,进程就可以对其进行处理。epoll 采用事件驱动模式,使用红黑树管理需要监听的文件描述符,用一个事件队列存放 I/O 就绪事件。调用 epoll_wait 时,内核将已就绪的事件从内核空间拷贝到用户空间,用户程序依次处理这些事件。若有大量事件就绪,需多次系统调用处理。

io_uring 和 epoll 在设计理念、实现机制和适用场景等方面都存在差异。从设计理念上看,epoll 主要用于 I/O 多路复用,解决一个进程监视多个文件描述符的问题;而 io_uring 是更广泛的异步I/O 框架,不仅用于事件通知,还能直接执行 I/O 操作,旨在提高大规模并发I/O 操作性能。在实现机制上,epoll 通过内核和用户空间的数据拷贝来传递事件信息,而 io_uring 基于两个共享环形缓冲区(SQ 和 CQ),用户程序将 I/O 请求写入SQ,内核处理完 I/O 操作后把结果写入 CQ,减少了用户态到内核态的上下文切换次数,且支持批量提交和处理 I/O 请求 。

在性能方面,在高并发场景下,io_uring 性能优势明显,能极大减少用户态到内核态的切换次数,测试显示连接数 1000 及以上时,io_uring 性能开始超越 epoll,其极限性能单 core 在 24 万 QPS 左右,而 epoll 单 core 只能达到 20 万 QPS 左右 。在连接数超过 300 时,io_uring 的用户态到内核态的切换次数基本可忽略不计 。

不过在某些特殊场景(如 meltdown 和 spectre 漏洞未修复时 ),io_uring 相对 epoll 的性能提升不明显甚至略有下降 。在适用场景上,epoll 适用于事件驱动的网络编程场景,如监视多个客户端连接的服务器,像 Nginx、Redis 等都基于 epoll 构建;io_uring 则更适合处理网络 I/O、文件 I/O、内存映射等多种场景,目标是实现 Linux 下一切基于文件概念的异步编程 。

io_uring 的编程复杂度相对较高,需要深入理解提交队列和完成队列的工作机制,手动管理 I/O 请求的提交、结果获取,以及处理队列初始化、事件提交与回收等操作,而 epoll 的编程相对简单,开发者只需关注文件描述符的事件注册(epoll_ctl)和事件处理(epoll_wait 返回后的逻辑) 。

Part6.使用 io_uring 的注意事项与挑战

6.1内核版本要求

io_uring 是 Linux 内核提供的特性,对内核版本有一定的要求。要充分利用 io_uring 的全部功能,建议使用 Linux 5.10 及以上版本的内核 。在较低版本的内核中,可能不支持 io_uring,或者虽然支持但存在功能缺陷和性能问题。在 Linux 5.4 版本之前,io_uring 的某些功能可能不够稳定,在处理某些复杂的 I/O 操作时可能会出现错误。如果你的系统内核版本较低,在考虑使用 io_uring 之前,需要先升级内核。内核升级过程可能会涉及到系统兼容性、驱动程序等一系列问题,需要谨慎操作。在升级内核之前,最好备份重要的数据,并在测试环境中进行充分的测试,确保升级后的系统能够正常运行。

6.2编程复杂度

虽然 io_uring 提供了强大的功能,但直接使用 io_uring 的系统调用进行编程是比较复杂的。它涉及到对提交队列(SQ)和完成队列(CQ)的详细操作,以及对各种 I/O 请求参数的设置。开发者需要深入理解 io_uring 的工作原理和机制,才能正确地使用它。例如,在设置提交队列条目(SQE)时,需要准确地设置操作码、文件描述符、缓冲区地址、数据长度等参数,任何一个参数设置错误都可能导致 I/O 操作失败。在处理完成队列条目(CQE)时,也需要正确地解析操作结果和错误码,进行相应的处理。

为了简化 io_uring 的使用,开发者可以借助 liburing 库。liburing 库是对 io_uring 系统调用的封装,提供了更高级、更易用的 API。通过 liburing 库,开发者可以更方便地初始化 io_uring 实例、提交 I/O 请求、获取完成事件等。使用 liburing 库中的 io_uring_queue_init 函数可以方便地初始化 io_uring 实例,使用 io_uring_get_sqe 函数可以从提交队列中获取一个空闲的 SQE,使用 io_uring_submit 函数可以提交 I/O 请求等。借助 liburing 库,开发者可以降低编程的复杂度,提高开发效率,但同时也需要了解 liburing 库的使用方法和相关的函数接口。

6.3应用适配难度

将现有的应用程序迁移到 io_uring 可能需要对代码进行较大的修改,存在一定的适配难度。因为 io_uring 的编程模型与传统的 I/O 模型有很大的不同,现有的应用程序可能是基于阻塞 I/O、非阻塞 I/O 或 I/O 多路复用等模型开发的,要迁移到 io_uring,需要重新设计和实现 I/O 相关的部分代码。

在一个基于 epoll 的 Web 服务器中,要将其迁移到 io_uring,需要重新编写事件处理逻辑、I/O 请求的提交和处理流程等。这不仅需要对 io_uring 有深入的理解,还需要对现有的应用程序架构有清晰的认识,确保迁移过程中不会影响应用程序的功能和稳定性。在迁移过程中,还可能会遇到一些兼容性问题,如与其他库或组件的兼容性等,需要进行仔细的测试和调试。

THE END