ZetCode

Rust async and Tokio

last modified November 13, 2026

In this article we write asynchronous programs in Rust with the Tokio runtime.

Asynchronous programming lets a program make progress on many tasks while some of them wait, typically for network or disk I/O. Instead of blocking a thread, a waiting task gives control back, and the thread runs another task in the meantime. Thousands of tasks can share a handful of threads.

An async fn returns a future, a value that represents a computation that has not finished yet. The .await operator waits for a future to complete. Futures are lazy: they do nothing until something polls them. That something is an executor, and the standard library does not provide one. The most widely used async runtime is Tokio, which provides the executor together with timers, channels, synchronization primitives, and async file and network I/O.

The examples were tested with Rust 1.97, the 2024 edition, and Tokio 1.54.1 on a four-core machine. They are run as Cargo projects with cargo run -q. Timings are rounded to a tenth of a second.

Setting up the project

We add Tokio with the full feature, which enables all of its components. Production code often lists only the needed features, such as rt-multi-thread, macros, time, and net.

Cargo.toml
[package]
name = "tokio_example"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1.54.1", features = ["full"] }

The dependency is added with cargo add.

$ cargo add tokio --features full
    Updating crates.io index
      Adding tokio v1.54.1 to dependencies
             Features:
             + bytes
             + fs
             + full
             + io-std
             + io-util
             + libc
             + macros
             + net
             + parking_lot
             + process
             + rt
             + rt-multi-thread
             + signal
             + signal-hook-registry
             + socket2
             + sync
             + time
             + tokio-macros
             - io-uring
             - mio
             - schedule-latency
             - taskdump
             - test-util
             - tracing
             - windows-sys
    Updating crates.io index
     Locking 24 packages to latest Rust 1.97.1 compatible versions

The first async program

An async program needs a runtime to execute the futures. The #[tokio::main] attribute creates one and runs the async main function on it.

main.rs
async fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

#[tokio::main]
async fn main() {
    let fut = greet("Lucia");
    println!("future created");

    let msg = fut.await;
    println!("{msg}");
}

The program calls an async function and awaits its result.

