进阶:绕开借用检查器的正当手段
并发与 Send / Sync
它在解决什么
「无畏并发」(fearless concurrency)是 Rust 最常被引用的口号之一。 它的准确含义很窄,但也很硬:
数据竞争是编译错误。
注意不是「并发 bug 都没了」—— 死锁、活锁、逻辑竞态照样会有。 编译器管的只有一件事:两个线程同时访问同一块内存、其中至少一个在写, 而且没有同步。
⭐ 而它用的不是什么新机制,就是所有权和 借用那两套规则,加上两个自动 trait。
起线程要 move
理由完全是生命周期那套推理:
编译器不知道新线程会活多久,而 main 里的局部变量在 main 结束时就没了 ——
借用可能悬空。move 把所有权搬进去,问题就不存在。
let data = vec![1, 2, 3];
let h = thread::spawn(move || data.len());
println!("{}", h.join().unwrap());
join() 返回 Result,因为那个线程可能 panic 了。
thread::scope:借出去,但保证收得回来
move 有个明显的不便:数据搬进去就拿不回来了。
如果只是想让几个线程并行处理同一份数据再汇总,
thread::scope 就是为这个形状准备的:
let total: i32 = thread::scope(|s| {
let h = s.spawn(|| data.iter().sum::<i32>()); // 借用,没有 move
h.join().unwrap()
});
println!("{total} {}", data.len()); // data 还在
它保证作用域结束前所有子线程都已经 join,于是借用不可能悬空。
换成普通的 thread::spawn 就是 E0373。
⇒ 「把数据分片交给几个线程算完再汇总」这个最常见的形状,
用 scope 不需要 Arc、也不需要 clone。
Send 和 Sync:两个自动 trait
| trait | 含义 |
|---|---|
Send |
这个类型的值可以被移动到另一个线程 |
Sync |
这个类型的引用可以被多个线程同时持有(等价于 &T 是 Send) |
绝大多数类型自动就是 Send + Sync —— 编译器按字段推导,
你几乎永远不需要手写这两个 impl。
🚨 最著名的例外就是 Rc:
它过不了线程边界,Arc 可以。
两者的差别只有一处:Rc 的计数是普通加减,Arc 的是原子操作。
所以 Rc 没实现 Send,编译器直接拒绝(E0277)。
⇒ 这就是「数据竞争是编译错误」的实际样子:
不是靠你记得别把 Rc 传出去,是传了就编译不过。
代价是 Arc 的计数比 Rc 慢一点 —— 所以单线程别用 Arc。
📌 同理,RefCell 是 Send 但不是 Sync
(它的借用计数器不是原子的),跨线程共享要用 Mutex / RwLock。
两条路线:共享状态 vs 消息传递
Arc<Mutex<T>>
两层包装,两件事:Arc 负责「多个线程共同拥有」,
Mutex 负责「同一时刻只有一个能改」。
少了 Mutex 就编译不过。
let counter = Arc::new(Mutex::new(0));
let c = Arc::clone(&counter);
thread::spawn(move || { *c.lock().unwrap() += 1; });
⚠️ lock() 返回 Result,是因为持锁的线程如果 panic 了,
锁会被标记为「中毒」(poisoned)—— 那意味着它保护的数据可能处于
半更新状态。unwrap() 在这里等于「那种情况我不处理」。
⭐ 注意锁是包着数据的(Mutex<T>),不是像别的语言那样在旁边放一个锁对象。
这让「忘了加锁就访问数据」这件事在类型上就不可能 ——
拿不到数据,除非先 lock()。
通道(mpsc)
let (tx, rx) = mpsc::channel();
// 每个线程拿一个 tx.clone(),往里发
drop(tx); // ⚠️ 原始的那个必须丢掉
let got: Vec<i32> = rx.iter().collect();
⚠️ 那个 drop(tx) 不是装饰:rx.iter() 要等到所有发送端都关闭才结束,
留着原始的 tx 会让它永远等下去 —— 而那是死锁,不是报错。
⚠️ 收到的顺序也是不确定的(哪个线程先到就先收到哪个),
所以那条示例里的 sort() 同样是必需的。
⇒ 两条路线的判据:要汇总计算结果、状态简单 → 共享 + 锁; 要流水线、要解耦生产和消费 → 通道。
编译器管不了什么
说清楚边界比说口号有用:
| 问题 | 编译器管吗 |
|---|---|
| 数据竞争(无同步的并发读写) | 管 |
| 悬空引用跨线程 | 管 |
| 死锁(两个锁按不同顺序拿) | 不管 |
忘了 drop(tx) 导致永远等待 |
不管 |
| 逻辑上的竞态(顺序假设错了) | 不管 |
| 性能(锁竞争、伪共享) | 不管 |
⇒ 所以「无畏」指的是那一类内存安全问题被消灭了, 而不是并发从此简单了。它换来的实际好处是: 你可以放心重构并发代码 —— 改错了大概率是编译不过,而不是线上偶发。
这一章到这里就完整了
| 篇 | 它回答的问题 |
|---|---|
| 智能指针与内部可变性 | 怎么正当地绕开「一个所有者」和「一人写」 |
| 迭代器与闭包 | 闭包怎么捕获环境,迭代器为什么是惰性的 |
| 本篇 | 并发下那两个自动 trait 挡住了什么 |
全书到这里
五章二十篇,从「为什么是 Rust」到「无畏并发」。 如果你从头读到这里,接下来最有用的事只有一件:去写一个真的东西。
⚠️ 这套教程刻意没写的几块:async/await、宏、unsafe、FFI。
不是遗漏 —— 它们都要求你先对前面这二十篇里的东西有手感,
而在那之前读它们只会变成背语法。
全部篇目见 Rust 教程首页。
本篇示例
下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。
`thread::spawn` 要求闭包拿走所有权
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let h = thread::spawn(move || data.len());
println!("{}", h.join().unwrap());
}编译通过 · 输出 "3\n"
对照:去掉 `move`
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let h = thread::spawn(|| data.len());
println!("{}", h.join().unwrap());
}编译不过:error[E0373]
对照项报 `E0373`:编译器不知道新线程会活多久,而 `data` 在 `main` 结束时就没了 —— 借用可能悬空。`move` 把所有权搬进去,问题就不存在了。⇒ 这是[生命周期](/rust/ownership/lifetimes/)那套推理在并发上的直接应用,不是一条新规则。
🚨 `Rc` 过不了线程边界,`Arc` 可以
use std::rc::Rc;
use std::thread;
fn main() {
let a = Rc::new(1);
let h = thread::spawn(move || *a);
println!("{}", h.join().unwrap());
}编译不过 · error[E0277]
rustc 原文
error[E0277]: `Rc<i32>` cannot be sent between threads safely
--> conc-rc-is-not-send.rs:6:27
|
6 | let h = thread::spawn(move || *a);
| ------------- -------^^^
| | |
| | `Rc<i32>` cannot be sent between threads safely
| | within this `{closure@conc-rc-is-not-send.rs:6:27: 6:34}`
| required by a bound introduced by this call
|
= help: within `{closure@conc-rc-is-not-send.rs:6:27: 6:34}`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
--> conc-rc-is-not-send.rs:6:27
|
6 | let h = thread::spawn(move || *a);
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library/std/src/thread/functions.rs:125:0对照:换成 `Arc`
use std::sync::Arc;
use std::thread;
fn main() {
let a = Arc::new(1);
let h = thread::spawn(move || *a);
println!("{}", h.join().unwrap());
}编译通过,输出:"1\n"
⭐ 两者的差别只有一处:`Rc` 的计数是**普通加减**,`Arc` 的是**原子操作**。所以 `Rc` 没实现 `Send`,编译器直接拒绝(`E0277`)。⇒ 这就是「无畏并发」的实际含义:**数据竞争是编译错误**,而不是靠你记得别把 `Rc` 传出去。代价是 `Arc` 的计数比 `Rc` 慢一点,所以单线程别用 `Arc`。
共享可变状态:`Arc<Mutex<T>>`
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut hs = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
hs.push(thread::spawn(move || {
*c.lock().unwrap() += 1;
}));
}
for h in hs {
h.join().unwrap();
}
println!("{}", *counter.lock().unwrap());
}编译通过 · 输出 "4\n"
对照:去掉 `Mutex`,直接改 `Arc<i32>`
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(0);
let mut hs = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
hs.push(thread::spawn(move || {
*c += 1;
}));
}
for h in hs {
h.join().unwrap();
}
println!("{}", *counter);
}编译不过:error[E0594]
`Arc` 负责「多个线程共同拥有」,`Mutex` 负责「同一时刻只有一个能改」—— 两件事,两层包装。对照项少了 `Mutex` 就编译不过(`E0594`)。⚠️ `lock()` 返回 `Result` 是因为**持锁的线程如果 panic 了,锁会被标记为中毒**;`unwrap()` 在这里等于「那种情况我不处理」。
通道:用消息传递代替共享
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for i in 0..3 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(i * i).unwrap();
});
}
drop(tx);
let mut got: Vec<i32> = rx.iter().collect();
got.sort();
println!("{got:?}");
}编译通过 · 输出 "[0, 1, 4]\n"
对照:把 `i * i` 改成 `i`
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for i in 0..3 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(i).unwrap();
});
}
drop(tx);
let mut got: Vec<i32> = rx.iter().collect();
got.sort();
println!("{got:?}");
}编译通过,输出:"[0, 1, 2]\n"
⚠️ 两处细节不是装饰:`drop(tx)` 必须有 —— `rx.iter()` 要等到**所有**发送端都关闭才结束,留着原始的 `tx` 会让它永远等下去(**死锁,而不是报错**)。`got.sort()` 也必须有 —— 三个线程谁先到是不确定的,不排序的话输出每次都可能不同。
`thread::scope`:不用 `move` 也能借出去
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let total: i32 = thread::scope(|s| {
let h = s.spawn(|| data.iter().sum::<i32>());
h.join().unwrap()
});
println!("{total} {}", data.len());
}编译通过 · 输出 "6 3\n"
对照:换成普通的 `thread::spawn`
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let h = thread::spawn(|| data.iter().sum::<i32>());
let total: i32 = h.join().unwrap();
println!("{total} {}", data.len());
}编译不过:error[E0373]
⭐ `thread::scope` 保证**作用域结束前所有子线程都已 join**,于是借用不可能悬空 —— 借出去的 `data` 在闭包里能用,作用域外**还能继续用**。对照项用普通 `spawn` 就是 `E0373`。⇒ 「把数据分片交给几个线程算完再汇总」这种最常见的形状,用 scope 不需要 `Arc`,也不需要 clone。