io_uring 的设计和演进

标题:The Design and Evolution of io_uring

日期:2026/08/20

作者:Jens Axboe

链接:https://www.youtube.com/watch?v=y4cb39pBp0I

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:我也写过两篇关联的文章。一篇是折腾使用方式:尝试 io_uring 低层接口编程,另一篇是谈论 I/O 调度(读无聊代码):Linux 内核的 io_uring 任务调度


无论如何,今天我打算稍微谈谈 io_uring 的设计和演进,以及在它之前的一些尝试。我不会深入探讨如何使用它的细节。相反,我只打算向你们展示它的一些核心部分,因为我认为,对于应用程序开发者来说,理解这些核心部分对于充分利用该接口非常重要。

我也不会展示很多基准测试(benchmarks)。人们总是说:“哦,你整天就在 Twitter 上发基准测试。”所以我今天不打算这么做,除了只展示这一个。因为我认为,这是我之前所做的大量工作的顶峰,即确保块 I/O(block IO)栈具有可扩展性。一旦我们实现了这一点(也就是我所说的“我的旧十年计划”),我们就需要另一个计划,那就是:“我们现在该如何利用它?”所以这只是一个基础服务器盒子的例子。感谢戴尔(Dell)把它寄给我。里面装了一堆 NVMe 驱动器,准确地说是 24 个。我们只对它进行 512 字节的 IOPS 测试,看看我们需要多少 CPU 来实现。结果这台机器有 80% 或更高的空闲率,并且从这 24 个驱动器中提取了大约 60 GB 的带宽。这就是我唯一要展示的基准测试,因为我认为它在存储方面以及我们要讲的故事中是非常相关的。

过去的 I/O 世界与背景

那么,我们曾经所处的世界是怎样的呢?Unix 有句名言:“一切皆文件”,对吧?无论它是什么都无所谓。即使在今天,一个任务(task)也可以是一个文件。你可以看到 pidpidpid,以及文件系统(fs)。这是事实,但同样从那时起,我们一直以不同的方式对待这些文件。如果你需要对常规文件进行 I/O 操作,你用一种方法;如果你想对套接字(socket)、管道(pipe)或任何支持轮询(pollable)的东西进行 I/O 操作,你使用的是一种完全不同的范式。

即使在常规文件上,我们长期以来也通过 libaio 或核心 AIO 基础设施支持异步 I/O。但那是一个非常小众的使用场景。你可以使用 O_DIRECT(直接 I/O),但你不能使用带缓存的 I/O(buffered IO),而且它在任何非常规文件上都不起作用。即使在 O_DIRECT 上,它也只是在“没出问题时才管用”。如果你负载过高,它就会阻塞,而你甚至都不会察觉到。你只会看到你的应用程序停滞了,然后你才会弄明白:“哦,我把这些磁盘逼得太紧了。”

所以核心问题真的是:为什么读取文件和读取套接字,我们竟然需要两个独立的接口来完成?其他操作系统实际上并不这样做。

简单介绍一下我自己,在 io_uringliburing 之前,我还写过 fio,大家可能听说过,一个存储基准测试工具。我写它是因为我厌倦了为性能问题编写复现程序,所以我觉得最好写一个工具,让人们可以按照自己想要的方式进行配置和运行。因此,我编写了块 I/O 栈、I/O 调度器以及存储领域的很多其他东西。在过去的不同时期,我甚至还参与过 IDE、SCSI 以及内存管理相关的工作。最近,正如我们刚才在视频中看到的,我一直在做一些和赛车相关的事情。7 月份我休假了,工程师在休假时会做什么?工作。他们会工作。我们刚才看到的那个团队,他们使用了制造车载 PDU 和 ECU 设备的供应商提供的最差劲的软件。所以我自己写了一个替代品。这只是展示了它的工作原理,它是用于数据分析等工作的。当然,它也需要一个 io_uring 角度的结合,所以有一个后端。这样你就可以从世界任何地方打开这些事件,它会使用结合了 KTLS 协议的 io_uring 零拷贝发送(TX zero-copy)来进行流传输。因为毫无疑问,我们还需要进行加密。其他的软件当然并不支持这些。

在 io_uring 之前的异步 I/O 尝试

让我们稍微谈谈在 io_uring 之前出现了什么,以及当时的一些尝试。我们之前谈到了一点关于 libaio 以及它的工作原理和其有限的用例。所以如果你不使用 O_DIRECT I/O,人们往往只有 POSIX 的选项——如果你曾经尝试过使用它们,你会发现它们是世界上性能最糟糕的 API。然后人们最终只能在用户空间构建自己的线程池,因为这真的是你唯一的选择。而且从来没有任何针对网络优化的此类解决方案。

