什么情况下应该使用 tokio::join!() 而不是 tokio::spawn()?

25

假设我想使用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()吗?
  • 如果是,使用哪些场景?(与下载网页无关)

我猜产生新任务可能只有微小的开销,但就这样?


4
您可能希望观看Jon Gjengset的“Rust表面”有关异步编程的视频,因为他会详细讲解每个异步构造的逻辑和其影响。 - Masklinn
Masklinn:真是个有趣的巧合……我在看完那一集后就发了这个问题!里面有很多有趣的解释,我绝对推荐大家去看! - Félix Poulin-Bélanger
我的个人策略是针对我的应用可能具有的任何独立的、高级别的工作单元进行“生成”。例如,在Web应用程序中,这可能是请求。原因是它们不太可能需要在彼此之间共享引用,所以“静态”要求不是一个问题,并且它可以让工作在需要时跨多个核心进行扩展。然后我使用join!(和select!try_join_all等)来处理其中的所有内容。话虽如此,我不能说这是否是一种理想的策略。 - Dominick Pastore
3个回答

18

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

在单线程运行时,它们实际上是相同的。在多线程运行时,像那样使用tokio::spawn!两次可能使用了两个独立的线程。

tokio::join!文档中:

通过在当前任务上运行所有异步表达式,这些表达式能够并发但不是并行运行。这意味着所有表达式都在同一线程上运行,如果其中一个分支阻塞了线程,则所有其他表达式将无法继续。如果需要并行性,请使用tokio::spawn对每个异步表达式进行分别处理,并将其加入到join!中。

对于像下载网页这样的I/O绑定任务,你不会注意到区别;大部分时间都将花在等待数据包上,每个任务可以高效地交错处理。
当任务更多地依赖 CPU 并且可能相互阻塞时,请使用 `tokio::spawn`。

是的,当我谈到“第二种情况”时,我简要提到了这一点,但我仍然想知道在实践中何时应该使用tokio::join!而不是tokio::spawn - Félix Poulin-Bélanger
是的,我同意当任务有点 CPU 密集(但不足以使用 tokio::task::spawn_blocking)时,最好使用 tokio::spawn,这样任务可以在单独的“伪阻塞”操作系统线程上运行。再次强调,我不知道何时使用 tokio::join! 会更有优势。 - Félix Poulin-Bélanger
2
我认为@kmdreko的答案可能已经涵盖了这一点,所以我不会在我的回答中重复。将数据移动到另一个线程的可能性引入了可能限制的约束,即Send + 'static。在这方面,join!更加灵活。 - Peter Hall
1
“join” 可能会更有效率,因为它比将两个任务添加到调度队列中要轻得多。虽然影响不大,但还是有区别的。 - Masklinn

13
我通常会从另一个角度考虑这个问题:为什么我要使用tokio::spawn而不是tokio::join呢?生成一个新的任务比连接两个futures有更多的限制,'static要求可能非常麻烦,因此这不是我的首选。
除了生成任务的成本(我想很小),还有向原始任务发送信号的成本。我也认为这是微不足道的,但您需要在您的环境和异步负载中测量它们,以查看它们是否真正产生影响。
但是,你是对的,使用两个任务的最大优点是它们有机会并行工作,而不仅仅是并发工作。但另一方面,async 最适合 I/O 密集型工作负载,在等待时间较长的情况下,根据您的工作负载,缺乏并行ism 可能不会产生太大的影响。
总的来说,tokio::join 更加灵活易用,我怀疑技术上的差异不会对性能产生影响。但一如既往,要进行度量!

1
很棒的答案!而且,我没有考虑到“static”的要求。 - Félix Poulin-Bélanger
kmdreko:你的回答让我对测量这种开销感到好奇。我在另一个答案中发布了一些微不足道的示例结果,但结果令人惊讶(至少对我来说是这样!) - Félix Poulin-Bélanger
2
作为Tokio的维护者,我并不完全同意这个答案。除非你正在连接两个小操作,否则生成可能会表现更好,即使在单线程运行时也是如此。 - Alice Ryhl

4

@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

编辑:这个基准测试存在许多缺陷(请参见评论),因此不要依赖它来获取具体数字。然而,结论是生成进程比加入2个任务更昂贵似乎是正确的。


1
然而,如果我将do_nothing()的future替换为tokio::time::sleep(Duration::from_micros(1)),差异仅为2%。因此,也许2000倍的差异是特定于空future的...我猜你总是需要针对你特定的用例进行基准测试才能确定! - Félix Poulin-Bélanger
1
我认为这个基准测试有点缺陷(主要是因为do_nothing()是零大小的,可以被优化掉,因此不太代表实际用例),但我确实认为它显示了tokio::spawn的成本大约在几微秒之内。 - kmdreko
没错,同意!我会编辑我的答案以反映这一点。 - Félix Poulin-Bélanger
2
这个基准测试之所以不好,还有另一个原因:它从调用block_on的内部产生。这意味着您正在对来自未由运行时拥有的线程的tokio::spawn的性能进行基准测试。如果您将其更改为在已生成的任务或当前线程运行时中运行基准测试,则产生速度会更快。(虽然仍不如tokio::join!快。) - Alice Ryhl
1
@AliceRyhl:哇!如果我将基准测试包装在tokio::spawn中,我可以得到大约104毫秒的时间,如果我使用当前线程调度程序,则需要大约201毫秒(与上面的862毫秒相比)。现在我发现令人困惑的是,简单的TCP回显服务器示例(https://docs.rs/tokio)在隐式的`block_on`主任务中为每个接受的连接生成一个任务...我想最好将整个`main`函数体包装在单个的`tokio::spawn`中。有点遗憾这就是行为,但现在我知道了。谢谢分享! :) - Félix Poulin-Bélanger
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接