-
Rust的async/await特性是通过一种称为协作式调度(cooperative scheduling)的机制来实现的,这对于编写异步Rust代码的人来说有一些重要的影响。
-
这篇博文的目标读者是异步Rust的新用户。我将使用Tokio运行时作为示例,但这里提出的观点适用于任何异步运行时。
-
如果你只从这篇文章中记住一件事,那应该是:
异步代码不应该长时间不到达.await。(注:指的是运行中)
Blocking vs. non-blocking code
-
编写一个可以同时处理许多事情的应用程序的质朴的方法是为每个任务生成一个新线程。如果任务的数量很少,这就是一个完美的解决方案,但是随着任务的数量变得越来越多,你最终会因为数量过多的线程而遇到问题。这个问题在不同的编程语言中有不同的解决方案,但它们都归结为同一件事: 非常快速地切换每个线程上当前运行的任务,这样所有的任务都有机会运行。在Rust中,这种切换发生在当你.await一些事上。
-
在写异步rust 时,短语阻塞线程(blocking the thread)意味着阻止运行时切换当前任务。这可能是一个主要问题,这意味着同一运行时上的其他任务将停止运行,直到线程不再被阻塞。为了防止这种情况,我们应该编写能够快速切换的代码,也就是不要长时间不进行.await。
让我们来举个例子:
use std::time::Duration;
#[tokio::main]
async fn main() {
println!("Hello World!");
// No .await here!
std::thread::sleep(Duration::from_secs(5));
println!("Five seconds later...");
}
上面的代码看起来是正确的,运行它将看起来正常工作。但它有一个致命的缺陷: 它阻塞了线程。上述情况没有其他任务,所以看不出问题,但在真正的程序中就不一样了。
为了说明这一点,考虑下面的例子:
use std::time::Duration;
async fn sleep_then_print(timer: i32) {
println!("Start timer {}.", timer);
// No .await here!
std::thread::sleep(Duration::from_secs(1));
println!("Timer {} done.", timer);
}
#[tokio::main]
async fn main() {
// The join! macro lets you run multiple things concurrently.
tokio::join!(
sleep_then_print(1),
sleep_then_print(2),
sleep_then_print(3),
);
}
Start timer 1.
Timer 1 done.
Start timer 2.
Timer 2 done.
Start timer 3.
Timer 3 done.
这个例子将花费三秒钟运行,timer将在没有任何并发的情况下一个接一个地运行。原因很简单: Tokio运行时无法将一个任务切换为另一个任务,因为这种切换只能发生在.await。因为sleep_then_print中没有.await,在运行时就不会发生切换。
- 如果我们使用Tokio的sleep函数,它使用一个.await来睡眠,那么这个函数就会正常工作:
use tokio::time::Duration;
async fn sleep_then_print(timer: i32) {
println!("Start timer {}.", timer);
tokio::time::sleep(Duration::from_secs(1)).await;
// ^ execution can be paused here
println!("Timer {} done.", timer);
}
#[tokio::main]
async fn main() {
// The join! macro lets you run multiple things concurrently.
tokio::join!(
sleep_then_print(1),
sleep_then_print(2),
sleep_then_print(3),
);
}
Start timer 1.
Start timer 2.
Start timer 3.
Timer 1 done.
Timer 2 done.
Timer 3 done.
这段代码只需一秒钟就可以运行,并且如预期地在同一时间正确地运行所有三个函数。
要知道,事情并不总是这么明显。通过使用tokio::join!,这三个任务都保证在同一个线程上运行
- 但如果你用tokio::spawn替换它,并使用一个多线程的运行时,你将能够同时运行多个阻塞任务直到用完线程。
use std::time::Duration;
async fn sleep_then_print(timer: i32) {
println!("Start timer {}.", timer);
// No .await here!
std::thread::sleep(Duration::from_secs(1));
println!("Timer {} done.", timer);
}
#[tokio::main]
async fn main() {
let task1 = tokio::spawn(async move {
sleep_then_print(1).await;
});
let task2 = tokio::spawn(async move {
sleep_then_print(2).await;
});
let task3 = tokio::spawn(async move {
sleep_then_print(3).await;
});
tokio::join!(task1,task2,task3);
}
这段代码只需一秒钟就可以运行,并且如预期地在同一时间正确地运行所有三个函数。
默认的Tokio运行时按照核心数生成线程,你的电脑通常会有8个CPU。这足以使你在本地测试时忽略这个问题,并在真实场景下运行代码时非常快地耗尽线程。
为了给出多少时间算的上过久的定义,一个好的经验法则是每个.await的时间间隔不超过10到100微秒。这取决于你正在编写的应用程序的类型。
如果我想要阻塞呢?
有时候我们只是想阻塞线程。这是完全正常的。有两个常见的原因:
1.昂贵的CPU-Bound(受限于CPU资源的)计算
2.Synchronous IO. 同步IO
在这两种情况下,我们都在处理一个会在一段时间内阻止任务.await的操作。为了解决这个问题,我们必须将阻塞操作移动到Tokio线程池外部的线程中去。关于这一点有三种形式可以使用:
1.使用tokio::task::spawn_blocking函数
2.使用rayon crate
3.通过std::thread::spawn创建一个专门的线程
让我们仔细浏览下每个解决方案,看看什么时候应该使用它。
-
spawn_blocking函数
Tokio运行时包含一个单独的线程池,专门用于运行阻塞函数,你可以使用 spawn_blocking在其上运行任务。这个线程池的上限是大约500个线程,因此可以在这个线程池上进行大量阻塞操作。由于线程池有如此多的线程,它最适合用于阻塞 I/O (如与文件系统交互)或使用阻塞数据库库(如diesel)。
线程池不适合昂贵的CPU-bound计算,因为线程池的线程数量比计算机上的 CPU核心数量多得多。当线程数等于CPU核心数时,CPU-bound的计算运行效率最高。也就是说,如果只需要运行几个CPU-bound的计算,我不会责怪你用spawn_blocking运行它们,因为这样做非常简单。
#[tokio::main] //此处引入tokio 宏/macro
async fn main() {
let t1 = tokio::task::spawn_blocking(|| {
//运行在一个阻塞的线程中
println!("hi1");//分配到专属线程池执行
});
let t2 = tokio::task::spawn_blocking(|| {
//运行在一个阻塞的线程中
println!("hi2");//分配到专属线程池执行
});
println!("hello");//因为任务处于阻塞,主线程最先输出
}
这个示例执行的结果是,先输出hello,再输出hi1和hi2,h1和h2输出顺序不定,为什么主线程在输出后不直接退出?因为还有任务处于阻塞没有返回,只有阻塞的任务完成,程序才会退出。如果执行t1.await,t2.await.会先输出hi1 hi2.
原文: https://ryhl.io/blog/async-what-is-blocking/
翻译: https://bingowith.me/2021/05/09/translation-async-what-is-blocking/