async fn greet(name: &str) -> String {

The function is declared to return String, but calling it actually returns an impl Future<Output = String>.

let fut = greet("Lucia");
println!("future created");

let msg = fut.await;

Calling the function only creates the future; its body runs when the future is awaited. .await can only be used inside async functions and blocks.

#[tokio::main]
async fn main() {

The macro turns the async main into a normal one that builds a multi-threaded runtime and calls block_on on the body.

$ cargo run -q
future created
Hello, Lucia!

Futures are lazy

A future that is never awaited never runs.

main.rs
async fn work() {
    println!("working");
}

#[tokio::main]
async fn main() {
    work();
    println!("done");
}

We call an async function without .await.

work();

The future is created and immediately dropped. The compiler warns about it, and the message working is never printed. This differs from JavaScript promises and C# tasks, which start running when they are created.

$ cargo run -q
warning: unused implementer of `Future` that must be used
 --> src/main.rs:7:5
  |
7 |     work();
  |     ^^^^^^
  |
  = note: futures do nothing unless you `.await` or poll them
  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default

done

Sequential awaits

Awaiting futures one after another runs them one after another.

main.rs
use std::time::{Duration, Instant};
use tokio::time::sleep;

async fn fetch(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    format!("{name} ({ms} ms)")
}

#[tokio::main]
async fn main() {
    let start = Instant::now();

    let a = fetch("users", 300).await;
    let b = fetch("orders", 500).await;
    let c = fetch("stats", 200).await;

    println!("{a}, {b}, {c}");
    println!("elapsed: {:.1}s", start.elapsed().as_secs_f64());
}

The fetch function simulates a network request with an async sleep.

sleep(Duration::from_millis(ms)).await;

The tokio::time::sleep function returns a future that completes after the given time. While it waits, the thread is free to run other tasks.

let a = fetch("users", 300).await;
let b = fetch("orders", 500).await;
let c = fetch("stats", 200).await;

Each request starts only after the previous one has finished, so the total time is the sum of the three delays.

$ cargo run -q
users (300 ms), orders (500 ms), stats (200 ms)
elapsed: 1.0s

Concurrency with join!

The tokio::join! macro polls several futures concurrently and waits until all of them complete.

main.rs
use std::time::{Duration, Instant};
use tokio::time::sleep;

async fn fetch(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    println!("{name} ready");
    format!("{name} ({ms} ms)")
}

async fn parse(text: &str) -> Result<i32, String> {
    sleep(Duration::from_millis(100)).await;
    text.parse().map_err(|_| format!("bad number: {text}"))
}

#[tokio::main]
async fn main() {
    let start = Instant::now();

    let (a, b, c) = tokio::join!(fetch("users", 300), fetch("orders", 500), fetch("stats", 200));

    println!("{a}, {b}, {c}");
    println!("elapsed: {:.1}s", start.elapsed().as_secs_f64());

    let res = tokio::try_join!(parse("12"), parse("x7"), parse("30"));
    println!("{res:?}");
}

The same three requests now overlap.

let (a, b, c) = tokio::join!(fetch("users", 300), fetch("orders", 500), fetch("stats", 200));

The total time equals the longest delay. The futures complete in the order of their delays, but the results are returned as a tuple in the order of the arguments. All three futures run within the current task; join! gives concurrency, not parallelism.

let res = tokio::try_join!(parse("12"), parse("x7"), parse("30"));

The try_join! macro works with futures returning Result. It returns the first error and drops the remaining futures, or a tuple of all Ok values.

$ cargo run -q
stats ready
users ready
orders ready
users (300 ms), orders (500 ms), stats (200 ms)
elapsed: 0.5s
Err("bad number: x7")

Spawning tasks

The tokio::spawn function submits a future to the runtime as an independent task. Tasks are the async counterpart of threads, but much lighter. On the multi-threaded runtime, they run in parallel on a pool of worker threads.

main.rs
use std::time::Duration;
use tokio::time::sleep;

#[tokio::main]
async fn main() {
    let mut handles = Vec::new();

    for id in 1..=4u64 {
        let handle = tokio::spawn(async move {
            sleep(Duration::from_millis(400 - id * 100)).await;
            println!("task {id} finished");
            id * id
        });
        handles.push(handle);
    }

    let mut results = Vec::new();
    for h in handles {
        results.push(h.await.unwrap());
    }
    println!("results: {results:?}");
}

We spawn four tasks that finish in reverse order.

let handle = tokio::spawn(async move {
    sleep(Duration::from_millis(400 - id * 100)).await;
    println!("task {id} finished");
    id * id
});

A spawned task may outlive the function that spawned it, so the future must be 'static: it cannot borrow local variables. The move keyword moves id into the async block. A task starts running immediately, even if its handle is never awaited.

results.push(h.await.unwrap());

Awaiting a JoinHandle returns Result<T, JoinError>, which is an error when the task panicked or was cancelled.

$ cargo run -q
task 4 finished
task 3 finished
task 2 finished
task 1 finished
results: [1, 4, 9, 16]

Collecting results with JoinSet

A JoinSet holds a group of tasks and returns their results in the order they complete.

main.rs
use std::time::{Duration, Instant};
use tokio::task::JoinSet;
use tokio::time::sleep;

async fn download(url: String, ms: u64) -> (String, usize) {
    sleep(Duration::from_millis(ms)).await;
    let len = url.len() * 100;
    (url, len)
}

#[tokio::main]
async fn main() {
    let jobs = [("example.com/a", 500), ("example.com/bb", 100), ("example.com/ccc", 300)];

    let start = Instant::now();
    let mut set = JoinSet::new();
    for (url, ms) in jobs {
        set.spawn(download(url.to_string(), ms));
    }

    let mut total = 0;
    while let Some(res) = set.join_next().await {
        let (url, len) = res.unwrap();
        println!("{:.1}s {url}: {len} bytes", start.elapsed().as_secs_f64());
        total += len;
    }
    println!("total: {total} bytes");
}

We simulate three downloads of different durations.

set.spawn(download(url.to_string(), ms));

The spawn method of the set spawns a task and keeps its handle.

while let Some(res) = set.join_next().await {

The join_next method waits for the next task to finish and returns None when the set is empty. Results can be processed as soon as they are ready. When a JoinSet is dropped, all tasks still in it are aborted.

$ cargo run -q
0.1s example.com/bb: 1400 bytes
0.3s example.com/ccc: 1500 bytes
0.5s example.com/a: 1300 bytes
total: 4200 bytes

Racing futures with select!

The tokio::select! macro waits on several futures and runs the branch of the first one to complete. The other futures are dropped, which cancels them.

main.rs
use std::time::Duration;
use tokio::time::sleep;

async fn mirror(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    format!("data from {name}")
}

#[tokio::main]
async fn main() {
    let winner = tokio::select! {
        res = mirror("europe", 300) => res,
        res = mirror("america", 150) => res,
        res = mirror("asia", 400) => res,
    };
    println!("{winner}");

    let res = tokio::select! {
        data = mirror("slow", 2000) => Some(data),
        _ = sleep(Duration::from_millis(500)) => None,
    };
    println!("{res:?}");
}

We ask three mirrors for the same data and use the fastest answer.

let winner = tokio::select! {
    res = mirror("europe", 300) => res,
    res = mirror("america", 150) => res,
    res = mirror("asia", 400) => res,
};

Each branch has a pattern, a future, and a handler. The value of the whole select! is the value of the chosen handler.

data = mirror("slow", 2000) => Some(data),
_ = sleep(Duration::from_millis(500)) => None,

Racing a future against a sleep implements a timeout. The slow request is cancelled after half a second.

$ cargo run -q
data from america
None

Timeouts and cancellation

The tokio::time::timeout function wraps a future with a time limit.

main.rs
use std::time::Duration;
use tokio::time::{sleep, timeout};

struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("{} dropped", self.0);
    }
}

async fn query(name: &'static str, ms: u64) -> String {
    let _g = Guard(name);
    sleep(Duration::from_millis(ms)).await;
    println!("{name} completed");
    format!("{name} result")
}

#[tokio::main]
async fn main() {
    let limit = Duration::from_millis(250);

    match timeout(limit, query("fast", 100)).await {
        Ok(v) => println!("ok: {v}"),
        Err(e) => println!("error: {e}"),
    }

    match timeout(limit, query("slow", 1000)).await {
        Ok(v) => println!("ok: {v}"),
        Err(e) => println!("error: {e}"),
    }
}

A Guard value prints a message when it is dropped, so we can see what happens to a cancelled future.

match timeout(limit, query("slow", 1000)).await {
    Ok(v) => println!("ok: {v}"),
    Err(e) => println!("error: {e}"),
}

The function returns Ok with the output when the future finishes in time, or an Elapsed error.

When the time is up, the slow future is dropped while waiting at its .await. Its local variables are dropped, so the guard message appears, but the code after the .await never runs: slow completed is not printed. In async Rust, any .await is a point where the function can be cancelled.

$ cargo run -q
fast completed
fast dropped
ok: fast result
slow dropped
error: deadline has elapsed

Channels

Tasks communicate through channels. The tokio::sync::mpsc channel has multiple producers and a single consumer.

main.rs
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(8);

    for id in 1..=3 {
        let tx = tx.clone();
        tokio::spawn(async move {
            for n in 1..=2 {
                sleep(Duration::from_millis(id * 70)).await;
                tx.send(format!("sensor {id}: reading {n}")).await.unwrap();
            }
        });
    }
    drop(tx);

    let mut count = 0;
    while let Some(msg) = rx.recv().await {
        println!("{msg}");
        count += 1;
    }
    println!("received {count} messages");
}

