土法炼钢兴趣小组的算法知识备份

unsafe Rust:当编译器不再替你扛枪

源码下载

本文相关源码已整理,共 3 个文件。

打开下载目录 →

目录

上一篇我们做了一件事:把一个 C echo server 逐步移植到 Rust,让编译器替我们拦住了五类 bug。文章结尾我留了一句话——“unsafe 不是不能用,但面积必须被控制。”

很多人把这句话理解成了”少用 unsafe 就行”。不对。问题不是频率,而是你在写下 unsafe 那个关键字的瞬间,到底承诺了什么,放弃了什么,以及如果你搞砸了会发生什么。

这篇文章不讲 unsafe 是什么——Rust Book 里写得很清楚。我只讲一件事:上一篇那个 Rust echo server 里真实存在的 unsafe 代码,为什么必须写成那样,以及它在哪些地方可能杀死你。

一、unsafe 关键字的真正含义

先把概念拧清楚。

unsafe 不是”我决定关掉所有检查”。它的含义是:我在这里做出了编译器无法验证的不变量保证。 编译器的态度不是”你小心点”,而是”你说了安全,那就是你的责任,出了事别找我。”

Safe Rust 给你四项保证:

  1. 不会有 use-after-free
  2. 不会有 data race
  3. 引用永远有效
  4. 类型系统没有漏洞

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_rawfrom_raw——就是 unsafe 的全部领地。在这段时间里:

其中第三种情况不是理论风险。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 没有这个保证。它可以:

创建裸指针不需要 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");
    }
}

注意这里的调用顺序:

  1. Box::into_rawOp 移到堆上并放弃所有权
  2. &mut *op_ptr 从裸指针创建一个可变引用——这一步是 unsafe 的
  3. 从 Op 里拿到 buf 的地址
  4. 把 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 用 SendSync 两个 trait 做线程安全保证:

大部分类型自动实现了这两个 trait。但有些情况下,编译器的自动推导会出错——不是推导太宽松,而是太保守。

举个例子。echo server 的 Op enum 里包含 OwnedFdOwnedFd 实现了 Send——你可以把一个 fd 的所有权发给另一个线程。但如果你自己包装了一个裸指针:

struct RingBuffer {
    data: *mut u8,
    len: usize,
    cap: usize,
}

编译器不会自动给 RingBuffer 实现 SendSync,因为 *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::pinpin! 宏会帮你处理。但如果你在写自己的 Future 实现或 async runtime,你必须理解这个约束。

五、Miri:unsafe 代码的最后防线

前面四节讲的都是”你必须自己保证的东西”。这一节讲一个能帮你验证的工具。

Miri 是 Rust 的实验性中间表示解释器,它能检测:

它不能检测的:

安装和使用:

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 能做的事情。

选择权在你手上。但别说编译器没警告过你。


延伸阅读:

完整代码:


By .