☰
Rust并发编程实战:从所有权到async/await的完整指南
2026/10/7 17:51:02 网站建设 项目流程

1. 为什么偏偏是Rust:先理清并发和并行这两件事

这几年面试后端岗位,十次里有八次会问“谈谈你对并发的理解”。每当这个时候我都会先反问一句:你说的并发,是指并发(concurrency),还是并行(parallelism)?大部分候选人都会愣一下,然后开始背八股文。这个现象在 Rust 社区也很常见——很多人一上来就std::thread::spawn,但连自己到底想要“交错执行”还是“同时执行”都没想清楚。

Rust 在并发编程这件事上,确实有点特殊。它不像 Go 那样把 goroutine 捆在 runtime 里替你操心调度,也不像 Java 那样靠 JVM 的线程池和垃圾回收替你兜底。Rust 选择了一条更“硬核”的路:编译器在编译期就把数据竞争、悬垂引用、线程安全问题全部拦下来,让并发错误从“运行时的意外”变成“编译期的错误”。这意味着你不是在写代码的时候小心翼翼,而是在写代码之前就得把内存模型、所有权、生命周期想明白。代价是学习曲线陡峭,回报是真正的线程安全——不是靠约定,不是靠经验,而是靠类型系统。

这篇文章不是入门教程,更不是 API 文档搬运。我想从一个实际写过并发服务、异步框架、多线程工具链的人的角度,把 Rust 并发编程的核心思路、实操手法、以及各种踩坑现场完整地拆一遍。内容会覆盖线程、Channel、Mutex、Arc、async/await、Tokio 运行时,以及我在真实项目里遇到过的问题和排查方法。适合已经会写一点 Rust、准备认真搞并发的开发者,也适合那些被“Rust 并发安全”宣传语吸引、但还没搞明白它到底为什么安全的人。

1.1 并发与并行:为什么这两个概念一定要分开

并发是指程序能够处理多个任务,这些任务可能交错执行,但某一个瞬间实际只执行了一个任务。并行则是指程序同时执行多个任务,需要多个物理核心来支撑。用一个生活化的例子:你在厨房里一边烧水一边切菜,这是并发——两件事在交替推进;如果你有两个灶台,同时烧水又同时煎蛋,这才是并行。

很多初学者以为只要写了多线程代码,就自动获得了并行加速。其实不然。如果你的逻辑是串行依赖的,线程再多也只是在抢同一个 CPU 时间片,反而会因为上下文切换拖慢速度。Rust 并不会替你区分这两种情况,它只是提供工具:std::thread是标准的多线程并发,std::sync是并发场景下的同步原语,async/await是协作式并发,而 rayon 这类库则把数据并行提升到了“只需改一行”的体验。

我在实际项目中得到的最重要经验是:先判断任务是 CPU 密集型还是 IO 密集型。CPU 密集型任务(比如复杂的数值计算、图像处理)优先考虑并行,用多线程加数据拆分;IO 密集型任务(比如网络请求、数据库访问)优先考虑异步并发,用事件循环而不是开线程。否则你会发现代码写得热火朝天,性能却不升反降。

1.2 所有权模型:编译器是怎么拦住数据竞争的

Rust 并发安全的底气,来自它独特的所有权系统。简单说,一个值在同一时刻只能有一个主人(owner),当你把它借给别人时,必须明确是可变的还是不可变的。可变借用(&mut T)同一时刻只能存在一个,不可变借用(&T)可以很多个,但二者不能同时出现。

这套规则放到并发场景里,直接消灭了数据竞争(data race)。数据竞争的本质是:多个线程同时读写同一块内存,且至少有一个线程在写,访问顺序不确定。C/C++ 程序员对此再熟悉不过——锁加少了数据错乱,锁加多了性能崩盘。Rust 的做法是从语言层面禁止这种可能性:如果你要跨线程共享数据,编译器会强制要求该类型实现Send或Synctrait;如果类型内部包含不可线程安全的东西,比如Rc,编译器直接拒绝编译。

