Skip to main content

Understanding Async State Machines

Learn what async state machines are, why they exist, and how they power async Rust.

What is an Async State Machine?

An async state machine is a data structure that represents the execution state of an asynchronous operation. In Rust, every async fn is automatically transformed by the compiler into a state machine.

From Code to State Machine

When you write this simple async function:

async fn fetch_user(id: u64) -> User {
let profile = fetch_profile(id).await;
let posts = fetch_posts(id).await;
User { profile, posts }
}

The Rust compiler transforms it into a state machine (conceptually):

enum FetchUserState {
Start { id: u64 },
WaitingForProfile { id: u64, future: FetchProfileFuture },
WaitingForPosts { profile: Profile, future: FetchPostsFuture },
Done { result: User },
}

Each .await point creates a new state where execution can pause and resume.

Why Do We Need State Machines?

The Problem: Blocking Threads

Traditional synchronous code blocks entire threads:

// Synchronous - thread sits idle waiting
fn fetch_user_sync(id: u64) -> User {
let profile = http_get_blocking("/profile"); // Thread blocked!
let posts = http_get_blocking("/posts"); // Blocked again!
User { profile, posts }
}

With 10,000 concurrent users:

  • Need 10,000 threads
  • Each thread: ~2MB stack
  • Total: 20GB RAM just for stacks! 😱

The Solution: Async State Machines

// Async - yields control when waiting
async fn fetch_user(id: u64) -> User {
let profile = fetch_profile(id).await; // Yields, doesn't block
let posts = fetch_posts(id).await; // Yields, doesn't block
User { profile, posts }
}

With 10,000 concurrent users:

  • Need 4-8 threads
  • Each future: ~2KB
  • Total: 20MB RAM for all tasks! ✨

How State Machines Work

State Transitions

┌─────────────┐
│ Start │
(id: 42)
└──────┬──────┘
│ fetch_profile().await

┌─────────────┐
│ WaitProfile │ ◄──┐
(waiting) │ │ Poll::Pending
└──────┬──────┘ │ (yield control)
│ ready? │
├────────────┘
│ Poll::Ready(profile)

┌─────────────┐
│ WaitPosts │ ◄──┐
(waiting) │ │ Poll::Pending
└──────┬──────┘ │
│ ready? │
├────────────┘
│ Poll::Ready(posts)

┌─────────────┐
│ Done │
(result)
└─────────────┘

The Poll Mechanism

// Simplified Future trait
trait Future {
type Output;

fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}

enum Poll<T> {
Ready(T), // Done! Here's the result
Pending, // Not done yet, wake me later
}

When you .await:

  1. Future is polled
  2. If Pending: control returns to executor
  3. Executor runs other futures
  4. When ready, executor polls again
  5. If Ready(value): continue execution

Visual Example: Web Request

async fn handle_request(req: Request) -> Response {
let user = db.get_user(req.user_id).await;
let perms = check_permissions(user).await;
let data = fetch_data(perms).await;
Response::ok(data)
}

Timeline of execution:

Time →
Task 1: ████░░░░░░░░████░░░░████████
↑ ↑ ↑ ↑ ↑
│ │ │ │ └─ fetch_data
│ │ │ └─ check_permissions
│ │ └─ waiting (yielded)
│ └─ db.get_user
└─ start

Task 2: ████████░░░░████████░░░░
(runs while Task 1 waits!)

Key: ████ = running ░░░░ = waiting/yielded

Memory Layout

State Machine Structure

// Your async function:
async fn example() {
sleep(1.secs()).await;
println!("Done!");
}

// Becomes approximately:
struct ExampleFuture {
state: ExampleState,
}

enum ExampleState {
Start,
Sleeping(Sleep),
Done,
}

Memory efficiency:

  • Size = largest variant
  • Stack allocated (usually)
  • No heap overhead!
Stack Layout:
┌─────────────────────┐
│ ExampleFuture │
│ ┌─────────────────┐ │
│ │ state: │ │
│ │ Sleeping { │ │ ← Current state
│ │ sleep: Sleep │ │
│ │ } │ │
│ └─────────────────┘ │
└─────────────────────┘
Total: ~32 bytes

Zero-Cost Abstraction

Async/await is truly zero-cost:

  1. Compile time: Everything resolved to state machine
  2. No runtime penalty: No vtables, no dynamic dispatch
  3. LLVM optimized: Aggressive inlining and optimization
  4. Predictable: No garbage collection pauses

Use Cases

1. Web Servers (Primary Use Case)

#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user));

Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}

async fn get_user(Path(id): Path<u64>) -> Json<User> {
let user = db.fetch_user(id).await;
Json(user)
}

Why async? Handle thousands of concurrent requests with minimal threads.

2. Database Operations

async fn get_user_data(id: u64) -> UserData {
let user = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE id = ?"
)
.bind(id)
.fetch_one(&pool)
.await?;

user
}

Why async? Don't block while waiting for database I/O.

3. Concurrent I/O

async fn load_dashboard(user: User) -> Dashboard {
// All three requests happen concurrently!
let (profile, stats, notifications) = tokio::join!(
fetch_profile(user.id),
fetch_stats(user.id),
fetch_notifications(user.id)
);

Dashboard { profile, stats, notifications }
}

