Rust 学习笔记:future、任务和线程
Rust 学习笔记:future、任务和线程
线程提供了一种实现并发的方法。对 future 和 Stream 使用 async 是另一种实现并发的方法。
这两种方法不是二选一。在许多情况下,选择不是线程还是异步,而是线程和异步。
许多操作系统已经提供了基于线程的并发模型,因此许多编程语言也支持它们。然而,这些模型并非没有权衡。在许多操作系统上,它们为每个线程使用相当多的内存,并且它们在启动和关闭时带来了一些开销。当操作系统和硬件支持线程时,线程也是一种选择。与主流桌面和移动计算机不同,一些嵌入式系统根本没有操作系统,因此它们也没有线程。
异步模型提供了一组不同的(最终是互补的)权衡。在异步模型中,并发操作不需要它们自己的线程。相反,它们可以在任务上运行。任务类似于线程,但不是由操作系统管理,而是由异步运行时管理。
我们可以通过使用异步通道构建流,并生成可以从同步代码调用的异步任务。
fn get_intervals() -> impl Stream<Item = u32> {
let (tx, rx) = trpl::channel();
trpl::spawn_task(async move {
let mut count = 0;
loop {
trpl::sleep(Duration::from_millis(1)).await;
count += 1;
if let Err(send_error) = tx.send(count) {
eprintln!("Could not send interval {count}: {send_error}");
break;
};
}
});
ReceiverStream::new(rx)
}
我们可以用线程做同样的事情。
fn get_intervals() -> impl Stream<Item = u32> {
let (tx, rx) = trpl::channel();
// This is *not* `trpl::spawn` but `std::thread::spawn`!
thread::spawn(move || {
let mut count = 0;
loop {
// Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`!
thread::sleep(Duration::from_millis(1));
count += 1;
if let Err(send_error) = tx.send(count) {
eprintln!("Could not send interval {count}: {send_error}");
break;
};
}
});
ReceiverStream::new(rx)
}
前者在运行时生成异步任务,后者生成操作系统线程,结果流也不会受到差异的影响。
尽管它们有相似之处,但这两种方法的行为非常不同,尽管在这个非常简单的示例中我们可能很难衡量它。我们可以在任何一台 PC 上生成数百万个异步任务。如果我们试图用线程来做这件事,我们就会耗尽内存!
然而,这些 API 如此相似是有原因的。线程充当同步操作集合的边界,线程之间可以并发。任务充当异步操作集合的边界,并发在任务之间和任务内部都是可能的,因为任务可以在其主体中的 future 之间切换。
future 是 Rust 并发的最小单位。每个 future 可能是其他 future 的树形结构。
运行时(特别是它的执行器)管理任务,而任务管理 future。在这方面,任务类似于轻量级的、由运行时管理的线程,只是增加了由运行时而不是操作系统管理的功能。
这并不意味着异步任务总是比线程好(反之亦然)。线程并发在某种程度上是比异步并发更简单的编程模型。这可能是优点,也可能是缺点。线程有点像“一次启动就进行到底”,它没有像 future 那样的原生机制,因此线程会一直运行,除非被操作系统打断。线程不像 future 那样内置对任务内并发性的支持。线程也没有取消机制——这是我们没有明确讨论的主题,但是当我们结束一个 future 时,它的状态会被正确地清理,这一事实暗示了这一点。
因此,任务给了我们对未来的 future 控制,允许我们选择何时以及如何将它们分组。事实证明,线程和任务通常可以很好地协同工作,因为任务可以(至少在某些运行时)在线程之间移动。事实上,在底层,我们一直在使用的运行时——包括 spawn_blocking 和 spawn_task 函数——默认是多线程的!
许多运行时使用一种称为“工作窃取”的方法,根据线程当前的使用情况,在线程之间透明地移动任务,以提高系统的整体性能。这种方法实际上需要线程和任务,因此也需要 future。
在考虑何时使用哪种方法时,请考虑以下经验法则:
-
如果工作是高度并行的,例如处理一堆数据,其中每个部分都可以单独处理,那么线程是更好的选择。
-
如果工作是高度并发的,例如处理来自不同来源的消息,这些消息可能以不同的间隔或不同的速率传入,那么异步是更好的选择。
如果同时需要并行性和并发性,则不必在线程和异步之间进行选择。你可以自由地使用它们,让每一个都发挥它最擅长的作用。
例如:
use std::{thread, time::Duration};
fn main() {
let (tx, mut rx) = trpl::channel();
thread::spawn(move || {
for i in 1..11 {
tx.send(i).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
trpl::run(async {
while let Some(message) = rx.recv().await {
println!("{message}");
}
});
}
我们首先创建一个异步通道,然后生成一个线程,该线程获得通道的发送端所有权。在线程中,我们发送数字 1 到 10,在每个数字之间休眠 1 s。最后,我们运行通过传递给 trpl::run 的异步块创建的 future。在 future 中,我们等待这些消息,接收并打印出来。
想象一下使用专用线程运行一组视频编码任务(因为视频编码是计算绑定的),但通知 UI 这些操作是通过异步通道完成的。在真实的用例中有无数这样的组合。