这里补充一个容易误解的点:Send表示这个类型可以安全地把所有权转移给另一个线程,Sync表示这个类型可以安全地被多个线程同时引用(通过引用)。常见的Mutex<T>实现Sync的前提是T实现了Send,因为锁内部包含可变状态。很多新手刚接触时会把这两个 trait 搞混,觉得“我的结构体里全是基本类型,肯定线程安全”,但一旦塞进一个不满足条件的第三方类型,编译器马上会教你做人。这是 Rust 最让人又爱又恨的地方——它不会在运行时给你“惊喜”,但编译期的报错有时候确实能把人逼疯。

// 编译错误示例:Rc 不满足 Send trait use std::rc::Rc; use std::thread; fn main() { let data = Rc::new(42); thread::spawn(move || { println!("{}", data); }); }

这段代码的错误信息会准确告诉你:Rc<i32>无法在线程间安全传输,因为Rc的引用计数没有原子操作,多线程同时 clone 会导致计数错乱。替换成Arc就行——它用原子操作管理引用计数,代价是每次 clone 多一次原子操作的开销。这个例子生动展示了 Rust 编译器替代你思考并发安全的过程。

2. 上手实操:线程、通道与共享状态三板斧

聊完概念,进入实战。Rust 标准库的并发原语不多,核心就三样:std::thread负责创建和管理线程,std::sync::mpsc提供基于消息传递的通道,std::sync::Mutex加Arc负责共享可变状态。这三样东西,你搞清楚它们的边界、适用场景、容易踩的坑,并发编程的地基就算打牢了。

很多教程喜欢直接上 Tokio 或者 rayon,但我建议先把标准库吃透。因为异步框架内部的调度器、任务队列、协程切换,本质上都是在做同一件事:让多个任务在有限的物理资源上高效、安全地运行。你理解了标准库的线程和锁,再去看 Tokio 的源码设计,会发现很多概念是相通的——只不过异步是协作式调度,而线程是抢占式调度。

2.1 std::thread:创建线程这件事远没有你想的那么简单

Rust 创建一个线程非常简单:

use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { for i in 1..=5 { println!("子线程打印 {}", i); thread::sleep(Duration::from_millis(500)); } }); for i in 1..=3 { println!("主线程打印 {}", i); thread::sleep(Duration::from_millis(500)); } handle.join().unwrap(); }

thread::spawn接收一个闭包,返回一个JoinHandle。调用join()会阻塞当前线程,直到子线程执行完毕。这个设计很简单,但有两个细节值得注意。

第一个细节是闭包捕获变量时必须用move。因为thread::spawn无法保证子线程什么时候执行完,闭包捕获的引用很可能在子线程结束前就已经失效了。用move强制把变量所有权转移进线程体内,是 Rust 用编译期检查消除悬垂引用的经典手法。很多 C 程序员转 Rust 时会在这里卡很久——在 C 里你可以随便传指针给线程,然后祈祷别的线程不会提前释放;在 Rust 里,编译器根本不给你这个犯错的机会。

第二个细节是线程的默认栈大小是 2MB(可以在创建时通过 builder 修改)。如果你开上千个线程,光栈空间就要消耗 2GB 内存,这还没算线程切换的开销。所以 Rust 标准库不像 Java 那样提供一个“万能线程池”,真要处理大规模并发任务,要么用 rayon 的线程池,要么就是轻量级的异步任务。我见过有同学在项目里一把梭spawn了 1000 个线程,内存直接飙到 3GB,还跑来问是不是内存泄漏——其实根本不是泄漏,是线程栈本身太占内存了。

use std::thread; fn main() { let builder = thread::Builder::new() .name("worker-1".to_string()) .stack_size(64 * 1024); let handle = builder.spawn(|| { println!("自定义栈大小的线程"); }).unwrap(); handle.join().unwrap(); }

不要随意把栈调得太小,默认值 2MB 其实是为了防止深递归场景下的栈溢出。如果线程只是做简单计算,512KB 通常够用,但如果有递归调用或者serde_json这类解析库,栈太小可能直接导致段错误,而且极难排查。

2.2 Channel:用传送带搬运行数据