Three sensor tasks send readings to the main task.

let (tx, mut rx) = mpsc::channel::<String>(8);

The channel is bounded and holds at most eight messages. When it is full, send waits until there is room, which slows down fast producers. This is called back-pressure.

let tx = tx.clone();
tokio::spawn(async move {

Each producer task gets its own clone of the sender.

drop(tx);

while let Some(msg) = rx.recv().await {

The recv method returns None when all senders are gone. We drop the original sender, so the loop ends after the last producer finishes.

$ cargo run -q
sensor 1: reading 1
sensor 2: reading 1
sensor 1: reading 2
sensor 3: reading 1
sensor 2: reading 2
sensor 3: reading 2
received 6 messages

Request and response with oneshot

A oneshot channel sends a single value. It is a common way to get a reply from another task.

main.rs
use tokio::sync::{mpsc, oneshot};

enum Command {
    Add(i64),
    Get(oneshot::Sender<i64>),
}

async fn counter(mut rx: mpsc::Receiver<Command>) {
    let mut total = 0;
    while let Some(cmd) = rx.recv().await {
        match cmd {
            Command::Add(n) => total += n,
            Command::Get(reply) => {
                let _ = reply.send(total);
            }
        }
    }
    println!("counter stopped");
}

#[tokio::main]
async fn main() {
    let (tx, rx) = mpsc::channel(16);
    let actor = tokio::spawn(counter(rx));

    for n in [5, 10, -3] {
        tx.send(Command::Add(n)).await.unwrap();
    }

    let (reply_tx, reply_rx) = oneshot::channel();
    tx.send(Command::Get(reply_tx)).await.unwrap();
    println!("total: {}", reply_rx.await.unwrap());

    drop(tx);
    actor.await.unwrap();
}

A counter task owns its state and handles commands from a channel. This pattern is known as an actor.

enum Command {
    Add(i64),
    Get(oneshot::Sender<i64>),
}

The Get command carries the sender half of a oneshot channel, through which the actor sends the answer.

let (reply_tx, reply_rx) = oneshot::channel();
tx.send(Command::Get(reply_tx)).await.unwrap();
println!("total: {}", reply_rx.await.unwrap());

The receiver half is itself a future. Because the counter state is owned by a single task, it needs no lock.

$ cargo run -q
total: 12
counter stopped

Shared state

State shared between tasks is wrapped in Arc and a mutex, as with threads.

main.rs
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::sleep;

#[tokio::main]
async fn main() {
    let cache: Arc<Mutex<HashMap<String, u32>>> = Arc::new(Mutex::new(HashMap::new()));
    let mut handles = Vec::new();

    for word in ["apple", "pear", "apple", "plum", "apple", "pear"] {
        let cache = Arc::clone(&cache);
        handles.push(tokio::spawn(async move {
            let mut map = cache.lock().await;
            sleep(Duration::from_millis(10)).await;
            *map.entry(word.to_string()).or_insert(0) += 1;
        }));
    }

    for h in handles {
        h.await.unwrap();
    }

    let map = cache.lock().await;
    let mut entries: Vec<_> = map.iter().collect();
    entries.sort();
    println!("{entries:?}");
}

Several tasks count words in a shared map.

let mut map = cache.lock().await;
sleep(Duration::from_millis(10)).await;

The tokio::sync::Mutex is locked with .await, so a task waiting for the lock does not block the thread. Its guard can be held across an .await, as here.

The std::sync::Mutex is faster and is the better choice when the lock is held only briefly and never across an .await. Holding a standard guard across an .await makes the future not Send, and tokio::spawn rejects it.

$ cargo run -q
[("apple", 3), ("pear", 2), ("plum", 1)]

Do not block the runtime

Async tasks share threads, so a task that blocks the thread, by sleeping synchronously or computing for a long time, stops all other tasks on that thread.

main.rs
use std::time::{Duration, Instant};

async fn tick(name: &str, start: Instant) {
    for _ in 0..3 {
        tokio::time::sleep(Duration::from_millis(100)).await;
        println!("{name} tick at {:.1}s", start.elapsed().as_secs_f64());
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let start = Instant::now();

    let bad = async {
        std::thread::sleep(Duration::from_millis(500));
        println!("blocking work done at {:.1}s", start.elapsed().as_secs_f64());
    };
    tokio::join!(tick("A", start), bad);

    println!("---");
    let start = Instant::now();
    let good = async {
        tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_millis(500)))
            .await
            .unwrap();
        println!("blocking work done at {:.1}s", start.elapsed().as_secs_f64());
    };
    tokio::join!(tick("B", start), good);
}

We use the single-threaded runtime to make the effect clearly visible.

#[tokio::main(flavor = "current_thread")]

The current_thread flavor runs all tasks on the main thread.

let bad = async {
    std::thread::sleep(Duration::from_millis(500));

The standard thread::sleep blocks the thread. The ticker cannot run for half a second; its first tick comes at 0.5s instead of 0.1s.

tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_millis(500)))
    .await
    .unwrap();

The spawn_blocking function runs blocking code on a separate thread pool and returns a future of its result. The ticker keeps its schedule. Use it for blocking libraries, synchronous file access, and CPU-heavy computations.

$ cargo run -q
blocking work done at 0.5s
A tick at 0.5s
A tick at 0.6s
A tick at 0.7s
---
B tick at 0.1s
B tick at 0.2s
B tick at 0.3s
blocking work done at 0.5s

Async file I/O

The tokio::fs module mirrors std::fs with async functions.

main.rs
use tokio::fs;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    fs::write("words.txt", "falcon\nsky\ncloud\nrock\n").await?;

    let text = fs::read_to_string("words.txt").await?;
    println!("{} bytes, {} lines", text.len(), text.lines().count());

    let mut file = fs::OpenOptions::new().append(true).open("words.txt").await?;
    file.write_all(b"forest\n").await?;
    file.flush().await?;

    let file = fs::File::open("words.txt").await?;
    let mut lines = BufReader::new(file).lines();
    while let Some(line) = lines.next_line().await? {
        print!("{} ", line.to_uppercase());
    }
    println!();

    fs::remove_file("words.txt").await?;
    Ok(())
}

