字节火山引擎的某 TiKV 同事在你刚加入沪深300量化团队的第二周走过来。他抱着一台戴尔笔记本,屏幕上是 3.5.2 L3 你亲手写的那个 SPSC 环形缓冲——给 510300.SH (沪深300 ETF) 行情事件用的,生产者一个核心、消费者一个核心,中间两个 AtomicUsize 当下标。"我们要把这段代码搬进 CFFEX 张江 COLO 的 prop 系统了。先过一遍 Miri,"他说,"你那个 unsafe 块的 // SAFETY: 注释呢?生产代码 review 不过。"你这才意识到:之前三个模块——3.5.1 学到借用检查器、3.5.2 写过 Arc<Mutex<T>> 和原子序、3.5.3 调过 #[target_feature(enable = "avx2")] unsafe fn——都建立在你没写过的 unsafe 之上。Vec<T> 内部那个指向堆的裸指针、Mutex<T>::lock 之下的 UnsafeCell、AtomicU64::compare_exchange 编译成的架构原语,全是别人写的不安全代码,你只是用户。本课把这条线补齐:unsafe 解锁了哪些能力、它们要求你维护哪些不变量、以及生产环境如何用 Miri + cargo-careful + // SAFETY: 评审把这些不变量盯死。
unsafe 解锁的五种能力
把 unsafe 块或 unsafe fn 想成对编译器说"接下来这几行,借用检查器无法验证安全性,我作为程序员手动承诺它是健全的"。它解锁五个动作,每一个都对应借用检查器原本会替你验证、现在要你自己维护的某条不变量。
// (1) 裸指针解引用 - 借用检查器无法证明 p 仍指向有效的 i32
let x: i32 = 42;
let p: *const i32 = &x;
// SAFETY: p 来自栈上仍存活的 x 的引用, 未发生再借用或释放
let v = unsafe { *p };
// (2) 调用 unsafe fn - 调用者必须满足函数的文档化前置条件
let ptr: *const u8 = b"hello".as_ptr();
// SAFETY: 字节字面量长度恰为 5, ptr 指向合法 UTF-8 的连续 5 字节
let s: &[u8] = unsafe { std::slice::from_raw_parts(ptr, 5) };
// (3) 读 static mut - 没有任何同步, 数据竞争由你保证
static mut COUNTER: u64 = 0;
// SAFETY: 仅在单线程初始化阶段访问, 启动后切换为 AtomicU64
// 生产代码请使用 AtomicU64 或 Mutex<u64>, 此处仅作演示
let c = unsafe { COUNTER };
// (4) 实现 unsafe 自动 trait - 你向编译器担保跨线程安全
struct MyType { ptr: *mut u8 }
// SAFETY: MyType 独占其 *mut u8 指向的堆分配, 不存在外部别名
unsafe impl Send for MyType {}
// (5) 读 union 字段 - 你担保读出的字节是该字段类型的合法值
#[repr(C)]
union FloatBits { f: f32, bits: u32 }
let u = FloatBits { f: 1.0 };
// SAFETY: 任何 32 位都是合法的 u32
let bits = unsafe { u.bits };
五条 SAFETY 注释的格式是生产纪律:每一个 unsafe 块,正上方一行 // SAFETY: 注释,陈述调用方与被调用方各自维护的不变量。cargo geiger 在依赖树里扫这些 unsafe 数量,review 时人工读这些 SAFETY 文本。
*const T 与 *mut T:裸指针机制
裸指针不是引用,没有生命周期、不参与借用检查、可空、可悬挂、可未对齐。它提供的操作:is_null、add(n) (按 T 步长前进)、offset(i) (按 T 步长正负偏移)、read / write (要求对齐)、read_unaligned / write_unaligned (不要求对齐——解析网络字节流时常用)。&T / &mut T 与 *const T / *mut T 之间的转换是 zero-cost,但反向 (*const T -> &T) 进入 unsafe,因为你要担保指针非空、对齐、指向合法 T、且在引用存活期间无别名违规。
// 解析 ITCH 5.0 字节流时常见的不对齐读取
fn read_be_u32(buf: &[u8], at: usize) -> u32 {
assert!(at + 4 <= buf.len());
let p = unsafe { buf.as_ptr().add(at) } as *const u32;
// SAFETY: 范围检查保证 4 字节在 buf 内; 不要求 4 字节对齐
let raw = unsafe { p.read_unaligned() };
u32::from_be(raw)
}
UnsafeCell<T>:内部可变性的唯一基石
Rust 的核心不变量是 &T -> &mut T 的别名 XOR 可变:当存在 &T 时,任何人都不可通过该地址修改 T。UnsafeCell<T> 是唯一对编译器说"我退出这条不变量"的类型——它告诉优化器:即使你看到 &UnsafeCell<T>,该地址下的字节也可能被改。Cell<T> / RefCell<T> / Mutex<T> / AtomicU64 全部在底层包含一个 UnsafeCell<T>,这是 std 公开源码:
pub struct UnsafeCell<T: ?Sized> { value: T }
impl<T> UnsafeCell<T> {
pub const fn new(value: T) -> Self { UnsafeCell { value } }
pub const fn get(&self) -> *mut T { &self.value as *const T as *mut T }
}
pub struct Cell<T: ?Sized> { value: UnsafeCell<T> }
pub struct RefCell<T: ?Sized> {
borrow: Cell<isize>,
value: UnsafeCell<T>,
}
pub struct Mutex<T: ?Sized> {
inner: sys::Mutex,
poison: poison::Flag,
data: UnsafeCell<T>,
}
Cell<T> 用 UnsafeCell<T> 加单线程整体替换 API;RefCell<T> 加一个 Cell<isize> 的运行时借用计数;Mutex<T> 加操作系统互斥量。没有 UnsafeCell<T> 就没有内部可变性——别用别的方式自己造。
MaybeUninit<T>:未初始化内存的安全表达
mem::zeroed::<T>() 不安全:T 可能是 &u8、Box<T>、bool,这些类型的全零位模式不是合法值;读出来的是即时 UB。MaybeUninit<T> 是编译器认可的"暂未初始化的 T-大小存储槽",它的 assume_init 是 unsafe,要求你已经把合法值写进去。逐槽初始化非 Copy 类型的 Box<[T]>:
use std::mem::MaybeUninit;
pub fn build_box_slice<T, F: FnMut(usize) -> T>(n: usize, mut f: F) -> Box<[T]> {
let mut slots: Box<[MaybeUninit<T>]> = Box::new_uninit_slice(n);
for i in 0..n {
slots[i].write(f(i));
}
// SAFETY: 上面循环对 slots[0..n] 每个槽位通过 MaybeUninit::write 写入了
// 一个合法 T 实例, 没有遗漏, 因此整个切片现在完全初始化,
// 从 Box<[MaybeUninit<T>]> 到 Box<[T]> 的转换是健全的。
unsafe { slots.assume_init() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_box_slice_string() {
let b = build_box_slice(4, |i| format!("item-{i}"));
assert_eq!(&*b, &["item-0", "item-1", "item-2", "item-3"]);
}
}
Pin<P> 与自引用 future:在签名层级读懂它
async fn 编译出的 Future 是一个状态机。当一个跨 .await 点的本地变量被另一个跨 .await 点的本地变量借用时,状态机内部出现自引用——一个 *const T 指向同一结构体内部的字段。此后移动这个状态机就是 UB,因为指针仍指向旧地址。Pin<&mut T> 就是给类型系统加的一句承诺:"T 不会被移出当前内存位置"。Future::poll 的签名因此写成 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>,而 pin-project 的 #[pin_project] 派生宏负责安全地"投影"通过 Pin<&mut Struct> 访问被钉住的字段,你不用手写 unsafe。本课只读签名,不写手工 Future——那是 Jon Gjengset 'Crust of Rust' 的 Pinning 视频与 pin-project 文档的事。
工作示例:把 SPSC 环写成安全 API
3.5.2 L3 那个直接暴露 unsafe 块的 SPSC 环,现在用 UnsafeCell<MaybeUninit<T>> 重写成安全外部 API、内部仅在两处持有 SAFETY 注释,用于 510300.SH 行情事件传递,容量 4096:
use std::cell::UnsafeCell;
use std::mem::MaybeUninit;
use std::sync::atomic::{AtomicUsize, Ordering};
use crossbeam_utils::CachePadded;
pub struct SpscRing<T> {
head: CachePadded<AtomicUsize>,
tail: CachePadded<AtomicUsize>,
slots: Box<[UnsafeCell<MaybeUninit<T>>]>,
mask: usize,
}
unsafe impl<T: Send> Send for SpscRing<T> {}
unsafe impl<T: Send> Sync for SpscRing<T> {}
impl<T> SpscRing<T> {
pub fn new(capacity_pow2: usize) -> Self {
assert!(capacity_pow2.is_power_of_two());
let slots: Box<[UnsafeCell<MaybeUninit<T>>]> =
(0..capacity_pow2).map(|_| UnsafeCell::new(MaybeUninit::uninit())).collect();
Self {
head: CachePadded::new(AtomicUsize::new(0)),
tail: CachePadded::new(AtomicUsize::new(0)),
slots,
mask: capacity_pow2 - 1,
}
}
pub fn try_push(&self, value: T) -> Result<(), T> {
let tail = self.tail.load(Ordering::Relaxed);
let head = self.head.load(Ordering::Acquire);
if tail.wrapping_sub(head) == self.slots.len() {
return Err(value);
}
let slot = &self.slots[tail & self.mask];
// SAFETY: tail 尚未通过 Release store 发布, 消费者不可能观察到
// 该索引已填充; 因此本侧在下一行 Release store 之前对该槽位拥有
// 独占写访问。
unsafe { (*slot.get()).write(value); }
self.tail.store(tail.wrapping_add(1), Ordering::Release);
Ok(())
}
pub fn try_pop(&self) -> Option<T> {
let head = self.head.load(Ordering::Relaxed);
let tail = self.tail.load(Ordering::Acquire);
if head == tail {
return None;
}
let slot = &self.slots[head & self.mask];
// SAFETY: 上一行对 tail 的 Acquire load 与生产者的 Release store
// 同步发生, 因此该槽位的值已初始化; 在下一行 Release store of head
// 之前, 消费者对该槽位拥有独占读访问。
let value = unsafe { (*slot.get()).assume_init_read() };
self.head.store(head.wrapping_add(1), Ordering::Release);
Some(value)
}
}
元素类型 MdEvent { ts_ns: u64, price_ticks: i32, qty: u32, side: Side }。unsafe impl<T: Send> Send for SpscRing<T> {} 与 unsafe impl<T: Send> Sync for SpscRing<T> {} 的健全性论证:虽然 try_push 与 try_pop 都取 &self 并通过 UnsafeCell 写入,但只有生产者写 tail、只有消费者写 head;生产者用 Acquire 读 head,消费者用 Acquire 读 tail——这条索引纪律保证两侧在各自当前槽位拥有独占访问,且 Release/Acquire 配对建立 happens-before。CachePadded 把两个原子各自放在独立缓存行(沿用 3.5.3 L1 的纪律,避免伪共享)。
生产工具链:Miri + cargo-careful + cargo-geiger
Miri 是 Rust 编译器自带的 MIR 解释器,它会在测试时检测裸指针越界读写、use-after-free、数据竞争、读未初始化内存、对齐违规、非法 bool / char / 枚举判别值等所有种类的未定义行为。cargo-careful 是日常驱动工具,用调试断言重编 std。cargo geiger 数依赖树中的 unsafe 块——CI 上设置上限,新依赖引入 unsafe 必须解释。
# 安装 Miri
$ rustup component add miri --toolchain nightly
# 用 Miri 跑测试套件
$ cargo +nightly miri test
# 在实验性 tree-borrows 别名模型下跑特定测试
$ MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test spsc_ring
# cargo-careful (日常驱动补充; 在带调试断言的 std 上跑测试)
$ cargo install cargo-careful
$ cargo +nightly careful test
# 数依赖树中的 unsafe 块
$ cargo install cargo-geiger
$ cargo geiger
国内一线团队——TiKV (CNCF)、字节火山引擎、蚂蚁链、美团基础设施——都把 Miri-in-CI + 严格 // SAFETY: 评审纳入流水线。研究前沿是形式化健全性证明 (Ralf Jung 的 RustBelt 工作);生产现场是 Miri 加注释纪律。
Exercise
实现并验证上面的 SpscRing<T> 安全 API,在 cargo +nightly miri test 下确认零 UB:
(a) 实现完整的 SpscRing<T> 并写 4 个测试:单线程 push-then-pop 往返;单线程填满到容量、触发 Err(value) 背压路径;两线程生产者-消费者传递 10_000 个 u64,断言消费者按生产顺序读到全部值;两线程中提前丢弃一个、验证存活线程在追赶到对端后 try_pop 返回 None(环不是 channel、无关闭态)。4 个测试全部在 Miri 下跑,提交日志显示零 UB。
(b) 给实现中的每个 unsafe 块写 // SAFETY: 注释,陈述 (i) 维护的是哪条不变量、(ii) 周围代码如何保证该不变量成立。
(c) 写一段 3-5 句话的论证:为什么 unsafe impl<T: Send> Sync for SpscRing<T> {} 是健全的——尽管 try_push 和 try_pop 都取 &self 并通过 UnsafeCell 写入,生产者侧索引纪律(只有生产者写 tail、只有消费者写 head、生产者用 Acquire 读 head、消费者用 Acquire 读 tail)给每一侧对其当前槽位的独占访问。
(d) 不实现,各两句话:(i) 为什么槽位存储用 MaybeUninit<T> 而非 Option<T>(答:Option<T> 强制每槽多一个 is_some 判别字节,内存翻倍且每次访问多一个分支;MaybeUninit<T> 让生产者-消费者纪律成为槽位有效性的唯一来源);(ii) 为什么头尾原子用 CachePadded 包裹(答:生产者写 tail、消费者写 head;若共享缓存行,每次 push/pop 都会让对端核心失效;CachePadded 把每个原子放在独立缓存行——沿用 3.5.3 L1)。
提示
先把单线程往返跑通: let r = SpscRing::new(8); r.try_push(1u64).unwrap(); assert_eq!(r.try_pop(), Some(1)); 然后逐步加并发与 Miri。
提示
两线程测试用 std::thread::scope 借出 &SpscRing<T> 给两个闭包: 生产者侧 for i in 0..10_000 { while ring.try_push(i).is_err() {} }; 消费者侧把读到的值 push 进 Vec 然后断言。
深入主题:三条容易被忽略的细线
第一条:unsafe 不传染。一个函数体内有 unsafe { ... } 块,并不意味着函数本身要标 unsafe fn——只有当这个函数对调用方暴露了未经检查的前置条件时才标。这条区分非常关键:Vec<T>::push 内部用了 unsafe,但它对外是安全 API,因为 Vec<T> 自身维持了所有不变量。本课上面五个 Fenced 代码块里的 unsafe 块,本质都属于"安全 API 内部维护不变量"的情形,函数对外签名都不带 unsafe。
第二条:UnsafeCell<T> 与 Cell<T> 的真正区别在于 API 形状,不在于底层。Cell<T> 通过 get / set / replace 整体替换值实现内部可变,完全规避了"任何时刻借出 &mut T"的需求,所以单线程下绝对安全。RefCell<T> 加运行时借用计数,允许借出 &T 与 &mut T,但用 borrow_mut() 重复借时 panic。Mutex<T> 把这个能力扩展到跨线程。三者底下都是同一个 UnsafeCell<T>,差别仅在调用方拿到的 API 礼仪。
第三条:Send 与 Sync 是 unsafe auto trait。编译器自动为"全部字段都 Send (或 Sync)"的类型实现它们——这就是为什么你不需要给 struct Foo(i32, String) 写任何东西就能跨线程发送。手动 unsafe impl Send for ... {} 仅在你的类型包含原始指针 / *mut T / FFI handle 这类编译器不知道其线程安全性的字段时才需要。一旦你写下这一行,你就独自承担了线程安全的论证。
量化场景下 unsafe 的三类典型用法
(一) 无锁数据结构内部。SPSC 环、MPMC 队列、crossbeam_epoch 的纪元回收、crossbeam_queue::ArrayQueue ——这些数据结构必须用 UnsafeCell<MaybeUninit<T>> 加原子顺序自己维护别名不变量,无法用 &T / &mut T 表达。本课的 SPSC 工作示例就是这一类的最小完整样例。生产中你大多直接使用 crossbeam_queue 而非手写,但本课的训练目的是让你能读懂并审计这些库的 unsafe 块。
(二) FFI 边界。下一课 L2 的 bindgen 生成绑定全部是 unsafe fn,因为 C 侧的语义不变量 (非空指针、合法 UTF-8、有限 float) 是 Rust 类型系统表达不了的。你的安全包装层把这些不变量编码为 Rust 类型或 assert! / panic,然后用 // SAFETY: 注释解释你为什么相信调用满足前置条件。
(三) 性能关键的不对齐读写。解析 ITCH 5.0 / SBE 编码的市场数据时,字节流不一定按 4 / 8 字节对齐;read_unaligned / write_unaligned 是 unsafe,但比"先 memcpy 到对齐缓冲再读"快得多。本课上面 read_be_u32 的 Fenced 代码块给出最小样例。
阅读清单
- course.rs 「Rust 圣经」unsafe 章节(中文)与 Rustonomicon 章节对应。
- the Rustonomicon (doc.rust-lang.org/nomicon/) 是权威参考。
- Mara Bos Rust Atomics and Locks 中文社区翻译第 1-2 章。
- Miri README 与
cargo-carefulREADME 给出工具安装与用法。 - 国内一线团队 (TiKV、字节火山引擎、蚂蚁链、美团基础设施) 把 Miri + cargo-careful + 严格
// SAFETY:评审纳入 CI。
下一课 (L2 「FFI、bindgen 与 cxx」) 把本课的 // SAFETY: 纪律外推到 C / C++ 互操作的边界——你将看到 bindgen 生成的函数声明全部是 unsafe fn,而把它们包装成安全 API 的工作,正是本课纪律的直接延续。