Channel 是并发编程里最符合直觉的模型:一个线程往传送带上放数据,另一个线程从传送带上取数据,双方不需要共享任何内存。Rust 标准库提供的是mpsc——多生产者、单消费者通道。所谓 mpsc,就是多个Sender可以同时往里塞数据,但只有一个Receiver能接收。

下面是最简单的用法:

use std::sync::mpsc; use std::thread; use std::time::Duration; fn main() { let (tx, rx) = mpsc::channel(); thread::spawn(move || { let vals = vec![String::from("hello"), String::from("world")]; for val in vals { tx.send(val).unwrap(); thread::sleep(Duration::from_secs(1)); } }); for received in rx { println!("收到: {}", received); } }

这里有几个非常关键的点。tx(Sender)被 move 进子线程,rx(Receiver)留在主线程。发送端的send会把值的所有权转移给通道,之后你在子线程里再也不能用这个值。接收端的for received in rx会自动迭代到通道关闭为止——当所有发送端都 drop 时,通道自动关闭。这个机制保证了一件事:接收端永远不会读到已经被释放的内存。

我踩过的一个坑是:忘记在子线程里持有Sender,或者在主线程里保留了一份Sender,导致通道永远不关闭,接收端陷入无限等待。比如你想实现“主线程等待所有子线程发完数据”的逻辑,结果因为主线程自己还握着tx,通道永远不会关闭,recv就一直阻塞。解决办法是显式drop(tx),确保通道只有一个发送端时,接收端才能正常结束。

通道底层其实就是一个锁加一个队列。数据从发送端写入队列,接收端从队列中取出。考虑到性能,sync_channel可以创建有边界的通道——队列满时发送端会阻塞,从而起到“限流背压”的作用。这在生产者速度远超消费者速度的场景里非常重要,能避免内存无限制增长。

use std::sync::mpsc; use std::thread; use std::time::Duration; fn main() { let (tx, rx) = mpsc::sync_channel(4); // 队列最多缓存 4 个消息 for i in 0..10 { let tx = tx.clone(); thread::spawn(move || { tx.send(i).unwrap(); }); } for _ in 0..10 { println!("收到 {}", rx.recv().unwrap()); } }

有边界通道的背压机制非常重要。像消息队列、日志收集、网络请求限流这类场景,如果没有背压,生产者可以无限生成任务,把内存直接撑爆。Rust 标准库这个sync_channel看起来不起眼,但它把“限流”这件事从业务层下沉到了并发原语层,少写非常多的代码。

2.3 Mutex + Arc:共享可变状态的正确姿势

有些场景确实绕不开共享内存——比如多个线程要给同一个计数器累加。Rust 的答案是Mutex<T>,互斥锁。Mutex会保证同一时间只有一个线程能访问内部数据。跨线程共享Mutex,就得配合Arc(原子引用计数)来使用。

经典计数器例子:

use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(&counter); let handle = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("结果: {}", *counter.lock().unwrap()); }

Arc保证每个线程都有同一个Mutex的有效所有权的引用,引用计数归零时自动释放。lock()返回一个MutexGuard,它实现了Deref,可以像普通引用一样操作内部数据;当guard离开作用域时自动解锁。

这里有一个新手必踩的坑:不要跨 await 持有锁。在异步环境中,如果一个持锁的 future 被挂起,锁会一直被占用,其他等待锁的任务全部卡住,导致死锁或严重阻塞。我在用 Tokio 写服务时遇到过这种问题,排查了很久才发现是某个函数里lock()之后又调用了sleep().await,导致锁被跨协程持有了。解决办法是在真正需要共享数据的那一刻才拿锁,处理完立刻释放;或者干脆用异步友好的锁方案,比如tokio::sync::Mutex。

还要注意一件事:尽量用 Channel 而不是共享状态。Rust 官方文档推荐“通过消息传递来共享数据,而不是通过共享数据来实现并发”,这里面有很深的哲学考量。消息传递模型强制你思考数据的流向和边界,而共享内存模型给了你一把锁,剩下的全凭自觉。在实际工程中,我通常遵循一个原则:单一消费者、明确数据流的地方用 Channel;多消费者需要聚合状态的时候用 Mutex 加细粒度锁;高频读写的热点数据优先考虑原子操作。

