Skip to content
Learn Rust
Theme

Async with Tokio

Rust has async and .await built into the language, like Python, TypeScript and C#. (Kotlin builds on suspend functions in the language, and its async { } and .await() are functions of the kotlinx.coroutines library.) What it doesn’t have built in is the runtime that runs them. You pick one, and Tokio is the one you will meet most (see Do I have to use Tokio?). If futures and polling are new to you, Async Basics explains them first, with no runtime.

RememberAn async fn returns a future that does nothing until you .await it or hand it to the runtime. tokio::spawn turns it into a task that runs on its own.

Project layout
async-basics/
├── src/
│   └── main.rs                 the program on this page
├── Cargo.lock                  created by Cargo
└── Cargo.toml                  dependencies go here
RustCargo.toml
[package]
name = "async-basics"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.54.1", features = ["full"] }
Rustsrc/main.rs
use std::time::Duration;
use tokio::time::{sleep, timeout};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // pretend this is network work
format!("{name} done after {ms}ms")
}
#[tokio::main]
async fn main() {
// run two tasks at the same time, then wait for each
let users = tokio::spawn(fetch("users", 30));
let orders = tokio::spawn(fetch("orders", 10));
println!("{}", users.await.unwrap());
println!("{}", orders.await.unwrap());
// join! runs both at once without spawning tasks
let (left, right) = tokio::join!(fetch("left", 20), fetch("right", 20));
println!("{left} / {right}");
// give up after a deadline, like context.WithTimeout in Go
match timeout(Duration::from_millis(50), fetch("slow", 500)).await {
Ok(result) => println!("{result}"),
Err(_) => println!("slow call timed out"),
}
}
$ cargo run
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow call timed out
Gomain.go
package main
import (
"context"
"fmt"
"time"
)
func fetch(ctx context.Context, name string, ms int) (string, error) {
select {
case <-time.After(time.Duration(ms) * time.Millisecond):
return fmt.Sprintf("%s done after %dms", name, ms), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx := context.Background()
users := make(chan string, 1)
orders := make(chan string, 1)
go func() { r, _ := fetch(ctx, "users", 30); users <- r }()
go func() { r, _ := fetch(ctx, "orders", 10); orders <- r }()
fmt.Println(<-users)
fmt.Println(<-orders)
left := make(chan string, 1)
right := make(chan string, 1)
go func() { r, _ := fetch(ctx, "left", 20); left <- r }()
go func() { r, _ := fetch(ctx, "right", 20); right <- r }()
fmt.Println(<-left, "/", <-right)
tctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if r, err := fetch(tctx, "slow", 500); err != nil {
fmt.Println("slow call timed out")
} else {
fmt.Println(r)
}
}
$ go run .
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow call timed out
  • #[tokio::main] starts the runtime and runs your async fn main. Think of it as the event loop (Node, asyncio) or scheduler (Go), switched on for this program. With the full feature this guide uses, its default runtime is multi-threaded (see the Tokio docs), which is why a spawned future must be Send (Send and Sync).
  • tokio::spawn is like go func(): the task starts running right away, even if you never await its handle (the spawn docs say so). Awaiting the handle waits for the task and gives you its result, wrapped in a Result (next section).
  • A future does nothing until it is awaited, spawned or passed to join!. Calling fetch(...) alone does nothing, unlike a goroutine. Async Basics shows that with no runtime at all.
  • timeout and tokio::select! cover timeouts and racing tasks, like asyncio.wait_for, Promise.race or Go’s select. When one branch of select! wins, the others are dropped, and dropping a future cancels it. The table below shows that Promise.race does not cancel the loser.
  • Blocking work like heavy math or a slow file read goes in tokio::task::spawn_blocking. Its docs say that a blocking call or a lot of compute in a future without yielding “may prevent the executor from driving other futures forward”, and that spawn_blocking runs the closure “on a thread dedicated to blocking operations”. Async Basics shows the problem on a one-thread executor.

Three things to know about tasks, all in this run:

  • ? works in an async fn that returns a Result, as in any other function (Errors).
  • .await on a JoinHandle<T> gives Result<T, JoinError>. Here T is itself a Result, so good and bad have two layers.
  • A panic inside a task ends that task, not the program: the handle’s Err answers is_panic() with true. abort() cancels a task from outside (is_cancelled()). Dropping the handle does not stop the task: the JoinHandle docs say it detaches it, and the last line shows the task still ran.

The panic message itself still goes to stderr. This run hides stderr with 2>/dev/null, because the message contains a thread number that changes from run to run (Errors covers panics).

Rustsrc/main.rs
use std::num::ParseIntError;
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::sleep;
// `?` works in an async fn that returns a Result, as in any other fn.
async fn double(text: &str) -> Result<u32, ParseIntError> {
let n: u32 = text.parse()?;
sleep(Duration::from_millis(5)).await;
Ok(n * 2)
}
#[tokio::main]
async fn main() {
// A JoinHandle gives back Result<T, JoinError>. Here T is itself a Result.
let good = tokio::spawn(double("21"));
let bad = tokio::spawn(double("x21"));
println!("good: {:?}", good.await);
println!("bad: {:?}", bad.await);
// A panic inside a task ends that task, not the program.
let crash = tokio::spawn(async {
let nothing: Option<u32> = None;
nothing.unwrap()
});
let error = crash.await.unwrap_err();
println!("panicked: {}, cancelled: {}", error.is_panic(), error.is_cancelled());
// abort() cancels a task from outside.
let sleeper = tokio::spawn(sleep(Duration::from_secs(3600)));
sleeper.abort();
let error = sleeper.await.unwrap_err();
println!("panicked: {}, cancelled: {}", error.is_panic(), error.is_cancelled());
// Dropping the handle does not stop the task.
let (done, finished) = oneshot::channel();
drop(tokio::spawn(async move {
println!("task: still running after its handle was dropped");
done.send(()).unwrap();
}));
finished.await.unwrap();
}
$ cargo run 2>/dev/null
good: Ok(Ok(42))
bad:  Ok(Err(ParseIntError { kind: InvalidDigit }))
panicked: true, cancelled: false
panicked: false, cancelled: true
task: still running after its handle was dropped

select! waits for several futures and returns when the first one finishes. Dropping the others is a cancellation, so it matters what a future had already done: Async Basics defines cancellation safety. In this run slow_next takes item 1 off the channel and then waits; the stop signal is already sent, so it wins and slow_next is dropped. Item 1 is gone, and only 2 and 3 are left. With recv as the branch, nothing was taken before the drop and the next recv still gets its message. (biased; makes select! poll the branches in the order written, so the output is the same every run. By default, the docs say, it picks a branch to check first at random.)

Rustsrc/main.rs
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio::time::sleep;
// Takes an item off the channel, then waits before handing it over.
async fn slow_next(rx: &mut mpsc::Receiver<u32>) -> Option<u32> {
let item = rx.recv().await?;
sleep(Duration::from_millis(20)).await;
Some(item)
}
// A channel already holding 1, 2 and 3.
fn filled() -> mpsc::Receiver<u32> {
let (tx, rx) = mpsc::channel(3);
for n in 1..=3 {
tx.try_send(n).unwrap();
}
rx
}
// A stop signal that is already sent.
fn stop_now() -> oneshot::Receiver<()> {
let (stop, stopped) = oneshot::channel();
stop.send(()).unwrap();
stopped
}
#[tokio::main]
async fn main() {
// `biased;` polls the branches in the order written, so the result is the same every run.
let mut rx = filled();
tokio::select! {
biased;
item = slow_next(&mut rx) => println!("slow_next gave {item:?}"),
_ = stop_now() => println!("stop won, slow_next was dropped"),
}
let mut left = Vec::new();
while let Ok(n) = rx.try_recv() {
left.push(n);
}
println!("left in the channel: {left:?}");
// The same race with `recv` itself, which Tokio documents as cancellation safe.
let (tx, mut rx) = mpsc::channel::<u32>(1);
tokio::select! {
biased;
item = rx.recv() => println!("recv gave {item:?}"),
_ = stop_now() => println!("stop won, recv was dropped"),
}
tx.send(7).await.unwrap();
println!("the next recv gives {:?}", rx.recv().await);
}
$ cargo run
stop won, slow_next was dropped
left in the channel: [2, 3]
stop won, recv was dropped
the next recv gives Some(7)

The select! docs list mpsc::Receiver::recv as cancellation safe, and AsyncReadExt::read_exact, AsyncWriteExt::write_all and Mutex::lock as not. Mutex::lock is on the “not” list for a different reason: it uses a queue for fairness and “cancellation makes you lose your place in the queue”. They define a safe future this way: “it must be a no-op to drop that future and recreate it”.

The gotcha at the bottom is the compile error you get from spawn. Without spawn there is no Send check, and the compiler accepts a std lock held across an .await. On a runtime with one thread it then hangs: the holder waits for its timer, and the asker’s lock() blocks the only thread, so the timer is never polled. The step is run under timeout 3, and exit code 124 means the program was still running when it was stopped. Async Basics shows the same hang without Tokio. The build output is left out.

Rustsrc/main.rs
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::sleep;
// One thread runs everything, so there is no `spawn` and no `Send` check.
#[tokio::main(flavor = "current_thread")]
async fn main() {
let lock = Mutex::new(0);
let holder = async {
let mut n = lock.lock().unwrap();
println!("holder: has the lock, now waits");
sleep(Duration::from_millis(10)).await;
*n += 1;
println!("holder: done");
};
let asker = async {
println!("asker: asks for the lock");
let n = lock.lock().unwrap(); // blocks the only thread
println!("asker: got it, value {}", *n);
};
tokio::join!(holder, asker);
}
$ cargo build
$ timeout 3 target/debug/async-basics; echo "exit code $?"
holder: has the lock, now waits
asker: asks for the lock
exit code 124

tokio::sync::Mutex is the lock made for this. The docs say its lock “is an async method so does not block, and the lock guard is designed to be held across .await points”. This run does the gotcha’s program with it (it compiles, even with spawn), then the holder and the asker, where the asker now waits its turn. The same page says “it is ok and often preferred to use the ordinary Mutex from the standard library in asynchronous code”: the async mutex is more expensive, and what it adds is keeping the lock across an .await. Kotlin’s kotlinx.coroutines has a Mutex whose lock is a suspending function (docs only, not run here).

Rustsrc/main.rs
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
// The program from the gotcha, with Tokio's Mutex: this one compiles, even with `spawn`.
let hits = Arc::new(Mutex::new(0));
let h = Arc::clone(&hits);
tokio::spawn(async move {
let mut n = h.lock().await;
sleep(Duration::from_millis(10)).await; // holding the lock across an await is what it is for
*n += 1;
})
.await
.unwrap();
println!("hits: {}", hits.lock().await);
// The holder/asker from the previous run: the asker waits its turn and nothing hangs.
let lock = Mutex::new(0);
let holder = async {
let mut n = lock.lock().await;
println!("holder: has the lock, now waits");
sleep(Duration::from_millis(10)).await;
*n += 1;
println!("holder: done");
};
let asker = async {
println!("asker: asks for the lock");
let n = lock.lock().await;
println!("asker: got it, value {}", *n);
};
tokio::join!(holder, asker);
}
$ cargo run
hits: 1
holder: has the lock, now waits
asker: asks for the lock
holder: done
asker: got it, value 1

