Skip to Content
Course content

147: async fn Desugaring

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've always found that the word "sugar" in programming is a bit of a trap. When we say something is "syntactic sugar," it sounds like it's just a cosmetic change—like a shortcut for your fingers. But with async fn in Rust, the desugaring process is actually a massive architectural shift. It's not just a shortcut; it's a complete transformation of your function into a state machine.

Let's actually look at this in action. I'll start with something simple: a function that pretends to fetch a user's name from a database.

The No-Op Surprise

async fn get_username(id: u32) -> String {
    // Imagine a network call here
    format!("User-{}", id)
}

fn main() {
    let name = get_username(42);
    println!("Got: {}", name);
}

If you're new to async, your first instinct is that this should print "Got: User-42". But if you try to run this, the compiler will scream at you. It'll tell you that you're trying to print a type that doesn't implement Display. Specifically, it'll tell you that get_username returns an impl Future<Output = String>, not a String.

This is our first clue. I wrote -> String, but the compiler sees -> impl Future. The async keyword has fundamentally changed the return type of the function.

Tricking the Compiler for Type Info

I want to know exactly what the compiler thinks this "Future" is. Since the type is anonymous (the compiler generates a unique name for it), I can't just type it out. A trick I always use is to intentionally cause a type mismatch to force the compiler to tell me what it's looking at.

fn main() {
    let name = get_username(42);
    let _: () = name; // I know this is wrong; I'm doing this on purpose.
}

The error message will look something like: expected `()`, found opaque type `impl Future<Output = String>`. This confirms that the async fn is actually a regular function that returns a object implementing the Future trait. The "desugaring" is essentially this:

// What I wrote:
async fn get_username(id: u32) -> String { ... }

// What the compiler sees (roughly):
fn get_username(id: u32) -> impl Future<Output = String> {
    async move {
        // the original function body goes here
    }
}

Where the Locals Go

Now, here is where it gets interesting. If a function returns immediately, that's fine. But async functions usually have .await points. When a function hits an .await, it pauses. But where do the local variables live while the function is paused? They can't stay on the stack because the function has technically already "returned" the Future object to the caller.

Let's add a local variable to see how it complicates things:

async fn fetch_and_process(id: u32) -> String {
    let query = format!("SELECT name FROM users WHERE id = {}", id); 
    let result = db_call(&query).await; // Pause point
    format!("Processed: {}", result)
}

In a normal function, query would be on the stack. In an async fn, the compiler desugars this into a hidden struct. It's essentially doing this behind the scenes:

enum FetchAndProcessState {
    Start { id: u32 },
    WaitingForDb { query: String }, // 'query' is moved into the state machine!
    Finished,
}

struct FetchAndProcessFuture {
    state: FetchAndProcessState,
}

impl Future for FetchAndProcessFuture {
    type Output = String;
    fn poll(mut self: Pin&mut Self, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // The logic to transition between states based on whether
        // the db_call is ready
    }
}

This is the "aha!" moment. The async fn is just a factory that produces a state machine. Every variable that exists across an .await point is moved into that struct. This is why async in Rust is so efficient—there's no hidden heap allocation for every single call (unless you Box it); it's just a struct that grows to fit the largest possible state of your function.

The Cost of the Sugar

Understanding this desugaring explains a lot of the "weird" parts of Rust async. For example, why do we need Pin? Because if the FetchAndProcessFuture struct contains a reference to one of its own fields (a self-referential pointer), moving that struct in memory would invalidate the pointer. Since the state machine is just a struct being passed around, we have to "pin" it in place to ensure those internal references stay valid.

Next time you write async fn, don't think of it as a function that runs asynchronously. Think of it as a function that builds a state machine which can be driven to completion by an executor.




📋 Practical Task

Manual Future State Machine Implementation

In this exercise, you will simulate the desugaring process. Instead of using async fn, you are going to manually implement the Future trait for a custom state machine.

The Goal: Create a TimerFuture that completes after a specific number of "polls" (to simulate time passing without needing a real system clock).

Requirements:

  • Define a struct TimerFuture that holds a count: u32.
  • Implement Future for TimerFuture.
  • The poll method should decrement the count.
  • If count is greater than 0, return Poll::Pending.
  • If count reaches 0, return Poll::Ready("Timer Finished!").
  • In your main function, use a simple loop or a basic executor to poll the future until it returns Ready, printing the current count at each step.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.