☰
无畏并发:为什么 Rust 能在编译期拦住数据竞争
2026/10/3 7:24:32 网站建设 项目流程

从 move 闭包与 E0373 讲到 Send/Sync 两个标记 trait,说清 Arc<Mutex> 为什么是 Rc<RefCell> 的线程安全对应物,以及 Rust 拦不住的死锁

上一篇结尾留下一个明确的约束:Rc<RefCell>不能跨线程。原因是Rc的计数器不是原子的,RefCell的借用检查也只在一个线程内部较劲。这一篇沿着这条线往多线程走。

为什么闭包必须加 move

先用最普通的方式,把主线程的一个Vec拿到新线程里去打印:

usestd::thread;letv=vec![1,2,3];lethandle=thread::spawn(||{println!("{:?}",v);});handle.join().unwrap();

编译不过,报 E0373:closure may outlive the current function, but it borrowsv, which is owned by the current function。

拆开这句报错。thread::spawn接收一个闭包,闭包按之前讲过的方式推断捕获——println!只需要引用,于是它选择借用v。问题在于生命周期:借用必须活得比被借的东西短(那是生命周期那篇的核心),可新线程要跑多久,编译器根本不知道,它没有任何依据断定"线程结束时v还活着"。反过来看更清楚——主线程可能立刻就结束、把v释放掉,而那个线程还没开始跑。跨线程的借用,编译器无法验证,所以一律拒绝。

修法是加move:

lethandle=thread::spawn(move||{println!("{:?}",v);});

move让闭包把v的所有权搬进去,而不是借。所有权一旦转移,主线程也不再持有v,就没有"谁先死"的问题了。

这里有一句值得记住的表述:move 覆盖的是 Rust 那个保守的默认借用,它并不允许你违反所有权规则。如果主线程在 spawn 之后接着用v,或者调用drop(v),那么加了move反而会在主线程报"使用了已移动的值"——因为所有权确实已经交出去了。move不是让两边都能用的魔法,它只是把关系明确下来:要么你留着,要么线程拿着。

Send 与 Sync:两个只贴标签的 trait

线程之间能传什么、不能传什么,由两个 trait 规定。它们的定义简短到几乎不像 trait:

pubunsafeautotraitSend{}pubunsafeautotraitSync{}

Send表示这个值可以安全地移交到另一个线程;Sync表示这个值的引用可以安全地被多个线程共享。

它们俩有两个共同点值得单独说。

第一,都是标记 trait:体内没有任何方法,存在的意义就是"贴上这个标签"。

第二,都是auto trait:编译器自动为每个类型派生,规则是"类型的每个字段都满足,它就满足"。所以绝大多数你见过的类型(整数、String、Vec、你自己写的 struct)都自动是Send加Sync,不需要做任何事。

那么谁不满足?上一节那两个类型正好是反例:Rc不是Send,而Arc是。标准库Send的文档把原因讲得很具体:如果两个线程各自 clone 同一个Rc,它们可能同时去改那个引用计数——因为Rc用的不是原子操作,这就是未定义行为。

Arc的 a 就是 atomic,代价是多付一点同步开销。

Arc 套 Mutex:把上一篇的两层结构原样换掉

Rc<RefCell>是两层结构,每层解决一个需求;到了多线程,这两层各自换一个对应物,需求本身一点没变:

需求单线程做法多线程对应物
多个所有者RcArc
共享状态下还能改RefCellMutex
两者叠加Rc<RefCell>Arc<Mutex>

外层好理解:计数器从非原子换成原子。

内层的替换更值得看一眼。RefCell和Mutex都在提供"可变性",但方式截然不同:RefCell是运行期记账——它记下当前有几个借用,冲突就 panic,而冲突只可能来自同一个线程里的代码;Mutex(mutual exclusion,互斥)是真正的阻塞——一个线程拿到锁之后,其他线程想拿同一把锁就会被挡住,一直等到锁被释放。

前者是"发现冲突就报错",后者是"从源头让冲突不可能发生"。

写一个测试代码,十个线程各自把计数器加一:

usestd::sync::{Arc,Mutex};usestd::thread;letcounter=Arc::new(Mutex::new(0));letmuthandles=vec![];for_in0..10{letcounter=Arc::clone(&counter);lethandle=thread::spawn(move||{letmutnum=counter.lock().unwrap();*num+=1;});handles.push(handle);}forhandleinhandles{handle.join().unwrap();}println!("Result: {}",*counter.lock().unwrap());

循环里那句let counter = Arc::clone(&counter);是必须的,也是第一次写多线程最容易漏的地方:闭包带了move,如果不先复制出一份新的Arc就丢进thread::spawn,那么第一轮循环就把counter整个移走了,第二轮会报"counter 在上一轮里已经被移动"。

这里的写法用同名变量遮住外层那个,每一轮拿到一个独立的句柄。

运行完,counter仍然是不可变的(它是let,不是let mut),却拿到了内部值的可变引用——这说明Mutex也提供内部可变性,和Cell那一族是同一类角色。

上一节用RefCell改Rc里的内容,这一节用Mutex改Arc里的内容,位置完全对应。