Why async? Parallel I/O without spawning threads.

4. Real-Time Communication

async fn handle_websocket(socket: WebSocket) {
while let Some(msg) = socket.recv().await {
let response = process(msg).await;
socket.send(response).await;
}
}

Why async? Efficient handling of many simultaneous connections.

When NOT to Use Async

CPU-bound work: Use threads or spawn_blockingSimple scripts: Overhead not worth it ❌ Single I/O operation: Just use sync ❌ Pure computation: No waiting involved

// ❌ DON'T DO THIS
async fn add(a: i32, b: i32) -> i32 {
a + b // No I/O! Async overhead for nothing!
}

// ✅ DO THIS
fn add(a: i32, b: i32) -> i32 {
a + b
}

Advantages

1. Resource Efficiency

Approach10K Concurrent OpsMemory Usage
Threads10,000 threads~20 GB
Async4-8 threads~20 MB

2. Scalability

Handle 100K+ concurrent connections on commodity hardware.

3. Composability

Easy to combine async operations:

// Sequential
let a = fetch_a().await;
let b = fetch_b().await;

// Concurrent
let (a, b) = join!(fetch_a(), fetch_b());

// Racing
let first = select! {
a = fetch_a() => a,
b = fetch_b() => b,
};

4. Backpressure Handling

stream::iter(items)
.map(|item| process(item))
.buffer_unordered(10) // Max 10 concurrent
.collect()
.await;

Trade-offs

Complexity

Async code is more complex:

  • Must understand futures, Pin, Send/Sync
  • "Function coloring" (async spreads through codebase)
  • Learning curve

Function Coloring Problem

Once one function is async, everything that calls it must be async too. This "colors" your entire codebase:

// ❌ Can't call async from sync!
fn sync_handler() {
let user = fetch_user(42).await; // ERROR: can't use .await in non-async
}

// ✅ Must make caller async
async fn async_handler() {
let user = fetch_user(42).await; // OK
}

How async-inspect helps:

Use the CLI to visualize which parts of your codebase are async:

$ async-inspect analyze --show-call-graph

Call Graph (async functions):
┌─────────────────────────────────┐
│ main (async)
│ ├─ handle_request (async)
│ │ ├─ fetch_user (async) │ ← async spreads up
│ │ │ └─ db_query (async)
│ │ └─ render_template (sync) │ ← can mix sync calls
│ └─ shutdown (async)
└─────────────────────────────────┘

The call graph shows:

  • Red functions: Must be async (call async code)
  • Green functions: Can be sync (no async calls)
  • Yellow functions: Performance bottlenecks

This helps you understand:

  1. Where async is necessary vs just convenient
  2. Async boundaries in your codebase
  3. Opportunities to break async chains with spawn_blocking

Debugging Difficulty

Async stack traces are opaque:

thread 'tokio-runtime-worker' panicked
at <impl Future for ...>::poll
??? (state machine internals)

This is why async-inspect exists! 🔍

We give you visibility into:

  • Current state of each task
  • Where tasks are blocked
  • State transition history
  • Task relationships

Best Practices

1. Don't Block the Executor

// ❌ BAD
async fn bad() {
std::thread::sleep(Duration::from_secs(1)); // Blocks executor!
}

// ✅ GOOD
async fn good() {
tokio::time::sleep(Duration::from_secs(1)).await; // Yields
}

2. Use Timeouts

use tokio::time::timeout;

async fn with_timeout() -> Result<Data> {
timeout(Duration::from_secs(5), fetch_data())
.await
.map_err(|_| Error::Timeout)?
}

3. Limit Concurrency

stream::iter(tasks)
.map(|t| process(t))
.buffer_unordered(100) // Only 100 concurrent
.collect()
.await

4. Instrument Your Code

#[async_inspect::trace]  // ← Add this!
async fn important_function() {
// Now you can debug it with async-inspect!
}

Common Pitfalls

1. Forgetting .await

// ❌ Returns future, doesn't execute!
fetch_user(42);

// ✅ Actually executes
fetch_user(42).await;

2. Sequential Instead of Concurrent

// ❌ SLOW - waits for each one
let a = fetch_a().await;
let b = fetch_b().await;

// ✅ FAST - both run at once
let (a, b) = join!(fetch_a(), fetch_b());

3. Blocking in Async Context

// ❌ Blocks executor thread
async fn bad() {
expensive_cpu_work(); // Blocks everyone!
}

// ✅ Runs on thread pool
async fn good() {
spawn_blocking(|| expensive_cpu_work()).await
}

How async-inspect Helps

Visibility Into State Machines

Instead of this:

??? mysterious hang ???

You see:

┌─────────────────────────────────┐
│ Task #42: fetch_user │
│ State: WaitingForPosts (2.3s)
│ Location: src/api.rs:156 │
│ │
│ Stuck at: fetch_posts().await │
│ Reason: HTTP timeout
└─────────────────────────────────┘

Features

  • 📊 Real-time monitoring - See all task states
  • 📈 Timeline view - Visualize execution flow
  • 🔗 Relationship graph - Understand dependencies
  • 💀 Deadlock detection - Find circular waits
  • Performance analysis - Identify bottlenecks

Further Reading


Next: Installation Guide