29-并发与线程
Arc、Send/Sync 与作用域线程19 分钟阅读
并发与线程 (Concurrency And Threads)
面向 Rust 1.97.1 (stable, 2026-07)。这篇重点不是背 API,而是先分清“线程、消息传递、共享状态、所有权边界”这四件事。
本篇能解决什么:
- 你是否会把 Rust 线程直接等同于 Go goroutine,结果在成本和约束上产生误解?
- 你是否想知道为什么
Arc<Mutex<T>>在 Rust 并发代码里这么常见? - 你是否总在
Send/Sync、spawn、借用跨线程这些报错上绕圈? - 你是否想比较“消息传递”和“共享状态”在 Rust 里的组织方式?
术语速查表:
| 术语 / 缩略词 | 全称 / 读法 | 中文 | 一句话解释 | Go 里的近亲 |
|---|---|---|---|---|
| thread | — | 线程 | 操作系统线程 | goroutine 不是同一层级 |
| channel | — | 通道 | 在线程间传值的消息通道 | channel |
Mutex<T> | — | 互斥锁 | 同一时刻只允许一个写访问者 | sync.Mutex |
RwLock<T> | — | 读写锁 | 允许多读单写 | sync.RWMutex |
Send | — | 可发送 | 所有权可安全移动到其他线程 | 近似“线程可转移” |
Sync | — | 可共享 | &T 可安全跨线程共享 | 近似“线程安全共享” |
热度索引:
| 热度 | 题目 |
|---|---|
common | Q1, Q2, Q3, Q4, Q5, Q6, Q7, Q8, Q9, Q10, Q11, Q12, Q13, Q14, Q15, Q17, Q18, Q19, Q20, Q21 |
occasional | Q16 |
Q1. Rust 的 thread::spawn 和 Go 的 goroutine 最容易混淆的点是什么?
Tags: common thread spawn
适用版本: Rust 1.0+
一句话答案: Rust 标准库的 thread::spawn 启动的是操作系统线程,不是像 goroutine 那样由语言运行时调度的轻量任务。
详细解答: 这会直接影响你对创建成本、共享数据方式和生命周期要求的预期。Rust 当然也有更轻量的异步任务,但那属于 async runtime,而不是这篇的线程 API。
| |
| |
🐹 Go 对比: Go 的 goroutine 更轻,常成千上万;Rust thread::spawn 更适合明确的线程级工作单元。
记忆点: 线程和异步任务是两套并发工具,不要混成一个概念。
Q2. 为什么 spawn 里的闭包总让我用 move?
Tags: common move spawn
适用版本: Rust 1.0+
一句话答案: 因为新线程可能活得比当前栈帧更久,move 能把需要的数据所有权转进线程,避免悬垂借用。
详细解答: 这不意味着“一律深拷贝”;很多类型只是移动其拥有权,堆上数据本体并不会复制。
| |
| |
🐹 Go 对比: Go 闭包默认捕获外部变量,但这也容易踩循环变量和共享可变状态的坑;Rust 则更早逼你明确所有权转移。
记忆点: 看到线程闭包,先问“这个数据是借用过去,还是所有权搬过去”。
Q3. 线程间传值时,channel 在 Rust 里是什么风格?
Tags: common channel
适用版本: Rust 1.0+
一句话答案: 标准库 mpsc 是“多生产者,单消费者”通道,发送的是值的所有权,不是隐式共享引用。
详细解答: 这与 Rust 的所有权模型非常契合:发送一个值通常就意味着把它交给接收方处理。
| |
| |
🐹 Go 对比: 概念上很像 channel,但 Rust 更强调“发送的是拥有权转移后的值”。
记忆点: mpsc 读作 multi-producer, single-consumer。
Q4. 什么时候该用 channel,什么时候该用 Arc<Mutex<T>>?
Tags: common channel shared-state
适用版本: Rust 1.0+
一句话答案: 如果更像“交任务 / 交结果”,优先 channel;如果更像“多线程共同维护同一份状态”,优先共享状态加锁。
详细解答: 两者都合法,但设计风格不同。channel 让数据在各线程之间流动;共享状态则让多个线程围着同一份数据协作。
| |
| |
🐹 Go 对比: Go 社区也常强调 “share memory by communicating”;Rust 因为所有权模型,更容易把这句话落成类型上的硬约束。
记忆点: “传消息” 和 “守同一份状态” 是两种不同建模方式。
Q5. Mutex<T> 的最小正确用法是什么?
Tags: common Mutex
适用版本: Rust 1.0+
一句话答案: 把临界区缩到最小,尽快拿锁、尽快释放锁,并通过作用域让 guard 尽早 drop。
详细解答: lock() 返回的是 guard,不是裸引用;guard 离开作用域时自动解锁,这也是 Rust RAII 风格在线程同步上的直接体现。
| |
| |
🐹 Go 对比: Go 常见模式是 mu.Lock(); ...; mu.Unlock();Rust 则更鼓励靠作用域自动解锁,减少忘记释放的风险。
记忆点: guard 活多久,锁就持有多久。
Q6. Mutex 的 poisoning 是什么,为什么 unwrap() 很常见?
Tags: common Mutex poisoning
适用版本: Rust 1.0+
一句话答案: 如果持锁线程在临界区 panic,锁会被标记为 poisoned;这不是死锁,而是标准库在提醒你共享状态可能已经不一致。
详细解答: 在很多简单程序里,直接 unwrap() 是合理的,因为一旦状态可能损坏,整个程序继续跑下去也未必有意义。
| |
| |
🐹 Go 对比: Go 的 sync.Mutex 没有 poisoning 语义;Rust 选择在标准库里把“状态可能不一致”这个风险显式化。
记忆点: poisoned lock 还能恢复,只是你要自己决定是否值得继续信任那份数据。
Q7. RwLock<T> 和 Mutex<T> 什么时候该切换?
Tags: common RwLock
适用版本: Rust 1.0+
一句话答案: 当读远多于写,而且读操作彼此能并行时,RwLock<T> 才值得考虑;否则先用 Mutex<T> 保持简单。
详细解答: RwLock 看起来更强,但并不总是更快。它的状态管理更复杂,而且实际性能非常依赖读写比例与平台实现。
| |
| |
🐹 Go 对比: 这和 sync.RWMutex 的取舍非常像:别因为 API 看起来“更高级”就默认选它。
记忆点: 没有明确读多写少证据时,优先 Mutex。
Q8. thread::scope 为什么这么重要?
Tags: common thread-scope
适用版本: thread::scope 自 Rust 1.63+
一句话答案: 因为它允许子线程安全借用外层栈数据,只要这些线程保证在作用域结束前全部 join 完成。
详细解答: 这很大程度上缓解了“普通 spawn 必须 'static”带来的心智门槛,尤其适合短生命周期并行工作。
| |
| |
🐹 Go 对比: Go 不需要单独的作用域线程 API 来借用栈数据,因为借用本身不是语言模型的一部分;Rust 则把这件事做得非常显式。
记忆点: 短生命周期并行任务,先想 thread::scope。
Q9. Send 和 Sync 怎么用最少的话记住?
Tags: common Send Sync
适用版本: Rust 1.0+
一句话答案: Send 是“值能不能搬到别的线程”,Sync 是“&T 能不能安全给别的线程共享”。
详细解答: 它们通常由编译器自动推导,所以你更多是在报错里认识它们,而不是手写实现它们。
| |
| |
🐹 Go 对比: Go 依赖 race detector 和约定检查共享安全;Rust 用 Send / Sync 把大量错误提前成编译时问题。
记忆点: 迁移看 Send,共享看 Sync。
Q10. 为什么 Rc<T> 不能在线程间传,而 Arc<T> 可以?
Tags: common Rc Arc Send
适用版本: Rust 1.0+
一句话答案: 因为 Rc<T> 的引用计数不是原子操作,多线程同时增减会数据竞争;Arc<T> 专门为此做了原子化。
详细解答: 这类报错经常是新手第一次在并发 Rust 里真正感受到“类型系统在拦线程安全 bug”。
| |
| |
🐹 Go 对比: Go 不会在类型层面区分“单线程引用计数对象”和“多线程引用计数对象”;Rust 这里做了显式拆分。
记忆点: 一看到线程边界,就检查自己手里是不是还拿着 Rc。
Q11. 简单计数器应该用 Mutex 还是原子类型?
Tags: common atomic Mutex
适用版本: Rust 1.0+
一句话答案: 只有一个简单整数状态时,原子类型通常更直接;涉及多个字段一致性或复杂临界区时,还是该用锁。
详细解答: 原子类型不是“更高级的锁替代品”,它只适合有限的一类低层状态共享。
| |
| |
🐹 Go 对比: Go 里也有 sync/atomic 与锁的区分;Rust 一样不建议拿原子操作硬拼复杂一致性逻辑。
记忆点: 单字段小状态才优先原子,多字段一致性直接上锁。
Q12. Rust 并发入门时最值得先养成的习惯是什么?
Tags: common mental-model
适用版本: Rust 1.0+
一句话答案: 先决定数据如何流动,再决定用线程、channel 还是锁;不要一上来就选同步原语。
详细解答: 很多并发设计问题并不是“该用哪把锁”,而是“这份数据到底该被转移、复制还是共享”。Rust 的类型系统会逼你回答这个问题,而这通常是好事。
| |
| |
🐹 Go 对比: 这条习惯在 Go 里也成立,但 Rust 会更强硬地把“数据流设计”变成编译期约束。
记忆点: 先画数据流,再挑并发原语。
Q13. Go 的 WaitGroup 在 Rust 里怎么做?
Tags: common WaitGroup join Barrier
适用版本: Rust 1.0+;thread::scope 自 1.63+
一句话答案: 最常见是收集 JoinHandle 再逐个 join;短生命周期并行用 thread::scope;要“大家到齐再继续”用 Barrier。
解答: Go 的 sync.WaitGroup 是“等一组 goroutine 结束”。Rust 标准库没有同名类型,但模式一一对应:spawn 返回的 handle 就是你的等待句柄;scope 在离开作用域时自动等完子线程(见 Q8)。需要 N 个线程互相等齐,再用 std::sync::Barrier。
| |
| |
Go 对比:
| |
- Go 怎么做:
Add/Done/Wait等一组结束。 - Rust 为什么不同:
join/scope/Barrier分工更细,和所有权、生命周期绑在一起。 - Go 程序员易踩的坑:到处找
WaitGroupcrate,却忘了handles+join已经够用。
记忆点:
- 等结束 →
join或thread::scope。 - 等齐步 →
Barrier。
Q14. 怎么判断自己可能写死锁了?怎么排查?
Tags: common deadlock Mutex
适用版本: Rust 1.0+
一句话答案: 线程卡住且 CPU 接近空闲、多把锁交叉持有、或“持锁再等 channel/再加锁”,都要怀疑死锁;用固定加锁顺序、缩小临界区、必要时超时/try_lock 排查。
解答: Rust 编译器防数据竞争,不防逻辑死锁。典型信号:进程挂死、top 里线程在睡、调试器里多个线程停在 lock/recv。常见成因:A 持锁 1 等锁 2,B 持锁 2 等锁 1;或持锁时又 recv 依赖对方先拿到同一把锁。排查:统一全局锁顺序、尽早 drop(guard)、用 try_lock 看谁在堵、必要时加日志打印“即将加锁/已解锁”。
| |
| |
Go 对比:
| |
- Go 怎么做:同样靠纪律与工具(race detector 不专查死锁)。
- Rust 为什么不同:RAII guard 少“忘 Unlock”,但交叉加锁照样死。
- Go 程序员易踩的坑:以为“能编译 = 不会卡死”。
记忆点:
- 编译通过 ≠ 不会死锁。
- 固定锁顺序 + 缩短持锁时间。
Q15. channel 对端关闭后发送/接收会发生什么?
Tags: common channel mpsc
适用版本: Rust 1.0+
一句话答案: 所有 Sender 都 drop 后,recv 得到 Err(断开);接收端已挂、再 send 也会得到 Err。不要对已关闭通道假设还能传值。
解答: mpsc 里“关闭”不是单独的 close() 调用,而是发送端全部被 drop。此时 rx.recv() 返回 Err(RecvError),rx.iter() 结束;若接收端先没了,tx.send(...) 返回 Err(SendError(_)) 并把值还你。多生产者时要等最后一个 Sender 也 drop,接收端才会看到“通道关了”。
| |
| |
Go 对比:
| |
- Go 怎么做:显式
close;向已关闭 channel 发送会 panic。 - Rust 为什么不同:靠 drop 发送端关闭;
send/recv用Result表达失败,而不是发送侧 panic。 - Go 程序员易踩的坑:找
close(tx),或克隆了tx却忘 drop 克隆,接收端永远等。
记忆点:
- 发送端全 drop → 接收结束。
- 接收端没了 →
send返回Err。
Q16. CPU 密集要不要上 rayon?Rust 为啥没有 Go 那种 data race detector?
Tags: occasional rayon data-race Send Sync
适用版本: Rust 1.0+(rayon 为外部 crate)
一句话答案: CPU 密集的数据并行可优先考虑 rayon;Rust 靠类型系统(Send/Sync)在编译期挡数据竞争,所以没有 Go 那种默认的动态 race detector 作为主力工具。
解答: rayon 提供并行迭代器等工作窃取线程池,适合“同一算法劈开多核算”。先确认是 CPU 瓶颈且任务可独立分片,再引入依赖;I/O 等待型并发更常看线程/async/channel。引入方式用命令而不是在文档里假装已有依赖:
| |
随后可在业务代码里写 use rayon::prelude::*; 并把 iter() 换成 par_iter()(需已加入依赖后才能编译)。Rust 为什么少谈 race detector:不安全的共享可变在安全代码里往往根本编不过(见 Q9、Q10);真正的数据竞争多出在 unsafe 或 FFI,那时用 MIRI、审代码、或专门工具,而不是日常默认开一个像 Go 的 -race。
| |
| |
Go 对比:
| |
- Go 怎么做:共享内存默认宽松,靠
-race动态抓竞争。 - Rust 为什么不同:安全子集用类型系统前置拦截;race detector 不是入门标配。
- Go 程序员易踩的坑:找不到
-race就以为 Rust“不管竞争”;其实大量问题在编译期已经被挡住。
记忆点:
- 数据并行 CPU 活 → 再考虑
cargo add rayon。 - 防数据竞争:类型系统为主,动态检测为辅。
Q17. Atomic 的 Ordering 入门怎么选?
Tags: common atomic Ordering
适用版本: Rust 1.0+
一句话答案: 计数器、统计、纯“原子改一下、不靠它同步别的数据”用 Relaxed;要搭配“释放/获取”建立线程间可见性用 Release/Acquire(或读写一体的 AcqRel);拿不准且性能不是瓶颈时用 SeqCst。
解答: Q11 讲了“简单计数器可走原子”;本题补 Ordering(内存序):它约束这次原子操作相对其他内存访问的可见顺序,不是“换成更强的 Ordering 就不会写错逻辑”。常见档位:
Relaxed:只保证这个原子变量本身的原子性,不额外同步别的读写。Release(写侧)/Acquire(读侧):配对后,Release 之前的写入对随后 Acquire 的读者可见。AcqRel:读改写(如fetch_add)时同时带 Acquire + Release。SeqCst:最强、最好讲清“全局总序”,开销通常最大。
| |
| |
「❌ 错误直觉」——以为随便写 Relaxed 就能当锁用:若线程 A 先写缓冲区再 Relaxed 置位,线程 B Relaxed 看到置位后读缓冲区,不保证看到完整写入。需要同步可见性时,至少用 Release/Acquire(或锁/channel)。
Go 对比:
| |
- Go 怎么做:
sync/atomic的操作语义更接近“足够强的原子”,很少让你在每次调用上点选 Ordering。 - Rust 为什么不同:把内存序暴露出来,避免为所有原子操作默认付出
SeqCst成本。 - Go 程序员易踩的坑:把
Relaxed当成“原子版普通变量赋值”,却用它去发布复杂结构。
记忆点:
- 纯计数 →
Relaxed;发布/订阅可见性 →Release/Acquire;不确定 →SeqCst。 - Ordering 管可见性,不替代锁对临界区的保护。
Q18. join 的返回值是什么?thread::Builder 怎么命名和调栈?
Tags: common join Builder stack
适用版本: Rust 1.0+
一句话答案: JoinHandle<T>::join() 得到 Result<T>:Ok 是闭包返回值,Err 是子线程 panic 的载荷;要设线程名或栈大小,用 thread::Builder,不要只靠默认 spawn。
解答: spawn 返回 JoinHandle<T>,T 就是闭包的返回类型。join() 会等线程结束并取回结果;子线程若 panic,主线程这边拿到 Err,可用 is_panic() 等方法查看,而不是默默丢结果。
| |
thread::Builder 可在 spawn 前配置名字(调试/日志里好认)和栈大小(递归深、大数组在栈上时才需要加):
| |
注意:Builder::spawn 返回 io::Result<JoinHandle<_>>(创建线程可能因系统限制失败);默认 thread::spawn 失败时会直接 panic。栈大小是平台相关的“请求值”,不是可移植精确保证。
Go 对比:
| |
- Go 怎么做:goroutine 没有
join返回值;常用 channel/WaitGroup收回结果。栈由运行时伸缩,一般不手调。 - Rust 为什么不同:OS 线程 + 所有权,结果和 panic 都经
JoinHandle显式交回。 - Go 程序员易踩的坑:
join().unwrap()把子线程 panic 直接打成主线程 panic,却没意识到这是在传播失败。
记忆点:
join→Result<T>:值或 panic。- 要名字/栈大小 →
thread::Builder;spawn失败要处理io::Result。
Q19. Condvar 最小正确用法是什么?(对标 Go sync.Cond)
Tags: common Condvar Mutex
适用版本: Rust 1.0+
一句话答案: 条件变量必须和 Mutex 配对:持锁检查条件,不满足就 wait(原子地放锁并等待),改条件后 notify_one/notify_all;用 while 循环 等条件,不要只用一次 if。
解答: std::sync::Condvar 对应 Go 的 sync.Cond:不是“睡一会”的定时器,而是“等某个被互斥保护的状态变真”。经典骨架是 (Mutex<T>, Condvar),常放进 Arc 共享。
| |
要点:wait 可能因虚假唤醒返回,或通知到达时条件又被别人改掉,所以必须 while !predicate。先改共享状态再 notify_*;通知时通常仍持有(或刚改完仍逻辑相关的)那把锁,具体习惯与 Go 类似——关键是状态更新与通知之间不要把条件弄丢。
Go 对比:
| |
- Go 怎么做:
Cond绑在Locker上;Wait/Signal/Broadcast。 - Rust 为什么不同:
wait吃掉并归还MutexGuard,所有权把“持锁等待”写进类型。 - Go 程序员易踩的坑:写成
if !ready { wait }只等一次;或没用同一把Mutex保护条件。
记忆点:
Mutex+Condvar+while条件。wait后必须重新检查谓词。- 改状态再
notify_one/notify_all。
Q20. 有界 sync_channel 和无限 mpsc::channel 怎么选?
Tags: common channel sync_channel backpressure
适用版本: Rust 1.0+
一句话答案: 默认 mpsc::channel 是无界队列,发送几乎总是立刻成功(内存撑不住另说);sync_channel(n) 是有界,队列满时发送端阻塞,用来做背压。需要限制在途任务/内存时用有界,否则无界更简单。
解答: 两者都是多生产者、单消费者。差别在容量与阻塞语义:
| |
| |
sync_channel(0) 是会合信道(rendezvous):发送与接收必须同时就绪才交接。无界 channel 在生产者远快于消费者时会堆内存;有界则把压力传回上游。异步运行时里还有各自的有界/无界通道,那是另一套 API;标准库线程场景先分清这两个。
Go 对比:
| |
- Go 怎么做:
make(chan T)/make(chan T, n);没有默认“无限缓冲”的 channel。 - Rust 为什么不同:
channel()默认无界,更像“先能发再说”;要背压显式sync_channel。 - Go 程序员易踩的坑:按 Go 无缓冲直觉去用
channel(),结果生产者狂发也不堵。
记忆点:
channel()≈ 无界;sync_channel(n)≈ 有界背压。- 防内存涨、要限流 → 有界。
sync_channel(0)≈ Go 无缓冲会合。
Q21. 多把锁怎么定顺序,才能少死锁?
Tags: common deadlock Mutex lock-order
适用版本: Rust 1.0+
一句话答案: 给锁排一个全局固定顺序(例如始终先 A 后 B),所有线程遵守同一顺序;拿不到完整集合时用 try_lock 失败回退,并尽量缩短持锁时间。编译器不防死锁(见 Q14)。
解答: 死锁经典形:线程 1 持锁 A 等锁 B,线程 2 持锁 B 等锁 A。破解法不是“少用锁”一句话,而是顺序纪律:
| |
| |
实践清单:文档化锁层级(L1/L2);禁止持锁时再调可能加同层锁的代码;持锁不做 I/O、不 recv 依赖对方先拿你这把锁的事;能一把锁保护的结构别拆成两把“图方便”。try_lock 适合“锁不住就放弃/重试”,不是替代全局顺序的银弹。
Go 对比:
| |
- Go 怎么做:同样靠约定顺序;
sync.Mutex无编译期顺序检查。 - Rust 为什么不同:RAII 少忘解锁,但交叉加锁照样死。
- Go 程序员易踩的坑:以为“能编译就不死锁”;多锁场景仍要顺序表。
记忆点:
- 全程序统一加锁顺序。
- 持锁要短,别嵌套未知锁。
- 排查手法见 Q14。