什么时候应该使用 tokio::join!() 而不是 tokio::spawn()?

When should you use tokio::join!() over tokio::spawn()?

假设我想使用 Tokio 同时下载两个网页...

要么我可以用 tokio::spawn():

来实现
async fn v1() {
    let t1 = tokio::spawn(reqwest::get("https://example.com"));
    let t2 = tokio::spawn(reqwest::get("https://example.org"));
    let (r1, r2) = (t1.await.unwrap(), t2.await.unwrap());
    println!("example.com = {}", r1.unwrap().status());
    println!("example.org = {}", r2.unwrap().status());
}

或者我可以用 tokio::join!():

来实现
async fn v2() {
    let t1 = reqwest::get("https://example.com");
    let t2 = reqwest::get("https://example.org");
    let (r1, r2) = tokio::join!(t1, t2);
    println!("example.com = {}", r1.unwrap().status());
    println!("example.org = {}", r2.unwrap().status());
}

在这两种情况下,两个请求都是同时发生的。但是,在第二种情况下,两个请求 运行 在同一个任务中,因此在同一个线程上。

所以,我的问题是:

我猜测生成新任务的开销非常,但真的如此吗?

差异取决于您如何配置 运行时间。 tokio::join! 将 运行 个任务同时 在同一任务中 ,而 tokio::spawn! 为每个任务创建一个新任务。

在单线程运行的时候,这些实际上是一样的。在多线程运行的时候,像这样使用tokio::spawn!两次可能使用两个单独的线程

来自 docs for tokio::join!:

By running all async expressions on the current task, the expressions are able to run concurrently but not in parallel. This means all expressions are run on the same thread and if one branch blocks the thread, all other expressions will be unable to continue. If parallelism is required, spawn each async expression using tokio::spawn and pass the join handle to join!.

对于 IO 密集型任务,例如下载网页,您不会注意到差异;大部分时间将花在等待数据包上,每个任务都可以有效地交错处理。

当任务更多 CPU 绑定并且可能相互阻塞时使用 tokio::spawn!

我通常会从另一个角度来看这个问题;为什么我要使用 tokio::spawn 而不是 tokio::join?产生一个新任务比加入两个 futures 有更多的限制,'static 要求可能非常烦人,因此不是我的首选。

除了生成任务的成本外,我认为这是相当微不足道的,还有在原始任务完成时发出信号的成本。我也猜这是微不足道的,但你必须在你的环境和异步工作负载中衡量它们,看看它们是否真的有影响。

但你是对的,使用两个任务的最大好处是它们有机会并行工作,而不仅仅是同时进行。但另一方面,async 最适合 I/O-bound 需要大量等待的工作负载,并且根据您的工作负载,这种缺乏并行性的情况不太可能产生太大影响。

总而言之,tokio::join 使用起来更好、更灵活,我怀疑技术差异会对性能产生影响。但一如既往:测量!

@kmdreko 的回答很好,我想补充一些细节!

如前所述,使用 tokio::spawn 有一个 'static 要求,因此以下代码段无法编译:

async fn v1() {
    let url = String::from("https://example.com");
    let t1 = tokio::spawn(reqwest::get(&url)); // `url` does not live long enough
    let t2 = tokio::spawn(reqwest::get(&url));
    let (r1, r2) = (t1.await.unwrap(), t2.await.unwrap());
}

但是,具有 tokio::join! 的等效代码段确实可以编译:

async fn v2() {
    let url = String::from("https://example.com");
    let t1 = reqwest::get(&url);
    let t2 = reqwest::get(&url);
    let (r1, r2) = tokio::join!(t1, t2);
}

此外,这个答案让我对生成新任务的成本感到好奇,所以我编写了以下简单的基准测试:

use std::time::Instant;

#[tokio::main]
async fn main() {
    let now = Instant::now();
    for _ in 0..100_000 {
        v1().await;
    }
    println!("tokio::spawn = {:?}", now.elapsed());

    let now = Instant::now();
    for _ in 0..100_000 {
        v2().await;
    }
    println!("tokio::join! = {:?}", now.elapsed());
}

async fn v1() {
    let t1 = tokio::spawn(do_nothing());
    let t2 = tokio::spawn(do_nothing());
    t1.await.unwrap();
    t2.await.unwrap();
}

async fn v2() {
    let t1 = do_nothing();
    let t2 = do_nothing();
    tokio::join!(t1, t2);
}

async fn do_nothing() {}

在发布模式下,我在我的 macOS 笔记本电脑上得到以下输出:

tokio::spawn = 862.155882ms
tokio::join! = 369.603µs

当然,这不是一个真实的用例,很可能空洞的未来被优化掉了。然而,它表明产生新任务的成本大约是几微秒! (862 毫秒/100,000 次迭代)