并发为什么难:数据竞争与不确定性
并发程序的 bug 之所以难查,是因为它们具有不确定性:同一份代码跑十次,崩溃可能只出现一次。最常见的元凶是数据竞争(data race)——两个或更多线程同时访问同一块内存,其中至少一个在写,又没有任何同步机制。在 C++ 里,这属于“未定义行为”,可能跑很久才在生产环境炸掉。
Rust 的卖点在于:它把“会不会数据竞争”这个问题提前到编译期回答。如果一段代码能编译通过,基本就可以认定它没有数据竞争。这不是靠你记得加锁,而是靠所有权与借用规则——编译器在类型层面就禁止了你把“既能被多个线程同时改、又没加保护”的数据到处传递。
记住这句话:在 Rust 里,并发错误首先是类型错误。要么编译通过、安全;要么根本编不过。你很少在运行时才第一次发现数据竞争。
- thread::spawn创建操作系统线程,返回 JoinHandle 用来等待它结束。
- channel多生产者单消费者的消息通道,在线程间安全地传递数据所有权。
- Arc+Mutex原子引用计数共享只读壳,Mutex 提供同一时刻唯一的可变访问。
- Send/Sync两个标记 trait,规定类型能不能跨线程安全转移或共享。
起一个线程:thread::spawn 与 JoinHandle
在 Rust 里开线程用 std::thread::spawn,传入一个闭包,闭包就是这个线程要跑的活儿。spawn 立刻返回一个 JoinHandle,你可以稍后用它来等待线程结束、取回闭包的返回值。
use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("子线程:{}", i);
}
"子线程干完了" // 闭包的最后一行就是返回值
});
println!("主线程:继续做自己的事");
// join() 会阻塞直到子线程结束,并取出它的返回值
let result = handle.join().unwrap();
println!("子线程返回:{}", result);
}
注意一个编译期的硬规则:闭包如果要借用主线程里的变量,编译器会要求这些变量的生命周期长于线程。可你 spawn 出去的线程可能比当前作用域活得久,编译器无法保证这一点,于是直接报错。这正是所有权系统在保护你——它替你想到了“主线程先结束、子线程还在引用已销毁数据”这种悬垂场景。
等线程结束:join 与返回值
join 返回一个 Result:如果子线程正常结束,里面是 Ok(闭包返回值);如果子线程 panic 了,join 会拿到 Err。也就是说,Rust 把“一个线程是否崩溃”显式地交到了你手里,而不是任其无声地拖垮整个进程。
use std::thread;
fn main() {
let mut handles = Vec::new();
for n in 1..=4 {
handles.push(thread::spawn(move || {
n * n // 每个线程算出一个平方
}));
}
let mut sum = 0;
for h in handles {
sum += h.join().unwrap(); // 依次收集结果
}
println!("平方和 = {}", sum); // 1+4+9+16=30
}
这里用了 move ||:它告诉闭包“把捕获的变量(这里是循环变量 n)的所有权直接搬进线程”。因为 n 是 Copy 类型,每个线程拿到自己那一份副本,互不干扰。move 是你处理线程与数据所有权关系时最常用的一个关键字。
线程间传消息:channel 与 mpsc
Rust 推崇的并发范式是“通过消息传递共享所有权,而不是通过共享内存来传递消息”。标准库提供了信道(channel),它是多生产者、单消费者(mpsc)的:可以有多个发送端 Sender,但只有一个接收端 Receiver。
use std::sync::mpsc::channel;
use std::thread;
fn main() {
let (tx, rx) = channel();
for i in 0..3 {
let tx = tx.clone(); // 每个线程克隆一个发送端
thread::spawn(move || {
tx.send(format("任务{}完成", i)).unwrap();
});
}
drop(tx); // 原始发送端丢掉,否则 recv 永远等不到结束
for received in rx { // 自动迭代直到信道关闭
println!("收到:{}", received);
}
}
消息传递的妙处在于:数据连同它的所有权一起被送过信道。接收方拿到消息后独占这份数据,发送方不再碰它——于是“同时读写同一份数据”这件事在结构上就不可能发生,根本不需要锁。recv() 是阻塞的,而 try_recv() 会立刻返回一个结果,适合你想顺手检查一下、不想卡住的时候。
共享可变状态:Arc 与 Mutex
有些场景就是要多个线程访问同一份数据——比如一个共享的计数器。这时组合是固定的两件套:Arc(原子引用计数,让一份数据被多处共享所有权)负责“共享”,Mutex(互斥锁)负责“同一时刻只允许一个线程进去改”。
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..10 {
let counter = Arc::clone(&counter); // 共享同一份的句柄
handles.push(thread::spawn(move || {
let mut num = counter.lock().unwrap(); // 拿到锁,离开作用域自动释放
*num += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("最终计数 = {}", *counter.lock().unwrap()); // 10
}
几个关键认知:第一,Arc 本身只提供共享只读,它不能让你改内部值——能改,全靠 Mutex 包在里面;第二,lock() 返回的守卫实现了 Deref,你可以像操作普通引用一样用它,守卫一旦离开作用域,锁就自动释放,无需手写 unlock;第三,unwrap() 是因为锁可能被一个持锁又 panic 的线程弄成“中毒”状态,实战里要按你的错误策略决定怎么处理。
Send 与 Sync:编译期的并发安全契约
Send 和 Sync 是 Rust 里两个没有方法的“标记 trait”,它们不改变运行时行为,只用于告诉编译器这个类型能不能安全地跨线程使用。理解它们,就理解了 Rust 并发安全的底层契约。
| 标记 trait | 含义 | 例子 |
|---|---|---|
| Send | 这种类型的所有权可以安全地转移到另一个线程 | 绝大多数普通类型;Rc 不是 Send |
| Sync | 这种类型可以安全地被多个线程同时引用(&T 是 Send) | Mutex<T>、Arc<T>;裸指针不是 Sync |
好消息是:你几乎不需要手动实现它们。如果一个类型的所有字段都是 Send / Sync,编译器会自动把它也标记为 Send / Sync。只有当你用了裸指针等“不安全”东西、需要人为承诺线程安全时,才会用 unsafe impl Send。这就形成了一个闭环:标准库的并发原语(Mutex、Arc、channel)被标成 Send+Sync,所以用它们搭出来的组合类型自动也线程安全;你一旦试图把一个非 Send 的值 move 进线程,编译器立刻拒绝。
实战:用线程池风格并行求和
把一段很长的数据切成几块,每块交给一个线程算局部和,最后合并——这是最经典的并行模式。用 move 把每块数据的所有权送进线程,再用 join 收回局部结果。
use std::thread;
fn main() {
let data: Vec<i64> = (1..=1_000_000).collect();
let n = data.len();
let chunk = n / 4;
let mut handles = Vec::new();
for i in 0..4 {
// 把这一段的所有权从原 Vec 中“切”出来送进线程
let part = data[i * chunk .. (i + 1) * chunk].to_vec();
handles.push(thread::spawn(move || -> i64 {
part.iter().sum()
}));
}
let total: i64 = handles.into_iter()
.map(|h| h.join().unwrap())
.sum();
println!("总和 = {}", total);
}
注意这里用 to_vec() 给每一段做了一份独立拷贝,于是每个线程独占自己那份数据,连锁都不需要——这就是“消息传递 / 数据分区”相比共享可变状态的优势:各干各的,天然没有竞争。生产环境里,更推荐用 rayon 这样的并行迭代器库,它会自动把 iter().sum() 这类操作在多核上摊开,而不必手写切分和线程管理。
并发编程的心智模型与检查清单
把 Rust 并发记成两条路:能传消息就别共享可变状态;必须共享时,就 Arc 包 Mutex。前者让数据随消息流动、各线程独占,后者用锁把并发访问串行化。Rust 的编译器会在你走错路时直接拦下,而不是让 bug 潜伏到线上。
写并发代码前,用这份清单快速自查:
- 子线程闭包捕获的变量,是否用
move明确了所有权转移? - 线程间是优先用 channel 传消息,而不是到处共享可变变量?
- 共享可变数据是否用了
Arc<Mutex<_>>,而不是裸的 &mut? - 所有
join()是否都被调用,避免线程被遗弃或返回值被忽略? - 持锁守卫的作用域是否尽量小,没把 IO 或耗时操作包在锁里?
- 编译报错“doesn't implement Send”时,是不是误用了
Rc之类非线程安全类型?
Rust 没有消灭并发的难度,它只是把难度从“运行时偶发崩溃”挪到了“编译时一次想清楚”。这种交换,对要长期维护的服务来说非常划算。
下一篇我们回到 C 语言:在没有所有权和编译器帮忙的世界里,结构体、位域与手动的模块化组织,就是你把程序整理清楚的全部手段。