TL;DR:
async/awaitin Rust lets you write non-blocking I/O code that looks sequential. Anasync fnreturns aFuture; a value representing a computation that hasn't run yet. Calling.awaiton aFuturesuspends the current task until it completes, without blocking the OS thread. Unlike Node.js or Go, Rust has no built-in async runtime; you need an executor like Tokio or async-std. This zero-cost design means async Rust compiles to state machines with no heap allocation overhead per.await.
What Is async/await in Rust?
async/await is Rust's syntax for writing asynchronous code; functions that can pause execution while waiting for I/O, timers, or other futures, without blocking the OS thread they run on.
// An async function returns a Future<Output = String>
async fn fetch_username(id: u64) -> String {
// .await suspends here until the future completes
let response = http_get(format!("/users/{id}")).await;
response.body
}The async keyword transforms a function into one that returns a Future. The Future only runs when polled by an executor; it does nothing by itself. .await is how you poll a future from within another async context.
How Does Rust's Async Model Work?
Rust async functions are compiled into state machines; each .await point becomes a state transition. No heap allocation is required per suspension point, making async Rust extremely low-overhead.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// These two tasks run concurrently on the same thread
let task1 = sleep(Duration::from_millis(100));
let task2 = sleep(Duration::from_millis(100));
// tokio::join! polls both futures simultaneously
tokio::join!(task1, task2);
// Total time: ~100ms, not 200ms
}When a future .awaits something that isn't ready yet, it yields control back to the executor, which can run other tasks. When the awaited value is ready, the executor resumes the task from where it left off.
What Is a Runtime and Why Does Rust Need One?
Rust's standard library defines the Future trait but provides no executor. You need a runtime; typically Tokio; to actually drive futures to completion.
// Without a runtime attribute, this does nothing:
async fn main() {
// This future is created but never polled
}
// With Tokio's macro, main() is driven by Tokio's executor:
#[tokio::main]
async fn main() {
println!("Running inside Tokio");
do_async_work().await;
}The most common runtimes:
| Runtime | Use case |
|---|---|
| Tokio | Default choice; full-featured, used by Axum, Reqwest, SQLx |
| async-std | Mirrors std API, simpler mental model |
| smol | Minimal, embeddable |
| Embassy | Embedded/bare-metal (no OS) |
How Do You Spawn Concurrent Tasks?
Use tokio::spawn to run futures concurrently as independent tasks, or tokio::join! to drive multiple futures in the same task.
use tokio::task;
#[tokio::main]
async fn main() {
// spawn; runs in background, can run on another thread
let handle = task::spawn(async {
expensive_computation().await
});
// do other work while computation runs...
other_work().await;
// wait for the spawned task
let result = handle.await.unwrap();
// join!; concurrent within same task
let (a, b) = tokio::join!(fetch_a(), fetch_b());
}tokio::spawn returns a JoinHandle; similar to a thread handle. The spawned task runs concurrently and may execute on a different worker thread in Tokio's thread pool.
How Is Rust Async Different From Other Languages?
Rust async is zero-cost and explicit; no garbage collector, no hidden runtime, no green threads. Every design decision prioritizes performance and control.
| Feature | Rust | Node.js | Go | Python |
|---|---|---|---|---|
| Syntax | async/.await | async/await | Goroutines (go func()) | async/await |
| Runtime | External (Tokio) | Built-in (libuv) | Built-in scheduler | Built-in (asyncio) |
| Memory model | Ownership + no GC | GC | GC | GC |
| Heap alloc per task | Optional (Box) | Yes | Yes (goroutine stack) | Yes |
| Compile-time errors | ✅ | ❌ | ❌ | ❌ |
Go uses goroutines; lightweight threads managed by the runtime; which are simpler but consume more memory per task. Rust's approach is more complex but has predictable, minimal overhead.
Frequently Asked Questions
Use async when your bottleneck is I/O (network, disk, database): many concurrent connections with little CPU work. Use OS threads when your bottleneck is CPU; parallel computation. For mixed workloads, use tokio::task::spawn_blocking to offload CPU-heavy work from the async executor.
tokio::spawn requires the future to be Send; meaning it can be moved to another thread. If your future holds a non-Send type (like Rc<T> or a raw pointer) across an .await, the compiler rejects it. Fix: use Arc<T> instead of Rc<T>, or use tokio::task::LocalSet for thread-local tasks.
async move { } creates a future that takes ownership of captured variables (like a move closure). Without move, the future borrows variables from its environment, which can cause lifetime errors. Use async move when spawning tasks that need owned data.
As of Rust 1.75, async fn in traits is stable for most use cases. For object-safe trait objects (dyn Trait), you may still need the async-trait crate to box the returned futures.
Sources
- The Rust Async Book: Official async Rust guide
- Tokio Tutorial: Hands-on async with Tokio
- std::future::Future: The core trait
Related Glossary Terms
- Tokio: The async runtime that drives most Rust async code
- Trait:
Futureis a trait; async functions returnimpl Future - Ownership: Ownership rules still apply across
.awaitpoints - Result: Async functions commonly return
Result<T, E> - Spawn: Async tasks are typically launched with
tokio::spawn - async-trait: Async trait methods are a common extension point around Rust's async model
Keep Reading
- Rust Ownership and Borrowing Explained: async builds directly on Rust's ownership model
- Rust on AWS Lambda: async/await in production serverless handlers
- Building AI Agents in Rust: async Rust powering concurrent AI workflows
