外观
第 15 章 小蚂蚁搬糖块
小螃蟹说
前 14 章,我们的程序都是一只蚂蚁干活:从头到尾,一步一步,干完收工。这一章,我们要组建一支蚂蚁工队——多只蚂蚁同时干活,速度翻倍!
这个"多只蚂蚁同时干活"的本事,叫并发(concurrency)。它是真实世界的常态:你一边听歌一边写作业,妈妈一边做饭一边回消息。程序也一样——下载文件和播放视频同时进行,靠的就是并发。
不过并发有个大难题:蚂蚁多了,会打架——两只蚂蚁同时往糖罐里放糖,罐子里的数可能记错。这一章,我们学 Rust 对付这个难题的三件法宝:
- 线程(thread):一只蚂蚁
- channel(通道):蚂蚁之间的接力棒
- Mutex(互斥锁):带锁的糖罐
我们的任务很实在:三本故事书(童话1.txt、童话2.txt、童话3.txt),三只蚂蚁同时数每本有多少字,最后把糖(字数)全放进一个糖罐,报总数。
15.1 预览:我们要做什么
- 准备三本故事书(三堆糖)
- 派三只小蚂蚁,同时数
- 每只蚂蚁数完,用接力棒把结果传回来
- 糖罐汇总,报总数
运行结果
text
蚂蚁工头小螃蟹开始派活……
童话1.txt 搬了 184 块糖
童话2.txt 搬了 84 块糖
童话3.txt 搬了 83 块糖
糖罐里一共 351 块糖!运行结果仅供参考
注意报数的顺序每次都可能不一样:蚂蚁们跑得快慢不定,谁先搬完谁先报。这不是 bug,这正是"同时干活"的样子——三只蚂蚁本来就分不出谁先到。
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| 线程 | 一只小蚂蚁(同时跑的代码) | thread::spawn |
move 闭包 | 把玩具搬进蚂蚁窝 | 让线程拿走自己的文件 |
| channel | 蚂蚁之间的接力棒 | 传回"搬了多少块" |
| Mutex | 带锁的糖罐 | 多只蚂蚁安全地加糖 |
| Arc | 线程版 Rc(第 14 章) | 多人共享糖罐 |
15.2 动手做
步骤一:准备糖块
新建项目,准备三本故事书:
bash
cargo new ant_brigade
cd ant_brigade在项目文件夹里建三个文本文件(童话1.txt、童话2.txt、童话3.txt),放上你喜欢的小故事(想偷懒?直接用第 3 章的《小螃蟹的生日》,再随便写两篇短的)。我们这一章就靠它们当"糖块"。
步骤二:一只小蚂蚁
先让一只蚂蚁跑起来。把 src/main.rs 替换成:
rust
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("小蚂蚁出发了!");
});
println!("主蚂蚁还在等……");
handle.join().unwrap();
println!("小蚂蚁回来了!");
}运行:
运行结果
text
主蚂蚁还在等……
小蚂蚁出发了!
小蚂蚁回来了!(也可能"小蚂蚁出发了!"先出现——两只蚂蚁谁先跑,说不准。)
三个新动作:
thread::spawn(闭包):派出一只小蚂蚁,闭包(第 11 章)就是它的任务单。spawn 一喊,小蚂蚁立刻开工,主线程(main 所在的蚂蚁)继续走自己的路——两只蚂蚁从此同时干活。
handle:spawn 交回来的"蚂蚁号牌"。有了它才能等这只蚂蚁。
handle.join():主蚂蚁等小蚂蚁回来。join 是"汇合":主蚂蚁站在这里,等这只蚂蚁干完活、退出,才继续走。没有 join,主蚂蚁可能说完"再见"就下班了,小蚂蚁的话都没机会说。
为什么要 join?
线程是"放飞"的:spawn 之后,主线程和它各跑各的,互不等。想"等它干完",就要 join。join 是等人,channel 是等消息——今天两种等法都会学到。
步骤三:一只蚂蚁一个文件
三堆糖,三只蚂蚁。把 main.rs 换成:
rust
use std::fs;
use std::thread;
fn main() {
let files = ["童话1.txt", "童话2.txt", "童话3.txt"];
println!("蚂蚁工头小螃蟹开始派活……");
for file in files {
thread::spawn(move || {
let content = fs::read_to_string(file).expect("找不到文件");
let count = content.chars().count();
println!("{}:{} 块糖", file, count);
});
}
println!("活派完了!回家吃饭!");
}运行一次:
运行结果
text
蚂蚁工头小螃蟹开始派活……
活派完了!回家吃饭!咦?!小蚂蚁一个都没报数,工头就回家吃饭了!
原因:主线程派完活,立刻跑到了"回家吃饭",程序结束——小蚂蚁们还在读文件,程序却已经关门了。它们的话,一句都没来得及说。
这里还有一个新朋友:move。move || { ... } 是"把用到的变量搬进闭包":file 这个文件名,被搬进了小蚂蚁的任务单。为什么要搬?因为线程可能比主线程活得久(可惜这里主线程跑太快),不搬进去,file 早就没了(第 3 章:借用不能超过借出者的寿命)。闭包 + move:小蚂蚁自己的玩具,自己保管。
小蚂蚁的抱怨
不 join、不传消息,线程就是"放了就忘"。想让蚂蚁们把话说完,得让主蚂蚁等消息——这就是下一步的接力棒。
步骤四:接力棒传结果(channel)
蚂蚁数完糖,怎么告诉工头?"接力棒"来也——channel(通道),读"钱诺"。
rust
use std::sync::mpsc;mpsc 是 "multiple producer, single consumer" 的缩写:多只蚂蚁送,一只蚂蚁收。channel 像一根水管:蚂蚁们(发送者)把消息从一头丢进去,工头(接收者)从另一头接。
把 main.rs 换成:
rust
use std::fs;
use std::sync::mpsc;
use std::thread;
fn main() {
let files = ["童话1.txt", "童话2.txt", "童话3.txt"];
let (sender, receiver) = mpsc::channel();
println!("蚂蚁工头小螃蟹开始派活……");
for file in files {
let sender = sender.clone();
thread::spawn(move || {
let content = fs::read_to_string(file).expect("找不到文件");
let count = content.chars().count();
sender.send((file, count)).expect("发送失败");
});
}
drop(sender);
for _ in 0..files.len() {
let (file, count) = receiver.recv().expect("接收失败");
println!("{} 搬了 {} 块糖", file, count);
}
}几个新动作:
mpsc::channel() 造一根水管,交回两个东西:sender(发送口)和 receiver(接收口)。
sender.clone():水管只有一个发送口,三只蚂蚁要同时递消息,怎么办?克隆!sender.clone() 是"再开一个发送口"(第 14 章 Rc::clone 的亲戚——channel 内部就是共享)。每只蚂蚁拿到自己的发送口,往同一根水管里丢消息。
sender.send((file, count)):丢消息——把"文件名 + 糖数"打包成元组(第 7 章),丢进水管。
drop(sender):主线程把自己的发送口关上。为什么?因为 recv 要等消息——如果发送口还开着,recv 会一直等下去,怕还有消息要来。主线程不留发送口,它就知道"没人再发了"。水管干了,收工。
receiver.recv():工头等消息。消息一到,立刻接住;没消息,就站着等(阻塞)。我们收 files.len() 次,正好 3 条。
运行:
运行结果
text
蚂蚁工头小螃蟹开始派活……
童话1.txt 搬了 184 块糖
童话2.txt 搬了 84 块糖
童话3.txt 搬了 83 块糖这次三只蚂蚁都报数了!因为主线程在 recv 上等着——它不回家吃饭了,一直等到 3 条消息到齐。channel 让工头学会了等消息。
顺序又乱了?
这次报数顺序也可能不同(童话2 先报)。每只蚂蚁跑完自己的就递消息,谁先跑完谁先递——这就是"同时干活"的真实样子。消息内容才是重点,顺序不重要。
步骤五:共享糖罐(Mutex)
报数是报完了,但糖还没"收进罐子"。工头想:最后得知道一共多少糖。
想法:让每只蚂蚁数完,把糖放进一个共享糖罐(总数 +1)。听起来简单,但有个陷阱:两只蚂蚁同时往罐子里加糖,会加错!
想象糖罐上写着一个数:184。蚂蚁 A 读"184",正要写"185",蚂蚁 B 也读了"184"……两个人都写"185",其实应该是 186——糖被算丢了一块! 这叫数据竞争(data race,第 3 章说过)。
Rust 的答案:Mutex(互斥锁,读"缪泰克斯")——带锁的糖罐:一只蚂蚁打开锁放糖,其他蚂蚁只能排队等;放完锁上,下一只再开。一次只有一只蚂蚁碰罐子,永远不乱。
把 main.rs 换成最终版:
rust
use std::fs;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let files = ["童话1.txt", "童话2.txt", "童话3.txt"];
let (sender, receiver) = mpsc::channel();
let total_counter = Arc::new(Mutex::new(0));
println!("蚂蚁工头小螃蟹开始派活……");
for file in files {
let sender = sender.clone();
let total_counter = Arc::clone(&total_counter);
thread::spawn(move || {
let content = fs::read_to_string(file).expect("找不到文件");
let count = content.chars().count();
let mut total = total_counter.lock().unwrap();
*total += count;
sender.send((file, count)).expect("发送失败");
});
}
drop(sender);
for _ in 0..files.len() {
let (file, count) = receiver.recv().expect("接收失败");
println!("{} 搬了 {} 块糖", file, count);
}
let total = total_counter.lock().unwrap();
println!("糖罐里一共 {} 块糖!", *total);
}三个新姿势:
Mutex::new(0):造一个带锁的糖罐,里面装着 0。Mutex<T> 是"包着 T 的锁罐子"。
Arc:第 14 章的 Rc 是"单线程共享遥控器";Arc 是"多线程共享遥控器"——原子计数(atomic),线程之间也安全。一只蚂蚁一份 Arc::clone,共享同一个糖罐。
total_counter.lock().unwrap():开锁!lock() 返回"锁住罐子的手"(第 8 章信封老朋友,.unwrap() 是"确定能开")。*total += count 往罐子里加糖——加完,这只手一松,锁自动关上(还记得第 3 章吗?值离开作用域自动清理——这把"锁"也一样)。
运行最终版:
运行结果
text
蚂蚁工头小螃蟹开始派活……
童话1.txt 搬了 184 块糖
童话2.txt 搬了 84 块糖
童话3.txt 搬了 83 块糖
糖罐里一共 351 块糖!351 = 184 + 84 + 83,一块糖都没丢! 三只蚂蚁同时搬,锁罐子保证不会加错。并发 + 安全,两个都要。
15.3 知识深挖
15.3.1 并发:多只蚂蚁同时干活
并发(concurrency)是"多件事同时推进"。Rust 的并发工具三件套,各有分工:
| 工具 | 管什么 | 比喻 |
|---|---|---|
| 线程 | 同时跑的代码 | 小蚂蚁 |
| channel | 线程之间传数据 | 接力棒 |
| Mutex / Arc | 共享数据不打架 | 带锁的糖罐 |
并发的两大难题,也被这两个工具分别解决:怎么通信?(channel 传消息)怎么共享?(Mutex 加锁)。Rust 的哲学是:能传消息就别共享,非共享不可就加锁——消息传递是更不容易出错的路。
15.3.2 线程
线程(thread)是操作系统里"同时跑的一段代码"。
rust
let handle = thread::spawn(move || {
// 小蚂蚁的任务
});
handle.join().unwrap(); // 等它三个要点:
spawn立即返回:主线程不等任务做完,继续走自己的move闭包:任务要用到的变量,必须搬进去(线程可能比主线程活得久)join等人:主线程想等它,就握住号牌handle.join()
主线程结束 = 整个程序结束——不管其他线程有没有干完(步骤三的血泪教训)。所以要么 join,要么 recv 等消息,总得等一个。
15.3.3 channel:接力棒
channel 是"线程之间的水管":一头进(send),一头出(recv)。
rust
let (sender, receiver) = mpsc::channel();
sender.send(消息).unwrap(); // 丢进水管
let 消息 = receiver.recv().unwrap(); // 接住(没有就等)mpsc = 多个发送者(sender.clone() 开新口)、一个接收者。send 移走所有权(消息进了水管,原主人没了,第 3 章)、recv 阻塞等待(没消息就站着等)。全部发送口都关了(drop(sender)),recv 才会知道"不会再来了"。
channel 比 Mutex 好用的地方:消息是搬过去的,不是共享的——水管里永远只有一份数据,谁也不会抢。能传消息,就别共享。
15.3.4 Mutex:带锁的糖罐
Mutex 让"多只蚂蚁碰一个罐子"不出乱子:
rust
let counter = Arc::new(Mutex::new(0));
// 每个线程里:
let mut guard = counter.lock().unwrap();
*guard += 1;
// guard 离开作用域,锁自动打开lock()开锁:拿回"锁住罐子的手"(guard)。一次只能有一只蚂蚁拿到手——其他蚂蚁在门口排队*guard += 1:往罐子里加糖- 自动解锁:guard 离开作用域就"松手",锁自动开——不用手动
unlock,想忘都忘不掉
Arc 是线程安全的 Rc(第 14 章):Rc 在单线程里计数,Arc 在多线程里计数。共享罐子,人人一份遥控器(Arc::clone)。
死锁是什么?
两只蚂蚁互相等对方的锁:蚂蚁 A 拿着糖罐 A 等糖罐 B,蚂蚁 B 拿着糖罐 B 等糖罐 A——两只都等不到,程序卡死。这叫死锁(deadlock)。好消息:我们的程序只有一把锁,不会死锁。多把锁的程序才要小心——Rust 能防数据竞争,防不了死锁(那是逻辑问题)。
15.3.5 Send 与 Sync:编译器当保安
Rust 还有个大招:两个特殊标记——Send(可以安全地搬到别的线程)和 Sync(可以安全地共享给别的线程)。它们像"合格证":
- 大多数类型(数字、String、Vec)天生合格
Rc不合格(单线程计数,进了别的线程会乱)Mutex、Arc合格
最妙的是:你根本不用管这些证。编译器自动检查——你在线程里用了 Rc,编译器立刻拦住:"Rc 不能过安检!"(试试看:把 15.2 步骤五的 Arc 换成 Rc,编译报错给你看。)线程安全的错误,在编译时就被拦下,而不是半夜三点在用户电脑上爆炸。
15.4 动脑筋练习
练习一:数行数
把"数糖块"改成"数故事的行数":每只蚂蚁报"搬了几行故事",糖罐里报总行数。
提示:只改一处——content.chars().count() 换成 content.lines().count()(第 3 章的老朋友)。顺便把打印里的"块糖"改成"行故事"。
点开看答案
把线程里的统计改成:
rust
let count = content.lines().count();再把三处"块糖"改成"行故事"。运行:
text
童话1.txt 搬了 7 行故事
童话2.txt 搬了 4 行故事
童话3.txt 搬了 5 行故事
糖罐里一共 16 行故事!7 + 4 + 5 = 16,一行不差。chars() 换 lines(),统计的东西就变了——小蚂蚁的"糖"是什么,由任务单说了算。
练习二:第四只蚂蚁
再写一篇小故事存成 童话4.txt,把它加进 files 数组。看看程序怎么"自动适应"——数一数,这次要改几处?
点开看答案
只改一处——数组加一个名字:
rust
let files = ["童话1.txt", "童话2.txt", "童话3.txt", "童话4.txt"];运行:
text
童话1.txt 搬了 184 块糖
童话2.txt 搬了 84 块糖
童话3.txt 搬了 83 块糖
童话4.txt 搬了 87 块糖
糖罐里一共 438 块糖!四只蚂蚁,四份报数,总数 438。for file in files 自动派了 4 只蚂蚁,files.len() 自动等 4 条消息——代码一行没加,蚂蚁工队就扩张了。 这就是"数据驱动"的好处:程序跟着数据走。
练习三:工头等蚂蚁(join 版)
现在工头是用 recv(等消息)确认蚂蚁干完的。另一种等法:join(等人)——把每只蚂蚁的号牌(handle)收进一个 Vec,最后挨个 join。
试试:在 spawn 时把 handle 存起来(handles.push(thread::spawn(...))),在收完消息之后,for handle in handles { handle.join()... }。
点开看答案
两处改动。第一处,spawn 的返回值收进 Vec:
rust
let mut handles = Vec::new();
for file in files {
let sender = sender.clone();
let total_counter = Arc::clone(&total_counter);
handles.push(thread::spawn(move || {
let content = fs::read_to_string(file).expect("找不到文件");
let count = content.chars().count();
let mut total = total_counter.lock().unwrap();
*total += count;
sender.send((file, count)).expect("发送失败");
}));
}第二处,收完消息后挨个 join:
rust
for handle in handles {
handle.join().expect("线程出错了");
}运行,结果一模一样。现在工头两样都等了:recv 等消息(蚂蚁报数),join 等人(蚂蚁回家)。双保险:消息到齐、人也到齐,才开糖罐。
想想:如果把 join 放在收消息之前,会怎样?工头先等人全回来,再看水管里的消息——也能收到,只是顺序变了。两种安排都行,这就是并发的弹性。
15.5 完整代码清单
项目结构:
text
ant_brigade/
├── Cargo.toml
├── 童话1.txt
├── 童话2.txt
├── 童话3.txt
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "ant_brigade"
version = "0.1.0"
edition = "2024"文件:童话1.txt
text
今天是小螃蟹 Ferris 的生日。
它早早起了床,穿上最喜欢的红色新衣裳。
朋友们都来啦!小乌龟带来一篮海藻,小海马带来一串亮晶晶的泡泡,寄居蟹带来一颗漂亮的贝壳。
大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"
吃完蛋糕,大家在海边玩了一下午。海水拍打着沙滩,笑声传得好远好远。
这真是最棒的一天!文件:童话2.txt
text
小海马想变成一只会飞的鱼。
它问老海马爷爷:"爷爷,我能飞吗?"
爷爷笑着说:"孩子,你游得比谁都快,这就是你的翅膀。"
小海马点点头,在海里游了一整天,开心极了。文件:童话3.txt
text
月亮弯弯的,像一只小船挂在天上。
小兔子想坐月亮船去星星家做客。
它蹦啊蹦,月亮船越来越近。
小兔子跳上船,星星们眨着眼睛欢迎它。
这一夜,小兔子做了个甜甜的梦。文件:src/main.rs
rust
use std::fs;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let files = ["童话1.txt", "童话2.txt", "童话3.txt"];
let (sender, receiver) = mpsc::channel();
let total_counter = Arc::new(Mutex::new(0));
println!("蚂蚁工头小螃蟹开始派活……");
for file in files {
let sender = sender.clone();
let total_counter = Arc::clone(&total_counter);
thread::spawn(move || {
let content = fs::read_to_string(file).expect("找不到文件");
let count = content.chars().count();
let mut total = total_counter.lock().unwrap();
*total += count;
sender.send((file, count)).expect("发送失败");
});
}
drop(sender);
for _ in 0..files.len() {
let (file, count) = receiver.recv().expect("接收失败");
println!("{} 搬了 {} 块糖", file, count);
}
let total = total_counter.lock().unwrap();
println!("糖罐里一共 {} 块糖!", *total);
}怎么运行:
bash
cd ant_brigade
cargo run运行检查单:
三只蚂蚁都报数,总数 = 三份报数之和(351)
多跑几次,报数顺序会变(并发证明)
把 thread::spawn 的 move 删掉,看编译器怎么教育你(闭包要搬走变量!)
把 Arc 换成 Rc,看编译器怎么拦住它(线程安全安检)
删掉 drop(sender),程序会怎样?(想想 recv 为什么等不到"不会再来了")
删掉最后 lock() 那一段,糖罐还有用吗?