锁守卫:不会忘记解锁,但拦不住死锁

counter.lock()返回的不是数据本身,而是一个MutexGuard,包在Result里。这个守卫实现了Deref——所以*num += 1能直接改里面的值;它还实现了Drop——离开作用域时自动释放锁。

Result那一层来自锁中毒(poisoning):如果持有锁的线程 panic 了,这把锁会被标记为"中毒",之后lock()返回Err,因为数据可能已经处于某个被破坏的中间状态。大多数用法直接unwrap(),把这个 panic 继续往上传,避免别人读到不一致的数据。中毒是建议性的,PoisonError提供了into_inner,真需要的话仍然能拿到数据。

现在说清楚一件重要的事:Rust 拦不住死锁。它管的是"内存不会被两个线程同时写坏",不是"你的加锁顺序一定对"。典型的几种情况:

  • 同一个线程对同一把锁加锁两次。标准库文档明确说这个行为未指定,只是保证"第二次调用不会返回"——可能 panic,可能死锁。
  • 两把锁,两个线程按相反顺序获取。A 拿着锁 1 等锁 2,B 拿着锁 2 等锁 1,永远互相等下去。
  • 守卫活得比需要的久。一边握着守卫一边去join另一个需要这把锁的线程,就会永久阻塞。

应对办法就是缩短临界区——用一对花括号把守卫圈在小范围里,或者显式drop它。这一点和上一篇的Rc循环引用是同一种性质:都属于类型系统管不到的"逻辑错误"。你能从编译器和文档里得到的,是确定它不会因为共享内存而烂掉;剩下的并发纪律,还是要你自己守。

无畏并发这个说法指什么

回过头看这一篇讲的东西,每一环都是编译期检查:跨线程借用被 E0373 拦下;Rc因为不满足Send而被拦下;共享可变数据必须走Mutex,锁的释放交给Drop保证。

The Rust Book 对这个效果有一段说明:Rust 团队最初以为保证内存安全和防止并发问题是两件不同的挑战、需要用不同的方法解决;后来发现所有权和类型系统是一套能同时管理这两件事的工具。

借助所有权和类型检查,许多并发错误在 Rust 里成了编译期错误,而不是运行期错误——于是团队给这部分起了个外号:fearless concurrency,无畏并发。

它承诺的不是"并发很容易",而是"你不需要靠运气去复现一个偶发的运行时 bug"。


术语表

  • 标记 trait(marker trait):体内没有任何方法、只用来给类型贴标签的 trait,Send和Sync都属于此类。
  • auto trait:由编译器自动为类型派生的 trait,规则是类型的每个字段都满足它就满足,因此绝大多数类型自动具备Send与Sync。
  • Send:表示该值可以安全地被移交到另一个线程。
  • Sync:表示该值的引用可以安全地被多个线程共享;等价定义是T: Sync当且仅当&T: Send。
  • Arc:原子引用计数的共享指针,Rc的线程安全版本,代价是计数操作有同步开销。
  • Mutex:互斥锁,保证同一时刻只有一个线程能访问它保护的数据;它同时提供内部可变性。
  • MutexGuard:lock返回的锁守卫,实现Deref(可以当引用用)和Drop(离开作用域自动解锁)。
  • 锁中毒(poisoning):持有锁的线程 panic 后,锁被标记为中毒,后续lock返回Err,以免其他线程读到可能不一致的数据。
  • 死锁(deadlock):两个线程各持一把锁、又都在等对方那把,或同一线程重复加锁,导致永久等待;类型系统无法阻止。

参考来源

  • The Rust Book 第 16 章总览 — 无畏并发的提出背景、所有权与类型系统同时管理内存安全与并发:https://doc.rust-lang.org/book/ch16-00-concurrency.html
  • The Rust Book 16.1「Using Threads to Run Code Simultaneously」— thread::spawn、join、move 闭包与 E0373 报错原文:https://doc.rust-lang.org/book/ch16-01-threads.html
  • The Rust Book 16.3「Shared-State Concurrency」— Mutex 定义、MutexGuard 的 Deref 与 Drop、Rc 跨线程的 E0277 报错、Arc 的原子计数与死锁类比:https://doc.rust-lang.org/book/ch16-03-shared-state.html
  • std::marker::Send 官方文档 — Send 的定义、Rc 为何不是 Send、Mutex 对 T: Send 的要求:https://doc.rust-lang.org/std/marker/trait.Send.html
  • std::marker::Sync 官方文档 — Sync 的定义与 T: Sync 等价于 &T: Send 的完整对应关系:https://doc.rust-lang.org/std/marker/trait.Sync.html
  • std::sync::Mutex 官方文档 — lock 返回 LockResult、中毒机制与 into_inner、重复加锁行为未指定、缩短临界区与死锁示例:https://doc.rust-lang.org/std/sync/struct.Mutex.html
  • The Rustonomicon「Send and Sync」— 两者是 unsafe 的标记 trait、自动派生规则与手写实现的注意事项:https://doc.rust-lang.org/nomicon/send-and-sync.html

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询