跳转到内容

Rust 信道(channel)

在并发那一页,线程之间用 Arc<Mutex<T>> 加锁来共享数据。这样可行,但还有一种往往更简单的做法:线程之间不共享数据,而是互相发送消息。

可以把信道(channel)想象成一根有两个口的管子。一个线程从发送端(sender)把值放进去,另一个线程从接收端(receiver)按同样的顺序把值取出来。谁也不跟谁共享东西:每个值都从一个线程移动到另一个线程。Rust 在这里借用了 Go 社区的一句话:“不要通过共享内存来通信,而要通过通信来共享内存。”

标准库的信道在 std::sync::mpsc 里。这个名字的意思是 multiple producer, single consumer(多个生产者,单个消费者):可以有很多发送端,但只有一个接收端。

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let message = String::from("来自工作线程的问候");
tx.send(message).unwrap();
});
let received = rx.recv().unwrap();
println!("main 收到了:{received}");
}

输出:

main 收到了:来自工作线程的问候
▶ 动手试一试

一步一步来看:

  • mpsc::channel() 返回一对值:一个 Sender 和一个 Receiver。按惯例,它们叫做 tx(transmit,发送)和 rx(receive,接收)。
  • move 把 tx 交给新线程,和你在并发那一页把数据移动到线程里的做法一样。
  • tx.send(value) 把一个值放进信道。它返回一个 Result,因为如果接收端已经不在了,发送就会失败。
  • rx.recv() 会等待,直到有值到达。只有当所有发送端都不在了、并且信道已经空了,它才会返回 Err。

注意这里不需要 join()。recv() 本身就会等消息,所以 main 不会提前结束。

Receiver 可以当作迭代器来用。对它写一个 for 循环,循环会替你不断调用 recv();等所有发送端都被丢弃后,循环会自己结束:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 1..=3 {
tx.send(i * 10).unwrap();
}
// 线程结束时,tx 在这里被丢弃
});
for value in rx {
println!("收到 {value}");
}
println!("信道已关闭,全部完成");
}

输出:

收到 10
收到 20
收到 30
信道已关闭,全部完成
▶ 动手试一试

这里只有一个发送端,所以值到达的顺序和发送的顺序完全一样。“发送端都不在了,循环就停止”这条规则很重要,下一节还会用到,先记住它。

想让多个线程都能发送,就用 tx.clone() 给每个线程一份自己的发送端。所有副本都往同一个信道里送值:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 1..=3 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("工作线程 {id} 报到")).unwrap();
});
}
drop(tx); // 丢弃原来的发送端,否则下面的循环永远不会结束
let mut messages: Vec<String> = rx.iter().collect();
messages.sort();
for m in messages {
println!("{m}");
}
}

输出:

工作线程 1 报到
工作线程 2 报到
工作线程 3 报到
▶ 动手试一试

有两点要注意:

  • drop(tx) 很关键。 每个线程结束时会丢弃自己的克隆,但 main 手里还拿着原来那个。只要还有一个发送端存在,接收端就会一直等下去,程序就会永远卡住。丢弃它就等于告诉信道:“main 不会再发送任何东西了。”
  • 线程结束的顺序不固定。 消息可能按工作线程 2、1、3 的顺序到达。所以这里先把它们收集起来再排序,让打印结果每次都一样。去掉 sort() 多运行几次,就能看到顺序在变。

不想一直等:try_recv 和 recv_timeout

Section titled “不想一直等:try_recv 和 recv_timeout”

recv() 会一直阻塞到有东西到达。有时候你并不想等。这时有两个“亲戚”可以帮忙:

  • try_recv() 会立刻返回:要么是一个值,要么是 Empty(还没有东西),要么是 Disconnected(所有发送端都不在了)。
  • recv_timeout(duration) 会等,但最多只等一段时间。时间到了还没有东西,就得到 Timeout。
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel::<i32>();
// 还什么都没发送:try_recv 会立刻返回。
println!("{:?}", rx.try_recv());
thread::spawn(move || tx.send(7).unwrap()).join().unwrap();
// 已经有一个值在等着,所以这里不用等就能拿到它。
println!("{:?}", rx.recv_timeout(Duration::from_secs(1)));
// 发送端不在了,信道也空了。
println!("{:?}", rx.try_recv());
}