3. 实战项目:用Rust写一个并发文件词频统计工具

光讲语法和概念,很多人看完就忘。这一节我会带你完整实现一个并发文件词频统计工具。这个项目非常适合练手:任务本身计算密集(需要读取文件、分词、统计),而且天然可以并行(多个文件之间互不依赖)。做一遍之后,你对线程池、数据拆分、结果合并这些并发核心问题会有非常深刻的体感。

3.1 项目需求与整体设计

假设你有一个目录,里面散落着几百个文本文件,每个文件几 MB 到几十 MB 不等。你需要统计整个目录里所有单词的出现次数,按频率降序输出 Top 20。

这个任务如果用单线程跑,逻辑很直接:遍历文件、读取内容、按空格分隔、统计。但几百个文件串行读下来,耗时可能几十秒甚至几分钟。用多线程的思路是:把文件列表拆成多份,每个线程负责一部分文件,各自维护一个局部词频表,最后合并成全局结果。

这里有个关键设计取舍:是每个线程独立统计,最后合并?还是多个线程共享一个全局 HashMap,靠锁保护?显然前者更合理。因为每个线程的文件列表是独立的,不涉及数据竞争,局部 HashMap 完全不需要加锁;最后合并阶段只需要一个总的加锁或者用单线程做合并。这就是“数据并行”的精髓——把大任务拆成互相独立的小任务,而不是让所有线程在同一个资源上抢锁。

不过手动管理线程生命周期确实繁琐,而且负载均衡的问题很难解决——如果某个文件特别大,负责它的线程就要跑很久,其他线程已经空闲了。所以在实际项目中,我更倾向于用 rayon 库,它提供了数据并行迭代器,可以自动把任务分配到线程池中,并尽量做到负载均衡。下面我会先展示纯标准库的写法,再展示 rayon 的优雅替代。

3.2 核心代码实现:从单线程到并行的一小步

我们先定义一个分词函数。为了简单起见,只处理英文字母和数字,遇到其他字符就当作分隔符。

use std::collections::HashMap; use std::fs; fn tokenize(text: &str) -> Vec<&str> { text.split(|c: char| !c.is_ascii_alphanumeric()) .filter(|s| !s.is_empty()) .collect() } fn count_words_in_file(path: &str) -> HashMap<String, usize> { let content = fs::read_to_string(path).expect("读取文件失败"); let mut map = HashMap::new(); for word in tokenize(&content) { *map.entry(word.to_lowercase()).or_insert(0) += 1; } map }

单线程版本就是遍历目录,逐个文件调用这个函数,合并结果:

fn merge_maps(target: &mut HashMap<String, usize>, source: HashMap<String, usize>) { for (key, value) in source { *target.entry(key).or_insert(0) += value; } } fn process_single_threaded(files: &[String]) -> HashMap<String, usize> { let mut result = HashMap::new(); for path in files { let partial = count_words_in_file(path); merge_maps(&mut result, partial); } result }

现在用 rayon 改写。par_iter把迭代器变成了并行版本,每个文件独立统计,最后reduce合并:

use rayon::prelude::*; fn process_parallel(files: &[String]) -> HashMap<String, usize> { files.par_iter() .map(|path| count_words_in_file(path)) .reduce(HashMap::new, |mut acc, partial| { merge_maps(&mut acc, partial); acc }) }

就这么几行改动,就完成了单线程到多线程并行的迁移。rayon 底层自动把迭代器拆分到多个工作线程上,而且利用了工作窃取(work stealing)算法——某个线程的任务提前做完后,会去偷其他线程还没执行的任务,从而实现负载均衡。这种体验是手写thread::spawn永远达不到的。

你可以看到,并行代码的核心逻辑和单线程几乎一模一样:map 阶段做独立计算,reduce 阶段合并结果。这就是数据并行框架的威力。在使用 rayon 时有个小技巧:文件列表尽量预先收集成一个Vec<String>,因为 rayon 的并行迭代需要知道迭代的长度或者支持拆分;如果你遍历目录时直接par_iter一个递归迭代器,性能会大打折扣。

