async/await is Rust's model for efficiently handling multiple asynchronous tasks (I/O, timers, network calls) within a single thread. The standard library itself does not have a runtime, so to actually run asynchronous code, a runtime like tokio is necessary. In this edition, we summarize the meaning of async fn, what .await does, the pattern for achieving concurrency with tokio::join!, and asynchronous channels all at once.
Learning Objectives
- Understand that
async fndoes not execute immediately and returns aFuture. - Know the roles of
.awaitand the runtime. - Write an asynchronous main using the
#[tokio::main]macro. - Run multiple tasks concurrently using
tokio::join!. - Use asynchronous message channels with
tokio::sync::mpsc.
Key Concepts
1) async fn and Future
async fn greet(name: &str) {
println!("Hello, {name}");
}
Even if you call this function, the body does not execute immediately. Instead, it returns a value that bundles the "task" called Future.
let fut = greet("Rust"); //Nothing has happened yet
fut.await; //At this time, the runtime executes the body
2) What .await does
- It pauses at that spot until
Futurefinishes. - "Pausing" does not mean occupying the thread while waiting — the runtime temporarily switches to another Future on the same thread and comes back.
3) Runtime — tokio
The Rust standard library does not have an asynchronous runtime. The most popular runtime is tokio.
Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
#[tokio::main] macro enables asynchronous main.
#[tokio::main]
async fn main() {
/* ... */
}
4) tokio::join! — Concurrency
Runs multiple Futures simultaneously and waits for all to finish.
let (a, b) = tokio::join!(task1(), task2());
Compared to sequential await, the only difference is when they are combined — execution is simultaneous.
5) tokio::task::spawn — Background task
Asynchronous version of thread::spawn. Spawns a new task and returns a JoinHandle. (Much lighter than a thread — tens of thousands in one process are fine.)
6) tokio::sync::mpsc
Asynchronous channel. The difference from std::sync::mpsc in Episode 21 is only that send().await and recv().await are possible.
Examples
Example 1 — ex01_simple.rs: First async fn
//Example 22-1: First async fn and tokio::main macro
//
//`async fn` does not run immediately when called, but returns a `Future`.
//`.await` is the trigger that actually drives that Future forward.
//The `#[tokio::main]` macro automatically starts the tokio runtime.
use tokio::time::{sleep, Duration};
async fn greet(name: &str) {
println!("Hello, {name}!");
sleep(Duration::from_millis(50)).await;
println!("Farewell to {name}.");
}
#[tokio::main]
async fn main() {
println!("Program start");
greet("Rust").await;
greet("Tokio").await;
println!("Program end");
}
Example 2 — ex02_join.rs: Concurrent execution
//Example 2 of Chapter 22: Running multiple asynchronous tasks concurrently with tokio::join!
//
//If two tasks are executed sequentially, it takes 100ms + 100ms = 200ms,
//but if executed concurrently with `join!`, it finishes in about 100ms.
use std::time::Instant;
use tokio::time::{sleep, Duration};
async fn task(id: u32, ms: u64) -> u32 {
println!("[{id}] start");
sleep(Duration::from_millis(ms)).await;
println!("[{id}] end");
id
}
#[tokio::main]
async fn main() {
let start = Instant::now();
//Awaiting two tasks concurrently — both Futures progress at the same time
let (a, b) = tokio::join!(task(1, 100), task(2, 100));
println!("Result: {a}, {b}");
println!("Total elapsed: {:?}", start.elapsed());
}
Example 3 — ex03_channel.rs: tokio mpsc
//Example 3 of Episode 22: Asynchronous message channel with tokio::sync::mpsc
//
//Similar to std::sync::mpsc from Episode 21, but sending and receiving can `.await`
//Works naturally on an async runtime.
use tokio::sync::mpsc;
use tokio::task;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(8); //Buffer 8
//Producer — spawned as a separate async task
let producer = task::spawn(async move {
for word in ["hello", "async", "rust"] {
tx.send(word.to_string()).await.expect("Receiver closed");
}
//tx is dropped here → rx also becomes aware of the termination
});
//Consumer — receive in the main task
while let Some(msg) = rx.recv().await {
println!("Received: {msg}");
}
producer.await.expect("Producer task panics");
println!("Completed");
}
Common Mistakes
Q. I called async fn but nothing happens.
A. The return value of async fn is a Future that bundles the "things to do." You need to attach .await or hand it to tokio::spawn(...) at runtime for it to actually execute.
Q. Difference between tokio::join! and the two .await?
A. Two sequential .await executions are sequential — the next starts only after the previous task finishes. join! runs the two tasks simultaneously. The more tasks that wait on external operations like I/O, the bigger the difference.
Q. It seems like a tokio::spawn task never finishes.
A. When main ends, the tokio runtime also terminates, forcibly stopping any spawned tasks. If you want to wait until they finish, explicitly join them with task.await.
Q. Which one between std::sync::mpsc and tokio::sync::mpsc?
A. If sending and receiving occurs in an async context (inside async fn), use tokio::sync::mpsc. For regular thread-to-thread channels, use std::sync::mpsc. Using a std channel in an async context can block threads and interfere with other tasks.
Summary
async fnreturnsFuture, and.awaittriggers the progress.- An asynchronous runtime is needed — the most common choice is tokio.
- Multiple tasks at once with
tokio::join!, background tasks withtokio::spawn. - In async contexts, use async channels like
tokio::sync::mpsc.
Try it yourself
Solve the assignment below. The reference solution is shown below.