输出:

Err(Empty)
Ok(7)
Err(Disconnected)
▶ 动手试一试

我们在接收之前先 join() 了那个线程,所以值已经在信道里等着了,输出每次都一样。

如果一个循环里还有别的事要做,try_recv() 就很好用,比如游戏循环每一帧检查一次有没有新消息。recv_timeout() 适合“工作线程太慢就放弃”这种场景。

普通的 channel() 没有大小限制。如果发送端比接收端快很多,消息就会在内存里越堆越多。mpsc::sync_channel(n) 创建的信道最多只能存放 n 条消息。满了以后,send() 会等待,直到接收端取走一条。这叫做背压(backpressure):慢的消费者会自动让快的生产者慢下来。

use std::sync::mpsc;
use std::thread;
fn main() {
// 同一时间只能放 2 条消息。
let (tx, rx) = mpsc::sync_channel(2);
let producer = thread::spawn(move || {
for i in 1..=5 {
tx.send(i).unwrap(); // 缓冲区满了就会阻塞
}
});
let total: i32 = rx.iter().sum();
producer.join().unwrap();
println!("总和 = {total}");
}

输出:

总和 = 15
▶ 动手试一试

生产者最多只能比 main 领先两个数字。如果写成 sync_channel(0),就完全没有缓冲区:每次 send() 都要等接收端准备好接手,就像当面把包裹交到对方手里一样。

现在把学到的东西组合起来。工作线程池(worker pool)就是几个线程从同一个队列里领任务,再把结果送回来。它用到两个信道:一个放任务,一个放结果。

这里有个问题:Receiver 不能克隆(毕竟是单个消费者)。所以工作线程们通过 Arc<Mutex<_>> 共享同一个任务接收端,轮流从里面取任务:

use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let (job_tx, job_rx) = mpsc::channel::<u64>();
let (result_tx, result_rx) = mpsc::channel::<(u64, u64)>();
// Receiver 不能克隆,所以工作线程们通过 Arc<Mutex<_>> 共享同一个。
let job_rx = Arc::new(Mutex::new(job_rx));
let mut workers = Vec::new();
for _ in 0..3 {
let job_rx = Arc::clone(&job_rx);
let result_tx = result_tx.clone();
workers.push(thread::spawn(move || loop {
let job = job_rx.lock().unwrap().recv();
match job {
Ok(n) => result_tx.send((n, n * n)).unwrap(),
Err(_) => break, // 没有任务了
}
}));
}
drop(result_tx);
for n in 1..=6 {
job_tx.send(n).unwrap();
}
drop(job_tx); // 告诉工作线程:不会再有新任务了
let mut results: Vec<(u64, u64)> = result_rx.iter().collect();
for w in workers {
w.join().unwrap();
}
results.sort();
for (n, square) in results {
println!("{n} 的平方是 {square}");
}
}

输出:

1 的平方是 1
2 的平方是 4
3 的平方是 9
4 的平方是 16
5 的平方是 25
6 的平方是 36
▶ 动手试一试

它是怎样干净利落地收尾的:

  1. drop(job_tx) 关闭了任务信道。之后每个工作线程的 recv() 都会返回 Err,工作线程就跳出循环。
  2. 工作线程结束时,它手里的那个 result_tx 克隆也被丢弃了。原来的那个我们早就丢弃了,所以三个工作线程都结束后,结果信道就关闭了。
  3. 然后 result_rx.iter() 停止。因为工作线程结束的顺序不固定,所以最后要对结果排序。

