本页目录

Rust II · 并发、trait 与 unsafe 边界

对标:The Rust Programming Language / Rust for Rustaceans(Gjengset)| 前置:rust-01(所有权)、par-02(无锁、数据竞争)、os-02(并发) 这一页把 Rust 的三块高级能力讲透:无畏并发(所有权如何在编译期消灭数据竞争——par 线所有噩梦的解药)、trait(Rust 的抽象与多态机制,接口 + 泛型的统一)、以及 unsafe(什么时候必须跳出安全网、以及安全抽象的边界哲学)。读完,你会明白 Rust 为什么能同时用于操作系统内核和 Web 后端。

学习层:把引用送进线程,就自动变成线程安全了吗?

具体谜题:计数器、通道与锁

两个线程各执行 100 次 counter += 1。若共享的是裸可变引用,最终值为何可能小于 200?把数据改为每线程局部计数后通过 channel 汇总,或放进 Arc<Mutex<u64>>,Rust 分别凭什么允许或拒绝这些写法?

先预测编译期边界

预测:① 裸共享可变引用不能安全跨线程,编译器拒绝;② channel 转移消息所有权,接收端获得唯一 owner,能避免共享写;③ Arc<Mutex<T>> 通过原子引用计数 + 互斥锁提供共享同步,但仍可能死锁或造成争用。

最小心智模型:Send、Sync 与同步协议

Send 约束值能否转移到另一线程,Sync 约束共享引用能否跨线程;它们是由内部字段递归决定的 trait 边界。类型系统阻止无同步的别名写入,运行时原语仍负责排队、互斥、唤醒和内存顺序。

形式机制与不变量

数据竞争要求两个并发访问同一位置、至少一个写、且没有 happens-before;Rust safe code 通过所有权/借用与 Send/Sync 边界拒绝这类别名。互斥不变量是同一时刻至多一个 guard 持有锁;channel 不变量是消息只能被符合类型的接收端消费一次。若原子 flag 用 release 写、acquire 读,则发布前的写入对读取方可见。

无锁算法还要保持 par-02 的线性化与回收不变量;trait 通过并不等于具体算法证明完成。

反例与失效边界

  • Arc 只安全管理共享所有权,不会让内部数据自动可变;Arc<RefCell<T>> 通常不能跨线程,因为 RefCell 没有线程同步。
  • Mutex 可避免数据竞争,却不能避免锁顺序造成的死锁、持锁 I/O 或饥饿。
  • unsafe 可以绕过检查器,但必须由作者恢复别名、生命周期、同步和 FFI 契约,否则“编译通过”没有安全含义。

迁移任务:从 L10 的 bug 到 Rust API

把 L10 的无锁栈、生产者消费者和共享计数器各画出所有权/同步图,分别选择 channel、Mutex、AtomicUsize 或安全封装。记录它们的线性化点、阻塞行为、内存序和错误路径,比较与 par-02 手写 CAS 的边界。

无 JavaScript 时的静态读法:两个线程各加 100 次,裸共享可变引用存在 data race,不能假设最终为 200;channel 让每个线程发送局部计数,主线程串行求和得到 200;Arc<Mutex<u64>> 每次加法需先锁定 guard,正确结果为 200,但会有锁争用。交互版切换裸引用、channel、Arc/Mutex 和原子计数,显示 Send/Sync gate、临界区时间线与结果不变量。

\n+

方案跨线程权限同步点预期结果
裸 &mut拒绝无不能构造
channel转移 ownersend/recv200
Arc/Mutex共享 + 独占 guardlock/unlock200
AtomicUsize原子读改写原子操作200
\n+

\n+

\n+\n+## 1. 无畏并发:数据竞争的编译期终结

par-02/os-02 里,数据竞争是最难调的 bug(间歇性、不可复现)。Rust 的洞见:数据竞争的定义是"≥2 线程访问同一数据、≥1 个写、无同步"——而 rust-01 的借用铁律(共享不可变 XOR 可变不共享)恰好禁止了"可变 + 共享"。把它扩展到线程间,数据竞争就在编译期不可能。靠两个 trait:

编译器自动为大多数类型推导这两个标记,并强制:想跨线程共享可变数据,必须用同步类型——Mutex<T>(互斥锁,os-02)、Arc<T>(原子引用计数,多线程共享所有权)、atomic(无锁原语,par-02)。忘了加锁?编译不过——borrow checker 拦下。"数据竞争在 Rust 里是编译错误而非运行时灾难",这就是"无畏并发"的字面兑现——你可以大胆写多线程,编译过了就没有数据竞争(死锁仍可能,那是逻辑问题 os-02,但数据竞争没了)。

典型模式:

这缝合了本站三条线:os-02 的锁/竞态 + par-02 的原子/内存序 + rust-01 的所有权 = Rust 把并发的正确性从"程序员自律"变成"编译器强制"。你读到这里回看 par-02 那些 ABA、内存序噩梦,会明白 Rust 消灭的正是它们。

2. Trait:Rust 的抽象引擎

trait 是 Rust 的接口/多态机制——定义"一个类型能做什么",是泛型和抽象的核心(类似 Java interface + Haskell typeclass,🔗 pl 线的类型类):

读法:trait 让 Rust 既有 C 的性能又有高级语言的抽象——泛型 + trait bound 在编译期解决,运行时零负担。你写 .iter().map().filter().collect() 这样的函数式链,编译后和手写循环一样快。

3. unsafe:安全网的边界

Rust 的安全保证靠借用检查器,但有些事它证明不了安全(却确实安全)——底层内存操作、调用 C、实现无锁结构(par-02)、硬件寄存器访问。这时用 unsafe 块,解锁五种超能力(如解引用裸指针、调用 unsafe 函数)。

关键哲学——安全抽象封装 unsafe:

这是 Rust 工程哲学的精髓:不是"永远安全"(那做不了系统编程),而是"默认安全 + 显式标记的不安全边界 + 人对边界负责"——大部分代码享受编译器保护,极少数底层代码明确标出、集中审查。这个"把危险局部化并显式化"的思想,本身就是一种优秀的工程纪律(🔗 与 sec 线"缩小攻击面"、系统线"隔离"同构)。

4. Rust 的定位:什么时候用它

5. 练习与要点

例 1(无畏并发实操) 用 Arc<Mutex<T>> 让多个线程安全地共享递增一个计数器——对比 os-02 里 C 版的数据竞争(那里要小心加锁、错了静默出错),Rust 里忘加锁直接编译不过。"编译器逼你正确"亲手体验。

例 2(trait 双分发) 同一个 Draw trait,用泛型(静态分发)和 Vec<Box<dyn Draw>>(动态分发)各写一次——理解"编译期特化 vs 运行时虚表"的取舍。

例 3(unsafe 边界) 读标准库 Vec::push 的实现(内部有 unsafe 处理未初始化内存),理解"unsafe 实现 + 安全接口"的封装模式——理解 Rust 不是没有 unsafe,而是把它关进审查过的盒子。\(\blacksquare\)


语言线完成(4 页)。下一页进入工程与全栈线——以你正在运营的 Medusa 为解剖标本,看一个请求从浏览器到数据库的完整旅程。