We write a file, append to it, and read it line by line.

fs::write("words.txt", "falcon\nsky\ncloud\nrock\n").await?;

The async versions have the same names and arguments as their standard counterparts.

let mut lines = BufReader::new(file).lines();
while let Some(line) = lines.next_line().await? {

The AsyncBufReadExt trait provides lines, and next_line returns Ok(None) at the end of the file. Operating systems offer no portable async file API, so Tokio runs file operations with spawn_blocking internally.

$ cargo run -q
22 bytes, 4 lines
FALCON SKY CLOUD ROCK FOREST

A TCP echo server

Network I/O is where async Rust shines. The program starts a server that returns each line in uppercase, and connects two clients to it.

main.rs
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};

async fn handle(stream: TcpStream) -> std::io::Result<()> {
    let (reader, mut writer) = stream.into_split();
    let mut lines = BufReader::new(reader).lines();
    while let Some(line) = lines.next_line().await? {
        writer.write_all(format!("{}\n", line.to_uppercase()).as_bytes()).await?;
    }
    Ok(())
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let addr = listener.local_addr()?;

    tokio::spawn(async move {
        loop {
            let (stream, peer) = listener.accept().await.unwrap();
            println!("server: connection from {}", peer.ip());
            tokio::spawn(handle(stream));
        }
    });

    let mut clients = Vec::new();
    for name in ["anna", "peter"] {
        clients.push(tokio::spawn(async move {
            let stream = TcpStream::connect(addr).await?;
            let (reader, mut writer) = stream.into_split();
            writer.write_all(format!("hello from {name}\n").as_bytes()).await?;
            writer.shutdown().await?;
            let mut lines = BufReader::new(reader).lines();
            let reply = lines.next_line().await?;
            Ok::<_, std::io::Error>(reply)
        }));
    }

    for c in clients {
        println!("client got {:?}", c.await.unwrap()?);
    }
    Ok(())
}