如果我们把时间再往前推一点,大概在 2006、2007 年左右,关于如何在内核中实现异步 I/O 有几种不同的尝试。如果你经历过那个时代,你可能会记得 Fibrils,或者 SysletsThreadlets。它们采取了大致相似的方法:它们假设一个操作不会阻塞。如果阻塞了,它们会以某种方式处理。至于“某种方式”具体是什么,这两种方法各不相同。它们还采取了一种策略:“我们要通过这种方式让任何系统调用都变成异步的”。所以你基本上只需填入一长串所有的系统调用参数(六七个),然后把它们发送给内核。这两种方案最终都没有被合并。

其中一些在编程上很难使用。首先,如果你使用 SysletThreadlet 方法,当你的应用程序进入内核执行 I/O 且事件发生阻塞时,它会向用户空间返回一个新线程供你使用。所以你的 API 体验是:你可能以 PID 10 进入内核,但你返回时可能是 PID 11。其中一些方案被认为是危险的,而且对用户非常不友好。所以这些方案最终都没有下文。Linus 确实喜欢其中的一些方法,但我认为还不足以在当年把它们合并进去。

在我看来,它们通常还弄错了一件事,那就是:一个异步 API 的“快速路径(fast path)”应该是你的数据已经被缓存的情况。你的“慢速路径(slow path)”往往是:我进入内核并启动这个操作,然后在某个时候得到通知。如果你在处理文件系统或接收数据,特别是在网络存储上发送数据,该操作将立即内联(inline)完成。因此,你根本不需要它的异步部分。如果你不处理这种情况,那么所有通过同步系统调用会很快完成的标准操作,在使用那些同步系统调用时仍然会快得多。所以 io_uring 的做法略有不同。我们将线程池部分更多地视为一种慢速路径。稍后我将在图表中向你们展示我们是如何走过不同阶段,以及 io_uring 在栈中采取了哪些路径的。

然后 Joran 几周前问我:“你是怎么把它合并到内核中的?这看起来肯定非常困难。”实际上并不是。我认为每个人长期以来都渴望有一个更好的 API。Linus 出了名地一直讨厌 epoll,主要是因为它的实现,而不是因为它是一个基于就绪状态(readiness-based)的机制。他还在私下里多次告诉我,他希望 io_uring 能成为人们直接开始使用的东西,从而不必再不断地修补 epoll。同样,他也一直讨厌 AIO。对他来说,那只是 Oracle 的东西,因为这差不多是它在 2000 年左右被引入时的背景。而且它不能作为一种通用的异步接口供人们读写文件使用。没有人会编写一个编辑器或任何类型的桌面程序去使用 libaio,因为你会被强迫使用 O_DIRECT。如果你想收获 O_DIRECT 的好处那通常没问题,但在可用性方面,这确实有点问题。

这甚至可以一直追溯到以前关于微软(Microsoft)的报告。我不知道你们是否还记得那些,在 1999 到 2000 年初期,微软赞助的一组基准测试四处流传,宣称 Windows 比 Linux 快得多。甚至在那个时候,人们就在讨论:“也许 Windows 使用的基于完成(completion-based)的模型实际上才是正确的选择。”

io_uring 的设计

那么让我们来看看它的设计。在其核心,它真的非常简单。它只是两个环(rings)。一端是应用程序,另一端是内核,每个环只是这两者之间通信的单行道。环的所有部分,比如环本身、头部(head)、尾部(tail)以及环所需的任何东西,都位于两者之间的共享存储(内存)区域。这意味着无论内核向其中写入什么,应用程序都能立即看到,反之亦然。所以就环本身而言并没有什么新奇之处。我认为唯一稍微有点独特的地方是,与需要在环中进行掩码(masking)操作不同,索引(头部和尾部)是自由运行的。所以它们在某个时间点会自然回绕(wrap)。所以当你递增它们时,你永远不需要做任何掩码操作。更重要的是,通过这种方式你可以获得环的完整大小,而相比之下,在普通的 2 的幂次方的环中,你会丢失一个元素——如果你有 256 个条目,在传统设计中你无法区分满和空,但使用这种方法你可以。