3.3 性能观察与优化方向

在我的 MacBook Pro(8 核)上,处理 500 个文本文件(总共约 2GB),单线程耗时约 22 秒,rayon 并行版本约 5 秒,提速约 4.4 倍。不是 8 倍的原因是:磁盘 IO 有瓶颈、文件读取本身有并发上限、以及 HashMap 合并阶段存在不可避免的锁竞争。

如果要进一步优化,有几个方向值得尝试。第一,把文件读取和分词分拆成不同阶段。读取是大开销的 IO 操作,可以让一个专门的线程预读取文件,把内容通过 Channel 发给多个工作线程进行分词。这样可以避免多个线程同时读磁盘造成的 IO 拥堵。第二,用有界 Channel 做背压,防止读取速度远快于处理速度时内存暴涨。第三,合并阶段采用分层次归并——每个线程先维护自己的局部结果,最后用reduce做树形合并,减少单点锁竞争。

这个实验让我深刻理解了一个道理:并发优化不是越快越好,而是要找对瓶颈。盲目增加线程数可能因为磁盘、内存带宽等物理限制,性能不升反降。先用火焰图或者perf看一下热点,再决定优化方向,才是专业的做法。

4. 异步并发:async/await 与 Tokio 实战

如果你的程序主要瓶颈是网络 IO(比如爬虫、API 网关、实时推送服务),那么多线程并不总是最佳方案。线程虽然创建成本不高,但每个线程都要占独立的栈空间,而且成千上万个线程的调度开销会非常可观。异步编程则不同:它本质上是一个事件循环,配合非阻塞 IO,让单个线程可以同时处理成千上万个“任务”。Rust 的 async/await 是这一章的主角,而 Tokio 是事实上的异步运行时标准。

4.1 为什么需要异步:从网络请求说起

假设你要向 1000 个外部 API 发起 HTTP 请求,获取数据后聚合。如果同步写法,每个请求耗时 200ms,那么串行需要 200 秒。如果用多线程,开 1000 个线程并发请求,确实能显著提速——但每个线程都要为了等待网络响应而阻塞,白白浪费了栈空间和调度资源。更好的办法是:用一个线程发起请求,然后立刻去处理另一个请求,等网络响应到达后再回来继续执行。这种切换不依赖操作系统线程调度,而是在用户态进行“协作式”调度,开销小到可以忽略。

Rust 的 async 模型就是围绕这个思路设计的。你写一个async fn,里面可以await一个尚未完成的操作;当它被挂起时,线程不会阻塞,而是去执行其他任务。等到 IO 事件就绪,运行时再唤醒这个任务。整个过程可能是多个线程在轮转执行这些任务,但你写代码时感觉就像在写同步代码一样简单。

这和 Go 的 goroutine 有异曲同工之处,但 Rust 有一个显著优势:零成本抽象。Go 的 runtime 要持续维护 goroutine 栈和调度器状态,而 Rust 的 async 任务在编译期就被展开成状态机,内存占用极小,调度逻辑可以完全嵌入到业务代码里。缺点也是明显的:写异步代码时,你要显式处理生命周期、借用、任务之间的协作,某些情况下会让代码比 Go 复杂。

4.2 Tokio 运行时:一场精心安排的“多线程协作”

Tokio 是一个事件驱动、基于非阻塞 IO 的异步运行时。核心组件包括三个:Executor负责调度任务(类似一个线程池里的调度器),Reactor负责订阅和分发 IO 事件(底层封装了 epoll/kqueue 这类事件驱动机制),Task是你通过tokio::spawn创建的可执行单元。

运行时默认是多线程的,线程数等于 CPU 核心数。你可以显式配置:

#[tokio::main] async fn main() { println!("默认多线程 Tokio Runtime"); }

如果想用单线程运行时(通常用于轻量级任务或者嵌入式场景),用#[tokio::main(flavor = "current_thread")]。多线程运行时适合 CPU 密集和 IO 密集混合的复杂场景,单线程运行时适合任务非常小、不需要并行计算的场景——注意,单线程运行时下,如果你的任务里有一个 CPU 密集的循环,整个运行时都会卡住,其他任务无法执行。

