Rust · lesson 21 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Async/Await in Rust
Learn Rust async/await with Tokio: lazy futures, join!, spawn, select!, timeouts, streams, and an echo server, each with real cargo run output.
By SkillByExample · Updated
The Basics: async and await
Async programming lets a single thread handle thousands of concurrent operations by suspending tasks that are waiting for I/O instead of blocking the thread. In Rust, async fn marks a function as asynchronous — calling it does not run the body immediately, it returns a Future. The body only runs when something drives that future to completion, typically a runtime like Tokio. This model gives you concurrency with near-zero overhead compared to spawning threads.
use tokio::time::{sleep, Duration};
async fn fetch_data(id: u32) -> String {
// .await suspends this task without blocking the thread
// Other tasks can run while this sleep is in progress
sleep(Duration::from_millis(100)).await;
format!("data for id={}", id)
}
#[tokio::main] // sets up the Tokio runtime and runs main as an async task
async fn main() {
let future = fetch_data(42); // nothing runs yet — this only builds the future
println!("future created");
let result = future.await; // now the body runs
println!("{}", result);
}
Add to Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
$ cargo run -q
future created
data for id=42
future created prints first even though fetch_data(42) was called on the line above it: calling an async fn only builds a state machine. The 100 ms sleep and the format! happen during .await.
The Future Trait
Understanding Future demystifies what async/await actually generates. The compiler transforms an async fn body into a state machine that implements Future. Each .await point becomes a state — the state machine records where it was and yields control back to the runtime. The runtime polls the future repeatedly, advancing it state by state until it completes.
// Simplified definition from std::future
pub trait Future {
type Output;
// poll() is called by the runtime — Ready means done, Pending means "call me again later"
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // computation is complete, here is the result
Pending, // not done yet, runtime will call poll() again when ready
}
You rarely implement Future manually — async fn generates the implementation for you. The Pin wrapper ensures the state machine’s internal self-references remain valid if the runtime moves it in memory.
Running Multiple Futures Concurrently
tokio::join! — Concurrent, Wait for All
Running futures sequentially with .await wastes time — each operation waits for the previous to finish. tokio::join! runs all the given futures concurrently within a single task, polling each one in turn. Total elapsed time is the duration of the longest operation, not the sum.
use tokio::time::{sleep, Duration, Instant};
async fn task(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{} done after {}ms", name, ms)
}
#[tokio::main]
async fn main() {
let start = Instant::now();
// All three tasks run concurrently — total time ~300ms, not 600ms
let (a, b, c) = tokio::join!(
task("A", 300),
task("B", 200),
task("C", 100),
);
println!("{}", a);
println!("{}", b);
println!("{}", c);
println!("total: {:.1}s", start.elapsed().as_secs_f64());
}
$ cargo run -q
A done after 300ms
B done after 200ms
C done after 100ms
total: 0.3s
C finished first, but join! returns the results in argument order. The total is the longest sleep, not the 0.6 s sum — awaiting the three calls one after another would take the full 0.6 s.
tokio::spawn — Independent Tasks
tokio::spawn submits a future to the Tokio thread pool as an independent task. Unlike join!, which polls futures within the same task, spawn can run on a different thread. Use it when a task is long-running, CPU-intensive, or when you need to fire-and-forget without waiting for the result immediately.
use tokio::task;
#[tokio::main]
async fn main() {
// Each spawn returns a JoinHandle — await it later to get the result
let h1 = task::spawn(async {
42
});
let h2 = task::spawn(async {
"hello from task"
});
// join! on the handles waits for both independent tasks to complete
let (r1, r2) = tokio::join!(h1, h2);
println!("{}", r1.unwrap());
println!("{}", r2.unwrap());
}
$ cargo run -q
42
hello from task
Awaiting a JoinHandle gives a Result: Err if the task panicked or was cancelled — hence the unwrap()s.
tokio::select! — First to Finish Wins
select! polls multiple futures simultaneously and proceeds with whichever completes first, cancelling the others. This is the building block for timeouts, cancellation, and event loops where you want to react to whichever signal arrives next.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let mut interval = tokio::time::interval(Duration::from_millis(200));
let deadline = sleep(Duration::from_millis(550));
tokio::pin!(deadline);
let mut ticks = 0;
loop {
tokio::select! {
_ = interval.tick() => {
ticks += 1;
println!("tick {}", ticks);
}
// When the deadline future completes, this branch wins
_ = &mut deadline => {
println!("deadline reached after {} ticks", ticks);
break;
}
}
}
}
$ cargo run -q
tick 1
tick 2
tick 3
deadline reached after 3 ticks
A Tokio interval fires its first tick immediately, then every 200 ms — at 0, 200 and 400 ms — so three ticks land before the 550 ms deadline. tokio::pin! is needed because select! polls &mut deadline on every loop iteration without consuming it.
Error Handling in Async Code
Async functions work seamlessly with Result and the ? operator. The rules are the same as synchronous code — ? propagates errors up to the caller, and the return type must be Result. The only difference is that you .await async operations before applying ?.
use std::io;
use tokio::fs;
async fn read_config(path: &str) -> Result<String, io::Error> {
// .await the async operation, then ? to propagate any error
let content = fs::read_to_string(path).await?;
Ok(content)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
match read_config("config.toml").await {
Ok(content) => println!("config: {} bytes", content.len()),
Err(e) => eprintln!("failed to read config: {}", e),
}
Ok(())
}
Run in a directory without config.toml:
$ cargo run -q
failed to read config: No such file or directory (os error 2)
HTTP Client with reqwest
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
futures = "0.3"
reqwest is an async HTTP client built on Tokio. Fetching multiple resources concurrently with join_all shows the real benefit of async — the requests all fly out simultaneously and results are collected when all have responded, far faster than fetching sequentially.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Post {
id: u32,
title: String,
body: String,
}
async fn fetch_post(id: u32) -> Result<Post, reqwest::Error> {
let url = format!("https://jsonplaceholder.typicode.com/posts/{}", id);
reqwest::get(&url).await?.json::<Post>().await
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// All three requests are in-flight at the same time
let futures = (1..=3).map(|id| fetch_post(id));
let posts = futures::future::join_all(futures).await;
for post in posts {
match post {
Ok(p) => println!("[{}] {} ({} chars of body)", p.id, p.title, p.body.len()),
Err(e) => eprintln!("error: {}", e),
}
}
Ok(())
}
This makes real HTTP requests, so it needs network access:
$ cargo run -q
[1] sunt aut facere repellat provident occaecati excepturi optio reprehenderit (158 chars of body)
[2] qui est esse (206 chars of body)
[3] ea molestias quasi exercitationem repellat qui ipsa sit aut (164 chars of body)
join_all comes from the futures crate (hence the extra dependency) and returns results in input order. rustls-tls keeps the build free of a system OpenSSL dependency.
Async TCP Server
An async TCP server handles every client connection as an independent task. When a client connection is accepted, tokio::spawn hands it off to a new task so the accept loop can immediately go back to waiting for the next connection. This lets a single-threaded runtime handle thousands of simultaneous clients efficiently.
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn handle_client(mut stream: TcpStream) {
let mut buf = vec![0u8; 1024];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) => return, // client disconnected cleanly
Ok(n) => n,
Err(e) => { eprintln!("read error: {}", e); return; }
};
// Echo the data back to the client
if let Err(e) = stream.write_all(&buf[..n]).await {
eprintln!("write error: {}", e);
return;
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Port 0 asks the OS for any free port
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
// The accept loop runs as its own task, forever
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
// Each client runs in its own independent task
tokio::spawn(handle_client(stream));
}
});
// Two clients talk to the server concurrently
let clients = ["hello", "world"].map(|msg| tokio::spawn(async move {
let mut conn = TcpStream::connect(addr).await.unwrap();
conn.write_all(msg.as_bytes()).await.unwrap();
let mut buf = vec![0u8; msg.len()];
conn.read_exact(&mut buf).await.unwrap();
String::from_utf8(buf).unwrap()
}));
for c in clients {
println!("echoed: {}", c.await?);
}
Ok(())
}
$ cargo run -q
echoed: hello
echoed: world
In a real server main would stop after spawning the accept loop and never return; here two client tasks connect, send, and read the echo, so the program can finish. When main returns the runtime shuts down and cancels the still-running accept loop.
Async Streams
Streams are the async equivalent of iterators — they produce values one at a time, and each next() call is an async operation that may suspend until the next value is ready. Use them for paginated API responses, database result sets, or any data source that trickles in over time.
[dependencies]
tokio-stream = "0.1"
use tokio_stream::{self as stream, StreamExt};
#[tokio::main]
async fn main() {
let mut s = stream::iter(vec![1, 2, 3, 4, 5]);
// while let drives the stream, awaiting each item
while let Some(n) = s.next().await {
println!("{}", n);
}
// Streams support familiar adapter-style operations
let sum: i32 = stream::iter(1..=100)
.filter(|x| x % 2 == 0)
.fold(0, |acc, x| acc + x)
.await;
println!("sum of even 1..=100: {}", sum);
}
$ cargo run -q
1
2
3
4
5
sum of even 1..=100: 2550
tokio_stream::StreamExt’s filter and fold take ordinary closures; only the final .await is asynchronous. (The futures crate has its own StreamExt whose closures return futures — |x| async move { ... } — and mixing up the two traits is a common compile error.)
Timeouts
Every async operation that could stall needs a timeout in production code. tokio::time::timeout wraps any future and cancels it if it has not completed within the deadline. It returns Ok(result) if the future finished in time, or Err(Elapsed) if it timed out — no threads are blocked in either case.
use tokio::time::{timeout, Duration};
async fn slow_operation() -> String {
tokio::time::sleep(Duration::from_secs(5)).await;
"done".to_string()
}
#[tokio::main]
async fn main() {
// The future is cancelled cleanly if it does not complete within 500ms
match timeout(Duration::from_millis(500), slow_operation()).await {
Ok(result) => println!("got: {}", result),
Err(_) => println!("operation timed out"),
}
}
$ cargo run -q
operation timed out
The program exits after about half a second, not five: when timeout gives up it drops slow_operation’s future, and a dropped future simply never runs again.
Key Concepts Summary
| Concept | Description |
|---|---|
async fn | Returns a Future; body is lazy — does not run until polled |
.await | Suspends current task until future resolves; thread is not blocked |
tokio::main | Starts the Tokio runtime and runs the async main function |
tokio::spawn | Spawns an independent async task on the thread pool |
tokio::join! | Runs futures concurrently in the same task, waits for all |
tokio::select! | Runs futures concurrently, proceeds on the first to complete |
tokio::time::timeout | Cancels a future if it does not complete within a deadline |
Stream | Async version of Iterator — values arrive over time |
Pin<&mut Self> | Prevents self-referential futures from being moved in memory |
Practice
-
In the
join!example, replacetokio::join!with three sequential awaits:let a = task("A", 300).await;and so on. What doestotalprint now?Solution
total: 0.6s. Each.awaitfinishes before the next future is even created, so the sleeps add up. -
Call
fetch_data(1);as a statement without.await. What does the compiler say?Solution
warning: unused implementer of `Future` that must be used ... = note: futures do nothing unless you `.await` or poll themIt is only a warning — the program compiles and silently skips the work.
-
Wrap the
slow_operationcall with a 6-second timeout instead of 500 ms. What prints, and how long does it take?Solution
got: done, after about 5 seconds — the operation finishes before the deadline, sotimeoutreturnsOk.