当你们通过 io_uring 提交 I/O 时,这是 I/O 在栈中的流动方式。如果你看左上方,你可以走三种不同的路径:

  1. 第一种是,假设我们正在从一个套接字读取数据。你去尝试执行读取,你拿到了数据,然后你发布一个 CQE(完成队列项)。CQE 会告诉你与该 I/O 相关的元数据,比如我们读取了多少字节。这就是快速路径。所以它直接贯穿执行,发布一个 CQE。

  2. 第二种情况是,如果你交互的文件支持在后端启动操作。比如存储设备就是一个例子。所以当你与磁盘对话时,你提交一个命令说:“嘿,我很想写入这些扇区。”然后在某个时刻,存储设备返回并触发一个 I/O 队列中断,说:“现在操作完成了,这是结果。”对于这种情况,io_uring 会暂停,直到我们收到这个回调。

  3. 然后第三种路径是,我们既无法直接执行,也无法在后端启动操作。在那种情况下,我们会检查并说:“当文件表示它现在准备好执行我们想要的操作时,我们能得到某种回调吗?”这就像是支持轮询(pollable)的文件。现在管道中有空间了,所以我们可以向其中写入。如果可以,我们就武装(arm)一个回调。当那个回调触发时,我们就会重试该操作。

这里可能发生的最后一种情况是,如果你无法从此特定设备轮询或获取就绪状态,那么我们就会将其排队到我们的线程池中。这是刚刚接触 io_uring 的人们最常误解的地方,认为它只是一个线程池类的东西。其实并非如此。它只是当我们没有其他选择时的一种回退机制(fallback)。如果你在做磁盘 I/O 和网络 I/O,线程池永远不会被卷入进来,除非你的系统出现了异常。

这里有一个例子。对于块 I/O,提交它。在某个时刻,我们得到一个块 I/O 队列回调。这会排队一个任务工作(task work)。稍后我们会深入探讨这是什么意思。然后我们发布一个 CQE。同样,对于网络 I/O 也非常直接。我们执行发送或接收。如果我们无法完成它,我们就武装轮询(arm the poll)。然后在某个时刻我们得到通知,重试该操作,然后希望能够发布一个 CQE。显然,如果你试图一次性发送兆字节的数据,而套接字缓冲区没有足够的空间,你可以多循环几次。但这正是它的要旨。

所以有了这两样东西,我们就得到了一种结果,至少在我看来和我所希望的那样:io_uring 带来了统一的 I/O 执行方式的大一统(great unifier)。所以现在我们终于有了一种对文件执行 I/O 的方法,不管是什么文件。我们可以说这是真的:“一切皆文件”,甚至对它进行 I/O 的方式也是一样的。我确实认为这也是 Windows 做对的事情之一。几年前看到他们添加了他们的 IO_RING API,我感到很有趣,如果你看看文档,那基本上是对 io_uring 的直接复制。关于这事我有一些故事,我想稍后在酒吧什么的可以分享。但我把它当作一种赞美。我希望他们或许能给出一点出处声明之类的,不过,我还是接受了。但在核心层面上,无论是什么文件,你提交一个操作,你就会得到一个完成事件。

聊聊 liburing

那么让我们来稍微谈谈 liburing。这是人们实际使用的 API。如果你曾经使用过 io_uring,你们中的大多数人可能甚至都没有看过原始的内核 API。liburing 的存在是为了抽象一些底层细节,使其更容易使用、更安全,并且在很多情况下,使用起来更快。

如果你从未使用过 liburingio_uring,这里有一个最基础的例子,并且解释了它的核心概念。在提交端,我们有一个 SQE(提交队列项)。所以你向 liburing 请求:“请给我一个。”当你拿到一个时,你填入你的文件描述符(file descriptor)、缓冲区(buffer)、偏移量(offset)或任何你想做的操作,然后你提交它,等待它,然后就完成了。这里在 liburing 的底层发生了一些事情。你可能会获取一堆 SQE 来执行更多的操作,且直到你调用 io_uring_submit 之前,我们都不需要开始接触与内核共享的状态。我们可以在 liburing 端的环本身中隐藏很多这类细节。反过来,如果我们从管道中读取,看起来也完全一样。这里没有任何区别。

此外,如果你能看出这里的区别,前面的例子实际上最终执行了两个系统调用。submit 将进入内核并要求它开始操作。如果完成队列(CQ ring)中没有可用的 CQE,wait_cqe 将进入内核并等待它们。当然,你可以将它们捆绑成一个调用。所以现在你做的是异步 I/O,但既然你立即等待操作结果,我们可以在单个系统调用中完成它。这不是最有趣的例子。我认为在正常的循环中,你在处理进来的事件并将东西发送出去。正常的路径是,你处理所有进来的数据并准备对它们的响应。然后你执行一次 submit 并将所有东西一并发送给内核。如果你同时提交多个操作,这就是系统调用批处理(syscall batching)发挥作用的地方。

