上一篇我们做了一件事:把一个 C echo server 逐步移植到 Rust,让编译器替我们拦住了五类 bug。文章结尾我留了一句话——“unsafe 不是不能用,但面积必须被控制。”
很多人把这句话理解成了”少用 unsafe
就行”。不对。问题不是频率,而是你在写下 unsafe
那个关键字的瞬间,到底承诺了什么,放弃了什么,以及如果你搞砸了会发生什么。
这篇文章不讲 unsafe 是什么——Rust Book 里写得很清楚。我只讲一件事:上一篇那个 Rust echo server 里真实存在的 unsafe 代码,为什么必须写成那样,以及它在哪些地方可能杀死你。
一、unsafe 关键字的真正含义
先把概念拧清楚。
unsafe
不是”我决定关掉所有检查”。它的含义是:我在这里做出了编译器无法验证的不变量保证。
编译器的态度不是”你小心点”,而是”你说了安全,那就是你的责任,出了事别找我。”
Safe Rust 给你四项保证:
- 不会有 use-after-free
- 不会有 data race
- 引用永远有效
- 类型系统没有漏洞
unsafe 块里,2-4
条保证全部由你自己维护。编译器只帮你检查语法,不帮你检查语义。这和
C 有什么区别?区别在于 C 是全局 unsafe,而 Rust
里这个危险区域有明确的边界。你知道哪里危险。
听起来没什么了不起?那来看真实代码。
二、FFI 边界:所有权穿越内核的裂缝
回到上一篇的 echo server。整个程序有一个核心问题:Rust 的所有权系统管不到 Linux 内核。
io_uring
的工作方式是你把一个操作(SQE)扔进内核的提交队列,内核异步执行,然后把结果(CQE)放进完成队列。问题是:SQE
里的 user_data 字段是一个
u64。内核不知道 Rust,不知道所有权,不知道
Box,也不知道
Drop。它只负责把你塞进去的那个 64
位数原样吐回来。
这就是为什么 echo server 里最关键的两个函数长这样:
/// 把 Op 交给内核 (所有权离开 Rust 的管辖范围)
fn submit_op(op: Op) -> u64 {
Box::into_raw(Box::new(op)) as u64
}
/// 从内核接回 Op (所有权回到 Rust)
unsafe fn reclaim_op(user_data: u64) -> Box<Op> {
Box::from_raw(user_data as *mut Op)
}Box::into_raw
做了一件事:把堆上对象的所有权从 Rust
类型系统中移除。调用之后,Rust 不再追踪这块内存,不会自动
Drop,不会阻止你重复访问,不会在你 move
之后报错。你把一块完好的 Rust 内存变成了一个裸地址。
Box::from_raw
做反向操作:把一个裸地址重新包装成 Box,告诉
Rust “这块内存的所有权现在归你管了”。
这两个函数之间的空间——从 into_raw 到
from_raw——就是 unsafe
的全部领地。在这段时间里:
- 如果你漏掉了
from_raw,内存泄漏。 - 如果你对同一个地址调了两次
from_raw,double free。 - 如果内核吐回了一个你没提交过的
user_data,你从一个随机地址重建了一个Box——这是段错误或更糟。
其中第三种情况不是理论风险。io_uring 的某些版本在 CQE
overflow 时会合成一个 user_data = 0 的
completion。如果你没检查就拿它做
from_raw,你在从 null pointer 构建
Box<Op>。
看看 echo server 里怎么处理的:
let cqes: Vec<_> = ring.completion()
.map(|cqe| (cqe.user_data(), cqe.result()))
.collect();
for (user_data, res) in cqes {
let op = unsafe { reclaim_op(user_data) };
// ...
}这段代码没有检查 user_data == 0
的情况。在当前的使用场景下,因为提交队列没有溢出,所以不会触发这个问题。但如果你把这段代码搬到一个高并发生产环境,忘了这个前提条件,你就种下了一颗定时炸弹。
这就是 unsafe 最危险的地方:不是当场炸,而是在某个你没预料到的条件下,悄悄炸。
回调里的 panic
FFI 边界还有另一个陷阱。如果你在 C 调用的 callback 里触发了 Rust panic,行为是未定义的。栈展开会穿过 C 的栈帧,而 C 不知道 Rust 的 panic 机制。
对于 io_uring
这种”提交-完成”模型,这个问题不太直接——你不是在 C callback
里执行 Rust 代码。但如果你用的是 libevent 之类注册回调的
API,你必须在 FFI 入口处用 catch_unwind 把
panic 拦住:
extern "C" fn my_callback(arg: *mut c_void) {
let result = std::panic::catch_unwind(|| {
// 实际逻辑
});
if result.is_err() {
// 记录错误,不能让 panic 穿越 FFI 边界
eprintln!("panic caught at FFI boundary");
}
}这不是”防御性编程建议”。如果你不做,gdb 会给你一个完全看不懂的栈回溯。
三、裸指针:合法的悬垂
safe Rust 里没有悬垂引用。borrow checker 保证每个
&T 和 &mut T
都指向有效内存。
裸指针 *const T 和 *mut T
没有这个保证。它可以:
- 为 null
- 指向已释放内存
- 未对齐
- 指向一个完全不同的类型
创建裸指针不需要
unsafe。解引用才需要。这个区分很重要——它意味着你可以自由地传递裸指针(比如存进
user_data),只要你在解引用时保证安全。
echo server 的 push_read
函数里有一个微妙的例子:
fn push_read(ring: &mut IoUring, fd: OwnedFd) {
let buf = vec![0u8; BUF_SIZE];
let raw_fd = fd.as_raw_fd();
let op = Op::Read { fd, buf };
let op_ptr = Box::into_raw(Box::new(op));
// op_ptr 是裸指针,我们从它里面取 buf 的地址
let op_ref = unsafe { &mut *op_ptr };
let buf_ptr = match op_ref {
Op::Read { buf, .. } => buf.as_mut_ptr(),
_ => unreachable!(),
};
let read_e = opcode::Read::new(
types::Fd(raw_fd), buf_ptr, BUF_SIZE as u32
).build().user_data(op_ptr as u64);
unsafe {
ring.submission().push(&read_e).expect("SQ full");
}
}注意这里的调用顺序:
Box::into_raw把Op移到堆上并放弃所有权&mut *op_ptr从裸指针创建一个可变引用——这一步是 unsafe 的- 从 Op 里拿到 buf 的地址
- 把 buf 地址和 op 地址一起交给内核
这里有一个关键不变量:buf
的内存地址在内核使用期间不能变。如果
Vec 在提交 SQE 之后、CQE 返回之前发生了
realloc(比如有人 push 了新元素),内核拿到的
buf_ptr 就变成了悬垂指针。
在当前代码里这不会发生,因为 op
的所有权已经通过 into_raw
移走了,没有人能再碰那个
Vec。但这个安全性不是编译器告诉你的——是你自己推理出来的。
MaybeUninit:更深一层的手动管理
如果你觉得 Box::into_raw
已经够底层了,来看看真正的手动内存管理。假设你要实现一个固定大小的
buffer pool:
use std::mem::MaybeUninit;
struct BufferPool {
slots: Box<[MaybeUninit<[u8; 4096]>]>,
used: Vec<bool>,
}
impl BufferPool {
fn new(count: usize) -> Self {
let slots = (0..count)
.map(|_| MaybeUninit::uninit())
.collect::<Vec<_>>()
.into_boxed_slice();
BufferPool {
slots,
used: vec![false; count],
}
}
fn alloc(&mut self) -> Option<&mut [u8; 4096]> {
let idx = self.used.iter().position(|&u| !u)?;
self.used[idx] = true;
let slot = &mut self.slots[idx];
// 写入零值来初始化
slot.write([0u8; 4096]);
// Safety: 刚刚通过 write() 初始化了这个 slot
Some(unsafe { slot.assume_init_mut() })
}
fn dealloc(&mut self, idx: usize) {
self.used[idx] = false;
// 注意:这里不需要 drop,因为 [u8; 4096] 没有析构器
// 但如果 T 有 Drop,你必须先 assume_init_read() 再标记为 unused
}
}MaybeUninit
的存在意义是告诉编译器:“这块内存可能没有被初始化,不要假设它包含有效值。”如果你直接用
mem::uninitialized::<T>()
来创建一个未初始化的值——别这么做,这个函数已经被废弃了,因为即使创建一个未初始化的
bool 都是未定义行为(bool 只能是 0
或 1)。
这和内存屏障那篇讲的问题有共同点:编译器和硬件的假设,比你以为的多得多。C 程序员对未初始化内存的态度是”读出来是垃圾值,但不会炸”。Rust(和 LLVM)的态度是”读未初始化内存是 UB,我可以基于’这不会发生’做任何优化”。
四、unsafe trait:自己担保线程安全
上一篇讲过,Rust
用 Send 和 Sync 两个 trait
做线程安全保证:
Send:这个类型可以安全地移动到另一个线程Sync:这个类型可以被多个线程同时通过共享引用访问
大部分类型自动实现了这两个 trait。但有些情况下,编译器的自动推导会出错——不是推导太宽松,而是太保守。
举个例子。echo server 的 Op enum 里包含
OwnedFd。OwnedFd 实现了
Send——你可以把一个 fd
的所有权发给另一个线程。但如果你自己包装了一个裸指针:
struct RingBuffer {
data: *mut u8,
len: usize,
cap: usize,
}编译器不会自动给 RingBuffer 实现
Send 或 Sync,因为
*mut u8 既不是 Send 也不是
Sync。如果你确信这个结构体的使用方式是线程安全的(比如没有共享的可变状态,每次只有一个线程持有它),你可以手动实现:
// Safety: RingBuffer 内部的裸指针指向独占分配的内存,
// 不存在跨线程共享。只有持有所有权的线程能访问数据。
unsafe impl Send for RingBuffer {}注意关键词:独占分配、不存在跨线程共享。这些是你做出的承诺。如果你撒谎了——比如实际上两个线程同时持有指向同一块内存的
RingBuffer——编译器不会报错,但你的程序会产生
data race。
这就是无锁代码那篇讲的问题在类型系统层面的投影:你自称 lock-free,自称 thread-safe,编译器选择相信你。但内核调度器不会。
Pin 的 unsafe 内核
如果你写过 async Rust,你见过
Pin<Box<dyn Future>>。Pin
的存在是为了阻止自引用结构被移动。为什么移动会出问题?因为如果一个结构体包含指向自身字段的指针,移动之后那个指针就悬垂了。
Pin::new_unchecked 是 unsafe
的,因为你在承诺”我不会再移动这个值”。如果你通过
mem::swap
或其他方式违反了这个承诺,自引用指针会悬垂,后续的访问就是
use-after-move。
大多数时候你不需要直接碰 Pin 的 unsafe
接口——Box::pin 和 pin!
宏会帮你处理。但如果你在写自己的 Future 实现或 async
runtime,你必须理解这个约束。
五、Miri:unsafe 代码的最后防线
前面四节讲的都是”你必须自己保证的东西”。这一节讲一个能帮你验证的工具。
Miri 是 Rust 的实验性中间表示解释器,它能检测:
- 使用未初始化内存
- 越界访问
- use-after-free
- 无效的裸指针解引用
- 违反借用规则(包括
unsafe块内部) - data race(使用
-Zmiri-preemption-rate增加检测概率)
它不能检测的:
- 逻辑错误(你的算法本身就是错的)
- 硬件相关行为(内存屏障、缓存一致性)
- 系统调用相关的正确性(io_uring 的行为 Miri 无法模拟)
- 性能问题
安装和使用:
rustup component add miri
cargo miri test对于 echo server 这样依赖 io_uring 系统调用的程序,Miri 跑不起来——它不能模拟内核接口。但你可以把 unsafe 逻辑抽离出来单独测试:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_submit_reclaim_roundtrip() {
let op = Op::Accept;
let user_data = submit_op(op);
let recovered = unsafe { reclaim_op(user_data) };
assert!(matches!(*recovered, Op::Accept));
}
#[test]
fn test_submit_reclaim_with_buffer() {
let buf = vec![1u8, 2, 3, 4];
let op = Op::Read {
fd: todo!("need a real fd for full test"),
buf,
};
let user_data = submit_op(op);
let recovered = unsafe { reclaim_op(user_data) };
match *recovered {
Op::Read { buf, .. } => assert_eq!(buf, vec![1, 2, 3, 4]),
_ => panic!("wrong variant"),
}
}
}上面这些测试可以在 cargo miri test
下跑。Miri 会验证 into_raw /
from_raw 的配对是否正确,有没有泄漏,有没有
double free。如果你写了下面这种代码:
let user_data = submit_op(op);
let _ = unsafe { reclaim_op(user_data) };
let _ = unsafe { reclaim_op(user_data) }; // double free!Miri 会直接报错,告诉你第二次 from_raw
访问的是已释放内存。
Miri 不是万能的。但对于 unsafe 代码来说,它是唯一一个能在不依赖具体硬件和操作系统的情况下,检查内存安全不变量的工具。如果你的 unsafe 代码连 Miri 都过不了,那它一定有问题。如果过了,也只是必要条件,不是充分条件。
六、unsafe 审计清单
写了这么多,最后把规则压缩成一张检查表。每次你写下
unsafe 的时候,逐条过一遍:
每个 unsafe 块必须回答的问题:
| # | 问题 | 回答不了就不要提交 |
|---|---|---|
| 1 | 这个 unsafe 块做了什么编译器不能验证的事? | 精确到具体操作 |
| 2 | 你维护了哪些不变量? | 写在 // Safety:
注释里 |
| 3 | 如果上游调用者传了错误的参数,会发生什么? | 必须是 safe 接口阻止了,不是”调用者应该知道” |
| 4 | 有没有对应的测试可以在 Miri 下跑? | 没有就补 |
| 5 | 这个 unsafe 能不能缩小范围? | 能就缩 |
unsafe 面积控制原则:
| 层次 | 允许 unsafe 的理由 | 不允许的理由 |
|---|---|---|
| FFI 绑定层 | 调用 C 函数、传递裸指针 | “比 safe 版本快 5%” |
| 数据结构内部 | 自引用、手动内存布局 | “borrow checker 太烦了” |
| 性能关键路径 | get_unchecked +
已证明的边界 |
“我觉得不会越界” |
| 应用逻辑 | 几乎没有 | 几乎所有理由 |
上一篇的
echo server 里,unsafe
代码集中在两个函数(submit_op /
reclaim_op)加上三个
ring.submission().push() 调用点。总共不到 20
行。剩下的 170 多行全是 safe
Rust,享受完整的编译器保证。这就是正确的比例。
结语
Safe Rust 是一个很好的默认值。它替你管理内存、追踪所有权、阻止 data race。但当你需要和内核对话、和 C 库握手、或者做编译器理解不了的手动优化时,你必须进入 unsafe 的领地。
这片领地不是无法无天的蛮荒之地。它有规则——只是规则由你自己执行。编译器从裁判变成了旁观者,它相信你说的每一句话。这是权力,也是负担。
如果你把 unsafe 当成绕过编译器的捷径,你会在生产环境里收到和 C 一样的礼物:段错误、data race、未初始化内存。如果你把它当成必须精确控制面积的外科手术,你可以在保留 90% 编译器保证的同时,做任何 C 能做的事情。
选择权在你手上。但别说编译器没警告过你。
延伸阅读:
- Rust 所有权:C++ RAII 本来想成为的样子 – unsafe 的理论前置
- 用 Rust 重写你的 C 网络服务器 – 本文所有代码的来源
- Linux 内核的内存屏障:一个让我调了三天的 bug – 编译器假设比你想象的多
- 大多数”无锁”代码其实不是无锁的 – 自称安全不等于安全
完整代码:
- echo_server.rs – Rust io_uring echo server(191 行)
- Cargo.toml
- 02-echo-server.c – C 基线(173 行)