iddqd:最难的一种不安全Rust

Lobsters Hottest 工具

摘要

本文介绍了 iddqd,这是一个 Rust 库,它提供了从值中借用键的映射,减少了重复和同步问题。本文讨论了编写不安全 Rust 代码的挑战以及该库如何保持正确性。

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

缓存时间: 2026/06/02 17:35

# iddqd,或最难的那种不安全 Rust | Oxide Computer Company 来源:https://oxide.computer/blog/iddqd-unsafe 我是 `iddqd`(https://docs.rs/iddqd)的主要作者,这是一个 Rust 库,用于实现键从值中借用的映射(以 Doom 作弊码(https://doomwiki.org/wiki/Doom_cheat_codes#All_Doom_engine_versions)命名)。在 Oxide,我们在 Omicron(https://github.com/oxidecomputer/omicron)中广泛使用它,这是我们的控制平面——运行在每个 Oxide 机箱核心(https://rfd.shared.oxide.computer/rfd/0048)的软件,为客户配置计算和存储等资源,并确保机箱长期稳定运行。`iddqd` 维护着此类大型记录的内存索引,这些记录在像这样的系统中随处可见,例如磁盘(https://github.com/oxidecomputer/omicron/blob/2e319eb3b9b9fcffb8cee71cffb9cd13397cb316/sled-agent/config-reconciler/src/internal_disks.rs#L602-L637)或 sled 库存(https://github.com/oxidecomputer/omicron/blob/2e319eb3b9b9fcffb8cee71cffb9cd13397cb316/nexus/types/src/inventory.rs#L682-L717)。因此,它必须是正确的:如果它行为异常,我们的控制平面可能会出现难以预测和诊断的故障。`iddqd` 底层包含相当多的不安全代码。最近有一些关于 Rust 重写中不安全代码数量的担忧(https://github.com/oven-sh/bun/issues/30719),所以我想写一些关于 `iddqd` 中的不安全代码以及我们如何驯服它的内容。 ## iddqd 解决了什么问题? (https://oxide.computer/blog/iddqd-unsafe#what-problem-does-iddqd-solve) 在 Rust 的标准库映射中,键与值分开存储。假设你想存储一个以电子邮件地址为键的记录映射。使用 `std::collections::BTreeMap`(https://doc.rust-lang.org/std/collections/struct.BTreeMap.html),你可能会写类似这样的代码: ```rust // Email 通常是一个 newtype,用于验证电子邮件地址格式是否正确。 // 在此示例中,为了简单起见,我们将其别名为 String。 type Email = String; struct User { name: String, age: u8, } let mut users = BTreeMap::<Email, User>::new(); users.insert( "[email protected]".to_string(), User { name: "Alice".to_string(), age: 30 }, ); users.insert( "[email protected]".to_string(), User { name: "Bob".to_string(), age: 35 }, ); ``` 这种方法有一个我认为相当严重的缺点:键(电子邮件)不与值(记录的其余部分)存储在同一个结构体中。你会如何处理这个问题?一种方法是同时传递电子邮件和用户,例如使用 `get_key_value`(https://doc.rust-lang.org/std/collections/struct.BTreeMap.html#method.get_key_value): ```rust fn process_user(email: &Email, user: &User) { /* ... */ } let (email, user) = users.get_key_value("[email protected]").unwrap(); process_user(email, user); ``` 作为扩展,你可以在获取时创建一个结合两者的结构体: ```rust struct UserRecord<'a> { email: &'a Email, user: &'a User, } let (email, user) = users.get_key_value("[email protected]").unwrap(); let record = UserRecord { email, user }; ``` 在实践中,当需要处理大量不同类型的记录时,这变得非常笨拙。或者,你可以将电子邮件在键和值中重复: ```rust struct User { email: Email, name: String, age: u8, } let mut users = BTreeMap::<Email, User>::new(); let email = "[email protected]".to_string(); users.insert( email.clone(), User { email, name: "Alice".to_string(), age: 30 }, ); ``` 但这存在风险:键和值中存储的电子邮件可能会不同步。 `iddqd` 提供了一个更好的替代方案。使用 `IdOrdMap`(https://docs.rs/iddqd/latest/iddqd/struct.IdOrdMap.html),你可以这样写: ```rust struct User { email: Email, name: String, age: u8, } // 你实现一个 trait,告诉 iddqd 如何从记录中提取键。 impl IdOrdItem for User { // 键类型可以从值借用! type Key<'a> = &'a Email; fn key(&self) -> Self::Key<'_> { &self.email } // 这个宏展开为少量样板代码,用于解决 Rust 借用检查器的限制。 id_upcast!(); } // 然后,你可以插入记录: let mut users = IdOrdMap::<User>::new(); users .insert_unique(User { email: "[email protected]".to_string(), name: "Alice".to_string(), age: 30, }) .unwrap(); users .insert_unique(User { email: "[email protected]".to_string(), name: "Bob".to_string(), age: 35, }) .unwrap(); // 并通过电子邮件查找: assert_eq!(&users.get("[email protected]").unwrap().name, "Alice"); assert_eq!(users.get("[email protected]").unwrap().age, 35); ``` 在 Oxide,这已被证明是一种非常宝贵的模式:我们控制平面中的许多记录都非常大(比如数据库查找),而 `iddqd` 非常适合它们。它还带有其他几个功能,直接解决了我们在 Oxide 遇到的一些痛点。值得一提的几个功能包括: - 对从多个字段借用的复杂键提供一等支持(https://docs.rs/iddqd/latest/iddqd/#examples),无需诉诸动态分发(https://github.com/sunshowers-code/borrow-complex-key-example)等变通方法。 - 每个项具有两个(https://docs.rs/iddqd/latest/iddqd/struct.BiHashMap.html)或三个(https://docs.rs/iddqd/latest/iddqd/struct.TriHashMap.html)键的映射,每个键独立索引相同的记录,无需手动维护多个映射。 - Serde 实现将其序列化(https://docs.rs/iddqd/latest/iddqd/struct.IdOrdMap.html#impl-Serialize-for-IdOrdMap%3CT%3E)为序列而不是映射,以便非字符串键可以在 JSON 中序列化\[1\](https://oxide.computer/blog/iddqd-unsafe#_footnotedef_1)。重要的是,这些实现会拒绝重复键。(为了向后兼容,也支持作为映射序列化(https://docs.rs/iddqd/latest/iddqd/id_ord_map/struct.IdOrdMapAsMap.html)。) 与我们的许多其他 crate 一样,`iddqd` 是为 Oxide 的需求而构建的,但对 Rust 社区也普遍有用。欢迎(https://crates.io/crates/iddqd)你在自己的项目中使用它。 ## 关于不安全 Rust (https://oxide.computer/blog/iddqd-unsafe#on-unsafe-rust) 在我继续之前,我想谈谈 `unsafe` 在一种内存安全语言中存在的意义。最大的问题是**未定义行为**(https://doc.rust-lang.org/reference/behavior-considered-undefined.html)(UB):程序行为不可预测,因为语言或编译器所做的核心假设被违反。如果没有任何安全代码可以利用它导致 UB,则称该抽象是*sound*的,否则是*unsound*的。绝大多数 Rust 代码是安全的,这(假设安全代码使用的任何不安全代码都是 sound 的)意味着不会发生 UB。 然而,由于静态分析的基本不可判定性(参见 Rice 定理(https://en.wikipedia.org/wiki/Rice%27s_theorem)),Rust 编译器(或任何终止的算法)不可能接受所有没有 UB 的程序并拒绝所有带有 UB 的程序。因此,在编写这样的算法时,作者必须做出决定:是拒绝一些没有 UB 的程序,接受一些带有 UB 的程序,还是两者兼而有之?Rust 编译器做了前者:在安全 Rust 的上下文中,它拒绝所有带有 UB 的程序,但也拒绝一些没有 UB 的程序。(这是正确的选择!) 如果你的程序处于无人区,虽然你知道它没有 UB,但仍然被拒绝,该怎么办?为了表达这类程序,Rust 编译器提供了一个逃生舱口:`unsafe` 关键字。通过编写它,作者承担了 soundness 的责任:他们保证没有安全代码可以利用此代码导致 UB,并且编译器应该信任他们。 什么是不安全代码没有 UB 的含义?非正式地说,它必须遵循 Rust 的规则(https://cacm.acm.org/research/safe-systems-programming-in-rust/)——即编译器为安全 Rust 证明的所有规则。这些规则的一些例子包括: - 没有数据竞争。 - 不能读取未初始化或已释放的内存。 - 对同一内存区域不能有多个 `&mut` 引用的别名。 - 不可变数据不能被修改。 Rust 的规则非常难以推理!我认为它们比 C 的规则难得多,特别是关于可变别名的规则\[2\](https://oxide.computer/blog/iddqd-unsafe#_footnotedef_2)。因此,谨慎使用不安全 Rust 通常涉及将其封装在一个安全抽象后面。 ### 不安全 Rust 需要对安全 Rust 进行推理 (https://oxide.computer/blog/iddqd-unsafe#unsafe-rust-requires-reasoning-about-safe-rust) 除最简情况外,如果不推理周围的安全 Rust,就不可能确定这样的抽象是 sound 的。在本节中,我将按难度递增的顺序讲解三个例子。 第一个例子是切片上的 `split_at_mut` 方法(https://doc.rust-lang.org/std/primitive.slice.html#method.split_at_mut)。此方法将可变切片分成两个独立的部分。`split_at_mut` 是一个安全的方法,但安全 Rust 缺乏表达这种切片分区的方式。因此,`split_at_mut` 需要不安全 Rust。其实现大致如下: ```rust fn split_at_mut(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) { assert!(mid <= slice.len()); let ptr = slice.as_mut_ptr(); let remaining = slice.len() - mid; unsafe { ( std::slice::from_raw_parts_mut(ptr, mid), std::slice::from_raw_parts_mut(ptr.add(mid), remaining), ) } } ``` 要确保 unsafe 块的正确性,你还需要考虑周围安全代码的几个方面: - 该函数接收了一个 `&mut [T]`,而不是例如 `&[T]` - `assert!` 确保 `mid` 在边界内 - 事实 `remaining` 是 `slice.len() - mid`,而不是例如 `(slice.len() - mid) + 1` 如果这些不变量(都是安全 Rust)中的任何一个没有被满足,安全抽象就会变得 unsound。 下一个级别是,在许多情况下,你还需要分析不安全代码所在的*整个模块*。考虑这个向量类型: ```rust struct MyVec<T> { ptr: NonNull<T>, len: usize, cap: usize, } impl<T> MyVec<T> { pub fn get(&self, i: usize) -> Option<&T> { (i < self.len).then(|| unsafe { &*self.ptr.as_ptr().add(i) }) } } ``` `get` 的 soundness 依赖于**模块中的每个其他方法**(安全和不安全的)确保 `len` 是正确的并且在边界内。封装和隐私(`len` 不能从模块外部修改)在这里承担了负载。 然而,最困难的情况是不安全代码是泛型的,并且回调到用户提供的代码中。在这种情况下,基本原则(https://doc.rust-lang.org/nomicon/safe-unsafe-meaning.html)是:*安全 Rust 代码,无论多么病态或恶意编写,绝不能导致不安全代码表现出未定义行为。*换句话说,你不再享有只分析你所看到的安全代码的奢侈;你必须站在攻击者的角度,考虑每一个可以编写的安全 Rust 程序,并对*所有*它们都具有弹性。 例如,用户代码可能以导致内部表或数据结构损坏的方式行为不当。安全但错误的 Rust 可能会导致不安全代码变慢、产生不正确的结果,甚至 panic,但绝不能导致 UB!(为什么要假设最坏情况?你可能会争辩说,善意的 Rust 开发者不太可能编写对抗性代码。一个严格的规则的原因在于,防范对抗性代码意味着也防范无辜的错误;一条明确的界限使得分配防范 UB 的责任变得更容易。) 一个例子是 `ExactSizeIterator`(https://doc.rust-lang.org/std/iter/trait.ExactSizeIterator.html)trait,它有一个 `len` 方法返回迭代器的确切大小。开发者可能会倾向于在不安全 Rust 中利用这一点: ```rust pub fn collect_exact<I>(iter: I) -> MyVec<T> where I: ExactSizeIterator<Item = T>, { let n = iter.len(); let mut v = MyVec::with_capacity(n); for (i, item) in iter.enumerate() { unsafe { std::ptr::write(v.ptr.as_ptr().add(i), item); } } v.len = n; v } ``` 如果迭代器行为良好,此代码不会导致 UB。但无法保证这种行为!完全可以在安全 Rust 中编写一个损坏的 `ExactSizeIterator` 实现,返回虚假的结果: ```rust struct BadIterator { remaining: usize, } impl Iterator for BadIterator { type Item = u32; fn next(&mut self) -> Option<u32> { (self.remaining > 0).then(|| { self.remaining -= 1; 42 }) } } impl ExactSizeIterator for BadIterator { fn len(&self) -> usize { 0 // 但实际上这会产生 `self.remaining` 个项! } } ``` `collect_exact` 在这种情况下会写入未分配的内存,因此它一般是不 sound 的\[3\](https://oxide.computer/blog/iddqd-unsafe#_footnotedef_3)。 另一个例子是 `std::io::Read` trait(https://doc.rust-lang.org/std/io/trait.Read.html),其中安全但错误的 `Read` 实现可能返回错误的读取字节数。有关更多讨论,请参见 Rust RFC 2930(https://github.com/rust-lang/rfcs/blob/master/text/2930-read-buf.md)。 `iddqd` 正好处于这种情况下:它由泛型数据结构组成,这些结构会调用用户提供的代码,因此它工作在最困难的级别\[4\](https://oxide.computer/blog/iddqd-unsafe#_footnotedef_4)。 ## iddqd 的工作原理 (https://oxide.computer/blog/iddqd-unsafe#how-iddqd-works) `iddqd` 的架构相当简单:它结合使用项列表(内部称为 `ItemSet`)和带有槽索引的表。例如,`IdHashMap`(https://docs.rs/iddqd/latest/iddqd/struct.IdHashMap.html)由以下部分组成: - 一个 `ItemSet`,它(类似于 `slab` crate(https://docs.rs/slab))内部具有: - 一个 `Vec<ItemSlot<T>>`,其中 `ItemSlot` 是一个有两个变体的枚举: - `Occupied(T)` - `Vacant { next: ItemIndex }`(其中 `ItemIndex` 表示 `Vec<ItemSlot<T>>` 中的整数索引) - 一个 `free_head: ItemIndex`,它指向最近释放的 `Vacant` 槽,或者是一个哨兵(在下图中标记为 `SENT`),表示没有空闲的 `Vacant` 槽可用。(`free_head` 和 `Vacant` 槽的组合形成一个*空闲链*。) - 一个基于 `ItemIndex` 的 `hashbrown::HashTable`(https://docs.rs/hashbrown/latest/hashbrown/struct.HashTable.html)(`hashbrown` 是标准库使用的高速哈希表实现) 例如,一个包含三个项的 `IdHashMap` 可能如下图所示。第一行是 `ItemIndex` 上的哈希表;第二行是 `Vec<ItemSlot<T>>`。 [iddqd diagram 01] 添加一个新项包括检查 `free_head` 以查看是否有任何 `Vacant` 槽可用。如果有,我们从该 `Vacant` 槽中读取 `next` 索引,用 `Occupied(...)` 覆盖该槽,然后将 `free_head` 设置为我们读取的 `next` 值。否则,我们在末尾推送一个新的 `Occupied` 槽。最后,更新哈希表,获取键并计算哈希,然后将新索引记录在哈希表中。 从上述状态开始:假设我们要插入一个新项 `t3`。调用 `insert(t3)` 会从空闲链中回收槽 `2`,将 `free_head` 前进到 `1`,并记录新的哈希条目: [iddqd diagram 02] 根据键删除项的过程与此相反:首先,使用从键计算的哈希查询哈希表,并从哈希表中移除找到的索引。拿到索引后,将 `ItemSlot` 替换为 `Vacant`,设置内部的 `next` 为当前的 `free_head` 值。最后,将 `free_head` 设置为刚刚替换为 `Vacant` 的索引。 从上述插入后的状态继续:调用 `remove(k0)` 会丢弃 `k0` 的哈希条目,将槽 `3` 标记为 `Vacant`,其 `next` 指向旧的 `free_head`(`1`),并将 `free_head` 更新为 `3`: [iddqd diagram 04] 这种模式推广到其他映射类型: - `IdOrdMap`(https://docs.rs/id

相似文章

安全 Rust 的边界

Lobsters Hottest

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

Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的

Hacker News Top

# Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的 来源:[https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html](https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html) *你可以在[这里](https://github.com/redixhumayun/simpledb/)找到该项目的源代码* 零拷贝是一种旨在消除内核与用户空间缓冲区之间 CPU 数据复制的技术,尤其在数据处理等高吞吐量应用中极具价值。

内存安全是生死攸关的问题

Lobsters Hottest

作者认为,内存不安全的开源软件极易受到即将到来的人工智能漏洞查找代理的攻击,这使内存安全成为道德义务,并且Rust必须作为领先且零开销的内存安全语言取得成功。