不安全世界中的安全

Lobsters Hottest 新闻

摘要

深入探讨 Netstack3 的设计理念,这是 Fuchsia 基于 Rust 的网络栈,在内测期间实现了极低的 bug 数量,现已部署在数百万台设备上,崩溃次数比前代减少了 20 倍。

<p><a href="https://lobste.rs/s/xcrvhr/safety_unsafe_world">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/29 09:56

# 在 `unsafe { world }` 中的安全性 来源:https://joshlf.com/posts/safety-unsafe-world/ *本文几乎逐字改编自我的 RustConf 2024 演讲。我将所有内容保持为 2024 年 9 月的现在时态。在末尾,我还加入了一些我原本计划展示但因时间关系在演示中跳过的一点点内容。* 观看演讲 (https://www.youtube.com/watch?v=qd3x5MCUrhw) · 查看幻灯片 (https://joshlf.com/files/talks/Safety%20in%20an%20Unsafe%20World.pdf) · 查看完整参考文献 (https://gist.github.com/joshlf/65ccb20e034445a0fc6595f3a270653d) ## Netstack3 那么,这次演讲是关于什么的呢?嗯,我先稍微留个悬念。首先,我们来谈谈 Netstack3。Netstack3 是 Fuchsia 的下一代纯 Rust 网络协议栈。它旨在取代用 Go 编写的 Netstack2。我大约六年前启动了 Netstack3,并领导了其开发四年,然后在过去两年里由另一个团队负责领导。我已经脱离该项目两年了,所以显然这已经超出了我的范围——这是一个巨大的团队努力——我将会大力夸赞那些伙伴们。现在有一批非常优秀的工程师在做这件事。 编写一个网络协议栈是一项艰巨的任务。它负责整个操作系统中几乎所有的网络流量。它实现了数十种协议,每种协议都有长达数百页的规范文档。而且,它是抵御任何不在设备物理前的攻击者的第一道防线。它也非常*庞大*。正如我刚才所说,我们花了六年时间才达到这个地步,这期间大约有十名开发人员浮动,代码分散在 63 个 crate 中,总计 192,000 行代码。这比 crates.io 上前十个 crate 的代码总和还要多。 在过去的一年里,团队一直在准备推出 Netstack3。你们当中具有网络背景的人会知道,你*不能仅仅*将网络代码部署到生产环境中。网络代码以难以测试而闻名,所以你不得不假设,尽管你尽了最大努力,你的代码仍然充满了 bug。对于这样一个规模的项目——部署一个全新、从头重写的网络协议栈——你可能会期望在实际面向真实用户之前,先在内部试用数月甚至数年。在那段时间里,你预计会发现开发过程中未曾见过的数十到数百个 bug。只有当你消灭了大部分 bug,并看到一段时间的相对稳定行为之后,才能最终部署给真实用户。 那么,让我们谈谈 Netstack3 的过程是怎样的。在 11 个月里,团队一直在加大内部试用计划的力度。在高峰期,该计划约有 60 台设备在开发者家中几乎全天候运行。再次强调,如果是任何其他网络协议栈,我们预计会在这段时间内发现一大堆 bug。那么,在过去一年里,团队在现场发现了多少个 bug?**三个。** > **来自未来的说明**:在本文发布时,Netstack3 已经投入生产,运行在数百万台设备上。在首次部署后,团队观察到每百万台设备每天的崩溃率比其前身 Netstack2 低约 20 倍,内存使用量减少了 50%。 --- 那么,这次演讲将有点像一次闪回。Netstack3 是围绕一种特定的方法来设计稳健系统的。希望我已经让你们相信,我们在稳健性方面至少做对了一些事。用一句话来说,这种方法就是: > 有 bug 的程序编译不过。 显然,我远非第一个提出这种方法的人。以下只是在其 API 中使用了这种方法的一些 crate 示例:`ghost-cell`、`session_types`、`nalgebra`、`mundane`、`indexing`、`zerocopy` 和 `bytemuck`。以下是一些关于此主题的文章样本,既有关于 Rust 的,也有关于其他语言的: - [References are like jumps](https://without.boats/blog/references-are-like-jumps/),作者 withoutboats - [Parse, don't validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/),作者 Alexis King - [Type Safety Back and Forth](https://www.parsonsmatt.org/2017/10/11/type_safety_back_and_forth.html),作者 Matt Parsons - [Ghosts of Departed Proofs](https://www.iog.io/papers/ghosts-of-departed-proofs-functional-pearls),作者 Matt Noonan - [The Typestate Pattern in Rust](https://cliffle.com/blog/rust-typestate/),作者 Cliff Biffle - [Compiler-Driven Development in Rust](https://www.youtube.com/watch?v=Kdpfhj3VM04),作者 No Boilerplate - [Some notes on Rust, mutable aliasing and formal verification](https://graydon2.dreamwidth.org/312681.html),作者 Graydon Hoare 因此,我这次演讲的目标不是引入一个新的想法。相反,我的目标是:首先,我将提出一个具体但通用的框架,试图统一这种方法在实践中表现出的各种不同方式,并用相同的基本概念来解释它们。其次,我将通过两个示例——一个来自标准库,一个来自 Netstack3——来展示我们如何用这个框架来解释它们。 不幸的是,25 分钟的时间不足以让我以想要的深度展示超过两个示例。在撰写这次演讲时,我不得不将很多内容留在了剪辑室。但我保证,这两个示例只是冰山一角,展示了这种方法的潜力。完整的参考文献列表 (https://gist.github.com/joshlf/65ccb20e034445a0fc6595f3a270653d) 包含了一个令人惊讶的多样性问题集,这些问题已经应用了这种方法。 ## 什么是“有 bug 的程序编译不过”? 在其网站上,Rust 宣传了三个特性:性能、可靠性和生产力。让我们聚焦于可靠性。网站上写道: > Rust 丰富的类型系统和所有权模型保证了内存安全和线程安全。 换句话说,开箱即用,Rust 保证了“有 bug 的程序编译不过”,但前提是我们将“bug”的定义限制在内存 bug 或线程 bug。别误会我的意思:内存安全和线程安全本身就意义重大。例如,Netstack3 能够为了性能而采用各种疯狂的缓冲区共享技巧,这些技巧在 C 或 C++ 中会非常危险。如果你对细节感兴趣,我在 2018 年的 Rust Belt Rust 上曾就这些技术做过一场演讲 (https://www.youtube.com/watch?v=UfMOOxOGCmA&list=PLgC1L0fKd7UlpVTHVfLYVtudVx8CzbSxW)。 但这只是故事的一半。许多我们希望防止的关键 bug 既不是内存 bug 也不是线程 bug。以 TCP 为例。如果你想实现 RFC 4614 (https://www.rfc-editor.org/info/rfc4614) 所称的 TCP “基本功能”,你需要实现六种不同的标准,总计 270 页。再加上“推荐增强功能”,你总共需要处理 18 种标准,共 476 页。而这还只是一个协议。再加上以太网、ARP、NDP、IPv4、IPv6、ICMP、IGMP、MLD、UDP 以及许多其他协议,以及它们之间所有微妙的相互作用,你就会开始意识到,内存和线程 bug 只是你能搞砸的所有有趣且令人兴奋的方式中的冰山一角。 这就是为什么在近一年的内部测试中只发现三个 bug 如此令人惊讶。仅靠内存安全或线程安全是绝对无法达到这种稳健程度的。 因此,我想说明的观点是: 1. 开箱即用,Rust 保证“有 bug 的程序编译不过”仅适用于我们实际上关心的 bug 的一个小子集。 2. 仅仅依靠语言本身就提供的东西,Netstack3 是无法达到它所具有的稳健程度的。 然而,Netstack3 所做的——以及我相信任何项目都可以做到的——是扩展 Rust 语言,以保证*几乎任何类型的 bug 都不会出现*,而不仅仅是内存或线程 bug。这就是我在演讲摘要中所说的“X 安全性”。就目前而言,Rust 提供了一组核心抽象——生命周期、引用、RAII、trait 等——并保证了与这些抽象相关的各种安全属性。但生态系统中绝大多数抽象是由库提供的:JSON、随机数生成器、密码学、正则表达式、日志、时间、TCP、PNG、SQL、命令行接口、线性代数、浏览器 API 等等。这些库通常不会提供与其抽象相关的、我们可能期望从语言中获得的那种程度的安全性。但情况不必如此。正如我们将在以下示例中看到的,仅凭 Rust 的核心抽象就足以表达我们可能关心的几乎任何安全属性。**我声称每个库都可以并且应该提供与其抽象相关的、我们已期望语言本身具备的同等安全性。** ## 一个通用的安全框架 为了解释这个框架,我们将转向不起眼的二叉树: ```rust struct Node<T> { left: Option<Box<Node<T>>>, right: Option<Box<Node<T>>>, value: T, } ``` 为了让我们的二叉树正确,我们当然需要内存安全,但我们还需要另一个安全属性。我们必须确保树是有序的:左子树中的每个值都小于我们节点的值,而我们节点的值小于右子树中的每个值。Rust 可以保证内存安全,但它无法看到这个排序要求,因此它不能为我们强制执行这个要求。 那么,我们如何确保我们永远不会生成一个排序无效的树呢?我们使用*不变量*的概念。首先,我们记录一个排序不变量,我们期望它对任何 `Node` 总是成立: ```rust struct Node<T> { // 不变量:`left` 中的所有值都小于 `value`。 left: Option<Box<Node<T>>>, // 不变量:`right` 中的所有值都大于 `value`。 right: Option<Box<Node<T>>>, value: T, } ``` 接下来,我们确保每次创建一个新的 `Node` 时,排序不变量都成立: ```rust impl<T> Node<T> { // 后置条件:`Node` 的内部字段不变量成立。 pub fn new(value: T) -> Node<T> { Node { left: None, right: None, value } } } ``` 最后,我们确保每次修改一个现有的 `Node` 时,如果排序不变量一开始成立,我们都能保持它: ```rust impl<T> Node<T> { // 前置条件:`Node` 的内部字段不变量成立。 // 后置条件:`Node` 的内部字段不变量成立。 pub fn insert(&mut self, value: T) -> Option<T> { // ... } } ``` 现在想象你是此模块之外的代码。(我们这里只限于安全代码;显然 `unsafe` 代码可以做任何它想做的事。)我声称,从此模块之外的安全代码角度来看,语言提供的安全不变量和此模块提供的排序不变量之间没有任何区别。如果你编写的代码如果运行起来会违反 Rust 的内存安全保证,Rust 会保证该代码编译不过。类似地,如果你在此模块之外编写代码,如果运行起来会违反排序不变量,那么同样,Rust 会保证该代码编译不过。换句话说,通过以这种方式组织代码,我们在某种意义上“教会”了 Rust 一个它以前不知道的新安全属性。 我们可以用这个 `Node` 示例来解释这个框架。它包含三个组成部分。 ### 定义 首先,**定义**。我们定义一个 Rust 类型系统可以推理的对象。在这个例子中,我们定义了 `Node` 结构体。我们给这个对象附加一个 Rust 无法推理的安全属性。在这个例子中,那就是排序属性。因为 Rust 无法推理这个安全属性,所以我们作为抽象的作者,有责任确保永远不违反它。这就引出了第二个组成部分。 ### 强制执行 其次,**强制执行**。在这里,我们强制执行我们的安全属性得到维护——这作为程序员的我们是责任。在这个例子中,我们使用字段私有化——我们确保我们的结构体字段是私有的。如果它们是公开的,外部代码就可以修改它们并违反我们的安全属性。接下来,我们确保我们*自己*编写的所有代码都保持了安全属性。在这个例子中,修改树的方法负责维护它的排序。 ### 消费 最后,**消费**。这是物有所值的地方。我们编写将安全属性作为前置条件消费的代码,并且仅凭该安全属性的维护才得以正确。在这个例子中,我们的方法可以在 `O(log N)` 时间内找到树中某个特定值的正确位置,而无需遍历整棵树。这种优化之所以有效,只是因为我们被保证树是有序的。而树之所以被保证有序,是因为我们以这种方式组织代码:**定义**并**强制执行**这个排序属性,以便我们稍后可以将其作为前置条件来消费。 有了这个框架,我们来看第一个示例。 ## 示例 1:线程安全 我们的第一个示例来自标准库,涉及线程安全。我要展示的是,不仅库可以使用语言的核心特性来构建新的安全保证,而且标准库*本身*已经使用了这种技术。事实上,Rust 宣传为其核心特性之一的线程安全实际上根本不是语言特性。它是在标准库中实现的,原则上,它本可以在第三方 crate 中实现。只是放在标准库中更方便。 假设你正在编写 `spawn`。`spawn` 是标准库中 `thread` 模块的一个真实函数(尽管为了简单起见我简化了它的签名): ```rust pub fn spawn<F>(f: F) { ... } ``` 它接受一个函数并生成一个新线程来运行该函数。如果我们思考如何实现 `spawn`,我们会立即遇到语言的一个限制:Rust 语言本身不提供任何与线程交互的机制。线程是一个操作系统概念,因此创建新线程需要走出语言,利用操作系统本身提供的 API。例如,在 POSIX 系统上,这可能需要调用 `libc` 的 `pthread_create` 函数: ```rust pub fn spawn<F>(f: F) { unsafe { libc::pthread_create(/* ... */) }; } ``` 这就是我们遇到第一个核心抽象的地方,我们将需要将其作为构建块使用。由于像 `pthread_create` 这样的 `libc` 函数是在语言外部实现的,Rust 无法了解它们的行为,因此它无法保证调用这样的函数不会违反内存安全。这就是为什么 Rust 引入了 `unsafe` 代码的概念。`unsafe` 代码是 Rust 无法保证不会违反内存安全的代码。这并不意味着它*一定*会违反内存安全。这只是意味着,单凭 Rust 自己,智能程度不足以证明它不会违反。相反,Rust 必须依赖程序员来做繁重的工作,并确认 `unsafe` 代码是正确的——或者说*健全(sound)*。这就是上面的 `unsafe` 块的用途。在 `unsafe` 块内部,Rust 让程序员自由地对 Rust 无法推理的操作进行操作。作为交换,程序员承担维护内存安全的责任。如果他们搞砸了,一切都没有保障。 这是我们使用核心抽象来扩展语言能力的第一个例子。Rust 本身提供了 `unsafe` 函数和 `unsafe` 块,但没有提供任何与线程交互的机制。然而,通过将 `unsafe` 与像 `pthread_create` 这样的外部 API 结合使用,我们可以“教会”语言关于线程的知识。然而,就目前所写,这个 `spawn` 的实现是不健全的。不是所有值都是线程安全的,意味着并非所有值都可以安全地在线程之间发送。

相似文章

每个人都应构建自己的网络栈

Hacker News Top

本文描述了在 DN42 去中心化网络上开发和部署一个名为 DNet 的自定义网络栈,展示了其用于 DNS 等服务,以促进动手实践的网络学习和实现。

安全 Rust 的边界

Lobsters Hottest

TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。