关注了就能看到更多这么棒的文章哦~
Safety in an unsafe world
By Daroc Alden
November 5, 2024
RustConf 2024
Gemini-1.5-flash translation
https://lwn.net/Articles/995814/
Joshua Liebow-Feeser 在 RustConf 上发表演讲,介绍了他的团队在开发 Fuchsia 操作系统 (幻灯片) 时使用的方法,该方法将任意约束(arbitrary constraints)编码到 Rust 类型系统中。这种技术在 Rust 社区中并不陌生,但 Liebow-Feeser 做得很好,既解释了该方法,又说明了为什么应该更广泛地使用它。
激发他的这个演讲的动机是来自 netstack3,Fuchsia 的全新网络栈,完全用 Rust 编写。该项目始于六年前,Liebow-Feeser 领导该项目四年。他说,网络栈是“非常严肃的”。它们负责几乎所有流量,通常实现数十种不同的协议,并且是防御攻击的第一道防线。它们也是纯粹的大型软件。
Netstack3 包含 63 个 crate 和 60 个人年的代码。它包含的代码量超过了 crates.io 上排名前十的 crate 的总和。在过去的一年里,代码终于准备就绪可以部署。但是,将其部署到生产环境中需要谨慎——网络代码很难测试,开发人员必须假设它存在漏洞。在过去十一个月中,他们一直在 60 台设备上全天候运行新的网络栈。Liebow-Feeser 说,在这么长的时间里,大多数代码预计会显示“大量的漏洞”。而 Netstack3 只有三个;能做到这么少,得归功于团队将尽可能多的重要不变量编码到类型系统中的方法。
方法
Liebow-Feeser 说,这不是一个新想法。许多人试图让有漏洞的程序根本无法编译。但 netstack3 团队有一个具体且通用的框架来处理这种设计。他将该过程分为三个步骤:定义、强制和消费(definition,enforcement,consumption)。对于定义阶段,程序员必须使用 Rust 可以推理的东西(通常是类型)并将所需的属性附加上去。这通常通过文档完成——例如,描述特定 trait 代表特定属性。然后,程序员通过确保所有直接处理类型的代码都维护相关的约束来强制执行属性。