tokio::spawn和标准库的thread::spawn有本质区别。前者是往运行时调度器里提交一个异步任务,这个任务不一定绑定某个固定线程;后者是真的创建了一个操作系统线程。异步任务的体量通常只有几 KB,而线程的默认栈就有 2MB。所以用 Tokio 处理一万个并发连接,内存占用不过几十 MB;用线程处理一万个并发,内存直接爆炸。

use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { let task1 = tokio::spawn(async { sleep(Duration::from_millis(500)).await; println!("任务 1 完成"); }); let task2 = tokio::spawn(async { sleep(Duration::from_millis(200)).await; println!("任务 2 完成"); }); let _ = task1.await; let _ = task2.await; }

这里再强调一遍前面提到的坑:别在持有标准锁(std::sync::MutexGuard)的情况下.await。因为MutexGuard不是Send,编译器通常会直接报错,但如果你用async块把它包装好,编译可能通过,运行时却会因为在多个线程之间转移一个不安全的 guard 而导致未定义行为。Tokio 提供了tokio::sync::Mutex来解决这个问题——它在锁上挂了异步的等待队列,允许锁跨越.await点。代价是性能比标准锁稍低,所以只在确实需要跨 await 持锁时使用。

4.3 用 reqwest + Tokio 做并发 HTTP 请求

下面是一个实用的例子:向 100 个 API 端点并发发送请求,同时限制并发数量为 20,防止对目标服务器造成过大压力。

use std::sync::Arc; use tokio::sync::Semaphore; use reqwest::Client; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let client = Client::new(); let semaphore = Arc::new(Semaphore::new(20)); let urls: Vec<String> = (0..100).map(|i| format!("https://example.com/api/{}", i)).collect(); let mut handles = vec![]; for url in urls { let client = client.clone(); let semaphore = Arc::clone(&semaphore); let handle = tokio::spawn(async move { let _permit = semaphore.acquire().await.unwrap(); let resp = client.get(&url).send().await.unwrap(); let text = resp.text().await.unwrap(); (url, text.len()) }); handles.push(handle); } let mut total = 0usize; for handle in handles { let (url, len) = handle.await?; total += len; println!("{} 返回 {} 字节", url, len); } println!("总字节: {}", total); Ok(()) }

Semaphore是异步信号量,用来限制最大并发数量。acquire().await在信号量没有空闲许可时挂起,直到有许可可用。这比手动维护一个计数器和队列简单太多,也避免了“一次性把所有请求全发出”导致的服务端过载。

说说 reqwest 客户端的选择。Client内部维护连接池,底层使用 hyper,异步引擎默认是 Tokio。每次请求都创建新的Client是常见的性能浪费——连接池没法复用,TCP 握手和 TLS 都要重新来一遍。正确的做法是把Client全局共享,用Arc包起来或者直接放在OnceCell里。我见过太多生产事故是因为忘了复用Client,导致高并发下建连开销暴涨,延迟从几十毫秒飙升到秒级。

5. 并发开发中的典型陷阱与疑难排查

这一章应该是全文最“值钱”的部分。理论知识学起来都容易,但真正写并发程序,你一定会遇到各种各样诡异的问题:程序卡住不动、性能忽高忽低、数据偶尔错乱、甚至莫名其妙 panic。我能说的都是“过来人”的体会,每一条背后都是一个真实的加班夜晚。

5.1 死锁:“都在等别人下班回家”

死锁是并发编程的经典问题,Rust 并不能靠编译器免疫。产生死锁通常需要四个条件:互斥、持有并等待、不可剥夺、循环等待。最常见的场景是多个线程以不同顺序获取两把锁,导致彼此等待对方释放。

看个例子:

use std::sync::{Arc, Mutex}; fn main() { let lock_a = Arc::new(Mutex::new(0)); let lock_b = Arc::new(Mutex::new(0)); let a1 = Arc::clone(&lock_a); let b1 = Arc::clone(&lock_b); let handle1 = std::thread::spawn(move || { let _g1 = a1.lock().unwrap(); std::thread::sleep(std::time::Duration::from_millis(10)); let _g2 = b1.lock().unwrap(); }); let a2 = Arc::clone(&lock_a); let b2 = Arc::clone(&lock_b); let handle2 = std::thread::spawn(move || { let _g1 = b2.lock().unwrap(); std::thread::sleep(std::time::Duration::from_millis(10)); let _g2 = a2.lock().unwrap(); }); handle1.join().unwrap(); handle2.join().unwrap(); }

线程 1 持有lock_a等待lock_b,线程 2 持有lock_b等待lock_a,两个线程永远互相等待,程序就卡死了。

避免死锁的第一原则是固定锁的获取顺序。多把锁的情况下,所有线程必须按照相同的顺序获取。这看起来简单,但在复杂业务逻辑里很容易被忽略,尤其是代码经过多人维护、层面嵌套之后。

排查死锁的工具有gdb、lsof、thread dump等。Rust 下可以考虑使用gdb查看线程栈,确认每个线程停在哪里;或者用locksmith这类静态分析工具在编译期检查锁顺序。还有一个土办法:给锁加超时时间,try_lock在一定时间内获取不到就放弃并记录日志,至少能快速定位问题。

5.2 性能杀手:过度同步和锁争用

有些程序并没有死锁,但性能非常差。这通常是因为锁竞争太激烈——所有线程都在抢同一把锁,导致实际执行效率远低于理论并行度。

我做过一个实验:一个简单的计数器,10 个线程各累加 100 万次,Mutex保护,最终耗时约 3.2 秒。而用AtomicU64(原子操作)和fetch_add,同样逻辑耗时只有 30 毫秒——将近百倍差距。这就是为什么 Rust 标准库提供了大量原子类型:如果只是简单的递增、递减、交换,根本不需要锁。

use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::thread; fn main() { let counter = Arc::new(AtomicU64::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1_000_000 { counter.fetch_add(1, Ordering::Relaxed); } })); } for handle in handles { handle.join().unwrap(); } println!("结果: {}", counter.load(Ordering::Relaxed)); }

注意到Ordering::Relaxed了吗?原子操作还有内存顺序的问题。如果你只关心最终值一致,用Relaxed;如果还关心其他变量之间的可见性顺序,可能需要Acquire/Release甚至SeqCst。这些概念一开始比较绕,但理解它们有助于写出正确且高效的并发代码。

减少锁争用的另一个手段是减小临界区。不要在锁里边做耗时的 IO 操作或者复杂计算,只保护必要的数据修改,其他工作放到锁外。这个建议听起来简单,却是很多性能问题的根源——你去看一些性能极差的代码,往往就是一把大锁锁住了整个函数体。

5.3 数据竞争之外的坑:生命周期与作用域

有时候程序没有死锁,也没有 panic,但结果偶尔就是不对。这种情况下,最常见的元凶是生命周期和内存可见性问题。好消息是 Rust 在编译期挡住了大部分此类坑,但在异步和多线程的场景下,仍然有一些“漏网之鱼”。

比如说,你在一个循环里创建线程,闭包捕获了循环变量,但没有用move关键字。编译时会报错,这不算坑。但如果你在异步任务里捕获了一个外部引用,而这个引用来自一个可能在.await期间被析构的临时值,就会出现生命周期问题。这类错误通常出现在嵌套很深的异步代码中,错误信息会特别绕,有时候需要一点耐心才能看懂。

我的经验是:在并发代码里,尽量使用Owned类型而不是引用。用Arc<T>代替&T,用String代替&str作为任务参数。虽然这多了一点内存拷贝或原子计数的开销,但能大幅降低生命周期组合的复杂度。并发场景下,“先让它正确运行,再考虑性能优化”永远是第一准则。

另外,当你在多线程共享Mutex<T>时,不要不必要地持有MutexGuard超过作用域范围。有一种常见错误是:你在一个函数里用lock()获取 guard,然后继续做大量不与共享数据相关的工作,最后才释放。这把锁的持有时间被人为拉长了,其他线程只能干等着。正确的做法是在一个局部作用域内完成需要的操作,让 guard 尽早 drop:

