Post

13. Async Await

13. Async Await

This is a Rust async/await guide tailored for experienced TypeScript developers.

As a senior TS developer you know the Event Loop, Promises, and the microtask queue. Rust’s async model is fundamentally different from JS/TS.

In TS, async is a runtime feature. In Rust, async is compile-time state-machine rewriting plus a small runtime driver.

We’ll cover concepts, implementation (traits), and comparison with TS/Go.


Async programming in Rust: async/await

1. Why async?

1.1 Blocking vs non-blocking

With synchronous I/O (e.g. reading a file, network request), the thread blocks until data is ready.

  • Cost: Threads are expensive (e.g. ~2MB per thread, context-switch overhead). With 10k connections you can’t afford 10k threads (C10k problem).

1.2 Async vs concurrency vs parallelism

  • Concurrency: Logically doing several things at once (one CPU switching between tasks).
  • Parallelism: Physically doing several things at once (multiple CPUs).
  • Async: A programming model for concurrency.

TS: Single-threaded concurrency via the Event Loop and I/O waiting. Rust: M:N threading: M green tasks (Futures) on N OS threads.

M (Tasks/Futures): many async tasks (green threads). N (OS Threads): a small number of OS threads (often ≈ CPU cores).


2. Core difference: Rust Future vs TS Promise

2.1 TS: eager execution

In TS, when you create a Promise, it starts running immediately.

1
2
3
4
5
6
// TS
const p = new Promise((resolve) => {
  console.log("1. Start cooking noodles"); // Runs immediately
  resolve("noodles");
});
console.log("2. Waiting for result");

Order: 1 → 2. The Promise runs even if you never await it (you just can’t get the result).

2.2 Rust: lazy execution

In Rust, an async block returns a value that implements the Future trait. Until you poll it (e.g. via an executor or .await), it does nothing.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Rust
async fn cook_noodle() {
    println!("1. Start cooking noodles");
}

fn main() {
    let f = cook_noodle(); // Nothing runs here; f is just a Future
    println!("2. Waiting result");

    // Must run it explicitly (e.g. executor::block_on or .await)
    // futures::executor::block_on(f);
}

Without an executor, you only see “2”; “1” never runs.

Analogy:

  • TS Promise: Order food; the kitchen starts cooking as soon as you order.
  • Rust Future: A recipe. You have the recipe, but until a chef (Executor) runs it, no food appears.

3. Under the hood: the Future trait

In Rust’s async world, everything centers on std::future::Future.

3.1 Definition

1
2
3
4
5
6
7
8
9
10
11
pub trait Future {
    type Output;

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

pub enum Poll<T> {
    Ready(T), // Done; return value
    Pending,  // Not done; keep waiting
}

Simple on the surface; this is where Rust’s zero-cost async abstraction lives.

3.2 How it runs: poll and waker

In TS, the runtime puts callbacks in the microtask queue. In Rust there is no built-in runtime. A third-party crate (e.g. Tokio, async-std) provides an Executor.

For Tokio, see Migrating to Rust Tokio (Chinese).

Flow:

  1. Executor calls the Future’s poll.
  2. Future checks internal state:
    • If ready → return Poll::Ready(data).
    • If not (e.g. socket still reading) → register a Waker, return Poll::Pending.
  3. Executor sees Pending; it may park the thread or run other tasks.
  4. Reactor (I/O layer) sees that the socket has data and calls the registered Waker.
  5. Waker tells the Executor: “That task can run again.”
  6. Executor calls poll again; this time it may get Poll::Ready.

3.3 Why Pin? (Self-referential structs)

The signature uses self: Pin<&mut Self>, not plain &mut Self. Why?

Reason: self-referential structs.

Rust’s async/await is implemented by compiler-generated state machines. See the widely read article Why async Rust (Chinese).

1
2
3
4
5
6
7
async fn example() {
    let x = [0; 1024]; // Big array
    let y = &x;        // y borrows x (self-reference!)
    some_io().await;   // Yield point
    println!("{:?}", y);
}

After compilation, the Future struct roughly looks like:

1
2
3
4
5
6
struct ExampleFuture {
    state: i32,
    x: [u8; 1024],
    y: *const [u8; 1024], // Pointer into x inside the same struct
}

If this struct is moved (e.g. from stack to heap, or when a Vec resizes), x’s address changes but y would still point to the old address (dangling pointer).

Pin: “Pins” the Future in memory so it is never moved. Only then is the internal self-reference safe.


4. Comparison: Rust vs TS vs Go

AspectTypeScript (Node.js)Go (goroutines)Rust (async/await)
ModelEvent Loop (single thread + callbacks)Green threads (M:N)State machines (zero-cost abstraction)
StackPromises on heapStackful (e.g. 2KB stack per goroutine)Stackless (compiled to fixed-size struct)
SchedulingCooperative (microtask queue)Preemptive (runtime scheduler)Cooperative (must await to yield)
OverheadMedium (GC + V8)Low (context switch)Very low (no runtime beyond the state machine)
Cross-threadNo (need Worker Threads)Yes (channels)Yes (Send/Sync traits)

4.1 Go: pros and cons

  • Pros: “Synchronous-looking code, asynchronous execution.” You write conn.Read(); it looks blocking but the runtime suspends the goroutine. Low mental load.
  • Cons: Heavy runtime (GC + scheduler). Each goroutine has at least ~2KB stack (and can grow). That’s a burden for embedded or extreme performance.

4.2 Rust: pros and cons

  • Pros: Zero-cost; no GC. A Future is just a struct. 100k Futures might be a few MB. You control layout.
  • Cons: More concepts: Pin, Send, Sync, and ecosystem split (Tokio vs async-std). If you do CPU-heavy work inside an async block without yielding, you can block the executor (cooperative scheduling).

5. Trait pitfalls in practice: Send and Sync

In TS you don’t think about “can this cross threads?” In Rust async, you must.

5.1 Futures across threads

Many executors (e.g. Tokio) are multi-threaded (work stealing). A task might start on one thread and resume on another.

So the Future and everything it captures must be Send.

1
2
3
4
5
6
7
8
9
10
// ❌ Bad
async fn process() {
    let rc = Rc::new(5); // Rc is !Send (refcount not atomic)

    some_io().await; // Yield: state machine stores rc

    println!("{}", rc);
}

tokio::spawn(process()); // Error: Future not Send

Fix: Use Arc (atomic reference counting) instead of Rc.

5.2 Async trait methods

Before Rust 1.75, you couldn’t write async fn directly in a trait.

1
2
3
4
5
// ❌ Used to be unsupported
trait Database {
    async fn fetch(&self, id: i32) -> User;
}

Reason: async fn desugars to impl Future; trait methods with impl Trait return types made trait objects (dyn Trait) problematic (size unknown).

Workarounds:

  1. Macro: Use #[async_trait]; it boxes the future (BoxFuture), so you get dynamic dispatch and heap allocation.
  2. Rust 1.75+: Native async fn in traits is supported, but there are still limitations (e.g. RPITIT) and Send bounds can be tricky.

6. Summary: mental map for TS developers

  1. Promise is hot, Future is cold: A Rust Future doesn’t run until you drive it (e.g. with an executor or .await).
  2. No built-in runtime: The language only gives you the state-machine transformation; you need a crate like tokio to run futures.
  3. Future is a state-machine struct: The compiler turns your async/await into an enum state machine.
  4. Pin is for memory safety: The generated Future may contain self-references; pinning prevents moves that would invalidate them.
  5. Send/Sync are mandatory: Because futures may move between threads, the compiler enforces that captured data is safe to send across threads.
This post is licensed under CC BY 4.0 by the author.