那么 liburing 除此之外还做了什么呢?嗯,它包含了所有的样板代码(boilerplate codes)。设置一个 io_uring 实例实际上只是调用一个单一的系统调用,但在底层需要进行一些内存映射(memory maps)等操作。liburing 会为你透明地处理这些。我没有在前面的例子中放上环的初始化代码,但它真的只是一个单一的调用:io_uring_queue_init,带上你想要的标志和行为。liburing 还为所有操作提供了大量辅助函数(helpers)。所以如果你需要准备一个 accept 请求,或者 openat,或者不管是啥,比如同步一个文件,你只需使用相应的辅助函数即可。它还处理了一些内核和应用程序之间的内存排序(memory ordering)问题。我发现这些是用户空间或任何程序员往往容易弄错的地方,而且没有人需要真正了解你在这些不同类型的写入之间需要哪种读写屏障(barriers)和排序的内在血腥细节。

它是与内核版本无关的(kernel version agnostic)。所以你可以拿着任何版本的 liburing 在旧内核上运行,完全没问题。你也可以拿着任何旧版本的 liburing 在新内核上运行,也没问题。它们确实是齐头并进的,因为当我在内核端发布新功能时,我们会在 liburing 中添加相应的支持。所以当然,像对待任何软件一样,你应该始终使用最新版本,这样你就能得到所有的好东西。

它不仅仅是语法糖(syntactic sugar)。这是真正使用模式存在的地方。里面有一堆例子。这也是我们存放所有 liburingio_uring 内核端回归测试的地方。每当内核端添加新功能时,我们都会向 liburing 添加丰富的特性测试和“尝试弄坏它”的破坏性测试。它拥有丰富的 man 手册页。我试图确保我们拥有的每一个函数都有恰当的文档说明。除此之外,我们最近添加了 section 7 类的 man 手册,以解释 io_uring 中部分的中心概念。有时候人们会问我:“这些注册的文件或缓冲区是什么,它们是如何工作的?或者提供的缓冲区(provided buffers)是什么?”我发现这是一个放置所有这些不同内容的好地方。我知道现在的人们不太喜欢读 man 手册了,但你需要某种格式的东西,而我总是倾向于那样做。我想如今,我们真正需要做的只是把这一切教给大语言模型(LLMs),因为现在很多代码都是那样写出来的。

哦,我还应该提到,我总是努力让它“易于使用且难以误用”,并且我试图确保所有东西都作为附属选项存在。这也是你在内核块层中使用的范式,当你添加特性时,如果你能将其作为合适的辅助函数来实现,你就可以把它排除在所有其他功能的快速路径之外。所以所有的东西都像是放在一旁,你只需要挑选你想用的那些。它不会试图强迫你使用任何特定的用例或范式。随着 liburing 的发展,以及 io_uring 的成长,希望它能保持适当的精简和快速。

io_uring 与 task_work 机制

那么让我们来稍微谈谈 io_uring 和任务工作机制(task_work),因为 task_work 确实是内核中实现这种异步魔法的核心概念之一。

那么什么是 task_work 呢?它实际上只是一些你希望在未来某个时刻运行的、带有数据的延迟函数。在 Linux 上,task_work 被用于比如释放文件描述符等事情。所以它直接在运行它的任务结构(task struct)中排队。这意味着你可以严格控制谁来运行它。当我们需要在非任务上下文(non-task context)中处理事件时——例如,当网络通知你就绪状态时,这些通知来自栈深处,由 I/O 队列中断或软中断触发;在块存储端同样如此——那么我们倾向于只是把它交给一个任务,重新武装一个 task_work 项,然后它随任务排队并在以后的某个时间点运行。

那么为什么我们选择 task_work?因为,在最初提交它的任务中运行该事物,并且相应地,在完成时也在该任务中处理数据,可以将所有内容尽可能地保留在你运行的实例的本地缓存(cache local)中。它也提供了一种序列化事物的方法。稍后我们将稍微探讨一下如何在不同线程之间使用 io_uring,但常见且提倡的用例始终是:你永远不应该在系统中的线程或任务之间共享同一个环。因此,这允许你将某些 task_work 绑定到仅发生在该特定任务上。