Server and clients run in one process as separate tasks.

let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;

Port 0 asks the operating system for any free port. We read the actual address back for the clients.

loop {
    let (stream, peer) = listener.accept().await.unwrap();
    println!("server: connection from {}", peer.ip());
    tokio::spawn(handle(stream));
}

The accept loop spawns a task for each connection, so the server handles clients concurrently. A thread per connection would be far more expensive with many clients.

writer.write_all(format!("hello from {name}\n").as_bytes()).await?;
writer.shutdown().await?;

The client splits the stream into a read half and a write half. Shutting down the write half tells the server that no more lines are coming.

$ cargo run -q
server: connection from 127.0.0.1
server: connection from 127.0.0.1
client got Some("HELLO FROM ANNA")
client got Some("HELLO FROM PETER")

Periodic tasks with interval

The tokio::time::interval function creates a ticker that fires at a fixed period.

main.rs
use std::time::{Duration, Instant};
use tokio::time::{MissedTickBehavior, interval};

#[tokio::main]
async fn main() {
    let start = Instant::now();
    let mut ticker = interval(Duration::from_millis(200));

    for i in 1..=4 {
        ticker.tick().await;
        println!("tick {i} at {:.1}s", start.elapsed().as_secs_f64());
    }

    ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
    tokio::time::sleep(Duration::from_millis(500)).await;
    ticker.tick().await;
    println!("late tick at {:.1}s", start.elapsed().as_secs_f64());
    ticker.tick().await;
    println!("next tick at {:.1}s", start.elapsed().as_secs_f64());
}

