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.
async-basics/ ├── src/ │ └── main.rs the program on this page ├── Cargo.lock created by Cargo └── Cargo.toml dependencies go here
[package]name = "async-basics"version = "0.1.0"edition = "2024"
[dependencies]tokio = { version = "1.54.1", features = ["full"] }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"), }}users done after 30ms orders done after 10ms left done after 20ms / right done after 20ms slow call timed out
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) }}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 yourasync fn main. Think of it as the event loop (Node, asyncio) or scheduler (Go), switched on for this program. With thefullfeature this guide uses, its default runtime is multi-threaded (see the Tokio docs), which is why a spawned future must beSend(Send and Sync).tokio::spawnis likego func(): the task starts running right away, even if you never await its handle (thespawndocs say so). Awaiting the handle waits for the task and gives you its result, wrapped in aResult(next section).- A future does nothing until it is awaited, spawned or passed to
join!. Callingfetch(...)alone does nothing, unlike a goroutine. Async Basics shows that with no runtime at all. timeoutandtokio::select!cover timeouts and racing tasks, likeasyncio.wait_for,Promise.raceor Go’sselect. When one branch ofselect!wins, the others are dropped, and dropping a future cancels it. The table below shows thatPromise.racedoes 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 thatspawn_blockingruns the closure “on a thread dedicated to blocking operations”. Async Basics shows the problem on a one-thread executor.
Errors, and what happens to a task
Section titled “Errors, and what happens to a task”Three things to know about tasks, all in this run:
?works in anasync fnthat returns aResult, as in any other function (Errors)..awaiton aJoinHandle<T>givesResult<T, JoinError>. HereTis itself aResult, sogoodandbadhave two layers.- A panic inside a task ends that task, not the program: the handle’s
Erranswersis_panic()withtrue.abort()cancels a task from outside (is_cancelled()). Dropping the handle does not stop the task: theJoinHandledocs 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).
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();}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 droppedselect! drops the losers
Section titled “select! drops the losers”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.)
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);}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”.
A lock held across an .await
Section titled “A lock held across an .await”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.
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);}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).
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);}hits: 1 holder: has the lock, now waits asker: asks for the lock holder: done asker: got it, value 1
Do I have to use Tokio?
Section titled “Do I have to use Tokio?”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.
The same job in your language
Section titled “The same job in your language”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 |
$ 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.
Try it yourself
Section titled “Try it yourself”Run these in the project from the top of the page; each result below comes from a run in the image.
- Make the deadline longer than the slow call. Change
from_millis(50)tofrom_millis(600)insrc/main.rs. The run below does it withsed -i, which is the GNU form used in the image; on macOS writesed -i ''or edit the file by hand. Thencargo run. Look at the last line: the call finished first, so it printsslow done after 500msand notslow call timed out. - 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.
$ 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
$ 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
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.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)}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);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.
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)
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.
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".
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".
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".
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".
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."
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".
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".
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.
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.
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());}1
Read more
Section titled “Read more”- The Tokio tutorial:
spawn, shared state, channels andselect!step by step. tokioon docs.rs, the version this page pins (1.54.1): theselect!cancellation safety lists,JoinHandleandsync::Mutexwith its “Which kind of mutex should you use?” section.- Asynchronous Programming in Rust, the Async Book. Its front page says it is being rewritten and is incomplete.
- In this course: Async Basics, Send and Sync, Shared state for
Mutex, and Channels.
The version this page is about is tokio 1.54.1, as pinned in Cargo.lock.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.