Articles / Rust

  • rust
  • async
  • tokio
  • futures
  • concurrency
  • debugging

Why Your Rust Vec of Futures Runs Sequentially Until You Spawn Them

You build a Vec of four async calls, await them in a loop, and the whole thing takes four times as long as one call. Nothing is broken. A Rust future is a value that does nothing until something polls it, so collecting them into a vector starts no work at all.

The code that looks concurrent and isn't

use std::time::{Duration, Instant};
use tokio::time::sleep;

async fn fetch(id: u32) -> u32 {
    sleep(Duration::from_millis(500)).await;
    id
}

#[tokio::main]
async fn main() {
    let start = Instant::now();
    let futs: Vec<_> = (0..4).map(fetch).collect();
    println!("built in {:?}", start.elapsed());

    let mut out = Vec::new();
    for f in futs {
        out.push(f.await);
    }
    println!("{:?} in {:?}", out, start.elapsed());
}

The first line prints a few microseconds. The second prints about two seconds. The vector was built instantly because building a future only captures its arguments; the sleeps begin when each one is first polled, and the loop does not poll the second until the first has finished.

Why a loop of awaits is sequential

f.await inside a for loop means "suspend this task until f completes". The loop body cannot reach the next element until then. There is exactly one future being polled at any moment, so there is nothing to overlap.

Coming from JavaScript this surprises people, because a promise starts running the moment it is created. A Rust future is the recipe, not the cooking.

Quick detour: why are they lazy?

Hang on, why is this designed that way? An async fn compiles to a state machine, and polling it advances that machine one step. No runtime is required to create one, so the language cannot start it for you. That is also why you can build futures without any executor and why they can be dropped before they ever run.

The cost is exactly the trap above: you must hand the future to something that will poll it.

Three ways to actually run them together

  • futures::future::join_all: polls all of them from one task.
  • tokio::spawn (or JoinSet): gives each its own task, which the runtime can move across threads.
  • buffer_unordered(n): concurrent, but with a cap on how many are in flight.

join_all: same task, overlapping waits

let futs: Vec<_> = (0..4).map(fetch).collect();
let out = futures::future::join_all(futs).await;
// about 500ms, results in input order

This is the smallest change and needs no 'static or Send bounds. The catch is that everything shares one task. The waits overlap, but CPU work does not run in parallel, and one blocking call stalls all of them.

spawn: independent tasks that start immediately

let handles: Vec<_> = (0..4)
    .map(|i| tokio::spawn(fetch(i)))
    .collect();

let mut out = Vec::new();
for h in handles {
    out.push(h.await.expect("task panicked"));
}

Notice the loop still awaits one handle at a time, yet the total is about 500ms. By the time you await the first handle, all four tasks are already running. Awaiting a JoinHandle only collects a result; it does not drive the work.

The price is that the future must be Send + 'static, so borrowed data has to be cloned or wrapped in an Arc. A panic in a task also arrives as an Err from the handle rather than unwinding your caller.

buffer_unordered: concurrency with a ceiling

use futures::{stream, StreamExt};

let out: Vec<u32> = stream::iter(0..1000)
    .map(fetch)
    .buffer_unordered(8)
    .collect()
    .await;

Spawning a thousand tasks that each open a socket is how you meet your file descriptor limit or get rate-limited. This keeps at most eight futures in flight and starts the next as soon as one finishes. Results come back in completion order, so carry the id with the result if order matters, or use buffered to keep input order.

Which one to reach for

  1. Small, fixed number of I/O waits and borrowed data: join_all or tokio::join!.
  2. Work that should survive independently or use several cores: spawn, ideally into a JoinSet so you can collect and abort them together.
  3. Unbounded or large input: buffer_unordered with a number you chose on purpose.

How to spot it in an existing codebase

The tell is a for or while loop with .await directly on the loop variable. Sometimes that is correct, for instance when each step depends on the last. When it is not, timing the loop with Instant as above makes the problem obvious: total time equals the sum of the parts rather than the longest part.

One last wrinkle: join_all returns a result for everything even if one future fails, and it does not cancel the rest. If you want fail-fast behaviour, try_join_all drops the remaining futures on the first error, which is a cancellation you should be deliberate about.