还有一个小细节:job_rx.lock().unwrap().recv() 是故意写在同一条 let 语句里的。锁会在这条语句结束时释放,这样当这个工作线程还在计算平方的时候,其他工作线程已经可以去领下一个任务了。

两者都是安全的,只是适合的问题形态不同:

适合用信道的情况…… 适合用 Arc<Mutex<T>> 的情况……
工作朝一个方向流动:生产者产出东西,消费者负责处理 很多线程需要读取和更新同一个值(计数器、缓存)
你需要一个由任务、事件或日志行组成的队列 数据很大,复制或到处移动会很浪费
每个值同一时间只有一个所有者 线程需要查看当前状态,而不是一连串的更新

一个好用的经验法则:如果你的程序可以描述为“这些线程告诉那个线程某件事”,就用信道;如果是“这些线程都在修改这个东西”,就用 Mutex。很多实际程序两者都会用,就像我们的工作线程池一样。

发送之后还在用这个值。 send() 会获取所有权,和把值传给函数一样。发送之后,这个值就归接收端了:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let message = String::from("hi");
tx.send(message).unwrap();
println!("sent {message}");
});
println!("{}", rx.recv().unwrap());
}
error[E0382]: borrow of moved value: `message`
--> src/main.rs:10:25
|
8 | let message = String::from("hi");
| ------- move occurs because `message` has type `String`, which does not implement the `Copy` trait
9 | tx.send(message).unwrap();
| ------- value moved here
10 | println!("sent {message}");
| ^^^^^^^ value borrowed here after move

这是 Rust 在保护你:如果发送方还能修改 message,两个线程就会同时碰到同一份数据。可以先打印再发送,或者发送一个 .clone()。

在循环里把发送端移动进多个线程。 第一个线程就把 tx 拿走了,第二个线程什么也拿不到:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 1..=3 {
thread::spawn(move || {
tx.send(id).unwrap();
});
}
for value in rx {
println!("{value}");
}
}
error[E0382]: use of moved value: `tx`
--> src/main.rs:8:23
|
5 | let (tx, rx) = mpsc::channel();
| -- move occurs because `tx` has type `std::sync::mpsc::Sender<i32>`, which does not implement the `Copy` trait
...
8 | thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop

修复方法是在循环开头写 let tx = tx.clone();,就像“多个生产者”那个例子一样。

程序永远不结束。 这种情况没有编译错误,所以特别隐蔽。如果你在遍历接收端(for x in rx),而某个发送端还活着(常常是 main 里原来那个 tx),循环就会永远等下去。程序“就那么卡住了”的时候,检查一下是不是每个发送端都被丢弃了。

std::sync::mpsc 能应付很多场景,不过你还会遇到两个常用的替代品:

  • crossbeam-channel(英文)提供多消费者信道(接收端可以克隆),还有一个 select! 宏,可以同时等待多个信道。
  • tokio::sync::mpsc 是给异步代码用的信道。如果你在用 async/.await(见 async/await),就用它代替标准库的信道,因为它的 recv().await 不会阻塞整个线程。
  • mpsc::channel() 给你一对 (Sender, Receiver)。send() 把值移动进去,recv() 等待下一个值。
  • 克隆发送端就能有多个生产者。接收端永远只有一个。
  • 遍历接收端的循环会在所有发送端都被丢弃时结束,所以别忘了 drop() 原来那个。
  • try_recv() 和 recv_timeout() 让你可以检查信道,而不用一直等下去。sync_channel(n) 把队列限制在 n 条消息。
  • 来自多个线程的消息到达顺序不固定:需要稳定的输出时,就排序或求和。
  • 数据从一个线程流向另一个线程时用信道;多个线程共享并修改同一个值时用 Arc<Mutex<T>>。

小测验

1. “mpsc”是什么的缩写?

2. 怎样让三个线程往同一个信道里发送?

3. 一个 for value in rx 循环永远不结束。最可能的原因是什么?

4. sync_channel(2) 里已经有 2 条消息时,send() 会怎样?

得分: 0 / 4