Async & Tokio

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

NotionDefinitionTool
Concurrencyseveral tasks make progress by taking turnsasync/await, tokio tasks
Parallelismseveral computations run at the same instant on several coresthreads, rayon
Asynchronousa task that waits gives control back instead of blockingasync/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 threadAsync task
Creation costa few tens of µs, a stack of several MBa few hundred bytes
Reasonable numbera few hundredhundreds of thousands
Task switchingdecided by the system, at any timeonly at .await points
Good forcomputation, blocking codeI/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:

FeatureContent
rt / rt-multi-threadsingle-thread / multi-thread runtime
macros#[tokio::main], #[tokio::test], join!, select!
timesleep, interval, timeout
syncchannels, Mutex, RwLock, Semaphore
netTCP, UDP
fsfiles
signalCtrl+C, Unix signals
test-utiltime 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}");
}
  • spawn starts the task immediately, without waiting for an .await.
  • It returns a JoinHandle<T>; awaiting it gives Result<T, JoinError> (Err if the task panicked or was cancelled).
  • The task must be Send + 'static: it can change thread and outlive the function that launches it (course send-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();
}
CodeFix
std::thread::sleeptokio::time::sleep(..).await
std::fs::read_to_string on a large filetokio::fs::read_to_string(..).await
computation longer than a few hundred µsspawn_blocking
synchronous library (blocking HTTP client, synchronous SQLite)spawn_blocking, or the async version of the library
std::sync::Mutex held during a waitrelease 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

NeedTool
a few known futures, all neededjoin! / try_join!
the first to finish, or a wait that can be stoppedselect!
list of futures in the same taskjoin_all, try_join_all
many jobs, on several threadsJoinSet
many jobs, with a simultaneous limitbuffer_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

ChannelSendersReceiversValuesUse
mpscseveralonemessage queuejob queue
oneshotoneonea single onesend back a reply
broadcastseveralseveral, each receives everythingqueuenotifications to everyone
watchoneseveral, see the latest valueone current valueconfiguration, 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();

9. Shared state

9.1 `Arc<Mutex<T>>` with a std lock

use std::sync::{Arc, Mutex};

#[derive(Clone)]
struct State { visits: Arc<Mutex<u64>> }

async fn handler(state: State) {
    *state.visits.lock().unwrap() += 1;      // lock taken and released with no .await in between
}

A std::sync::Mutex is the right choice as long as the lock is never held across an .await: it is faster than an async lock.

9.2 When you need an async lock

use std::sync::Arc;
use tokio::sync::Mutex;

async fn write(file: Arc<Mutex<tokio::fs::File>>, line: &[u8]) {
    use tokio::io::AsyncWriteExt;
    let mut f = file.lock().await;           // wait for the lock without blocking the thread
    f.write_all(line).await.unwrap();        // lock held during the write: required here
}
std::sync::Mutextokio::sync::Mutex
Taking the locklock() blocks the thread (briefly)lock().await suspends the task
Holding across an .awaitno (blocks, and the future is no longer Send)yes
Speedfasterslower
Forshort sections, without waitingresource used during a wait (connection, file)

9.3 Many readers: `RwLock`

use std::sync::{Arc, RwLock};

let cache: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(vec![]));
let reading = cache.read().unwrap().len();       // several simultaneous readers
cache.write().unwrap().push("x".into());         // a single writer

9.4 A number or a flag: atomics

use std::sync::atomic::{AtomicU64, Ordering};
static REQUESTS: AtomicU64 = AtomicU64::new(0);
REQUESTS.fetch_add(1, Ordering::Relaxed);

The details of the sharing primitives are in the concurrence course.

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}");
    }
}
MethodEffect
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().awaitnext element
collect().awaitgather 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
}
VersionSimultaneousLimitOrderSeveral threads
1 sequentialno—yesno
2 join_allallnoneyesno
3 JoinSetallnonearrivalyes
4 bufferednyesyesno
5 JoinSet + Semaphorenyesarrivalyes

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

PitfallSymptomFix
future never awaitednothing happens, unused warning.await or spawn
std::thread::sleep, heavy computation in a taskthe whole server slows downtokio::time::sleep, spawn_blocking
std::sync::MutexGuard held across an .await"future is not Send", deadlocksrelease before, or tokio::sync::Mutex
mpsc sender never droppedwhile let Some(..) = rx.recv() never endsdrop(tx) after the clones
unbounded channelmemory keeps growingmpsc::channel(n)
network call without a time limittasks suspended indefinitelytimeout
launching thousands of tasks at onceremote service saturatedSemaphore, buffer_unordered
error in a detached tasklost without a tracelog inside the task
block_on inside a runtimepanic.await directly
sequence of operations interrupted by cancellationinconsistent statetransaction, 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.
  • .await suspends the task without blocking the thread; two successive .await calls remain sequential.
  • join! to wait together, select! for the first one, JoinSet for a group of tasks.
  • Never blocking code in a task: spawn_blocking.
  • std::sync::Mutex as 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).