Async Rust

Course 3 · Ch 3
Async Rust
Rust's other concurrency model — lightweight, cooperatively-scheduled tasks for I/O-bound work

Course 2 Chapter 5 covered real OS threads. This chapter covers Rust's other concurrency model: async/.await, for cases where spinning up a full OS thread per task is too heavy — many concurrent I/O-bound operations like network requests or file I/O.

async fn and Futures

async fn fetch(url: &str) -> String { /* ... */ } let future = fetch("https://example.com"); // nothing runs yet let result = future.await; // NOW it actually runs

Calling an async fn does not run its body immediately — it returns a Future, a value representing "this computation, not yet done." A Future does nothing until it's actually driven forward. This is a genuinely different mental model from JavaScript Promises, which start running eagerly the moment they're created — Rust Futures are lazy, a real, easy-to-miss difference for anyone coming from JS's async model.

.await: Driving a Future to Completion

.await can only be used inside an async fn or async block. It suspends the current async function until the awaited Future resolves — without blocking the underlying OS thread; other tasks can run on that same thread while this one is suspended. That's the core efficiency win: many tasks sharing few OS threads.

Rust Has No Built-In Async Runtime

Course 1/2's std::thread::spawn needed no external crate. Async is different: the standard library defines the Future trait and the async/.await syntax, but includes no runtime to actually run futures — an external crate is required. tokio is the dominant choice:

#[tokio::main] async fn main() { let result = fetch("https://example.com").await; }

A genuinely important, sometimes-surprising fact: an async fn called with no runtime present, and its Future never awaited, literally does nothing — the future is simply dropped, unpolled.

tokio::spawn: Concurrent Async Tasks

let handle = tokio::spawn(async { fetch("https://example.com").await }); let result = handle.await.unwrap();

Analogous to Course 2's thread::spawn, but spawns a lightweight async task onto tokio's own scheduler, not a full OS thread — thousands of these can run cheaply, unlike OS threads. .await on the handle echoes Course 2's .join(), now async.

Contrast With Node's Event Loop (js3-3)

Node's event loop is single-threaded, with an implicit runtime always running, built directly into the platform. Rust's async has no implicit runtime at all — one must be explicitly chosen and started (like tokio) — and tokio itself is typically multi-threaded by default, unlike Node's single JS thread. Genuinely different concurrency models sitting underneath a superficially similar async/await syntax both languages happen to use.

Contrast With Go's Goroutines (Course 2, go2-3)

Go's goroutines are the closest existing comparison in this course — also lightweight, also cooperatively scheduled under the hood. But Go's goroutines need no special syntax at the call site at all (go someFunc(), and ordinary-looking blocking code just works via the runtime's scheduler). Rust's async is explicit and colored — an async fn can genuinely only be awaited from another async context, the well-known "function coloring" critique of async/await as a language feature in general, not unique to Rust. Go deliberately avoided this entirely by never requiring a different function signature for concurrent code — a real, fair trade-off worth naming honestly, not a case where either design is simply better.

Go GoroutinesRust Async
Call-site syntaxgo someFunc() — no special function signatureasync fn + .await — "colored" functions
RuntimeBuilt directly into the languageNone built-in — must choose one (e.g. tokio)
Blocking-looking codeJust works via the runtime's schedulerMust be async-aware throughout the call chain

async fn

Returns a lazy Future immediately — the body doesn't run until awaited.

Future (Lazy)

A value representing "not yet done" — genuinely different from JS's eager Promises.

.await

Suspends without blocking the OS thread — other tasks run in the meantime.

tokio::spawn

A lightweight async task, not a full OS thread — cheap enough to spawn thousands.

Async vs. OS Threads: Which Tool for Which Job
Async shines for many concurrent I/O-bound tasks — thousands of network connections, a busy web server — where OS threads would be too heavy per-task. OS threads (Course 2, Chapter 5) remain the right choice for CPU-bound parallel work, or a small, fixed number of genuinely parallel workers. Neither is simply "better" — they solve different shaped problems.
Forgetting .await Silently Does Nothing
Calling an async function and forgetting .await compiles fine — it just produces a Future value that's immediately dropped, and the function's body never runs at all. The compiler does emit a warning ("unused implementer of Future that must be used" or similar), but it's easy to miss, and the resulting bug — something that looks like it should have happened, silently didn't — can be genuinely confusing to diagnose. A real, common, async-specific mistake.

Coding Challenges

Challenge 1

Write an async function greet(name: &str) -> String returning a greeting, and a #[tokio::main] async fn main() that awaits it and prints the result. Explain what would happen (or rather, not happen) if the .await were removed.

📄 View solution
Challenge 2

Using tokio::spawn, write code that spawns 3 concurrent async tasks (each simulating work with a short tokio::time::sleep), awaits all 3 handles, and prints a message once all have completed.

📄 View solution
Challenge 3

Explain the "function coloring" problem in your own words, using async fn as the example — specifically, why an ordinary, non-async function generally can't call .await on a Future the way an async function can.

📄 View solution

Chapter 3 Quick Reference

  • async fn — returns a lazy Future immediately; the body doesn't run until awaited
  • .await — suspends the current async function without blocking the OS thread
  • Rust ships no built-in async runtime — an external crate like tokio is required
  • tokio::spawn — a lightweight async task, analogous to thread::spawn but far cheaper
  • Node's event loop is single-threaded and implicit; tokio is typically multi-threaded and must be explicitly started
  • Go's goroutines require no special function signature; Rust's async fn is "colored" and needs an async context throughout
  • Async suits many I/O-bound tasks; OS threads suit CPU-bound or small-fixed-worker-count parallel work
  • Forgetting .await compiles but silently does nothing — the future is dropped, unpolled
  • Next chapter: Macros — declarative macros (macro_rules!) and a brief intro to procedural macros