进阶篇 (5讲)
你好,我是 Mike。今天我们来了解并发编程的另一种范式——使用 channel 在不同的任务间进行通信。
channel 翻译成中文就是通道或管道,用来在 task 之间传递消息。这个概念本身并不难。我们回忆一下上节课的目标:要在多个任务中同时对一个内存数据库进行更新。其实我们也可以用 channel 的思路来解决这个问题。
我们先来分解一下任务。
基于这个思路,我们来重写上一节课的示例。
我们使用 tokio 中的 MPSC Channel 来实现。MPSC Channel 是多生产者,单消费者通道(Multi-Producers Single Consumer)。
MPSC 的基本用法如下:
let (tx, mut rx) = mpsc::channel(100);
使用 MPSC 模块的 channel() 函数创建一个通道对,tx 表示发送端,rx 表示接收端,rx 前面要加 mut 修饰符,因为 rx 在接收数据的时候使用了可变借用。channel 使用的时候要给一个整数参数,表示这个通道容量多大。tokio 的这个 mpsc::channel 是带背压功能的,也就是说,如果发送端发得太快,接收端来不及消耗导致通道堵塞了的话,这个 channel 会让发送端阻塞等待,直到通道里面的数据包被消耗到留出空位为止。
MPSC 的特点就是可以有多个生产者,但只有一个消费者。因此,tx 可以被随意 clone 多份,但是 rx 只能有一个。
前面的例子,我们用 channel 来实现。
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let mut db: Vec = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let (tx, mut rx) = mpsc::channel::(100); // 创建channel
let tx1 = tx.clone(); // 拷贝两份arc
let tx2 = tx.clone();
let task_a = tokio::task::spawn(async move {
if let Err(_) = tx1.send(50).await { // 发送端标准写法
println!("receiver dropped");
return;
}
});
let task_b = tokio::task::spawn(async move {
if let Err(_) = tx2.send(100).await { // 发送端标准写法
println!("receiver dropped");
return;
}
});
let task_c = tokio::task::spawn(async move {
while let Some(i) = rx.recv().await { // 接收端标准写法
println!("got = {}", i);
db[4] = i;
println!("{:?}", db);
}
});
_ = task_a.await.unwrap();
_ = task_b.await.unwrap();
_ = task_c.await.unwrap();
}
//输出
got = 50
[1, 2, 3, 4, 50, 6, 7, 8, 9, 10]
got = 100
[1, 2, 3, 4, 100, 6, 7, 8, 9, 10]
^C
代码第 6 行,我们使用 let (tx, mut rx) = mpsc::channel::
第 8 行和第 9 行,clone 了两份 tx。因为 tx 本质上实现为一个 Arc 对象,因此 clone 它也就只增加了引用计数,没有多余的性能消耗。
第 11 行和第 17 行,创建了两个工作者任务,在里面我们用 if let Err(_) = tx1.send(50).await 这种写法来向 channel 中发送信息,因为向 MPSC Channel 中灌数据时,是有可能会出错的,比如 channel 的另一端 rx 已经关闭了(被释放了),那么这时候再用 tx 发数据就会产生一个错误,所以这里需要用 if let Err(_) 这种形式来处理错误。
第 24 行,创建一个代理任务 task_c,使用这种写法 while let Some(i) = rx.recv().await 来接收消息。这里 rx.recv().await 获取回来的是一个 Option
可以看到,当业务正常进行时,这个程序不会自动终止,而是会一直处于工作状态,最后我们得用 Ctrl-C 在终端终止它的运行。为什么呢?因为 while let 没有退出。rx.recv().await 一直在等待下一个 msg 的到来,但是前面两个发消息的任务 task_a、task_b 的工作已经完成,退出了,于是没有角色给 rx 发消息了,它就会一直等下去。这里的 .await 是一种不消耗资源的等待,tokio 保证这种等待不会让一个 CPU 忙空转。
第 31 行~第 33 行的顺序在这里并不是很重要,你可以试试改变 task_a、task_b、task_c 的 await 的顺序,看看输出结果的变化。花几分钟理解了这个过程后,你会发现这个方案的思维方式和前面使用锁的方式完全不同。这其实是一种常见的设计模式:代理模式。
tokio::task::spawn() 这个 API 有个特点,就是通过它创建的异步任务,一旦创建好,就会立即扔到 tokio runtime 里执行,不需要对其返回的 JoinHandler 进行 await 才驱动执行,这个特性很重要。
我们使用这个特性分析一下前面的示例:task_a、task_b、task_c 创建好之后,实际就已经开始执行了。task_c 已经在等待 channel 数据的到来了。第 31 到 33 行 JoinHandler 的 await 只是在等待任务本身结束而已。我们试着修改一下上面的示例。
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task;
use tokio::time;
#[tokio::main]
async fn main() {
let mut db: Vec = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let (tx, mut rx) = mpsc::channel::(100);
let tx1 = tx.clone();
let tx2 = tx.clone();
let task_a = task::spawn(async move {
println!("in task_a 1");
time::sleep(Duration::from_secs(3)).await; // 等待3s
println!("in task_a 2");
if let Err(_) = tx1.send(50).await {
println!("receiver dropped");
return;
}
});
let task_b = task::spawn(async move {
println!("in task_b");
if let Err(_) = tx2.send(100).await {
println!("receiver dropped");
return;
}
});
let task_c = task::spawn(async move {
while let Some(i) = rx.recv().await {
println!("got = {}", i);
db[4] = i;
println!("{:?}", db);
}
});
_ = task_c.await.unwrap(); // task_c 放在前面来await
_ = task_a.await.unwrap();
_ = task_b.await.unwrap();
}
// 输出
in task_a 1
in task_b
got = 100
[1, 2, 3, 4, 100, 6, 7, 8, 9, 10]
in task_a 2
got = 50
[1, 2, 3, 4, 50, 6, 7, 8, 9, 10]
^C
在这个示例里,我们在 task_a 中 sleep 了 3 秒(第 16 行)。同时把 task_c 放到最前面去 await 了(第 39 行)。可以看到,task_b 发来的数据先打印,3 秒后,task_a 发来的数据打印了。
实际对于 main 函数这个 task 来讲,它其实被阻塞在了第 39 行,因为 task_c 一直在 await,并没有结束。task_a 和 task_b 虽然已经结束了,但是并没有执行到第 40 行和第 41 行去。对整个程序的输出来讲,没有执行到第 40 行和第 41 行并不影响最终效果。你仔细体会一下。
所以使用 task::spawn() 创建的多个任务之间,本身就是并发执行的关系。你可以对比一下这两个示例。
tokio::mpsc 模块里还有一个函数 mpsc::unbounded_channel(),可以用来创建没有容量上限的通道,也就意味着,它不具有背压功能。这个通道里面能存多少数据,就看机器的内存多大,极端情况下,可能会撑爆你的服务器。而在使用方法上,这两种 channel 区别不大,因此不再举例说明。如果你感兴趣的话可以看一下我给出的链接。
如果现在我们要在前面示例的基础上增加一个需求:我在 task_c 中将 db 更新完成,想给 task_a 和 task_b 返回一个事件通知说,我已经完成了,应该怎么做?
这个问题当然不止一种解法,比如外部增加一个消息队列,将这两个消息抛进消息队列里面,让 task_a 和 task_b 监听这个队列。然而这个方案会增加对外部服务的依赖,可能是一个订阅 - 发布服务;task_a 和 task_b 里需要订阅外部消息队列,并过滤对应的消息进行处理。
tokio 其实内置了另外一个好用的东西 Oneshot channel,它可以配合 MPSC Channel 完成我们的任务。Oneshot 定义了这样一个模型,这个通道只能用一次,也就是说只能发送一条数据,发送完之后就关闭了,对应的 tx 和 rx 就无法再次使用了。这个很适合等待计算结果返回的场景。我们试着用这个新设施来实现一下我们的需求。
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio::task;
use tokio::time;
#[tokio::main]
async fn main() {
let mut db: Vec = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let (tx, mut rx) = mpsc::channel::<(u32, oneshot::Sender)>(100);
let tx1 = tx.clone();
let tx2 = tx.clone();
let task_a = task::spawn(async move {
time::sleep(Duration::from_secs(3)).await;
let (resp_tx, resp_rx) = oneshot::channel();
if let Err(_) = tx1.send((50, resp_tx)).await {
println!("receiver dropped");
return;
}
if let Ok(ret) = resp_rx.await {
if ret {
println!("task_a finished with success.");
} else {
println!("task_a finished with failure.");
}
} else {
println!("oneshot sender dropped");
return;
}
});
let task_b = task::spawn(async move {
let (resp_tx, resp_rx) = oneshot::channel();
if let Err(_) = tx2.send((100, resp_tx)).await {
println!("receiver dropped");
return;
}
if let Ok(ret) = resp_rx.await {
if ret {
println!("task_b finished with success.");
} else {
println!("task_b finished with failure.");
}
} else {
println!("oneshot sender dropped");
return;
}
});
let task_c = task::spawn(async move {
while let Some((i, resp_tx)) = rx.recv().await {
println!("got = {}", i);
db[4] = i;
println!("{:?}", db);
resp_tx.send(true).unwrap();
}
});
_ = task_a.await.unwrap();
_ = task_b.await.unwrap();
_ = task_c.await.unwrap();
}
// 输出
got = 100
[1, 2, 3, 4, 100, 6, 7, 8, 9, 10]
task_b finished with success.
got = 50
[1, 2, 3, 4, 50, 6, 7, 8, 9, 10]
task_a finished with success.
^C
解释一下这个例子,这个例子里的第 9 行,把消息类型定义成了 (u32, oneshot::Sender
然后第 16 行,在 task_a 中创建了一个 Oneshot channel,两个端为 resp_tx 和 resp_rx。然后在第 17 行,把 resp_tx 实例直接放在消息中,随着 MPSC Channel 一起发送给 task_c 了。然后在 task_a 里用 resp_rx 等待 oneshot 通道的值传过来。这点很关键。task_b 也是类似的处理。
在 task_c 里,第 51 行收到的消息是 Some((i, resp_tx)),task_c 拿到了 task_a 和 task_b 里创建的 Oneshot channel 的发送端 resp_tx,就可以用它在第 55 行把计算的结果发送回去: resp_tx.send(true).unwrap();。
这个例子非常精彩,也是一种比较固定的模式。因为通道两个端本身就是类型的实例,当然可以被其他通道传输。这里我们 MPSC + Oneshot 两种通道成功实现了 Request/Response 模式。
接下来我们再介绍一下 tokio 中的其他 channel 类型。tokio 中还有两个内置通道类型,用得不是那么多,但功能非常强大,你可以在遇到合适的场景时再去具体研究。
广播模式,实现了 MPMC 模型,也就是多生产者多消费者模式,可以用来实现发布 - 订阅模式。每个消费者都会收到每个生产者发出的同样的消息副本。你可以查看链接了解学习。
broadcast 通道实际已覆盖 SPMC 模型,所以不用再单独定义 SPMC 了。
watch 通道实际是一个特定化版本的 broadcast 通道,它有 2 个特性。
它适用于一些特定的场景,比如配置更新需要通知工作任务重新加载,平滑关闭任务等等。你可以通过我给出的链接进一步学习。
前面示例中 task_c 很关键。为什么呢?因为它不但起到了搜集数据执行操作的作用,它还把整个程序阻塞住了,保证了程序的持续运行。那如果一个程序里面没有负责这个任务的角色,应该怎么去搜集其他任务返回的结果呢?我们在第 13 讲中已经提到了一种方式。
use tokio::task;
async fn my_background_op(id: i32) -> String {
let s = format!("Starting background task {}.", id);
println!("{}", s);
s
}
#[tokio::main]
async main() {
let ops = vec![1, 2, 3];
let mut tasks = Vec::with_capacity(ops.len());
for op in ops {
// 任务创建后,立即开始运行,我们用一个Vec来持有各个任务的handler
tasks.push(tokio::spawn(my_background_op(op)));
}
let mut outputs = Vec::with_capacity(tasks.len());
for task in tasks {
// 在这里依次等待任务完成
outputs.push(task.await.unwrap());
}
println!("{:?}", outputs);
}
上面的代码有两个关键要点。
这代表了一种模式。这个模式有个特点,就是要等待前面任务结束,才能拿到后面任务的返回结果。如果前面某个任务执行的时间比较长,即使后面的任务实际已经执行完了,在最后搜集结果的时候,还是需要等前面那个任务结束了后,我们才能搜集到后面任务的结果。比如:
use std::time::Duration;
use tokio::task;
use tokio::time;
#[tokio::main]
async fn main() {
let task_a = task::spawn(async move {
println!("in task_a");
time::sleep(Duration::from_secs(3)).await; // 等待3s
1
});
let task_b = task::spawn(async move {
println!("in task_b");
2
});
let task_c = task::spawn(async move {
println!("in task_c");
3
});
let mut tasks = Vec::with_capacity(3);
tasks.push(task_a);
tasks.push(task_b);
tasks.push(task_c);
let mut outputs = Vec::with_capacity(tasks.len());
for task in tasks {
println!("iterate task result..");
// 在这里依次等待任务完成
outputs.push(task.await.unwrap());
}
println!("{:?}", outputs);
}
// 输出
iterate task result..
in task_a
in task_b
in task_c // 在这之后会等待 3 秒,然后继续打印
iterate task result..
iterate task result..
[1, 2, 3]
上面的示例创建了三个任务 task_a、task_b、task_c,在 task_a 里等待 3 秒返回,task_b 和 task_c 都是立即返回。执行的时候,当打印出 "in task_c" 后,会停止 3 秒左右,然后继续打印剩下的,印证了我们前面的分析。
tokio 提供了一个宏 tokio::join!(),用来简化上面代码的写法,表示等待所有任务完成后,一起返回一个结果。用法如下:
use std::time::Duration;
use tokio::task;
use tokio::time;
#[tokio::main]
async fn main() {
let task_a = task::spawn(async move {
println!("in task_a");
time::sleep(Duration::from_secs(3)).await; // 等待3s
1
});
let task_b = task::spawn(async move {
println!("in task_b");
2
});
let task_c = task::spawn(async move {
println!("in task_c");
3
});
let (r1, r2, r3) = tokio::join!(task_a, task_b, task_c);
println!("{}, {}, {}", r1.unwrap(), r2.unwrap(), r3.unwrap());
}
// 输出
in task_a
in task_b
in task_c
1, 2, 3
这两个示例基本等价,都是在所有任务中等待最长的那个任务执行完成后,统一返回。你可以想想为什么它们差不多。
在实际场景中,还有另外一大类需求,就是在一批任务中,哪个任务先执行完,就马上返回那个任务的结果。剩下的任务,要么是不关心它们的执行结果,要么是直接取消它们继续执行。
针对这种场景,tokio 提供了 tokio::select!() 宏。用法如下:
use std::time::Duration;
use tokio::task;
use tokio::time;
#[tokio::main]
async fn main() {
let task_a = task::spawn(async move {
println!("in task_a");
time::sleep(Duration::from_secs(3)).await; // 等待3s
1
});
let task_b = task::spawn(async move {
println!("in task_b");
2
});
let task_c = task::spawn(async move {
println!("in task_c");
3
});
let ret = tokio::select! {
r = task_a => r.unwrap(),
r = task_b => r.unwrap(),
r = task_c => r.unwrap(),
};
println!("{}", ret);
}
// 输出
// 第一次
in task_b
in task_a
2
in task_c
// 第二次
in task_a
in task_c
in task_b
2
// 第n次
in task_a
in task_c
in task_b
3
请注意示例里第 21 行到第 25 行的写法,这是 tokio::select! 宏定义的语法,不是 Rust 标准语法。变量 r 表示任务的返回值。当你多次执行上面代码后,你会发现,输出结果并不固定,你可以想一下为什么会这样。
这节课我们讨论了在 Rust 中如何应用 channel 这种编程范式,在并发编程中避免使用锁。Rust 的 tokio 库提供了常用的通道模型基础设施。
每种通道都有各自的用途,适用于不同的场景需求。这一讲我们重点讲解了前两种通道,只要你掌握了它们,另外两种使用方式也是差不多的。这节课讨论的这些模式相当固定,只要照搬套用就可以了。
本讲代码链接:https://github.com/miketang84/jikeshijian/tree/master/16-channel
你可以说一说从任务中搜集返回结果有几种方式吗?