这就是最初 io_uring 顶层的样子,因为我们仅仅使用了通用的内核基础设施来实现 task_work。在 Linux 内核中,其运行方式是在内核的任意切入和切出时执行。这意味着 io_uring 可以正在一边做着它自己的事,然后后端会去执行一个 stat 系统调用之类的操作,而这反过来又会重新进入 io_uring 并执行一堆其他操作。在现实世界中,这被证明是相当不幸的,原因有两个。一个是信号机制的成本高昂,强制进出内核的切换也是昂贵的;此外,运行一些与后端本来想做的事情高度不相关的工作,这也是高昂且不幸的。这还意味着我们没有严格的控制权:当我们向一个任务排队一堆 task_work 时,我们究竟在什么时候决定运行它,以及我们如何运行它?

所以我们提出了一个叫做“延迟任务运行(deferred task run)”的概念,它最终仍然使用 task_work,但对其进行了非常严格的管理。只有 io_uring 自己才能运行它自己的 task_work。这样做的好处是,当你在做后端循环时,你倾向于在等待端进行一点批处理。所以你可能会说:“哦,我准备等待 20 微秒让这些事件进来。”但是如果你此时刚好有 4 个事件准备好了,那就说:“好的,我会处理这批事件,唤醒我,我会搞定它。”一旦我们有了延迟任务运行机制,我们就可以非常严格地对其进行管理,以至于等待执行这些操作的应用程序在这些事件可用之前甚至都不会被唤醒。在此之前,它更像是一种“唤醒并检查”的工作方式。这使得在高吞吐量情况下的开销显著降低。这里的吞吐量,我指的是每秒大量操作数。

作为一个例子,我们再往底层走一点,我保证这就是我们要讲的最底层了。我们最近改变了处理这个 task_work 的核心数据结构。最初我们一直使用 Linux 无锁链表(lockless lists),这工作得很好,而且它们实际上非常快。但是正如大多数无锁链表一样,它们最终变成了后进先出(LIFO)的顺序。这意味着,由于我们希望在 io_uring 中具备公平性的概念,到了运行所有这些 task_work 的时候,我们需要反转这个链表然后执行。对于链表中的几个元素来说,这很好。这实际上也是有益的,因为你可以顺便获得一些免费的缓存预取(cache preloading)。但是你真的不知道你的代码将在什么类型的系统上运行,你最终可能会在积压队列中有数百个项目要运行,而反转这个链表所消耗的 CPU 周期变得令人望而却步,你的缓存预取也起不到任何作用。事实上,它只会冲刷掉你的缓存。所以我们最近加入了由 Dimitri Vojkov 提供的一种算法,它实际上非常巧妙,也非常简单。如果你还没看过,LWN 上有一篇关于它的文章,因为它在应用程序等其他场景中也可能有用武之地。虽然在链表项上存在一个关于竞态处理的略微奇怪的边界情况,但由于我们运行它的方式,这对 io_uring 来说并不影响。除开这一点,它确实简单得优美。我认为这也是一个很好的例子:你不能总是只顾着添加新特性。那是简单的部分。但是,不要忘记内核中那些一直在运行的核心部分,即便它们看起来并不是特别性感。它们消耗着 CPU 周期,如果可以的话,把这些开销削减掉是很重要的。

io_uring 的演进与新特性

所以我将稍微回顾一下我们的演进过程:我们从哪里开始,我们走过的路。

我们在添加 io_uring 后不久添加的一个关键特性,是在与一位同事交谈后产生的。他说:“当我做套接字 I/O 时,有时它最终会进入这个线程池。”我说:“是啊,如果没有数据,你为什么还要把它发送到线程池呢?”这正如你可能意识到的,类似于 2000 年初提出的最初的异步操作思路。他说:“为什么你不像 poll 那样处理它,然后利用它来触发重试呢?”所以那个想法甚至都不是我提出来的。但这正是 fast poll(快速轮询)功能标志的由来,我们开始为支持轮询的文件以更高效的方式驱动操作。

注册缓冲区(Registered buffers) 是另一个。那是一个相当早期的功能。它基本上削减了你在执行 O_DIRECT 时内核中发生的一些步骤。最上面的线表示如果你试图从 NVMe 设备读取一个 4K 块(或其他大小)时会发生什么:一旦你进行了提交,你需要将用户数据映射到内核中。你需要获取对它的引用。然后你执行 I/O,然后你再把所有这些东西解开。有了注册缓冲区,我们在初始化时做一次这个操作,并添加注册缓冲区,现在你就可以直接执行 I/O 而不需要进行这些步骤。所以这是一个重要的延迟降低机制。特别是对于线程 I/O,当 get_user_pages 触及你的内存管理(MM)结构时,而该结构往往在系统中的所有线程之间共享。

