问题的历史
在 Rust 的初期设计阶段,设计者面临一个关键的困境:像循环图和运行时借用检查容器这样的基本数据结构需要通过共享引用进行修改,但这与语言排他的可变访问的基本原则直接相悖。为了在不妨碍零成本抽象原则的情况下解决这个问题,UnsafeCell 被引入作为唯一一种放弃与共享引用 &T 相关的不变性保证的原语,成为所有安全内部可变性抽象的基础。
问题描述
Rust 编译器利用 &T 的不变性来执行激进的优化,例如值缓存和指令重排,假设在引用的生命周期内,底层内存不会变化。UnsafeCell 向编译器发出信号,表明其内容可能在通过共享引用访问时发生变化,有效地禁用了对封闭数据的这些优化。然而,这种选择不适用于通过 UnsafeCell::get() 获取的原始指针派生的引用;一旦这个指针被转换为 &mut T,标准的别名规则便绝对严格地重新确定。
解决方案
解决方案要求程序员维护不变式,即从 UnsafeCell 的原始指针生成的任何可变引用 &mut T 必须是整个生命周期内对该内存的唯一 活跃访问路径。这种独占性禁止了在可变引用存在期间通过任何其他指针、引用或后续对 get() 的调用进行并发读写。UnsafeCell 并没有禁用借用检查器;它只是将确保时间独占和防止数据竞争的责任从编译器转移到开发者身上。
问题描述
我们正在为一个低延迟交易系统架构一个高吞吐量的指标聚合器,多个线程更新与特定金融工具相关的计数器。共享地图在初始化后是不可变的,但指标值需要频繁增加。使用 Mutex<u64> 会导致不可接受的争用,而 AtomicU64 对复杂合成指标类型则不够充分。我们需要无锁、零分配的更新,针对 Arc 指针背后的结构,且没有运行时借用检查。
考虑过的不同解决方案
解决方案 1:分片互斥锁
我们评估了将每个指标包装在 Mutex 中,并将其分布在 256 个分片上以减少争用。这种方法提供了简单的安全性和易于维护的代码。但分析结果表明,即使没有争用,Mutex 操作也消耗了数百纳秒,因为 futex 系统调用和缓存一致性协议,违反了我们严格的亚微秒延迟预算。
解决方案 2:使用 Boxed Values 的 AtomicPtr
另一种方法是将值存储为 AtomicPtr<Metric> 并利用比较和交换循环进行更新。这消除了阻塞,但需要为每次增加分配新的 Box 实例,导致严重的内存压力和分配器争用。此外,这还复杂化了内存回收,需要危险指针或基于纪元的垃圾回收,大幅提高了代码复杂性和审计表面。
解决方案 3:带缓存行对齐的 UnsafeCell
我们选择将指标存储在缓存行对齐的结构中的 UnsafeCell<Metric> 中,确保写入不同分片的线程永远不会共享缓存行。每个线程通过 UnsafeCell::get() 获取原始指针,在更新时转换为 &mut Metric ——我们的分片逻辑保证没有其他线程可以访问该特定槽,从而保证安全,并进行变更。这需要 unsafe 块和正式证明,以确保我们的哈希一致性保证在并发访问时没有碰撞。
选择了哪个解决方案以及原因
我们选择了解决方案 3,因为它在满足激进延迟要求的同时,提供了对原始内存的零成本抽象。分片保证作为独占访问的手动证明,使我们能够利用 UnsafeCell 而不产生运行时同步开销。我们使用 MIRI 和 loom 并发模型检查器进行了安全性验证,以彻底验证在所有可能的线程交错下没有发生别名违规。
结果
该实现达到了亚 100 纳秒的更新延迟,热路径上没有分配。然而,在后续重构过程中出现了一个微妙的回归,其中一个维护任务意外地遍历了所有分片而没有获取隐式的分片锁,从而创建了对相同指标的两个可变引用。MIRI 在 CI 时立即将其标记为未定义行为,强调 UnsafeCell 即使在理论上保证安全的架构设计下也要求严格的纪律。
为什么同时持有两个来自 UnsafeCell 的可变引用是未定义行为,即使 UnsafeCell 明确放弃了标准借用规则?
UnsafeCell 在类型级别放弃了对共享引用的不变性保证,但并未放松 &mut T 类型本身的基本不变式。当你调用 get() 时,你会得到一个原始指针 *mut T,它没有生命周期或别名约束。然而,瞬间你解引用这个指针进入 &mut T,你就向编译器断言这个引用是独占的。对重叠内存创建两个这样的引用,即使来自同一个 UnsafeCell,违反了支撑 Rust 内存模型的 别名 XOR 变更 规则,从而无论引用是如何构造的,都会立即导致未定义行为。
MIRI 如何检测 UnsafeCell 不变式的违规行为,为什么代码可以通过生产测试但在 MIRI 下失败?
MIRI 实现了 Stacked Borrows(或可选的 Tree Borrows)别名模型,通过抽象的 "标签" 跟踪内存访问权限。当你从 UnsafeCell 创建引用时,MIRI 会分配一个唯一的标签。任何试图在第一个引用激活期间使用不同标签访问相同内存的操作都构成违规。代码通常通过标准测试,因为硬件内存模型是宽容的,良性数据竞争在实践中可能不会导致崩溃。然而,MIRI 严格执行理论模型,捕捉诸如通过在没有适当同步的情况下从同一 UnsafeCell 创建共享引用而使可变引用失效的越界行为,即使汇编代码在当前 CPU 架构上似乎有效。
解释为什么 Cell<T> 在变更时不需要 unsafe 块,而 UnsafeCell<T> 需要,并识别使这种区别成为可能的特定安全保证。
Cell<T> 通过从不暴露对其内部数据的引用,允许在没有 unsafe 的情况下实现内部可变性;它仅允许复制值(set)或取出值(get)对于实现了 Copy 的类型,或者对于非 Copy 类型进行移动(replace)。由于 Cell 从不将 &T 或 &mut T 交给包含的值,因此不可能违反别名规则——没有引用会出现别名。相反,UnsafeCell 提供 get(),返回原始指针 *mut T,允许创建引用。这种灵活性对于复杂的就地变更是必要的,但它完全将确保独占性和防止数据竞争的责任转移到程序员身上,因此需要 unsafe 块。