What you'll learn:
- How Rust's
Futuretrait differs from Go's goroutines and Python's asyncio- Tokio quick-start: spawning tasks,
join!, and runtime configuration- Common async pitfalls and how to fix them
- When to offload blocking work with
spawn_blocking
async fnRust's async model is fundamentally different from Go's goroutines or Python's asyncio.
Understanding three concepts is enough to get started:
Future is a lazy state machine — calling async fn doesn't execute anything;
it returns a Future that must be polled.tokio, async-std, or smol.
The standard library defines Future but provides no runtime.async fn is sugar — the compiler transforms it into a state machine that
implements Future.// A Future is just a trait:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
// async fn desugars to:
// fn fetch_data(url: &str) -> impl Future<Output = Result<Vec<u8>, Error>>
async fn fetch_data(url: &str) -> Result<Vec<u8>, reqwest::Error> {
let response = reqwest::get(url).await?; // .await yields until ready
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
use tokio::task;
#[tokio::main]
async fn main() {
// Spawn concurrent tasks (like lightweight threads):
let handle_a = task::spawn(async {
sleep(Duration::from_millis(100)).await;
"task A done"
});
let handle_b = task::spawn(async {
sleep(Duration::from_millis(50)).await;
"task B done"
});
// .await both — they run concurrently, not sequentially:
let (a, b) = tokio::join!(handle_a, handle_b);
println!("{}, {}", a.unwrap(), b.unwrap());
}
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Blocking in async | std::thread::sleep or CPU work blocks the executor | Use tokio::task::spawn_blocking or rayon |
Send bound errors | Future held across .await contains !Send type (e.g., Rc, MutexGuard) | Restructure to drop non-Send values before .await |
| Future not polled | Calling async fn without .await or spawning — nothing happens | Always .await or tokio::spawn the returned future |
Holding MutexGuard across .await | std::sync::MutexGuard is !Send; async tasks may resume on different thread | Use tokio::sync::Mutex or drop the guard before .await |
| Accidental sequential execution | let a = foo().await; let b = bar().await; runs sequentially | Use tokio::join! or tokio::spawn for concurrency |
// ❌ Blocking the async executor:
async fn bad() {
std::thread::sleep(std::time::Duration::from_secs(5)); // Blocks entire thread!
}
// ✅ Offload blocking work:
async fn good() {
tokio::task::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_secs(5)); // Runs on blocking pool
}).await.unwrap();
}
Comprehensive async coverage: For
Stream,select!, cancellation safety, structured concurrency, andtowermiddleware, see our dedicated Async Rust Training guide. This section covers just enough to read and write basic async code.
Tokio's spawn creates a new asynchronous task — similar to thread::spawn but
much lighter:
use tokio::task;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// Spawn three concurrent tasks
let h1 = task::spawn(async {
sleep(Duration::from_millis(200)).await;
"fetched user profile"
});
let h2 = task::spawn(async {
sleep(Duration::from_millis(100)).await;
"fetched order history"
});
let h3 = task::spawn(async {
sleep(Duration::from_millis(150)).await;
"fetched recommendations"
});
// Wait for all three concurrently (not sequentially!)
let (r1, r2, r3) = tokio::join!(h1, h2, h3);
println!("{}", r1.unwrap());
println!("{}", r2.unwrap());
println!("{}", r3.unwrap());
}
join! vs try_join! vs select!:
| Macro | Behavior | Use when |
|---|---|---|
join! | Waits for ALL futures | All tasks must complete |
try_join! | Waits for all, short-circuits on first Err | Tasks return Result |
select! | Returns when FIRST future completes | Timeouts, cancellation |
use tokio::time::{timeout, Duration};
async fn fetch_with_timeout() -> Result<String, Box<dyn std::error::Error>> {
let result = timeout(Duration::from_secs(5), async {
// Simulate slow network call
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, Box<dyn std::error::Error>>("data".to_string())
}).await??; // First ? unwraps Elapsed, second ? unwraps inner Result
Ok(result)
}
Send Bounds and Why Futures Must Be SendWhen you tokio::spawn a future, it may resume on a different OS thread.
This means the future must be Send. Common pitfalls:
use std::rc::Rc;
async fn not_send() {
let rc = Rc::new(42); // Rc is !Send
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", rc); // rc is held across .await — future is !Send
}
// Fix 1: Drop before .await
async fn fixed_drop() {
let data = {
let rc = Rc::new(42);
*rc // Copy the value out
}; // rc dropped here
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", data); // Just an i32, which is Send
}
// Fix 2: Use Arc instead of Rc
async fn fixed_arc() {
let arc = std::sync::Arc::new(42); // Arc is Send
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", arc); // ✅ Future is Send
}
Comprehensive async coverage: For
Stream,select!, cancellation safety, structured concurrency, andtowermiddleware, see our dedicated Async Rust Training guide. This section covers just enough to read and write basic async code.
See also: Ch 5 — Channels for synchronous channels. Ch 6 — Concurrency for OS threads vs async tasks.
Key Takeaways — Async
async fnreturns a lazyFuture— nothing runs until you.awaitor spawn it- Use
tokio::task::spawn_blockingfor CPU-heavy or blocking work inside async contexts- Don't hold
std::sync::MutexGuardacross.await— usetokio::sync::Mutexinstead- Futures must be
Sendwhen spawned — drop!Sendtypes before.awaitpoints
Write an async function fetch_all that spawns three tokio::spawn tasks, each
simulating a network call with tokio::time::sleep. Join all three with
tokio::try_join! wrapped in tokio::time::timeout(Duration::from_secs(5), ...).
Return Result<Vec<String>, ...> or an error if any task fails or the deadline
expires.
use tokio::time::{sleep, timeout, Duration};
async fn fake_fetch(name: &'static str, delay_ms: u64) -> Result<String, String> {
sleep(Duration::from_millis(delay_ms)).await;
Ok(format!("{name}: OK"))
}
async fn fetch_all() -> Result<Vec<String>, Box<dyn std::error::Error>> {
let deadline = Duration::from_secs(5);
let (a, b, c) = timeout(deadline, async {
let h1 = tokio::spawn(fake_fetch("svc-a", 100));
let h2 = tokio::spawn(fake_fetch("svc-b", 200));
let h3 = tokio::spawn(fake_fetch("svc-c", 150));
tokio::try_join!(h1, h2, h3)
})
.await??;
Ok(vec![a?, b?, c?])
}
#[tokio::main]
async fn main() {
let results = fetch_all().await.unwrap();
for r in &results {
println!("{r}");
}
}