We wait for several ticks, then fall behind on purpose.

ticker.tick().await;

The first tick completes immediately. Unlike sleeping in a loop, the ticker keeps a fixed schedule, so the time spent between ticks does not accumulate as drift.

ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);

After the 500 ms pause, two ticks were missed. With the Skip behavior, the late tick fires at once and the next one returns to the original 200 ms grid. The default Burst behavior would fire the missed ticks in quick succession.

$ cargo run -q
tick 1 at 0.0s
tick 2 at 0.2s
tick 3 at 0.4s
tick 4 at 0.6s
late tick at 1.1s
next tick at 1.2s

Limiting concurrency

Starting too many operations at once can overload a server or exhaust file handles. A Semaphore limits how many tasks do the work at the same time.

main.rs
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio::time::sleep;

#[tokio::main]
async fn main() {
    let limit = Arc::new(Semaphore::new(2));
    let start = Instant::now();
    let mut set = JoinSet::new();

    for id in 1..=6 {
        let limit = Arc::clone(&limit);
        set.spawn(async move {
            let _permit = limit.acquire().await.unwrap();
            println!("job {id} starts at {:.1}s", start.elapsed().as_secs_f64());
            sleep(Duration::from_millis(300)).await;
        });
    }

    set.join_all().await;
    println!("all done at {:.1}s", start.elapsed().as_secs_f64());
}

Six jobs run, at most two at a time.

let _permit = limit.acquire().await.unwrap();

The semaphore has two permits. A task that cannot get one waits. The permit is returned when _permit is dropped at the end of the task. Note that the name must not be just _, which would drop the permit immediately.

set.join_all().await;

The join_all method waits for all tasks in the set. Six jobs of 300 ms in pairs take 0.9 seconds.

$ cargo run -q
job 1 starts at 0.0s
job 2 starts at 0.0s
job 3 starts at 0.3s
job 4 starts at 0.3s
job 6 starts at 0.6s
job 5 starts at 0.6s
all done at 0.9s

Errors, panics, and cancellation

A task can return an error, panic, or be aborted. The JoinHandle reports which one happened.

main.rs
use std::time::Duration;

async fn risky(n: u32) -> Result<u32, String> {
    tokio::time::sleep(Duration::from_millis(10 * n as u64)).await;
    if n == 3 {
        return Err(format!("job {n} failed"));
    }
    if n == 4 {
        panic!("job {n} panicked");
    }
    Ok(n * 10)
}

#[tokio::main]
async fn main() {
    let handles: Vec<_> = (1..=4).map(|n| tokio::spawn(risky(n))).collect();

    for (i, h) in handles.into_iter().enumerate() {
        match h.await {
            Ok(Ok(v)) => println!("job {}: {v}", i + 1),
            Ok(Err(e)) => println!("job {}: error: {e}", i + 1),
            Err(e) if e.is_panic() => println!("job {}: panicked", i + 1),
            Err(e) => println!("job {}: {e}", i + 1),
        }
    }

    let task = tokio::spawn(async {
        tokio::time::sleep(Duration::from_secs(10)).await;
    });
    task.abort();
    let err = task.await.unwrap_err();
    println!("cancelled: {}", err.is_cancelled());
}

The fourth job panics; the third returns an error.

Ok(Ok(v)) => println!("job {}: {v}", i + 1),
Ok(Err(e)) => println!("job {}: error: {e}", i + 1),
Err(e) if e.is_panic() => println!("job {}: panicked", i + 1),

The outer Result comes from the task, the inner one from our function. A panic in a spawned task does not crash the program; the runtime catches it, prints the message, and reports it through the handle.

task.abort();
let err = task.await.unwrap_err();

The abort method cancels a task at its next .await. Awaiting the handle then returns a cancelled JoinError.

$ cargo run -q
job 1: 10
job 2: 20
job 3: error: job 3 failed

thread 'tokio-rt-worker' (167716) panicked at src/main.rs:9:9:
job 4 panicked
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
job 4: panicked
cancelled: true

Source

Tokio tutorial

tokio crate documentation

Asynchronous Programming in Rust

In this article we have written asynchronous programs with Tokio. Thread-based concurrency is covered in Rust threads and Rust channels.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.