Async & Tokio
1. Synchronous, asynchronous, parallel
1.1 The problem
A web server spends most of its time waiting: for the database, the network, the disk. In synchronous code, a thread that waits does nothing else.
// Synchronous: each call blocks the thread until the reply
fn handle() {
let u = read_user(); // 20 ms of waiting
let o = read_orders(); // 30 ms of waiting
// total: 50 ms during which the thread is unusable
}
1.2 Three notions not to confuse
| Notion | Definition | Tool |
|---|---|---|
| Concurrency | several tasks make progress by taking turns | async/await, tokio tasks |
| Parallelism | several computations run at the same instant on several cores | threads, rayon |
| Asynchronous | a task that waits gives control back instead of blocking | async/await |
Async is for waiting on many things at once with few threads: thousands of connections on a few threads. It does not make a computation faster. For heavy computation (hashing, image processing), threads are what matter (section 5).
1.3 Thread or async task
| System thread | Async task | |
|---|---|---|
| Creation cost | a few tens of µs, a stack of several MB | a few hundred bytes |
| Reasonable number | a few hundred | hundreds of thousands |
| Task switching | decided by the system, at any time | only at .await points |
| Good for | computation, blocking code | I/O: network, database, files |
2. Futures
2.1 `async fn` returns a future
async fn greet() -> String {
"hello".to_string()
}
// is equivalent to:
fn greet2() -> impl std::future::Future<Output = String> {
async { "hello".to_string() }
}
Calling greet() runs nothing: it creates a future, a value that represents a computation not yet done.
2.2 A future does nothing without `.await`
async fn write_log() {
println!("written");
}
async fn example() {
write_log(); // NOTHING happens. Warning: unused implementer of `Future`
write_log().await; // now the function runs
}
Rust futures are lazy: unlike JavaScript promises, they only start when you await them (or hand them to spawn).
2.3 `.await`: wait without blocking
async fn handle() {
let u = read_user().await; // the task is suspended; the thread serves other tasks
let o = read_orders().await;
}
This code is still sequential: read_orders only starts after read_user. The difference from synchronous code: while waiting, the thread handles other requests. To run both at the same time: section 6.
2.4 `async` blocks and `async` closures
let future = async {
let a = 1;
a + 1
};
let x = 5;
let with_capture = async move { x * 2 }; // move: the block owns x
let f = async |n: u32| n + 1; // async closure (Rust 1.85+)
2.5 What a future is, in short
A future is a state machine generated by the compiler. Each .await is a point where the function can stop and then resume. The runtime calls its poll method: the future answers "done, here is the value" or "not yet, wake me up when it is ready". Everything that lives across an .await is stored in this machine, which explains the rules about Send (course send-sync).
3. The tokio runtime
Rust does not provide an async runtime: you need a library that runs futures. tokio is the most used (axum, sea-orm and Runique are built on it).
3.1 Installation
[dependencies]
tokio = { version = "1", features = ["full"] }
full enables everything; a library or an optimised binary only picks what it needs:
| Feature | Content |
|---|---|
rt / rt-multi-thread | single-thread / multi-thread runtime |
macros | #[tokio::main], #[tokio::test], join!, select! |
time | sleep, interval, timeout |
sync | channels, Mutex, RwLock, Semaphore |
net | TCP, UDP |
fs | files |
signal | Ctrl+C, Unix signals |
test-util | time control in tests |
3.2 `#[tokio::main]`
#[tokio::main]
async fn main() {
println!("inside the runtime");
}
// is equivalent to:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
println!("inside the runtime");
});
}
3.3 Variants
#[tokio::main(flavor = "current_thread")] // a single thread: lighter, for a CLI tool
async fn main() {}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)] // chosen number of threads
async fn main2() {}
By default, the multi-thread runtime creates one thread per core.
3.4 Calling async code from synchronous code
fn tool() {
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt.block_on(async { 42 });
}
block_on inside a runtime that is already running panics ("Cannot start a runtime from within a runtime"): use it only at the boundary, in a main or a synchronous tool.
4. Launching tasks
4.1 `tokio::spawn`
#[tokio::main]
async fn main() {
let task = tokio::spawn(async {
// runs in parallel with main, possibly on another thread
21 * 2
});
let result = task.await.unwrap(); // JoinHandle: wait for the end and get the value
println!("{result}");
}
spawnstarts the task immediately, without waiting for an.await.- It returns a
JoinHandle<T>; awaiting it givesResult<T, JoinError>(Errif the task panicked or was cancelled). - The task must be
Send + 'static: it can change thread and outlive the function that launches it (coursesend-sync).
4.2 Detached task
tokio::spawn(async {
send_email().await; // nobody waits for the result
});
If the JoinHandle is ignored, the task keeps running anyway. But a panic or an error in the task goes unnoticed: log errors inside it.
4.3 Passing data to a task
use std::sync::Arc;
let config = Arc::new(String::from("prod"));
for i in 0..3 {
let config = Arc::clone(&config); // one copy of the pointer per task
tokio::spawn(async move {
println!("task {i}: {config}");
});
}
4.4 `JoinSet`: a group of tasks
use tokio::task::JoinSet;
let mut group = JoinSet::new();
for id in 1..=5 {
group.spawn(async move { id * 10 });
}
while let Some(r) = group.join_next().await { // in order of COMPLETION, not launch
println!("{}", r.unwrap());
}
A JoinSet keeps the tasks together: join_next returns results as they come, and all the remaining tasks are cancelled if the JoinSet is dropped. This is the safe way to launch a variable number of tasks.
5. Blocking code
5.1 The problem
async fn bad() {
std::thread::sleep(std::time::Duration::from_secs(1)); // blocks the whole THREAD
let h = argon2_hash("password"); // 100 ms computation: blocks too
}
A runtime thread that blocks cannot move any other task forward. With 4 threads and 4 requests hashing a password, the whole server is frozen.
5.2 The fixes
async fn good() {
// 1. Wait: the async version
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// 2. Heavy computation or blocking library: a dedicated thread
let hash = tokio::task::spawn_blocking(|| argon2_hash("password"))
.await
.unwrap();
}
| Code | Fix |
|---|---|
std::thread::sleep | tokio::time::sleep(..).await |
std::fs::read_to_string on a large file | tokio::fs::read_to_string(..).await |
| computation longer than a few hundred µs | spawn_blocking |
| synchronous library (blocking HTTP client, synchronous SQLite) | spawn_blocking, or the async version of the library |
std::sync::Mutex held during a wait | release before the .await (section 9) |
spawn_blocking runs the closure on a separate set of threads, meant for blocking.
6. Waiting on several futures
6.1 `join!`: all of them, at the same time
async fn user() -> String { "ada".into() }
async fn orders() -> Vec<u32> { vec![1, 2] }
async fn page() {
// Sequential: 20 ms + 30 ms = 50 ms
let u = user().await;
let o = orders().await;
// Simultaneous: max(20, 30) = 30 ms
let (u, o) = tokio::join!(user(), orders());
}
join! moves both futures forward in the same task, in turns: no new thread, no Send required.
6.2 `try_join!`: all of them, or the first error
async fn a() -> Result<u32, String> { Ok(1) }
async fn b() -> Result<u32, String> { Err("failure".into()) }
async fn combine() -> Result<u32, String> {
let (x, y) = tokio::try_join!(a(), b())?; // stops at the first error
Ok(x + y)
}
6.3 `select!`: the first one to finish
use std::time::Duration;
async fn fast() -> &'static str { "fast" }
async fn slow() -> &'static str { tokio::time::sleep(Duration::from_secs(5)).await; "slow" }
async fn race() {
tokio::select! {
r = fast() => println!("{r}"),
r = slow() => println!("{r}"), // never printed: this future is CANCELLED
}
}
As soon as one branch finishes, the other futures are dropped: their work in progress is abandoned (section 12).
6.4 `select!` in a loop
use tokio::sync::mpsc;
async fn run_loop(mut rx: mpsc::Receiver<String>, mut stop: tokio::sync::oneshot::Receiver<()>) {
loop {
tokio::select! {
Some(msg) = rx.recv() => println!("message: {msg}"),
_ = &mut stop => {
println!("stop requested");
break;
}
}
}
}
&mut stop: we borrow the future instead of consuming it, so it can be reused on each turn.
6.5 A list of futures: `join_all`
use futures::future::join_all;
async fn load(id: u32) -> u32 { id }
async fn load_all() -> Vec<u32> {
let futures = (1..=10).map(load);
join_all(futures).await // all at the same time, results in order
}
6.6 Choosing
| Need | Tool |
|---|---|
| a few known futures, all needed | join! / try_join! |
| the first to finish, or a wait that can be stopped | select! |
| list of futures in the same task | join_all, try_join_all |
| many jobs, on several threads | JoinSet |
| many jobs, with a simultaneous limit | buffer_unordered (section 11) or Semaphore (section 10) |
7. Time: sleep, interval, timeout
use std::time::Duration;
use tokio::time::{interval, sleep, timeout};
async fn timing() {
sleep(Duration::from_millis(100)).await;
let mut tick = interval(Duration::from_secs(60));
for _ in 0..3 {
tick.tick().await; // the first tick is immediate
println!("every minute");
}
match timeout(Duration::from_secs(2), slow_operation()).await {
Ok(value) => println!("{value}"),
Err(_) => println!("too long: giving up"),
}
}
timeout cancels the operation if the delay is exceeded. Every network request to an external service should have a time limit: without one, a slow service blocks the tasks that wait for it indefinitely.
interval catches up on missed ticks by default; tick.set_missed_tick_behavior(MissedTickBehavior::Delay) shifts instead of catching up.
8. Channels
8.1 The four tokio channels
| Channel | Senders | Receivers | Values | Use |
|---|---|---|---|---|
mpsc | several | one | message queue | job queue |
oneshot | one | one | a single one | send back a reply |
broadcast | several | several, each receives everything | queue | notifications to everyone |
watch | one | several, see the latest value | one current value | configuration, state |
8.2 `mpsc`: a job queue
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(100); // capacity 100: beyond that, send waits
for i in 0..3 {
let tx = tx.clone();
tokio::spawn(async move {
tx.send(format!("job {i}")).await.unwrap();
});
}
drop(tx); // otherwise rx would never end
while let Some(msg) = rx.recv().await { // None when all senders are dropped
println!("{msg}");
}
}
The capacity limits memory: when the queue is full, send().await waits for the consumer to catch up. This is backpressure. unbounded_channel has no limit, so it can grow memory without end if the consumer is slower.
8.3 `oneshot`: one reply
use tokio::sync::{mpsc, oneshot};
enum Command {
Read { key: String, reply: oneshot::Sender<Option<String>> },
}
async fn handler(mut rx: mpsc::Receiver<Command>) {
let mut data = std::collections::HashMap::new();
data.insert("a".to_string(), "1".to_string());
while let Some(Command::Read { key, reply }) = rx.recv().await {
let _ = reply.send(data.get(&key).cloned());
}
}
async fn client(tx: mpsc::Sender<Command>) {
let (reply_tx, reply_rx) = oneshot::channel();
tx.send(Command::Read { key: "a".into(), reply: reply_tx }).await.unwrap();
let value = reply_rx.await.unwrap();
}
This is the "actor" pattern: a single task owns the state, the others send it commands. No lock is needed.
8.4 `broadcast` and `watch`
use tokio::sync::{broadcast, watch};
let (tx, mut rx1) = broadcast::channel::<String>(16);
let mut rx2 = tx.subscribe();
tx.send("to everyone".into()).unwrap(); // rx1 and rx2 each receive the message
let (cfg_tx, mut cfg_rx) = watch::channel("v1".to_string());
cfg_tx.send("v2".into()).unwrap(); // readers see the latest value
// cfg_rx.changed().await: wait for the next change
let current = cfg_rx.borrow().clone();
10. Limiting concurrency
10.1 The problem
// Launching 10,000 requests at once: the remote service refuses, memory explodes
// for url in urls { tokio::spawn(download(url)); }
10.2 `Semaphore`
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
async fn download(id: u32) -> u32 { id }
async fn all(ids: Vec<u32>) -> Vec<u32> {
let limit = Arc::new(Semaphore::new(10)); // at most 10 at the same time
let mut group = JoinSet::new();
for id in ids {
let permit = Arc::clone(&limit).acquire_owned().await.unwrap(); // waits for a free slot
group.spawn(async move {
let r = download(id).await;
drop(permit); // frees the slot
r
});
}
let mut results = Vec::new();
while let Some(r) = group.join_next().await {
results.push(r.unwrap());
}
results
}
A Semaphore holds N permits; each task takes one and gives it back by dropping it. The 11th waits for a slot to free up.
11. Streams
A stream is the async equivalent of an iterator: a sequence of values that arrive over time.
use futures::stream::{self, StreamExt};
async fn load(id: u32) -> u32 { id * 2 }
async fn example() {
// Like an iterator: map, filter, collect... but with .await
let doubled: Vec<u32> = stream::iter(1..=5).then(load).collect().await;
// At most 4 simultaneous loads, results in order of arrival
let fast: Vec<u32> = stream::iter(1..=100)
.map(load)
.buffer_unordered(4)
.collect()
.await;
// Go through them one by one
let mut s = stream::iter(vec!["a", "b"]);
while let Some(x) = s.next().await {
println!("{x}");
}
}
| Method | Effect |
|---|---|
then(f) | applies an async function, one element at a time |
map(f).buffer_unordered(n) | up to n futures at the same time, results in order of arrival |
map(f).buffered(n) | same, results in the original order |
next().await | next element |
collect().await | gather everything |
Streams come from the futures crate (StreamExt) or from tokio-stream. A chunked HTTP response, the lines of a file or the messages of a WebSocket are streams.
12. Cancellation
12.1 Dropping a future cancels it
In Rust, cancelling a future means no longer waiting for it: it is dropped at its last suspension point, and the code after that .await never runs.
async fn transfer() {
debit().await;
// if the future is cancelled here (timeout, lost select!, client disconnected)...
credit().await; // ...this line never runs
}
Causes of cancellation: timeout exceeded, losing branch of a select!, JoinHandle::abort(), dropped JoinSet, and, in a web server, the client closing the connection (axum drops the handler future).
12.2 Protecting yourself
// 1. Transaction: nothing is committed until commit is reached
async fn safe_transfer(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm::DbErr> {
use sea_orm::TransactionTrait;
let txn = db.begin().await?;
// debit(&txn).await?; credit(&txn).await?;
txn.commit().await // cancelled before: the transaction is rolled back on drop
}
// 2. Detach the work that must run to the end
async fn handler() {
tokio::spawn(async {
// keeps going even if the client disconnects
});
}
12.3 Cancel safety in `select!`
A losing select! branch is dropped. If it had already consumed data (read part of a message), that data is lost. The tokio documentation states for each method whether it is cancel safe: mpsc::Receiver::recv is (the message stays in the channel), AsyncReadExt::read_exact is not.
13. Clean shutdown
use tokio::signal;
#[tokio::main]
async fn main() {
let (stop_tx, _) = tokio::sync::broadcast::channel::<()>(1);
let mut stop_rx = stop_tx.subscribe();
let worker = tokio::spawn(async move {
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {
println!("working");
}
_ = stop_rx.recv() => {
println!("worker stops cleanly");
break;
}
}
}
});
signal::ctrl_c().await.unwrap(); // wait for Ctrl+C (or SIGTERM with signal::unix)
let _ = stop_tx.send(()); // notify all tasks
worker.await.unwrap(); // wait for them to finish
}
With axum: axum::serve(listener, app).with_graceful_shutdown(signal) stops accepting connections and lets the requests in progress finish. tokio_util::sync::CancellationToken is the dedicated tool to broadcast a shutdown to many tasks.
14. The same operation written in several ways
Loading 20 profiles from a remote service.
use futures::stream::{self, StreamExt};
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
async fn profile(id: u32) -> String { format!("profile {id}") }
// 1. Sequential: simple, slow (20 x the latency)
async fn v1() -> Vec<String> {
let mut r = Vec::new();
for id in 1..=20 {
r.push(profile(id).await);
}
r
}
// 2. join_all: everything at once in the same task, order kept, no limit
async fn v2() -> Vec<String> {
futures::future::join_all((1..=20).map(profile)).await
}
// 3. JoinSet: separate tasks, on several threads, order of arrival
async fn v3() -> Vec<String> {
let mut g = JoinSet::new();
for id in 1..=20 {
g.spawn(profile(id));
}
let mut r = Vec::new();
while let Some(p) = g.join_next().await {
r.push(p.unwrap());
}
r
}
// 4. Stream with a limit: at most 5 at a time, order kept
async fn v4() -> Vec<String> {
stream::iter(1..=20).map(profile).buffered(5).collect().await
}
// 5. JoinSet + Semaphore: separate tasks and a limit
async fn v5() -> Vec<String> {
let limit = Arc::new(Semaphore::new(5));
let mut g = JoinSet::new();
for id in 1..=20 {
let l = Arc::clone(&limit);
g.spawn(async move {
let _p = l.acquire().await.unwrap();
profile(id).await
});
}
let mut r = Vec::new();
while let Some(p) = g.join_next().await {
r.push(p.unwrap());
}
r
}
| Version | Simultaneous | Limit | Order | Several threads |
|---|---|---|---|---|
| 1 sequential | no | — | yes | no |
2 join_all | all | none | yes | no |
3 JoinSet | all | none | arrival | yes |
4 buffered | n | yes | yes | no |
5 JoinSet + Semaphore | n | yes | arrival | yes |
For network calls, version 4 is often the best: simple, limited, ordered. Several threads (3, 5) only help if each task also does computation.
15. Examples in Runique
15.1 A periodic background task
// runique/src/middleware/session/session_db.rs (excerpt)
pub fn spawn_cleanup(&self, period: tokio::time::Duration) {
let db = self.db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(period);
loop {
interval.tick().await;
let now = chrono::Utc::now().naive_utc();
// deletes expired sessions; any error is logged
// Entity::delete_many().filter(Column::ExpiresAt.lt(now)).exec(&*db).await
}
});
}
loop + interval in a detached task: the cleanup runs for the whole life of the server. The connection is cloned before the move (section 4.3), and errors are logged inside the task, since nobody waits for its result (section 4.2).
15.2 A blocking file read
// runique/src/forms/fields/media.rs (excerpt)
let path = filename.to_string();
let read = tokio::task::spawn_blocking(move || read_image_dimensions(&path)).await;
Reading the dimensions of an uploaded image is a synchronous disk and decoding operation: it goes through spawn_blocking so it does not block the thread that handles other requests (section 5).
15.3 A detached task to reveal nothing
// runique/src/auth/password.rs (excerpt)
// Everything that depends on the account runs in the background, its
// lookup and the token's write included: the page answers the same, in
// the same time, for every address.
tokio::spawn(async move {
let Some(user) = BuiltinUserEntity::find_by_email(&engine.db, &email).await else {
return;
};
// ... token creation, email sending ...
});
On the "forgot password" page, if the response waited for the account lookup and the email sending, its duration would reveal whether the address exists (an unknown address would answer faster). By handing all this work to a detached task, the page answers immediately and the same way for every address. This is a protection against timing attacks, obtained with a simple spawn.
16. Common pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| future never awaited | nothing happens, unused warning | .await or spawn |
std::thread::sleep, heavy computation in a task | the whole server slows down | tokio::time::sleep, spawn_blocking |
std::sync::MutexGuard held across an .await | "future is not Send", deadlocks | release before, or tokio::sync::Mutex |
mpsc sender never dropped | while let Some(..) = rx.recv() never ends | drop(tx) after the clones |
| unbounded channel | memory keeps growing | mpsc::channel(n) |
| network call without a time limit | tasks suspended indefinitely | timeout |
| launching thousands of tasks at once | remote service saturated | Semaphore, buffer_unordered |
| error in a detached task | lost without a trace | log inside the task |
block_on inside a runtime | panic | .await directly |
| sequence of operations interrupted by cancellation | inconsistent state | transaction, or spawn |
17. Exercises
Exercise 1: parallelise
page() calls three independent functions one after the other. Rewrite it so they run at the same time, returning the first error.
async fn profile() -> Result<String, String> { Ok("p".into()) }
async fn friends() -> Result<Vec<String>, String> { Ok(vec![]) }
async fn notifications() -> Result<u32, String> { Ok(0) }
// Solution:
async fn page() -> Result<(String, Vec<String>, u32), String> {
tokio::try_join!(profile(), friends(), notifications())
}
Exercise 2: time limit and retries
Call call() with a 2-second limit, up to 3 attempts, waiting 100 ms, 200 ms then 400 ms between them.
use std::time::Duration;
async fn call() -> Result<u32, String> { Ok(1) }
// Solution:
async fn with_retries() -> Result<u32, String> {
let mut delay = Duration::from_millis(100);
for attempt in 1..=3 {
match tokio::time::timeout(Duration::from_secs(2), call()).await {
Ok(Ok(v)) => return Ok(v),
Ok(Err(e)) if attempt == 3 => return Err(e),
Err(_) if attempt == 3 => return Err("timeout exceeded".into()),
_ => {
tokio::time::sleep(delay).await;
delay *= 2;
}
}
}
unreachable!("the loop always returns on the third attempt")
}
Exercise 3: limiter
Write a function that processes a list of identifiers with at most 3 simultaneous jobs, returning the results in the original order.
use futures::stream::{self, StreamExt};
async fn process(id: u32) -> u32 { id }
// Solution:
async fn all(ids: Vec<u32>) -> Vec<u32> {
stream::iter(ids).map(process).buffered(3).collect().await
}
18. Cheat sheet
#[tokio::main] async fn main() {} #[tokio::test] async fn t() {}
let h = tokio::spawn(async move { .. }); h.await?; tokio::task::spawn_blocking(|| ..).await?
tokio::join!(a(), b()) tokio::try_join!(a(), b())? tokio::select! { x = a() => .., _ = b() => .. }
let mut g = JoinSet::new(); g.spawn(f); g.join_next().await
tokio::time::sleep(d).await interval(d).tick().await timeout(d, f).await
mpsc::channel(n) oneshot::channel() broadcast::channel(n) watch::channel(v)
Semaphore::new(n).acquire().await stream::iter(v).map(f).buffered(n).collect().await
signal::ctrl_c().await axum::serve(..).with_graceful_shutdown(..)
Key points
- A future does nothing until it is awaited or launched with
spawn. .awaitsuspends the task without blocking the thread; two successive.awaitcalls remain sequential.join!to wait together,select!for the first one,JoinSetfor a group of tasks.- Never blocking code in a task:
spawn_blocking. std::sync::Mutexas long as it is not held across an.await.- Every external wait has a time limit, every burst has a concurrency limit.
- Cancelling a future stops its execution at the current
.await: protect sequences of operations with a transaction. - A detached task can be used to make a response independent of a secret (constant time).