Concurrent Is Not Parallel

Concurrent is not parallel

The most common Tokio confusion: tokio::join! and tokio::spawn look similar but buy different things. join! gives you concurrency inside one parent task. spawn gives you a task the runtime can schedule across worker threads. Concurrency lets one thread make progress on multiple things by switching at .await points. Parallelism is multiple OS threads running at once. Tokio gives both, but not from the same primitive.

When join! is enough

join! is the right tool when the work is mostly waiting on async I/O:

let a = fetch_a();
let b = fetch_b();
let (a, b) = tokio::join!(a, b);

Two HTTP requests, a socket read and a timer, a store read and a config fetch — all of these spend most of their time awaiting I/O. At any instant one thread is polling the parent task; when one branch hits .await, the runtime polls the other. The two futures make progress without strict ordering and without crossing thread boundaries. Less overhead than spawn, one parent task to reason about.

It is not the right tool for CPU-heavy multicore work. Both branches share one parent task, which runs on one worker at a time. No second core gets involved.

When spawn is needed

tokio::spawn is where actual cross-thread scheduling begins. The runtime can place the spawned task on any worker:

let a = tokio::spawn(async move { sync_account(account_a).await });
let b = tokio::spawn(async move { sync_account(account_b).await });
let a = a.await??;
let b = b.await??;

Now the runtime is free to run a and b on different worker threads. If the work is async-heavy but with enough computation between awaits, this actually uses multiple cores.

Cost: spawned tasks must own their data (async move) and must be Send + 'static. If a task holds non-Send state across .await, it's not spawn-safe.

The rule that follows

If the work is mostly I/O, join! is often enough — less overhead, one parent. If the work should become its own scheduled unit, may outlive the current stack frame, or should run across worker threads, use spawn.

The trap is reaching for spawn reflexively, thinking "more spawns = more parallel." Spawn overhead has its own cost. For two awaits that share a parent context, join! is cheaper and just as effective.

Where this generalizes

The same distinction shows up in any async runtime with both "fire-and-forget" and "compose futures" primitives:

In every case, the question to ask is: does my work block other work from making progress, or does it just need to be in flight at the same time? Concurrency answers the second. Parallelism is for the first.

See also