关键的是,程序员必须在某个时候使用字段私有性或 `unsafe` 标记来确保这些约束在其他地方不会被破坏。例如,具有所有公共字段的结构可以从任何地方构造,因此无法依赖于与该类型相关的约束。如果结构中包含私有(private)字段,则只能在该特定模块中构造它,这意味着程序员可以审核所有用法。当出于某种原因,无法通过添加私有字段来解决时,Rust 程序员可以回退到将事物标记为 `unsafe`,以便其他程序员在使用结构或 trait 之前有望阅读文档。最后,其他代码可以将该属性作为保证来依赖,例如针对它进行优化。
在使用此方法的代码库中,每个保证将有一个单独的 Rust 模块来处理属性的内部细节。然后,其余代码可以依赖于类型检查器来确保该属性在所有地方都成立。为了更具体地了解在实践中是什么样的,Liebow-Feeser 介绍了几个例子。
他给出的第一个例子几乎是微不足道的:一棵需要在节点之间强制执行排序约束的二叉树。当然,Rust 本身无法检查这一点。那么程序员如何确保永远不会生成无效的树呢?按照 Liebow-Feeser 的流程,他们必须首先记录需求。然后,他们确保直接处理树中创建和修改节点的代码都充分尊重了该约束。他们还必须确保相关细节隐藏在私有字段后面,以便模块之外的代码无法干预。最后,其余代码现在可以信赖树始终处于有序状态的事实。
这种方法对于任何实现过数据结构的程序员来说都熟悉。但 Liebow-Feeser 说,这种方法可以推广到更细微的属性。他的第二个例子实际上来自 Rust 的标准库。Rust 承诺线程安全,但语言本身实际上不知道线程安全属性。这些都实现在标准库中。
标准库作者想要保证的一个相关属性是,永远不会将非线程安全的闭包(closers)传递给 `std::thread::spawn()`。为此,他们创建了一个不安全的 trait (`Send`),它代表可以在另一个线程上安全运行的代码。`Send` 不安全是因为它本质上不危险,而仅仅是因为它代表了编译器无法检查的属性。然后,该 trait 可以 用作绑定 来限制哪些函数可以传递给 `spawn()`。最后,虽然安全的 Rust 代码无法直接实现该 trait,但标准库添加了各种 trait 推断规则和派生宏,以让其他代码实现该 trait。(事实上,这个说法略微简化了一下,因为 `Send` 是一个 自动 trait——一个不稳定的特性,指示编译器自动为兼容类型实现 trait,尽管 Liebow-Feeser 在演讲中没有提到这一点。)
自动死锁预防
Liebow-Feeser 给出的最后一个例子要复杂得多,并且直接来自 netstack3 代码。他解释说,网络栈需要多线程和细粒度锁,以提高性能。Rust 的现有库可以保证代码是线程安全的,但它们不能保证它不会死锁。由于有这么多开发人员在这样一个大型代码库上工作,能够知道代码不会死锁是一个重要的属性,而且不容易保证。Netstack3 有 77 个互斥锁,跨越数千行代码。它的前身 netstack2(用 Go 实现)存在很多死锁。
作为第一步,开发人员为每个互斥锁添加了名称,形式为通用 `Id` 类型参数。相关的 ID 都是零大小类型,因此它们在运行时不存在,并且没有运行时开销。然后,他们定义了两个不安全的 trait:`LockAfter` 和 `LockBefore`。这些代表了它们听起来的样子——对于特定互斥锁在另一个互斥锁之后被锁定,必须为相关类型实现 `LockAfter`。他们添加了一个派生宏,因此为每个 ID 类型添加的样板代码很少,并添加了泛型 trait 实现,这样如果锁图中存在循环,则由于重叠的 trait 定义会导致编译时错误。
但是,为了使锁定的 trait 存在真正有用,开发人员还需要添加使用它们的函数。在这种情况下,他们创建了一个新的 `Context` 类型,它携带一个 ID 类型。它们互斥锁类型上的 `lock()` 函数接受一个可变借用的上下文,因此原始上下文在互斥锁解锁之前不能使用。同时,它提供了一个带有锁 ID 的新上下文,该上下文只能用于锁定具有正确 `LockAfter` 实现的互斥锁。
因此,从所有实现此模块之外的代码的角度来看,互斥锁只能使用适当的未借用上下文对象锁定。上下文对象对如何锁定互斥锁施加全局排序,并且尝试添加不正确的 `LockAfter` 实现(一个允许循环的实现)是一个编译错误。程序员可以自由地锁定他们可以通过编译器获取的任何互斥锁,并且确信这不会导致死锁。反过来,这使得更容易证明向实现添加更多细粒度锁定是合理的。在运行时,与该保证相关的所有类型级机制都被编译掉了,因此没有运行时开销。
结论
Liebow-Feeser 说,在实践中,实际上存在一些关于所提出的简化死锁预防示例的问题。但总的来说,这种将语言不知道的属性“教”给 Rust 的能力,以便现在在编译时强制执行它,这就是他喜欢将 Rust 称为“X-safe”语言的原因。它不仅是内存安全或线程安全,而且对于任何在类型系统中实现的 X 都是 X 安全的。
他以呼吁人们自己尝试一下作为演讲的结束,特别是在尚未解决的新领域。他鼓励人们将 panic 或返回 `Option` 的函数视为不良好的代码——这些都是代码可能在编译时编码必要约束的地方。他呼吁听众“使你的 API 准确地匹配问题的结构”。同时,他警告人们,合适的解决方案并不总是相同的。对于 netstack3 中的死锁,他们使用 trait 和 trait 推断规则来确保没有循环。但并非所有情况都适合使用 trait;重要的是要遵循他提出的方法。
Liebow-Feeser 认为“这可以重塑我们进行软件工程的方式”。在许多领域,正确性很重要,并且成功地扩展这些领域的软件将需要使工具能够验证正确性,他说。他的预测是否正确还有待观察——但在任何情况下,他谈到的方法似乎是一个很好的框架,可以统一确保程序安全的不同技术。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~

878

被折叠的 条评论
为什么被折叠?



