Back to Blog

async/await with the Standard Library: Building Non-Blocking Code from First Principles

Rust async/await starts with Future, poll, Pin, and Waker. Learn these primitives first, then Tokio’s runtime and scheduling become easier to understand.

AcademySeptember 16, 20265 min read
async/await with the Standard Library: Building Non-Blocking Code from First Principles

async/await with the Standard Library: Building Non-Blocking Code from First Principles (Before You Need Tokio, Understand What Tokio Does For You)

Before you reach for Tokio—before you add a runtime dependency, configure a thread pool, or annotate your main function with #[tokio::main]—it's worth understanding what async/await looks like with just the standard library. Not because you'll use raw std async in production (you probably won't), but because understanding the primitive layer makes everything above it make sense.

Think of it like learning to drive stick before driving automatic. You don't need to know how a clutch works to drive a modern car. But if you do know, you understand what the automatic transmission is doing for you, why certain behaviors occur, and what's happening when things go wrong. Same principle here: understanding std::future::Future, Pin, and manual polling makes Tokio's abstractions transparent rather than magical.

At RantAI, our engineers understand async at the primitives level because it makes debugging production async code dramatically easier. When an async task deadlocks or a future isn't making progress, understanding the poll model tells you why—instead of staring at opaque runtime behavior. This article, drawn from Chapter 6, Section 6.3 of our guide "The Rust Programming Language," covers async/await at the standard library level.

The Standard Library's Async Primitives

Rust's standard library provides the trait definitions and language-level syntax for async, but not the runtime. It gives you Future, async fn, .await, Pin, and Waker. What it doesn't give you is a task scheduler, I/O event loop, or timer implementation—those come from runtimes like Tokio.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

// A simple future that's immediately ready
struct Ready<T>(Option<T>);

impl<T> Future for Ready<T> {
    type Output = T;

    fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<T> {
        Poll::Ready(self.0.take().expect("polled after completion"))
    }
}

// Usage
async fn example() -> i32 {
    let value = Ready(Some(42)).await;
    value + 1
}

This Ready future is trivial—it immediately returns its value. But it demonstrates the mechanics: implement Future, define poll, return Ready(value) or Pending. The .await syntax calls poll under the hood.

The async fn Transformation

// The compiler transforms this:
async fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Into something conceptually like this:
fn add(a: i32, b: i32) -> impl Future<Output = i32> {
    async move { a + b }
}

async fn is syntactic sugar for a function that returns impl Future. The async block captures the parameters and creates a future. The body executes when the future is polled.

Building Async Compositions Without a Runtime

use std::future::Future;

// Combine two futures (conceptually — real join! needs a runtime)
async fn fetch_both(a: impl Future<Output = String>, b: impl Future<Output = String>) -> (String, String) {
    let result_a = a.await;
    let result_b = b.await;
    (result_a, result_b)
}

This executes a and then b sequentially—not concurrently. True concurrent execution requires a runtime that can poll multiple futures. This is exactly why runtimes like Tokio exist: the language provides the syntax and trait definitions, but concurrent scheduling requires infrastructure.

Pin: Why Futures Can't Move

Here's the one concept that trips up even experienced Rust developers:

async fn example() {
    let data = vec![1, 2, 3];
    let reference = &data;
    some_io_operation().await;  // Suspend point — future might be moved in memory
    println!("{:?}", reference);  // reference must still be valid after resumption
}

When a future is suspended at .await, it might be moved to a different memory location by the runtime. But reference points to data within the same future struct—if the future moves, reference becomes a dangling pointer. Pin prevents the future from being moved after creation, ensuring self-references remain valid.

You rarely interact with Pin directly—async fn and .await handle it for you. But understanding why it exists explains the occasional "cannot be unpinned" error message and why Box::pin(future) sometimes appears in async code.

When You Need a Runtime (And Which One)

The standard library provides the building blocks. Runtimes provide the execution engine:

  • Tokio: The most popular. Full-featured: I/O, timers, synchronization, multi-threaded scheduler. Use for servers, networking, and most async applications.

  • async-std: API mirrors std. Simpler than Tokio. Good for learning and straightforward applications.

  • smol: Minimal and composable. Good for embedding async into larger systems.

For most production Rust, Tokio is the default choice—which is why the next several articles focus on it.

Broader Implications: Understanding the Abstraction Stack

At RantAI, we teach async Rust bottom-up: first the Future trait and poll model (this article), then Tokio's runtime and task scheduling (next articles). This approach means our engineers can diagnose async issues at the right level of abstraction—runtime scheduling problems at the Tokio level, future composition problems at the language level, and polling problems at the trait level.

Practical Applications & Strategic Takeaways

For newcomers: You'll use Tokio for real work, but understanding Future::poll and Pin makes Tokio's behavior comprehensible rather than magical. Invest 30 minutes here; save hours debugging later.

For library authors: If you're writing an async library, target std::future::Future—not Tokio specifically. This makes your library runtime-agnostic and usable with any async runtime.

For embedded developers: The standard library's async primitives work without std (in #![no_std] environments). You can use async/await in embedded systems with minimal, custom executors.

Our Commitment to Open Knowledge

RantAI is committed to open education. Standard library async is covered in Chapter 6, Section 6.3 of our guide, "The Rust Programming Language," freely available online.

Explore these concepts further: https://trpl.rantai.dev

Support Our Mission & Get Your Handbook

Did learning the poll model change how you think about async runtimes? Share your "aha" moment!

#RustLang #AsyncAwait #Futures #StdLib #RantAI #LearnRust #Pin #Poll #SystemsProgramming #NonBlocking

Want to learn more?

Connect with our team to discuss how AI can transform your enterprise.

Contact Us