前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【翻译】withoutboats 的 io-uring 笔记

【翻译】withoutboats 的 io-uring 笔记

作者头像
MikeLoveRust
发布2020-07-22 16:06:51
7910
发布2020-07-22 16:06:51
举报

原文: Notes on io-uring 地址: https://boats.gitlab.io/blog/post/io-uring/ 时间: 2020-05-06 作者: Withoutboats (Saoirse Shipwreckt@Github)

去年秋天,我正在开发一个库,创建一套安全的API,实现在一个 io-uring 实例的基础上执行 future。虽然最后发布了一个叫 iou 的 liburing 的 binding 库,但其与 future 集成的 ostkreuz 库最终未能发布。我不知道将来是否会继续这项工作,但是有些人已经开始开发目标类似的库了,因此我想就我在 io-uring 和 Rust 的 future 模型上的学习情况做一些笔记。这篇文章假定你对 io-uring API 有一定了解。这个文档(https://kernel.dk/io_uring.pdf)提供了关于 io-uring 的高级概述。

首先:所有安全的公共 API 的健全性(soundness)是 Rust 库强制的最低要求。不健全(unsoundness)的设计毫无希望,不应被视为可选项。如果我们实际上无法在 Rust 健全性要求的前提下获得足够的性能,那么用户们应该编写健全的版本,然后与 Rust 项目合作以改进 Rust,以创建健全且能满足它们性能需要的 API 。但是,我不认为 io-uring 真的需要为 Rust 进行任何更改以适应其高性能程序的编写。

棘手的问题:完成,取消和 buffer 管理

首先,让我们回顾一下集成 io-uring 和 Rust 的棘手问题。

非阻塞 IO 有两种 API:就绪式完成式。在就绪式 API 中,程序会要求操作系统告知什么时候一个 IO 句柄就绪,可以执行 IO,然后由程序执行这次 IO(由于句柄已准备就绪,因此不会阻塞)。在完成式 API 中,程序会要求操作系统在某些句柄上执行 IO,并在 IO 完成时接收操作系统的通知。epoll 是准备就绪式 API,而 io-uring 是完成式 API。

在 Rust 中,可以通过停止轮询 future 以实现隐式地取消 future。这很适合就绪式 API,因为对于一个已经取消了的 future 来说你可以忽略 IO 的就绪。但是,如果你传进去一个 buffer 来让完成式 API 向其中执行 IO,则即使你取消这个 future,内核也将对该缓冲区进行写入或读取。

这意味着,如果传递一个切片用作 IO 的缓冲区,然后取消这个在 IO 上等待的 future,则在内核实际完成 IO 之前,你无法继续使用该切片。代码展示如下:

代码语言:javascript
复制
// 这个 future 会等待从 `file` 到 `buffer` 的读操作
let future = io_uring.read(&mut file, &mut buffer[..]);

// 取消这个 future,我们不关心这次 IO 了:
drop(future);

// 哎呀, 这是一个跟内核之间的数据竞争! 内核
// 正试图向 `buffer` 中写入数据, 因为取消
// 那个 future 并没有真正取消这此 IO:
buffer.copy_from_slice(..);

因此,无论用户空间的程序发生什么情况,我们都需要通过某种方法来确保,内核在完成 IO 之前在逻辑上是借用了这个 future。这就是那个棘手问题。

在析构(Drop)时阻塞行不通

一种常见解决方案是在 future 的析构函数中阻塞等待 IO 的完成。这样的话,如果 future 被取消,它将阻塞到 IO 实际完成为止。这是不健全的(unsound),难堪大用。

任何对象都可以轻而易举且安全地泄漏到 Rust 中,以至于依赖在生命周期结束时运行的析构函数是不合理的。作为这个世界上在 Rust 内存泄漏规则方面以及用户可以依赖的健全性的消息最灵通的人之一,我可以自信地向你保证用户将永远无法依赖运行析构函数。这种设计是行不通的。

而且,即使你接受了这种不健全(或通过把构造 future 的操作标记为 unsafe 之类的方法来强行使这种“不健全”变得“正确”),要依赖这种设计也是一个非常糟糕的主意。在析构时阻塞似乎是基于这样的假设——你实际上不想取消这些任务——但是用户确实希望取消这些任务。

如果你在析构函数中阻塞了整个线程(在目前的 Rust 中确实只能这样),那么你就是因为这一次 IO 阻塞了整个线程:这是非常糟糕的性能倒退。而即使有了异步析构函数,你也会因为等待 IO 的完成而阻塞当前任务。但是基于你的库构建的用户代码已经不再关心这次 IO 了。现在有很多人在 io-uring 中寻找类似文件系统 IO 之类的东西,在该处阻塞 IO 似乎是合理的。但是 io-uring 是 Linux 上所有 IO 的未来,包括网络 IO。如果你为了完成 IO 而阻塞任务执行,超时会导致程序中断。

(这个问题也可以通过将“取消”提交给 io-uring 来缓解,这样就有望及时取消内核 IO。但是这样一来你就是在提交更多的事件并执行更多的 syscall 来取消这个 future 了,这种取消方式仍然增加了不必要的开销。)

我看到的处理取消的唯一有效方式是:io-uring 必须为完成事件(completion event)分配一个唤醒器(waker),以实现由 CQE 触发一次唤醒。future 对象必须能够访问这个分配的唤醒器(waker),在该唤醒程序中它可以进行注册,使该任务不再关心这个 future,这样 CQE 的处理代码就不会在 IO 完成时唤醒该任务。或许析构函数也可以向内核提交一个“取消”的请求(尽管我至少会考虑仅以机会主义的方式将这个“取消”作为其他提交的附加条件提交给内核)。

内核必须拥有 buffer 的所有权

去年八月,泰勒·克莱默(Taylor Cramer)发表了一篇文章,提出固定的(pinning) buffer 可能是解决这个问题的一种方法。他的想法是这样的:使用实现 Unpin 的自定义 buffer 类型,可以确保在 buffer 变得无效之前将其析构。重要的是要注意,这与保证被析构(guaranteed to be dropped)是不同的:它仍然有可能不会被析构,而 buffer 如果不被析构其对应的内存空间将永远不会被释放。buffer 类型将有一个析构函数,用泰勒的话说就是“注销自身” —— 实际上这是不可能的,所以我认为泰勒本来想说的是缓冲区的析构函数而不是 future 的析构函数阻塞了这项任务的完成。

然而,这不是解决问题的方法。在 IO 完成之前,使 buffer 无效是不够的。尽管内核写入已被释放的内存将是非常糟糕的,但这并不是唯一可能出错的方法。如果用户 drop 了 future,他们仍然持有 buffer 的句柄,还是可以在用户空间对这个 buffer 进行读写入操作。这与要使用该buffer 的内核 IO 也形成了数据竞争(data race)。仅仅保证不 drop buffer 是不够的,我们还必须保证内核对 buffer 具有独占的访问权。

逻辑上的所有权是在 Rust 当前的类型系统中实现此功能的唯一方法:内核必须拥有 buffer 的所有权。没有健全的方法可以把借来的切片传递给内核,然后等待内核完成对它的 IO,以确保同时运行的用户程序不会以不同步的方式访问这个 buffer。除传递所有权外,Rust的类型系统无法对内核的行为进行建模。我强烈鼓励每个人都转为基于所有权的模型,因为我非常相信这是创建 API 的唯一可靠方法。

而且,这种设计实际上也是比较优越的。io-uring 有许多 API —— 它们的数量和复杂性都在不断增长——都是围绕着允许内核为你管理 buffer 来设计的。通过所有权来传递 buffer 使我们能够访问这些 API,并且从长远来看,它将成为性能最高的解决方案。让我们接受内核拥有 buffer 所有权的设计,并在该接口之上设计高性能的 API 吧。

buffer 拷贝以及 IO 相关的 trait

还有一个显而易见的问题我没还提到过:IO 相关的 trait。ReadWrite,以及它们的扩展 AsyncReadAsyncWrite 都基于一种 API 设计:由调用者管理 buffer,而 IO 对象只负责对它们执行 IO。 这与 内核拥有buffer的所有权 就不一致了。安全地实现这些 API 的唯一方法就是管理一组单独的 buffer,然后将其复制到传递进来的 buffer 中。这是一种额外的不必要的内存拷贝。这种方法不是很好。

但是,我在异步采访中提倡(知道这一点)我们将 AsyncReadAsyncWrite 合并为标准。为什么?原因很简单:AsyncReadAsyncWrite 就像它们的同步副本一样,代表了由调用者管理的 buffer 的读写接口。如果要通过某些底层 OS 接口安全地管理此操作的唯一方法是执行额外的复制,就可以了。它工作得很好,因为还有另一个接口计划与被调者管理的 buffer 一起使用:AsyncBufRead

AsyncBufRead 完美地描述了 io-uring 的读取行为:你正在读取的对象还为你管理 buffer,并且在需要时仅提供对其缓冲区的引用。既然 io-uring 提供了让人信服的动机,我们还可以提供 BufWriteAsyncBufWrite 来表示类似的写操作。

由于 Linux 和 Windows 都倾向于在最低级别使用完成式的 API(出于这个原因,像 mio 之类的库已经在 Windows 上使用了 buffer 池),这需要在内核中或尽可能靠近内核的位置管理 buffer,我们将可能会看到框架趋向于使用缓冲的 IO 接口以实现最佳性能。以下的设计思路是可接受的:由于某种原因,我们在 std 中都已经有两个接口。它们表示不同的用例,并且在某些领域中,一个用例有时会占据另一个用例的主导地位。

当然,也许某些用户确实希望在调用者中某个位置的更高级别上控制 buffer,因为他们以这种方式获得了其他一些优化。这与让内核控制 buffer 所带来的优化之间存在着内在的冲突:我们不能轻易让双方都控制缓冲区的生命周期。IO 库可能会通过公开其他 API 来恢复用户所需的任何优化。

还有更多有趣的问题

所以我觉得这就是我们都应采用并继续为之努力的解决方案:由 io-uring 控制 buffer,io-uring 上最快的接口就是 缓冲接口(buffered interfaces),而 非缓冲接口(unbuffered interfaces) 会产生额外的副本。我们可以不用再强迫语言去做一些不可能的事情了。不过,还是有很多有趣的问题。

io-uring 在 io 的实际管理方式上具有很大的灵活性。你是否有一个线程来管理所有完成情况,还是在提交事件时以机会方式管理完成情况?我们应该仅对文件系统 IO 进行 io-uring 并等待 epoll 实例完成,还是将所有内容移至 io-uring?我们如何与仍在使用 epoll 的库集成在一起?你想如何一起对 io 事件进行排序(io-uring提供了多种方式)?你的程序有单个还是多个?IO 超时比用户空间超时好吗?

我希望从长远来看,我们可以使最终用户能够轻松地按照这些思路进行选择,并为 reactor 的构建者提供其特定用例所需的行为。等到我们把它搞清楚了,Linux 上异步 IO 的激动人心的时代就会来临。

译者注:

withoutboats 的 io-uring 库已经发布了 https://github.com/withoutboats/ringbahn 。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-07-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Rust语言学习交流 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 棘手的问题:完成,取消和 buffer 管理
  • 在析构(Drop)时阻塞行不通
  • 内核必须拥有 buffer 的所有权
  • buffer 拷贝以及 IO 相关的 trait
  • 还有更多有趣的问题
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档