unsafe、FFI 与并发内存模型:把安全边界讲清楚
第 17 章你已经见过 unsafe 长什么样。但"见过"和"敢在项目里用"之间隔着一整套认知:unsafe 到底解除了什么约束、Send/Sync 凭什么能自动推导、&T 明明不可变为什么 Cell 还能改、C 那边传过来的指针该怎么安全地接住。这一章按"先建立心智模型,再动手写 FFI"的顺序展开,最后给你一份 UB 清单——写 unsafe 的人手里必须有一份。
unsafe 到底解锁了什么:四种超能力 + 一个容易忘的
关键认知先摆在这里:unsafe 不是一个"关闭安全检查"的开关。进了 unsafe 块,借用检查、类型检查、生命周期检查全都照常工作。它只做一件事:允许你做编译器无法验证的那几件事,代价是"正确性由你负责"。
| 超能力 | 具体操作 | 你要自己保证什么 |
|---|---|---|
| ① 解引用裸指针 | *const T / *mut T 的读写 |
指针非空、已对齐、指向有效内存、没有被别人可变借用(别名规则)。这四条少一条都是 UB。 |
| ② 调用 unsafe 函数 | 调用 unsafe fn、extern 函数、unsafe 方法 |
满足被调函数在文档里写明的"安全前置条件"(safety contract)。这些条件不会在类型里体现,只能读文档。 |
| ③ 访问可变静态变量 | 读写 static mut |
static mut 没有同步保证,跨线程访问就是数据竞争。正确做法是改用 AtomicXxx、OnceLock、Mutex。 |
| ④ 实现 unsafe trait | unsafe impl Send for X {} |
编译器无法验证的"语义承诺",比如"我的裸指针内部其实有锁保护"。 |
| ⑤ 访问 union 字段 | u.i32_field |
union 不做类型检查,读到的可能是"用另一种类型写进去的位模式",写错就是 UB。 |
四种超能力的最小示例(每一行都要能说清"我为什么是安全的")
论为什么 unsafe fn 的"前置条件"不能靠类型表达
① 类型系统能表达的是"结构性事实":这个值是不是空、生命周期够不够长、有没有被同时可变借用。它表达不了"这个 *const c_char 是不是 NUL 结尾""这个 fd 是不是已经关了""这个数组的第 N 个元素是不是已初始化"。
② 所以 Rust 的约定是:unsafe fn 的文档必须有一个 # Safety 段落,写清调用方要满足什么。这是纯社会契约,编译器不检查,但 Code Review 和 clippy::missing_safety_doc 会检查。你在自己的库里写 unsafe fn 时,不写这段文档等于给别人埋雷。
③ 更重要的工程习惯:能封装就封装。一个 crate 里所有 unsafe 应该集中在少数几个模块里,对外只暴露 safe fn。判断标准是"如果我把 unsafe 块里的代码原样复制出去,它还敢保证安全吗"——不敢,就说明抽象边界立对了;敢,说明这个 unsafe 块其实是多余的。
unsafe 块不是"免责声明",写错照样 UB
最常见的三类 UB,都发生在"看起来很正常"的代码里:
① 别名违规。同时存在 &mut T 和 &T(或两个 &mut)的时期重叠,哪怕它们指向不同元素。这是 Vec::as_mut_ptr 类代码最爱踩的坑,而且通常不会崩,只会在某个优化等级下产生诡异的错误结果。
② 未初始化内存被读到。let x: u32 = unsafe { mem::uninitialized() }; 是纯 UB。0 值对 bool、char、引用、枚举都是"无效值"——构造出来就是 UB,不是"读到脏数据"那么简单。
③ 悬垂指针。Box::into_raw 之后忘 Box::from_raw 是泄漏(安全但浪费),反过来 from_raw 一个不是 Box::into_raw 来的指针就是 UB。中间态是 into_raw → from_raw → 再用裸指针,double free。
验证手段:cargo +nightly miri test(Miri 会在解释执行时抓 UB)、cargo +nightly carefy/cargo careful、以及 cargo test --release(有些 UB 只在优化后暴露)。
Send / Sync:编译器是怎么"自动推导"出并发安全的
Send 和 Sync 是自动 trait(auto trait):你从来不需要为普通类型手写它们,编译器会按规则推导。规则非常简单,但推论非常强。
| trait | 语义 | 推导规则与常见反例 |
|---|---|---|
| Send | 值可以转移到另一个线程 | 若 struct S { a: A, b: B } 且 A: Send、B: Send,则 S: Send。反例: Rc<T>、*mut T、MutexGuard(在某些版本)。原因是引用计数非原子,跨线程会竞争。 |
| Sync | &T 可以在多个线程共享 |
若所有字段 Sync,则 S: Sync。反例: Cell<T>、RefCell<T>、Rc<T>——它们能在只有 &T 的情况下改内部状态,多线程共享就会数据竞争。 |
| 两者关系 | T: Sync 等价于 &T: Send |
记住这条等价关系,很多困惑立刻消解:&T 能跨线程传递,当且仅当 T 是 Sync。 |
| 组合结论 | Arc<T>: Send + Sync 要求 T: Send + Sync |
所以 Arc<RefCell<T>> 不是 Sync,跨线程会编译报错;Arc<Mutex<T>> 才是标准答案(Mutex 把内部可变性做成了 Sync 的形态)。 |
用 PhantomData 精确控制自动推导的结果
什么时候必须手写 unsafe impl Send
unsafe impl Send 是把编译器的安全网剪了
unsafe impl Send for X {} 的语义是"我向编译器保证 X 可以安全地移动到别的线程,出问题我自己负责"。它不会带来任何运行时检查,编译器此后完全不再审查这个类型。
判断要不要写,问自己两个问题:① 这个类型的字段里,有没有"非线程安全"的东西(Rc、RefCell、裸指针、第三方 C 句柄)?② 即使有,我的封装是否真的保证了跨线程访问的安全(有锁、按线程隔离、无共享状态)?第二个问题答不上来就不要写,改为让类型只在单线程内使用,出错时编译器会拦住你——那正是我们想要的。
还有一条经验:同时实现 Send + Sync 的风险远高于只实现 Send。Sync 意味着 &X 能在多线程共享,如果你没有真正的同步手段,这就是在制造数据竞争。
UnsafeCell / Cell / RefCell:内部可变性的三层设计
有一个反直觉的事实:&T 是不可变引用,但 Cell<T> 能在只有 &Cell<T> 的情况下修改里面的值。这看起来和"不可变引用"矛盾,实际上是编译器留的一个"后门",这个名字就叫 UnsafeCell。
三者是层层包装的关系,从最底层往上看
| 类型 | 可变性 | 检查时机 | Sync? | 适用场景 |
|---|---|---|---|---|
UnsafeCell<T> | 裸指针读写 | 无(你自己保证) | 否 | 实现自己的并发原语:锁、原子结构、无锁队列。日常业务不要直接用。 |
Cell<T> | 整取整存 | 编译期(类型层面) | 否 | T: Copy 的计数器、标志位、状态机字段。零运行时开销,比 RefCell 快。 |
RefCell<T> | 借用进出 | 运行期(panic) | 否 | 单线程内需要"持有引用再修改"的场景:树结构、观察者、缓存。借用违规会 panic。 |
Mutex<T> | 加锁访问 | 运行期(阻塞) | 是 | 跨线程共享可变状态的标准答案。Arc<Mutex<T>> 是后端代码里出现频率最高的组合。 |
RwLock<T> | 读写锁 | 运行期(阻塞) | 是 | 读多写少。注意写饥饿与平台实现差异,别默认它一定比 Mutex 快。 |
论为什么编译器要靠 UnsafeCell 这个"标记"
① 编译器会基于 &T 做优化。看到 &T,rustc 可以假设"通过这个引用,这块内存不会被别人改",于是把多次读取合并、把值缓存在寄存器里(LLVM 的 noalias 属性就是干这个的)。如果允许 &Cell<T> 偷偷改内容而不告诉编译器,那些优化就会产生错误结果——这是 C 语言 const 语义被 cast 掉之后经典的 bug 来源。
② UnsafeCell 就是这个"告诉编译器"的手段。它的类型声明里带了 #[lang = "unsafe_cell"],编译器看到这个类型出现在字段中就关闭对该字段的 noalias 假设。所以一切的"内部可变性"都必须最终包含一个 UnsafeCell,否则就是 UB——哪怕你逻辑上真的没并发。Cell、RefCell、Mutex、AtomicU32、RwLock 内部都藏着它。
③ 反过来给你一个判据:想在 &self 方法里改状态,合法路径只有三条——用 Cell/RefCell(单线程)、用 Mutex/Atomic(多线程)、自己用 UnsafeCell 并说明为什么安全。如果你觉得"我直接 transmute 一下 &T 成 &mut T 就行",那不是捷径,那是 UB。
extern "C" 与 repr(C):跨语言的第一道门槛
FFI 有两类需求:导出(Rust 写库给 C/Python/Go 调)和导入(Rust 调 C 库)。两者的语法都是 extern "C",但陷阱完全不同。
导出:Rust 实现、C 调用
导入:Rust 调 C 库
#[repr(C)]:让结构体的内存布局跟 C 一致
extern "C" 边界是 UB
Rust 的 panic 默认是 unwind(栈展开),而 C 没有 unwind 的语义也没有清理器。你的 extern "C" 函数里如果 unwrap() 失败或者数组越界 panic 了,栈展开穿到 C 代码里就是未定义行为(在 Rust 1.81 之后的行为是直接 abort 进程,比之前"静默 UB"好一些,但仍然不是你能接受的)。
三种正确做法:① 在函数体内把 panic 全部转成错误码(std::panic::catch_unwind 包一层,返回 -1);② 用 extern "C-unwind" 明确声明"我允许展开"(需要宿主语言也支持);③ 在 crate 层面配 panic = "abort",让 panic 直接终止进程,至少行为是确定的。生产上的 FFI 库一般用方案 ①,并在文档里写清"这个函数永不 panic"。
顺带一个同类坑:extern "C" 函数里 unwrap() 配合 Mutex 中毒(poison)也很容易踩——一个线程 panic 后 Mutex 变"中毒",后续 lock().unwrap() 会连环 panic。
bindgen:把 C 头文件变成 Rust 绑定
手写 FFI 声明又慢又容易写错(尤其是带位域的结构体和宏常量)。bindgen 读 C/C++ 头文件,在构建期自动生成 bindings.rs。
标准 -sys crate 结构(社区惯例:foo 提供安全封装,foo-sys 只放自动生成的裸绑定)
build.rs:生成绑定的关键配置
再包一层安全 API(这才是别人 import 的东西)
论为什么社区约定用 foo + foo-sys 两层
① foo-sys 是"会变的、机器生成的、unsafe 的"。头文件升级、bindgen 版本升级、目标平台不同,都会让这层代码大改。把它单独放一个 crate,diff 就干净、评审就快,也方便 pin 住版本。
② foo 是"稳定的、手写的、安全的"。它负责把裸指针变成 &[u8]、把返回码变成 Result、用 Drop 管生命周期、把 unsafe impl Send/Sync 的 SAFETY 注释写清楚。使用者看到的是纯 safe Rust API。
③ 这套分层带来一个非常实际的收益:unsafe 的数量被固定在少数几个文件里。审计时你只需要审 foo 这一个 crate 的安全封装,不需要看整个依赖树。Rust 的 #![forbid(unsafe_code)] 属性可以用在业务 crate 上,让"业务代码里禁止出现 unsafe"变成编译器强制。
字符串与所有权跨边界:CStr / CString / into_raw
跨语言传字符串是 FFI 里最容易出内存问题的地方,因为两边对"谁负责释放"的约定完全不同。
| 类型 | 方向 | 关键点 |
|---|---|---|
CString | Rust 持有所有权,借给 C | 内部保证 NUL 结尾、不允许内部有 NUL 字节(::new 会返回 Err)。as_ptr() 得到 *const c_char,有效期只到 CString 被 drop 为止。 |
&CStr | 借用别人的 C 字符串 | 用 CStr::from_ptr(p) 从裸指针转,要求 p 非空且 NUL 结尾。返回的 &CStr 生命周期是"推断出来的",编译器并不知道 C 那边什么时候释放,必须靠人保证。 |
CString::into_raw | Rust 把所有权交给 C | C 用完后必须调 CString::from_raw 还回来(由 Rust 侧提供释放函数)。不还 = 泄漏;还两次 = double free。 |
c_void | 不透明指针 | 对应 C 的 void*。现在推荐 std::ffi::c_void,老代码里的 std::os::raw::c_void 是同一类型。 |
OsString/PathBuf | 平台原生字符串 | 路径、环境变量不要走 CString,用 OsStr::as_bytes() / OsStrExt::from_bytes(Unix)才能正确处理非 UTF-8。 |
完整的所有权交接模式(这是 FFI 里最标准的写法)
Box 的所有权交接:同一个套路用在任意结构体上
CString::as_ptr() 的结果存起来,然后 CString 被 drop 了
这是 FFI 中最隐蔽的一类 bug:let p = CString::new("x").unwrap().as_ptr(); —— 临时 CString 在这一行末尾就被释放了,p 立刻变成悬垂指针。C 那边读到的可能是任何东西,也可能在几小时后才崩。
正确写法是把 CString 绑定到变量、活到调用结束:let c = CString::new("x").unwrap(); call(c.as_ptr());。
另外三条高频坑:① 忘写配对的 *_free 函数,宿主语言没法释放导致内存泄漏(这是 Box::into_raw 之后最常见的事故);② 同一个指针释放两次(例如 C 侧和 Rust 侧都以为自己是所有者),表现为 double free 崩溃;③ 用 mem::forget 之后忘了它其实是"故意泄漏"——如果对应的 from_raw 永远不会被调用,这块内存就永久丢失了,cargo miri 能帮你发现这类问题。
① unsafe 只解锁四件事:解裸指针、调 unsafe 函数、访问 static mut、实现 unsafe trait(外加访问 union 字段)。借用检查、类型检查全都还在。
② unsafe fn 必须有 # Safety 文档,因为它的前置条件无法用类型表达,只能靠文档 + Review。
③ Send 管"能不能搬",Sync 管"能不能共享";T: Sync 等价于 &T: Send;Arc<Mutex<T>> 是跨线程共享可变状态的标准答案。
④ UnsafeCell 是所有内部可变性的地基:Cell(整取整存、无开销)、RefCell(运行期借用检查、会 panic)、Mutex(跨线程 + 阻塞)。
⑤ 跨语言结构体必须 #[repr(C)],枚举用 #[repr(u8)],newtype 用 #[repr(transparent)];panic 绝不能穿过 extern "C"。
⑥ bindgen 只负责生成裸绑定,务必再包一层 safe API;社区惯例是 foo-sys(自动生成) + foo(手写安全层)。
⑦ 所有权交接必须成对:into_raw 配 from_raw、Box::into_raw 配 Box::from_raw、mem::forget 配 Vec::from_raw_parts。少一半就是泄漏或 double free。
⑧ 写完 unsafe 一定跑一次 cargo +nightly miri test,Miri 能在解释执行时抓住绝大多数 UB。
1.(概念题)进了 unsafe 块之后,Rust 还会检查借用规则和生命周期吗?
查看答案
会。unsafe 只允许"解裸指针、调 unsafe 函数、访问 static mut、实现 unsafe trait、访问 union 字段"这几件事,借用检查、类型检查、移动语义、Drop 全部照常生效。所以你仍不能在一个 unsafe 块里绕开借用检查器。
2.(排错题)Arc<RefCell<Vec<u8>>> 直接 thread::spawn 会怎样?为什么?
查看答案
编译不过:RefCell 不是 Sync,所以 Arc<RefCell<T>> 不是 Send。RefCell 可以在只有 &self 的情况下改内部状态,多线程共享必然数据竞争。改成 Arc<Mutex<Vec<u8>>>,或者每个线程各自 clone 一份数据。
3.(原理题)&T 是不可变引用,为什么 Cell::set 能改内容而不算违反规则?
查看答案
因为 Cell 内部包含 UnsafeCell,编译器看到它会关闭"这块内存不会被改"的优化假设。换句话说,"&T 不可变"是编译器用来做优化的契约,而 UnsafeCell 是官方指定的"豁免标记"。任何内部可变性类型(Cell/RefCell/Mutex/Atomic)最终都包着一个 UnsafeCell,没有它就是不合法(UB)。
4.(FFI 题)一个 Rust 函数返回 *mut c_char 给 C,为什么还必须在 Rust 侧提供一个 xxx_free 函数?
查看答案
因为内存是 Rust 的分配器分配的,C 侧free() 会用到不同的分配器,是 UB。必须由 Rust 提供配对的释放函数,内部调 CString::from_raw(或 Box::from_raw)把所有权收回来再 drop。同一份原则也适用于 Vec、Box、任何 into_raw 出去的指针。
5.(填空/排错题)下面代码为什么崩溃?let p = CString::new("hi").unwrap().as_ptr(); unsafe { call(p) };
查看答案
临时的 CString 在分号处就被 drop 了,p 是悬垂指针。改法:let c = CString::new("hi").unwrap(); unsafe { call(c.as_ptr()) }——把 CString 绑定到变量上,让它活到调用结束。这类"借用了一个立刻消失的拥有者"的 bug 在 FFI 代码里非常常见。