24

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?

  • 4
    You may want to watch Jon Gjengset's "Crust of Rust" on async, as he basically goes through the logic of every logical async construct and theur implications. – Masklinn Oct 20 '21 at 06:02
  • Masklinn: What a funny coincidence... I posted this question right after watching that episode! There was a bunch of interesting explanations and I would definitely recommend it! – Félix Poulin-Bélanger Oct 20 '21 at 16:04
  • My personal strategy is to `spawn` for whatever independent, top-level units of work my application might have (if any). For example, with a web application, that might be requests. Reason being, they aren't likely to need to share references between each other, so the `'static` requirement isn't a problem, and it lets the work scale across multiple cores if necessary. Then I use `join!` (and `select!`, `try_join_all`, etc.) for everything within. That said, I can't say whether this is an ideal strategy. – Dominick Pastore Aug 06 '23 at 14:20

3 Answers3

18

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.

Peter Hall
  • 53,120
  • 14
  • 139
  • 204
  • Yeah I alluded to that briefly when talking about the "second case", but I'm still left wondering when I should use `tokio::join!` over `tokio::spawn` in practice! – Félix Poulin-Bélanger Oct 20 '21 at 02:59
  • Yeah I agree that when tasks are a bit CPU-bound (but _not_ enough to use `tokio::task::spawn_blocking`), it's definitely better to use `tokio::spawn` so that the tasks can run on separate "pseudoblocked" OS threads. Again, I don't know when it would be advantageous to use `tokio::join!`. – Félix Poulin-Bélanger Oct 20 '21 at 03:06
  • 2
    I think @kmdreko's answer may cover that so I won't repeat it in my own answer. The possibility of moving data to another thread introduces contstraints that could be limiting, ie `Send + 'static`. `join!` is more flexible in that regard. – Peter Hall Oct 20 '21 at 03:12
  • 1
    `join` would probably also be slightly more efficient as it would be much lighter in resource than having to add two tasks to the scheduling queue. It's unlikely to matter much but... – Masklinn Oct 20 '21 at 06:03
12

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
  • 42,554
  • 6
  • 57
  • 106
  • 1
    Great answer! Also, I didn't think about the `'static` requirement. – Félix Poulin-Bélanger Oct 20 '21 at 03:15
  • kmdreko: Your answer got me curious about measuring this overhead. I posted some results in a separate answer for a trivial example, but the results are surprising (at least to me!) – Félix Poulin-Bélanger Oct 20 '21 at 03:49
  • 1
    As a Tokio maintainer, I don't really agree with this answer. Unless you are joining two small operations, spawning is likely to perform better, even on a single-threaded runtime. – Alice Ryhl Jun 14 '22 at 09:31
4

@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.

  • 1
    However, if I replace the `do_nothing()` future with `tokio::time::sleep(Duration::from_micros(1))`, the difference is just 2%. So maybe the 2000x difference was specific to the empty future... I guess you always need to benchmark for your specific use case to be sure! – Félix Poulin-Bélanger Oct 20 '21 at 04:02
  • 1
    I think this is a bit of a flawed benchmark (primarily because `do_nothing()` is zero-sized and can be optimized away, and therefore not very representative of a real-world use-case), however, I do think it shows that the cost of `tokio::spawn` is in the order of a handful of microseconds. – kmdreko Oct 20 '21 at 05:05
  • Yeah, agreed! I'll edit my answer to reflect that – Félix Poulin-Bélanger Oct 20 '21 at 16:08
  • 2
    This benchmark is also bad for another reason: It spawns from within a call to `block_on`. This means that you are benchmarking the performance of `tokio::spawn` from a thread not owned by the runtime. If you change it to run the benchmark within a spawned task or the current-thread runtime, spawning becomes much faster. (Though still not as fast as `tokio::join!`.) – Alice Ryhl Jun 14 '22 at 09:28
  • 1
    @AliceRyhl: Wow! Indeed, I get around 104 ms if I wrap the benchmark inside `tokio::spawn` and around 201 ms if I use the current-thread scheduler (compared to 862 ms above). Now I find it confusing that the simple TCP echo server example (https://docs.rs/tokio) spawns a task per accepted connection inside the implicit `block_on` main task... I guess it'd be better to wrap the entire body of `main` inside a single `tokio::spawn`. Kinda sad that's the behaviour, but now I know. Thanks for sharing! :) – Félix Poulin-Bélanger Jun 15 '22 at 14:26
  • Yeah, having your accept loop in `block_on` can also hurt performance, unfortunately. – Alice Ryhl Jun 16 '22 at 13:05
  • It's also important to realize that the join! macro adds extra overhead to every poll of the futures being joined, which is not included in this benchmark since the futures don't need to be polled more than once. – Alice Ryhl Jun 16 '22 at 13:08