与此类似,我们有一种**注册文件(Register files)**的方法。所以你不需要总是去执行 fgetfput 操作。我不想在这上面花太多时间,但是如果你曾经对你的后端进行过性能剖析(profiling),并且如果你运行的是多线程应用程序,你可能在你的内核跟踪记录中看到过 fgetfput,而这个特性有助于避免这种情况。

多次触发(Multi-shot) 我认为是一个有趣的特性。从一开始,我对 io_uring 的概念是,你提交的每一件事都会有一个完成事件,且仅仅只有一个。所以它们很好地一一对应。但这最终成为问题,或者说不一定是问题,但它会导致效率低下。比如,也许你只是想继续从这个套接字或这个管道接收数据,或者也许你想继续在这个套接字上接收连接。所以我们不如保持挂起(armed)状态,每当有事情发生时,我们就触发一次重试并再做一次。这就是 multi-shot 概念的由来,它适用于 io_uring 中的一堆不同操作。所以你武装它一次,然后它就不断地告诉你什么时候数据到达,直到发生错误为止。CQE 中会有一个标志,告诉你是否应该期待来自这个请求的更多完成事件。

所以它基本上改变了这种单次接收的模式。你会看到接收操作附加了一个缓冲区,对吧?那是我们在最初的 io_uring 中不得不做的另一件事。如果你有成千上万个套接字,并且你正在这些套接字上等待接收,那么分配静态缓冲区并将其分配给接收操作是不幸的。所以现在随着 multi-shot 的加入,它的工作方式是:现在我们有了所谓的提供的缓冲区环(provided buffer rings)。这是应用程序向内核声明的一种方式:“这是一堆你可以用于接收的缓冲区。”然后当接收准备好工作时,它会从这些缓冲区中选择一个,接收数据,然后发布你的 CQE,CQE 会告诉你“这就是我用于这次接收的缓冲区”。然后你的想法是在某个时候将它们回收回环中。这就在某种程度上解除了立即将数据与读或接收操作绑定的概念,你可以让它自动发生。提供者环中的这些缓冲区可以是任何你想要的大小,它们甚至支持增量操作。所以你可以把一个大缓冲区放进去,当一个接收进来时,它只需要多少就用多少,然后在下一次操作中,它会继续从那个缓冲区的前部抓取空间。

接收捆绑(Receive bundles) 是我们添加的另一个功能。其背后的理念是,从用户空间你可以查询套接字中有多少数据,但你也可以在网络栈中实际执行接收时在内核内部进行查询。因此,如果你有提供的缓冲区环,并且你正在从套接字读取 4K 或任意大小的数据,内核会告诉你套接字中还剩多少数据,然后你可以执行单次接收,在随后的一次操作中将它们全部读取出来。这就把需要通过网络栈进行 N 次接收来排空套接字的过程变成了一次接收。这里昂贵的部分不是 io_uring 方面,而是遍历网络栈的过程,特别是当你锁定一个套接字时发生的所有不相关的事情,更甚者是在操作结束时解锁它的时候。

零拷贝网络(Zero-copy networking) 也被添加进来了。我们在零拷贝发送(TX zero-copy)方面已经支持了很长时间。最初的实现并不是超级高效。现在我认为它与使用同步系统调用一样高效,实际上,我认为甚至更好。如果你曾使用同步系统调用进行零拷贝 TX,你可能已经注意到需要处理控制消息(CMSG)或错误队列(error queue)的这个麻烦事,因为令人惊讶的是,同步系统调用没有办法告诉你什么时候重用你的缓冲区是安全的。所以你需要能够在一旁查询。使用 io_uring,你只会得到另一个发布的 CQE,说:“哦,现在,当对方确认(ACK)了 TCP 数据后,这个缓冲区可以空闲使用了。”

还有其他用例。ublk(用户空间块设备) 是一两年前启动的一个项目。你基本上可以把它看作是用于块设备的 FUSE。在其核心,io_uring 仅仅是内核和应用程序之间的一个快速传输通道。所以 ublk 利用它把块设备的所有智能逻辑放到用户空间,然后仅使用 io_uring 来管理它的内核端。所以它表现为一个常规的块设备,但在另一端你可以用它做任何你想做的事情。如果你想让它像 NBD(网络块设备)那样作为一个网络块设备,你可以做到;或者你可以让它看起来像循环设备(loop device)。所以你几乎可以把你的块设备变成任何你想要的东西。我们实际上在 Meta 与 Pure Storage 的项目中使用了这个,我们用 QLC 闪存作为硬盘的替代品,我认为他们去年做过关于这个的演讲。但在那个时候,你只是暴露了 NVMe 设备(它在后端只是几块闪存),作为一个 ublk 设备。最近,ublk 的原作者明雷(Ming Lei)也发布了一个 Target(目标端)实现。所以这是一个用户空间 NVMe over Fabrics 的 Target,但它全在用户空间运行,仅使用 io_uring 来回发送这些命令。我认为 ublk 非常巧妙,看到其他内核开发者在以新颖的方式采用和使用它总是很酷。实际上,FUSE 也有正在进行的工作,以改善他们拥有的零拷贝支持。