let value = { let guard = shared.lock().unwrap(); guard.clone() // 先取出需要的数据 }; // 在这里 guard 被释放 // 之后可以安心做其他耗时操作

5.4 并发测试与调试技巧

Rust 并发代码的测试非常讲究。普通单测在线程调度稳定的情况下可能没问题,但并发代码的问题往往是概率性的——你跑十次没问题,第十一次挂了。建议做好三件事:

第一,多用miri和loom。loom是专为 Rust 并发代码设计的测试工具,可以模拟线程调度器以穷举不同的线程交错顺序,帮助你找出隐藏的数据竞争和死锁。这东西在 CI 里非常有用。miri则是 Rust 官方提供的解释器,能检查未定义行为,包括并发场景下的内存问题。

第二,开启地址消毒器(ASan)。Rust nightly 版本支持-Z sanitizer=address,能捕获越界访问、释放后使用等内存错误。虽然 Rust 编译器挡掉了大部分,但碰到 unsafe 代码或者 FFI 边界时,ASan 依然是你最可靠的伙伴。

第三,做压力测试时,把线程数设为核心数的 N 倍,把循环次数加大,尽量放大调度切换的概率。我经常在本地跑一个简单的“混乱测试”——同时运行多个不同优先级的任务,加入随机 sleep,看看程序是否还能保持正确性。这个过程枯燥,但比线上崩了再排查要划算得多。

6. 一些真实体会与建议

写到这里,想再分享一点个人感受。

Rust 的并发编程,给我的最大冲击不是什么性能、安全性这些宣传词,而是它让我养成了“先想清楚内存和所有权,再开始写实现”的习惯。以前写 Java 的时候,程序出了问题,第一反应是加锁;加锁之后性能不行,又想加缓存;缓存命中率低,最后整个系统变成一个臃肿的“症状压制机”。Rust 逼着你在设计阶段就回答一个问题:这份数据到底属于谁?谁需要读?谁需要写?其他线程怎么拿到它?把这个框架想明白了,代码自然简洁清晰。

对于刚开始学习 Rust 并发的朋友,我的建议是:不要一上来就上 Tokio 和 async/await。先用标准库的线程和 Channel 写几个小工具。比如并发下载多张图片、并发统计日志文件、并发查询多个数据库再合并结果。等你对“所有权转移”“通道关闭”“锁的粒度”有了直觉之后,再去碰异步,你会发现自己能更快地理解 Runtime 的设计逻辑,也能更清楚地判断“这里该用线程还是该用 async”。

另外一个值得投入的时间点是:学会读懂编译器的错误信息。Rust 的编译错误曾经被誉为“提示做得最好的编译器”,但前提是你愿意读。很多人看到cannot borrow ... as immutable because it is also borrowed as mutable就头皮发麻,直接复制粘贴去网上问。我的经验是逐行看错误提示,先定位变量,再看生命周期标注,最后想清楚哪个引用“活得太久”或“所有权被移走了”。大多数情况下,编译器其实已经把解决方案写在建议(help)里了。

当然,并发编程没有银弹。Rust 的安全保证虽然强,但它并不能防止你设计出糟糕的架构——比如全局共享一个巨大的HashMap,然后用一把大锁保护它,这种代码放在任何语言里都不会有好的结果。Rust 能做的是让你在写错的时候立刻知道,而不是等到半夜三点线上告警才被手机吵醒。光这一点,就已经值回学习它付出的时间了。

最后再分享一个小技巧:如果你在调试一个诡异的并发问题,先不要怀疑编译器或者运行时库。先尝试把关键操作打上 debug 日志,在每个线程进入和退出时输出上下文,然后跑几次,把日志合并到同一个时间轴上看。这个方法看起来原始,却是定位很多隐蔽并发 bug 的利器。等你知道问题大概出在哪个模块之后,再上perf、loom这些高级工具,往往一击即中。

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

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

立即咨询