No. Rust’s async needs a runtime to drive it, and the language doesn’t include one, so you choose a crate. By crates.io’s numbers Tokio is far ahead of the other two runtimes I checked: on 25 October 2026 the recent_downloads field (the 90-day count) of crates.io’s API was 274,409,987 for tokio, against 11,769,534 for async-std and 7,213,990 for smol. The numbers change every day; open those URLs to see today’s. A download count includes every project that pulls the crate in indirectly, so it shows how often Tokio is depended on, not how many people picked it. The async-std README now says it “has been discontinued; use smol instead”, and the smol README describes it as “A small and fast async runtime.”

The crates you will meet build on Tokio: in this site’s own Cargo.lock files, axum (axum guide), reqwest (it appears in the axum guide’s lock file), tonic (gRPC guide) and sqlx-core (sqlx guide, which turns Tokio on with its runtime-tokio feature) all list tokio as a dependency.

Each cell below is the Rust program’s job: two tasks, two at once, then a 50 ms deadline on a 500 ms call. The runs below used the image’s Python 3.13, Node 24, Java 25, Kotlin 2.4 and .NET 10. The C and C++ cells name the usual tools and nothing was run for them.

Language Usual choice What the run showed
Rust Tokio, a crate above
Go goroutines, channels and context, built in the Go program above prints the same four lines
Python asyncio, built in wait_for cancelled the slow call: it printed (slow was cancelled) before the timeout line
TypeScript Promises and the event loop, built in Promise.race returned at the deadline, and the slow call still finished: (slow finished anyway, nobody was waiting) printed after it
Java CompletableFuture (with orTimeout), or threads including virtual threads CompletableFuture printed the same four lines; virtual threads not run here
Kotlin coroutines, with the kotlinx.coroutines library the same four lines, using the copy of the library in the Kotlin compiler’s lib directory (not a project dependency)
C# async/await with Task, built in the same four lines with Task.WhenAll and WaitAsync
C threads, or an event loop library like libuv not run
C++ C++20 coroutines with a library such as Asio not run
terminal
$ python3 fetch.py   # Python, asyncio
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
(slow was cancelled)
slow call timed out
$ node fetch.ts   # TypeScript, Promises
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow call timed out
(slow finished anyway, nobody was waiting)
$ java Main   # Java, CompletableFuture
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow call timed out
$ java -cp fetch.jar:kotlinx-coroutines-core-jvm.jar FetchKt   # Kotlin, kotlinx.coroutines
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow call timed out
$ dotnet run   # C#, Task
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow call timed out

In Python and Rust the slow call is stopped at the deadline: Python printed that it was cancelled, and the timeout docs say that after the deadline “the future is canceled”. In TypeScript the slow call was already running and Promise.race did not stop it. I did not check what happens to the slow call in the Java and C# runs.

Run these in the project from the top of the page; each result below comes from a run in the image.

  1. Make the deadline longer than the slow call. Change from_millis(50) to from_millis(600) in src/main.rs. The run below does it with sed -i, which is the GNU form used in the image; on macOS write sed -i '' or edit the file by hand. Then cargo run. Look at the last line: the call finished first, so it prints slow done after 500ms and not slow call timed out.
  2. Run on one thread. Start from the original program again (undo step 1, because the 600 ms deadline changes the last line). Change #[tokio::main] to #[tokio::main(flavor = "current_thread")] (the docs list both flavors). Look at the output: the same four lines. The flavors differ in how many threads run tasks and so in what can go wrong, as the lock section showed.
terminal
$ sed -i 's/from_millis(50)/from_millis(600)/' src/main.rs
$ cargo run
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow done after 500ms
terminal
$ sed -i 's/#\[tokio::main\]/#[tokio::main(flavor = "current_thread")]/' src/main.rs
$ cargo run
users done after 30ms
orders done after 10ms
left done after 20ms / right done after 20ms
slow call timed out
Gotcha

Holding a lock across an .await

Looks fine: in Go it runs fine. Rust won't compile it.

Works in Go. In Rust it's just a rule.Not run in Kotlin on this page.Not run in Python on this page.Not run in TypeScript on this page.Not run in Java on this page.C# refuses this too. Same idea, different words.Not run in C on this page.Not run in C++ on this page.
Rustsrc/main.rs✗ won't compile
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[tokio::main]
async fn main() {
let hits = Arc::new(Mutex::new(0));
let h = Arc::clone(&hits);
tokio::spawn(async move {
let mut n = h.lock().unwrap();
tokio::time::sleep(Duration::from_millis(10)).await; // still holding the lock
*n += 1;
})
.await
.unwrap();
println!("{}", hits.lock().unwrap());
}
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
hits := 0
done := make(chan bool)
go func() {
mu.Lock()
time.Sleep(10 * time.Millisecond) // still holding the lock
hits++
mu.Unlock()
done <- true
}()
<-done
fmt.Println(hits)
}
$ go run main.go✓ runs
1

There is no Kotlin version of this one.

There is no Python version of this one.

There is no TypeScript version of this one.

There is no Java version of this one.

using System;
using System.Threading.Tasks;
var gate = new object();
var hits = 0;
await Task.Run(async () =>
{
lock (gate)
{
await Task.Delay(10); // still holding the lock
hits++;
}
});
Console.WriteLine(hits);
$ dotnet run✗ refuses too
Program.cs(10,9): error CS1996: Cannot await in the body of a lock statement

There is no C version of this one.

There is no C++ version of this one.

What does the compiler say? Guess first, then open.
$ cargo build
error: future cannot be sent between threads safely
   --> src/main.rs:8:5
    |
  8 | /     tokio::spawn(async move {
  9 | |         let mut n = h.lock().unwrap();
 10 | |         tokio::time::sleep(Duration::from_millis(10)).await; // still holding the lock
 11 | |         *n += 1;
 12 | |     })
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@src/main.rs:8:18: 8:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, i32>`
note: future is not `Send` as this value is used across an await
   --> src/main.rs:10:55
    |
  9 |         let mut n = h.lock().unwrap();
    |             ----- has type `std::sync::MutexGuard<'_, i32>` which is not `Send`
 10 |         tokio::time::sleep(Duration::from_millis(10)).await; // still holding the lock
    |                                                       ^^^^^ await occurs here, with `mut n` maybe used later
  ... (notes pointing into library source code trimmed)
What happens in Go

Go compiles and runs this. A sync.Mutex is not tied to a goroutine or a thread (the sync docs), so holding it while the goroutine sleeps is allowed.

Why it looks fine

Taking a lock, waiting, then changing the data and unlocking is ordinary code. Go's sync docs even say a locked Mutex "is not associated with a particular goroutine".

Why it looks fine

Taking a lock, waiting, then changing the data and unlocking is ordinary code. Go's sync docs even say a locked Mutex "is not associated with a particular goroutine".

Why it looks fine

Taking a lock, waiting, then changing the data and unlocking is ordinary code. Go's sync docs even say a locked Mutex "is not associated with a particular goroutine".

Why it looks fine

Taking a lock, waiting, then changing the data and unlocking is ordinary code. Go's sync docs even say a locked Mutex "is not associated with a particular goroutine".

What happens in C#

C# rejects an await inside a lock statement. The C# docs say "You can't use the await expression in the body of a lock statement."

Why it looks fine

Taking a lock, waiting, then changing the data and unlocking is ordinary code. Go's sync docs even say a locked Mutex "is not associated with a particular goroutine".

Why it looks fine

Taking a lock, waiting, then changing the data and unlocking is ordinary code. Go's sync docs even say a locked Mutex "is not associated with a particular goroutine".

What Rust sees

tokio::spawn asks for a future that is Send, because the default runtime can resume a task on a different worker thread after any .await. The guard from a standard Mutex is not Send: the standard library docs say that on platforms that use POSIX threads a lock must be released on the thread that acquired it, so the guard may not travel with the task.

Why Rust says no

The Tokio docs say it is "ok and often preferred" to use the standard Mutex in async code. The rule is to drop the guard before the next .await: lock, change and unlock in one statement, as the fix does. If the lock has to stay held across an .await (the docs' example is a database connection), use tokio::sync::Mutex, whose guard is designed for it. The compiler is not the only protection you need: the section above shows the same mistake, without spawn, compiling and then hanging, so for the one-thread case it is a real Rust bug and not only a rule.

The fix

Compiles and runs.

Rust, fixedsrc/main.rsRun ↗
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[tokio::main]
async fn main() {
let hits = Arc::new(Mutex::new(0));
let h = Arc::clone(&hits);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await; // wait first
*h.lock().unwrap() += 1; // then lock, change, unlock, all on one line
})
.await
.unwrap();
println!("{}", hits.lock().unwrap());
}
$ cargo run
1

The version this page is about is tokio 1.54.1, as pinned in Cargo.lock.