所以我们的 io_uring 不仅仅是读和写操作。还有一大堆其他的操作。我不会一一介绍。我认为特别酷的一个是 Futex。当你能在实际获取到锁的时候得到通知,为什么你还要在一个 Futex 上睡眠呢?我认为那是一个非常巧妙的特性。如果你正在处理大量作业或构建系统的批处理,waitid 功能也是类似的。当你只需用 multi-shot 武装一个操作,每当发生你感兴趣的事情它就会告诉你,你为什么还要去等待 ID 呢?io_uring_cmd(URing Command) 是此类特性的另一个例子。它有点像,我敢说,它有点像 ioctl,或者说异步的 ioctl。所以任何文件类型都可以定义什么 URing Command 对它们意味着什么,并且它们拥有自己的操作码(opcode)空间。Btrfs 已经用它来进行其能够发送以更新事物的增量块(delta chunks)的发送和接收。NVMe 可以将它用于直通(pass-through)。如果人们觉得文件系统太慢,他们倾向于直接与块设备对话,然后又觉得“块栈太慢了,我干脆用直通模式”。所以通过 io_uring_cmd,你可以做各种奇特的事情。

关于安全性的争议与现状

现在我们将谈谈关于安全性的故事,因为这总是被提起。希望我们能稍微澄清一下事实。

当初,我认为在我这边以及 Linus 那边都有一定程度的兴奋,他非常愿意尽早合并 io_uring。所以我们最终留下了一些非常早期的缺陷,我认为这些本可以避免。但当时的情况是这样的:主要问题在于,我们最初的 I/O 工作队列(即我们拥有的工作线程池)使用了内核线程(kernel threads)。如你们所知,内核线程可以做任何事情。它们没有关联任何内存管理(MM)结构。因此,如果你需要从一个任务读取或写入,并将工作推给一个内核线程,内核线程需要采用该任务的 MM 结构。同样,任务中还有一大堆其他的东西,比如命名空间(namespaces)、凭证(credentials)以及你可能拥有的任何东西。如果你看看 Linux 内核中的 task_struct 结构,你会对其绝对庞大的规模感到震惊。而且我认为真的没有人能很好地概述所有不同事物的含义以及它们是如何联系在一起的。因此,许多最初的缺陷实际上仅仅是因为我们使用了内核线程,然后费尽心机地试图采用想要执行操作的任务的“个性(personality)”,但显然我们总是会漏掉一些东西,因为那只是一种糟糕的实现方式。

所以针对这一点的结构性修复是我们称之为 io_worker 的机制。这实际上只是内核派生(fork)一个线程的方法,看起来就像是你调用了 pthread_create,只不过我们可以从内核内部来做这件事。这意味着现在你“免费”得到了一切,可以这么说。没有任何你需要手动去追踪的东西。这个修复最终也被反向移植到了 5.10 长期稳定版内核中,因为那是一个运行时间很长的稳定内核。所以唯一没有这个修复的内核是老旧的 5.4 稳定版,如果有人还在用那个版本,那么我认为你有比这个特定问题大得多的麻烦。所以我认为目前在这个特定的战线上,我们处于良好的状态。

所以你可能会问,为什么今天它在某些地方仍然被禁用?嗯,那是问题的另一部分,人们往往倾向于将这两件事混为一谈。那就是,最初的安全性情况:是的,我们有合法的 Bug。Google 支付了大量奖金给那些打破 io_uring 限制的人,愿上帝保佑他们。最终我认为那是一件好事,因为它让很多人开始审视它并发现了这些问题。但它今天在某些地方仍被禁用,这主要归结于不是内核中的安全问题,而是应用程序无法像限制其他东西那样去限制它。如果你看看容器系统和 systemd 之类的东西,它们倾向于附加 seccomp 过滤器来完全禁止某个系统调用或其中的某些部分。例如,它们可能允许你使用 openat,但它们可能不允许你使用你可以指定的一些解析标志。或者它们可能允许你创建一个套接字,但也许不能是原始套接字(raw socket),也许你只能做流式或 UDP 套接字等等。这在 io_uring 中就是不可能做到的。

