If you’ve come from Go or Python’s asyncio, Rust’s async will feel both familiar and weirder. Familiar because the shape is the same: cooperative tasks, an event loop, futures you await. Weirder because Rust enforces things at compile time most languages let slide — ownership, lifetimes, Send + Sync — and because async is a syntax, not a runtime.

This post is the explanation I wish I’d had before writing my first Tokio service. If you’ve already shipped one, you’ll find the production patterns at the end useful.

The split: async vs Tokio

In Rust, async fn is just sugar that compiles to a state machine implementing Future. The language doesn’t ship a runtime. A Future does nothing on its own; you need an executor to drive it.

Tokio is that executor. It’s the de facto runtime in 2026; smol, async-std, and glommio exist but you’ll meet Tokio in 95% of jobs.

// Just declares a Future. Nothing runs yet.
async fn hello() -> &'static str { "hi" }

// Runs it.
#[tokio::main]
async fn main() {
    println!("{}", hello().await);
}

#[tokio::main] expands to roughly:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            // your async main body
        });
}

For services, leave the macro alone. You only build a runtime by hand when you want fine control (single-threaded, custom thread count, etc.).

Tasks — the unit of concurrency

A task is a future the runtime is actively driving. Spawn one with tokio::spawn:

let handle = tokio::spawn(async {
    fetch_user(42).await
});

let user = handle.await??;   // first ? for join error, second ? for the inner Result

Three rules to internalize:

  1. Tasks must be 'static and Send. No borrowed references, no Rc, no thread-locals across .await.
  2. Tasks run on the runtime’s thread pool. Don’t assume order; the runtime picks.
  3. Tasks are lightweight. Spawning thousands is fine; they’re not OS threads.

If you need to share data, use Arc<...> or a channel. If you need to mutate, Arc<Mutex<...>> (or, more commonly in async, Arc<RwLock<...>> or a channel).

“But my future doesn’t run!”

A future that’s never awaited or spawned is silent. This compiles and does nothing:

async fn ping() { println!("ping"); }

fn main() {
    let _ = ping();   // Future created, dropped. Never executed.
}

