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

Viewed 6471

Let's say I want to download two web pages concurrently with Tokio...

Either I could implement this with 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());
}

Or I could implement this with 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());
}

In both cases, the two requests are happening concurrently. However, in the second case, the two requests are running in the same task and therefore on the same thread.

So, my questions are:

  • Is there an advantage to tokio::join!() over tokio::spawn()?
  • If so, in which scenarios? (it doesn't have to do anything with downloading web pages)

I'm guessing there's a very small overhead to spawning a new task, but is that it?

3 Answers

The difference will depend on how you have configured the runtime. tokio::join! will run tasks concurrently in the same task, while tokio::spawn! creates a new task for each.

In a single-threaded runtime, these are effectively the same. In a multi-threaded runtime, using tokio::spawn! twice like that may use two separate threads.

From the 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!.

For IO-bound tasks, like downloading web pages, you aren't going to notice the difference; most of the time will be spent waiting for packets and each task can efficiently interleave their processing.

Use tokio::spawn! when tasks are more CPU-bound and could block each other.

I would typically look at this from the other angle; why would I use tokio::spawn over tokio::join? Spawning a new task has more constraints than joining two futures, the 'static requirement can be very annoying and as such is not my go-to choice.

In addition to the cost of spawning the task, that I would guess is fairly marginal, there is also the cost of signaling the original task when its done. That I would also guess is marginal but you'd have to measure them in your environment and async workloads to see if they actually have an impact or not.

But you're right, the biggest boon to using two tasks is that they have the opportunity to work in parallel, not just concurrently. But on the other hand, async is most suited to I/O-bound workloads where there is lots of waiting and, depending on your workload, is probably unlikely that this lack of parallelism would have much impact.

All in all, tokio::join is a nicer and more flexible to use and I doubt the technical difference would make an impact on performance. But as always: measure!

@kmdreko's answer was great and I'd like to add some details to it!

As mentioned, using tokio::spawn has a 'static requirement, so the following snippet doesn't compile:

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());
}

However, the equivalent snippet with tokio::join! does compile:

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);
}

Also, that answer got me curious about the cost of spawning a new task so I wrote the following simple benchmark:

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() {}

In release mode, I get the following output on my macOS laptop:

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

EDIT: This benchmark is flawed in many ways (see comments), so don't rely on it for the specific numbers. However, the conclusion that spawning is more expensive than joining 2 tasks seems to be true.

Related