所以我们就去修复了这个问题。所以现在我们有了 cBPF 任务过滤。我最初将其写成了 eBPF,因为当你使用 cBPF 时会感觉它很古老。但是很多人告诉我:“seccomp 使用 cBPF 是有原因的,那是因为在很多这类环境中 eBPF 被禁用了。”我说:“好吧。”现在我写了这个可以用 eBPF 解决的东西,但这些环境不允许 eBPF。所以它必须重写。所以现在你可以将简单的 cBPF 程序附加到一个任务上,它会在 fork 时被继承,而且无论谁需要继承它或在下游继承它,都只能进一步限制操作的数量。所以它的工作方式与 seccomp 过滤器的模式完全一样,在那个特定操作的链中,只要有一个过滤器检查后说“不,你不允许这样做”,就会被阻止。所以现在它遵循了 seccompsystemd 的模型。所以现在我们只是在稍微等待这些工具的采用。我认为 systemd 的人已经采用了它。我想我需要去和 Docker 的人谈谈,以把那里的情况也解决掉。

所以就今天的现状而言,我认为我们并没有收到很多关于这方面的问题。我在内核安全邮件列表中,过去六个月里一直非常有趣。涌入的漏洞报告数量巨大。至于它们的质量,也许就不敢恭维了。但我能看到每一个进来的报告,我们偶尔也会收到针对 io_uring 的报告。前几天我刚收到一个,但在这一点上,情况并不比 epollfutex 等人们高度依赖的核心组件更糟。我敢说可能还要好。所以我认为今天我们的安全状况和它们一样好。我不知道你们是否看到了可能是上周或上上周的新闻,说是“Linux 一天内发布了 432 个 CVE”。我当时想:“哈,不知道我们有没有。”所以我去检查了一下。在那个特定批次里 io_uring 的漏洞数是零。所以我认为今天我们有一个非常好的故事。它在诸如 seccomp 和 Docker 之类的东西中仍然被禁用的事实,在我看来,完全是出于不相关的原因,比如曾经缺乏细粒度过滤。但人们把这两者混为一谈,而你最好不要去读 Hacker News 上的报道,因为评论区就是一场灾难。但在我看来,整个关于“哦,io_uring 如此不安全,它是一场安全灾难”的说法,在现阶段已经严重过时和被误解了。

未来展望与结论

我在这里接受大家的掌声。谢谢。谢谢。看起来我的时间有点紧了,所以我打算加快点速度,然后再进行问答环节。

即将到来的一件事是对零拷贝的改进,特别是接收端(RX)。现在,我们支持主机内存。我们将很快支持设备内存,所以你可以直接接收到你的 GPU 中,举个例子;或者你可以直接从你的 NVMe 设备发送。我认为这将会非常巧妙。

预映射 DMA(Premap DMA) 是我最近把玩的东西。今年 5 月我参加了我们每年都会举办的 Linux 存储、文件系统和内存管理会议(在萨格勒布举行)。一些三星(Samsung)的人参会并问:“我们怎样才能更接近 SPDK 的性能?”我总是在想:“哦,我们当然想击败 SPDK,但在某种程度上,你就是无法与那种绕过内核(bypass)的东西竞争。”而且当你在运行内核旁路时,如果搞砸了,后果也得你自己承担。所以我们更进了一步。我想:“如果我们在这个预注册的缓冲区中也包含 DMA 映射会怎样?”那实际上带来了一个非常不错的效率提升。所以我认为我们将尝试在这方面做点文章。这有一点棘手,因为如果你的文件系统分布在五个不同的设备上,这事就变得有点意思了。但至少我希望这会在未来实现。

协议感知唤醒(Protocol Aware wakes)。当你在接收数据时,这是一个有趣的功能。当你正在处理像 Thrift 或类似协议的数据时,你可能对你要接收的实际数据长什么样有所了解。如果这些东西很大,你要接收比如 2MB 的包,你最终将不得不进行大量的唤醒和接收操作。如果你可以只是将一个 BPF 程序附加到 io_uring 的轮询门禁(poll gate)上会怎样?它可以去检查报头,并确切地知道有多少数据在那里,并且它可以决定在所有数据都到达时才唤醒你,对吧?所以现在你可以在一次操作中处理单个完整的帧,而不是需要做一堆小的增量唤醒。