Your Rust async code is a “liar”—and I’m going to strip its state machine down to the bones.

Have you ever written an async function, happily called it, and then… the program just ended with nothing happening?

You stare at the screen: Where did my code go? Where’s my println!? Did I just run emptiness?

Don’t panic. You’re not alone. Welcome to the world of Rust async, where the first rule is: what you see isn’t necessarily what’s happening.

async/await is Rust’s concurrency ace. It lets you write non-blocking, high-performance programs with code that looks synchronous, handling thousands of connections with ease. But behind the elegant sugar lies a precise mechanism that confuses many newcomers.

Today, we’ll go into Rust’s engine room and make Future, .poll(), and the state machine crystal clear.

Act 1: The big misunderstanding — async fn does not run immediately

Start with the most basic “trick”. You write:

async fn say_hello() {
    println!("Hello, from the future!");
}

Then you call it:

fn main() {
    say_hello();
    // Program exits; nothing is printed
}

Why? Because calling an async function does not execute its body. It returns a value called a Future.

What is a Future?

Think of it as a “voucher” redeemable in the future.

The voucher itself does nothing. Holding it won’t magically produce a prize. say_hello() returns such a voucher that says: “I promise to print a line at some point in the future.”

Your main gets the voucher and throws it away. The program ends.

Remember: an async function returns a plan, not a result.

Act 2: Ignition — the executor and .await

How do we redeem the voucher?

You need an executor, like tokio or async-std.

The executor is like a kitchen manager with infinite energy.

Your Future (the voucher) is a set of recipes.

When you hand recipes to the manager, work actually begins. In code, this handoff is often handled by the #[tokio::main] macro or a manual block_on.

#[tokio::main]
async fn main() {
    say_hello().await; // This time, it prints!
}

Notice the magical .await. It’s the ignition switch.

What does .await do? It tells the kitchen manager (executor):

“Hey, I’m starting say_hello. If it needs time (like preheating an oven), don’t just wait there. Go do other things. When I’m ready, I’ll notify you.”

This is the core of async/await: non-blocking. .await is the point where a task can pause and yield control.

Act 3: Under the hood — Future is a state machine

Now the final reveal. When the compiler sees your async fn, it secretly transforms it into a struct that implements the Future trait.

That struct is a small state machine.

Consider a slightly more complex example:

async fn cook_meal() {
    println!("Start washing vegetables...");
    let ingredients = chop_vegetables().await; // first suspension point
    println!("Chopping done, start frying...");
    let dish = fry_in_pan(ingredients).await; // second suspension point
    println!("Serve!");
}

The compiler turns it into a state machine roughly like this:

  • State 0: initial
  • State 1: chopping (waiting for chop_vegetables)
  • State 2: frying (waiting for fry_in_pan)
  • State 3: done

The executor repeatedly asks the state machine one question via .poll(): “Are you done yet?”

.poll() can return:

  1. Poll::Ready(result): Done! Here’s the result.
  2. Poll::Pending: Not ready. I’m waiting for something (network, timer). Go do other work. I’ll use a Waker to notify you when to come back.

The flow looks like this:

  1. Manager polls cook_meal.
  2. Code runs println!("Start washing vegetables...").
  3. Hits chop_vegetables().await, manager starts polling the chopping task.
  4. Chopping takes time → returns Pending. Manager shelves cook_meal and handles other recipes.
  5. Later, an external event (I/O, timer) fires and uses the Waker to wake cook_meal.
  6. Manager polls again; it resumes from where it paused and gets the chopped ingredients.
  7. Hits fry_in_pan().await; repeat.
  8. Eventually, .poll() returns Ready; task completes.

No magic. Just a precise state machine driven by poll and Waker. Your async code runs in small pieces, cooperatively scheduled.

Recap: Putting the elephant in the fridge

ConceptPlain analogyPurpose
async fna recipeDefines a task to run later; doesn’t run itself.
Futurevoucher + progress trackerThe state machine that records where execution is.
Executor (e.g., Tokio)tireless kitchen managerSchedules/advances many futures to keep the kitchen busy.
.await“wait, but don’t idle”Suspension point that yields control to the executor.
.poll()“Done yet?”Advances the state machine one step.
Wakertimer/alarmNotifies the executor to poll again when ready.

Next time your async code behaves counterintuitively, remember: it’s not lying. It’s faithfully following the kitchen workflow. Your job is to write good recipes and place .await at the right spots.


Follow Rexai Programming on WeChat to unlock more dev magic.