Compiler will warn (#[must_use]), but it’s still the most common newbie bug. The fix is .await (drive it inline) or tokio::spawn (drive it in the background).

Concurrency patterns

join! — run two things concurrently, wait for both

use tokio::join;

let (user, weather) = join!(fetch_user(42), fetch_weather("Bangalore"));

Same task, two futures. Both run on the current task. If either panics, the others continue, then the panic propagates.

try_join! — same, but short-circuits on Result::Err

use tokio::try_join;

let (user, weather) = try_join!(fetch_user(42), fetch_weather("Bangalore"))?;

Rule of thumb: if the futures all return Result, use try_join!. Otherwise join!.

JoinSet — fan out N tasks, collect as they finish

use tokio::task::JoinSet;

let mut set = JoinSet::new();
for url in urls {
    set.spawn(async move { fetch(url).await });
}

while let Some(res) = set.join_next().await {
    match res {
        Ok(Ok(body)) => process(body),
        Ok(Err(e))   => tracing::warn!(error = %e, "fetch failed"),
        Err(e)       => tracing::error!(error = %e, "task panicked"),
    }
}

JoinSet is the workhorse for “do N things in parallel, handle each result.” Far better than Vec<JoinHandle> because:

  • It returns results in completion order — you make progress on the fast ones.
  • It cleans up properly when dropped (with abort_all).

select! — race futures, take the first

use tokio::select;
use tokio::time::{sleep, Duration};

let result = select! {
    body = fetch_one(url1) => body,
    body = fetch_one(url2) => body,
    _ = sleep(Duration::from_secs(2)) => return Err("timeout"),
};

select! polls all branches concurrently, runs the first to complete, drops the rest. That last part is critical and where most bugs come from.

Cancellation: the part that bites

Dropping a future cancels it. There’s no “kill task” syscall; the runtime stops polling, the state machine drops, and any borrowed resources release. This is mostly elegant — until your future was halfway through a database transaction.

Two important consequences:

1. Cancellation is async-clean by default

Most things you’d want canceled clean up correctly: closing TCP streams, releasing DB connections to the pool, dropping Tokio mutexes. Just dropping the future works.

2. Cancellation safety in select!

let mut buf = Vec::new();
loop {
    select! {
        // ❌ Bad: read_to_end is NOT cancellation-safe. Cancelled mid-read,
        //    you lose any bytes already read.
        _ = sock.read_to_end(&mut buf) => break,

        // ✅ Good: tokio::sync::Notify::notified is cancellation-safe.
        _ = shutdown.notified() => return,
    }
}

The select! macro requires that all branches be cancellation-safe — the future, if dropped before completion, must leave no important work undone. The Tokio docs flag which are and aren’t.

When in doubt: tokio::pin! your future once, then select! with &mut. That way if a different branch wins, the in-progress future is paused, not dropped, and you can resume it.

Channels — communicating between tasks

Tokio gives you four:

ChannelCapacityUse
tokio::sync::mpscbounded or unboundedmany producers, one consumer
tokio::sync::oneshotone valuereply channels in request/response patterns
tokio::sync::watchlatest-onlybroadcast a config / shutdown signal
tokio::sync::broadcastbounded, multi-consumerpub/sub

90% of the time you want mpsc:

use tokio::sync::mpsc;

let (tx, mut rx) = mpsc::channel::<Job>(100);

// producers
let tx2 = tx.clone();
tokio::spawn(async move {
    while let Some(job) = pull_jobs().await {
        tx2.send(job).await.unwrap();
    }
});

// consumer
while let Some(job) = rx.recv().await {
    handle(job).await;
}

Bounded channels apply back-pressure — if the consumer falls behind, producers block. That’s almost always what you want. Unbounded channels happily eat your memory while you debug why latency exploded.

oneshot is the slick pattern for request/response between tasks:

let (resp_tx, resp_rx) = oneshot::channel();
queue.send((job, resp_tx)).await?;
let result = resp_rx.await?;

The worker task replies via resp_tx.send(value); the caller awaits resp_rx. Clean, no shared state.

Mutexes — std::sync vs tokio::sync

let m = std::sync::Mutex::new(0);   // OK if you never .await while holding the guard
let n = tokio::sync::Mutex::new(0); // Use when you DO need to .await while holding it

Rule:

  • Holding a std::sync::Mutex guard across an .await is a bug — it pins your task to the thread, kills the runtime’s ability to suspend it, and can deadlock.
  • tokio::sync::Mutex is async-aware but ~10× slower for simple locks. Use it only when you genuinely need to await inside the critical section.

Most of the time, the right pattern is: lock briefly, copy the data out, drop the guard, then await:

let user = {
    let map = state.users.lock().unwrap();
    map.get(&id).cloned()
};
let resp = call_api(user).await?;     // no lock held here

Spawning blocking work

Tokio’s runtime is non-blocking. If your code calls std::fs::read_to_string (sync I/O) or hashes a password with bcrypt (CPU-bound), you’ll stall the worker thread. Other tasks on that thread freeze.

Two fixes:

// Sync I/O / CPU-heavy short bursts → spawn_blocking
let hash = tokio::task::spawn_blocking(move || {
    bcrypt::hash(password, 12)
}).await??;

// Long-running blocking work → real OS thread, not the blocking pool
std::thread::spawn(move || { /* hours of work */ });

spawn_blocking runs on a separate, larger pool (default 512 threads). Use it for predictably-bounded blocking work; for unbounded blocking, prefer a dedicated thread.

Tracing async code

Async stacks are useless in panics. Use tracing:

use tracing::{info, instrument};

#[instrument(skip(db))]
async fn create_user(db: &PgPool, payload: CreateUser) -> Result<User> {
    info!(email = %payload.email, "creating user");
    // ...
}

#[instrument] wraps the function in a span. Every log inside it carries email, function name, request id, etc. With tracing-subscriber and the JSON formatter, you have structured async traces.

OpenTelemetry support in tracing-opentelemetry ties spans to your existing observability stack.

Common production patterns

Graceful shutdown

let token = tokio_util::sync::CancellationToken::new();
let shutdown = token.clone();

tokio::spawn(async move {
    tokio::signal::ctrl_c().await.ok();
    shutdown.cancel();
});

// inside workers:
loop {
    select! {
        _ = token.cancelled() => break,
        item = queue.recv() => process(item).await,
    }
}

CancellationToken is the right way to broadcast “stop now” across many tasks. Beats hand-rolled watch channels for shutdown.

Per-request timeouts

use tokio::time::{timeout, Duration};

let body = timeout(Duration::from_secs(5), fetch_one(url)).await??;

Wrap external calls. Don’t trust other people’s networks.

Bounded concurrency

use tokio::sync::Semaphore;
use std::sync::Arc;

let sem = Arc::new(Semaphore::new(8));

for url in urls {
    let permit = sem.clone().acquire_owned().await?;
    tokio::spawn(async move {
        let _permit = permit;       // dropped when task ends, releasing the slot
        fetch(url).await
    });
}

When you don’t want to flood a downstream with 1000 simultaneous requests, the semaphore is the lever.

When async Rust isn’t the answer

  • A small CLI that does one thing and exits. Sync is simpler.
  • A worker that’s CPU-bound — async won’t help, threads will.
  • Your team is new to Rust and the use case is I/O-light. std::thread::spawn is fine.

The async machinery in Rust is genuinely heavier than sync. The reward is huge for I/O-bound services, modest for everything else.

Read this next

  • Production HTTP Service in Rust — applies all of this to a real service.
  • The Tokio Tutorial — official, excellent, free.
  • “Async Rust: A Practical Introduction” by Tokio’s team.

If you want a small cargo generate template demonstrating these patterns end-to-end (channels + select + cancellation + tracing), it’s on rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .