Rust Futures Guide: Building Async Foundations
A Future is a value that may not be ready yet — it represents a computation that will complete at some point in the future. Futures are the foundation of Rust's async/await system, enabling thousands of concurrent tasks on a handful of OS threads. Unlike threads (which are preemptively scheduled by the operating system), futures are cooperatively scheduled by an async runtime, which polls them to check if they're ready. This guide covers the Future trait, how polling works, and how to implement custom futures for specialized use cases.
Key Takeaways
- The
Futuretrait has a single method:poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>. Poll<T>returns eitherPoll::Ready(value)(computation complete) orPoll::Pending(not ready yet; check back later).- Polling model: An async runtime repeatedly calls
poll()on futures, advancing them toward completion. - Async/await desugars to futures: When you
awaita future, the compiler transforms it intopoll()calls under the hood. Pin<&mut Self>ensures the future doesn't move in memory (required for self-referential structs);Contextcontains a waker for notifying the runtime.- Custom futures are rare; use
async fnorasync { }blocks instead. ImplementFuturemanually only when building runtime infrastructure or highly specialized types.
What Is the Future Trait?
A Future in Rust is a trait that represents an asynchronous computation — a task that may not complete immediately. Futures are lazy: they don't do any work until polled. This laziness is what makes Rust async efficient: the runtime can manage thousands of futures on a few threads by polling only those that are likely to make progress.
The trait is minimal but powerful:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Key fields:
Output: The type of value the future produces when ready.poll(): Called by the async runtime to check if the future is ready.Pin: Ensures the future's memory location is stable (important for async functions that store references across await points).Context: Provides a waker to notify the runtime when the future is ready to progress.
How Does the Polling Model Work?
When you await a future, the async runtime calls poll() on it. The method returns one of two outcomes:
Poll::Ready(output): The future is complete; the runtime returns the output and moves on.Poll::Pending: The future isn't ready yet; the runtime parks the task and schedules something else.
Here's a simplified view of how tokio (a popular async runtime) drives futures:
// Pseudo-code: what the runtime does
while task_queue.has_tasks() {
let task = task_queue.pop();
match task.future.poll(&mut cx) {
Poll::Ready(output) => {
// Future is done, store output
task.complete(output);
}
Poll::Pending => {
// Future isn't ready; it will call wake() on the context
// when it's time to try again (e.g., socket is readable)
task_queue.push_back(task);
}
}
}
Key insight: The runtime doesn't waste CPU spinning. Instead, it relies on wakers: when a future becomes ready (e.g., a network socket receives data), the corresponding waker is called, signaling the runtime to poll the future again.
How Does Async/Await Desugar Into Polling?
When you write async code with await, the compiler transforms it into manual poll() calls. Consider this example:
async fn fetch_user(id: u64) -> User {
let data = fetch_from_db(id).await; // Await a future
process_user(data)
}
The compiler roughly transforms it to a state machine that polls fetch_from_db(id) and, once ready, calls process_user(). You rarely need to understand this transformation, but knowing it exists helps you understand why async fn returns a Future.
How Do You Implement a Custom Future?
Manually implementing Future is uncommon — use async fn or async { } blocks whenever possible. However, for specialized cases (custom schedulers, runtime infrastructure, unique timing logic), you can implement it directly.
Here's a simple delay future that completes after a specified duration:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
struct Delay {
when: Instant,
}
impl Delay {
fn new(duration: Duration) -> Self {
Delay {
when: Instant::now() + duration,
}
}
}
impl Future for Delay {
type Output = ();
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> {
if Instant::now() >= self.when {
Poll::Ready(())
} else {
Poll::Pending
}
}
}
#[tokio::main]
async fn main() {
println!("Waiting...");
Delay::new(Duration::from_millis(100)).await;
println!("Done!");
}
Important notes:
Pin<&mut Self>: Required to ensure the future doesn't move in memory. It's a wrapper that enforces structural pinning.- Ignoring
Context: In this example, we don't usecx, but in a real implementation, you'd callcx.waker().wake()when the future is ready, signaling the runtime. - Return
Poll::Pendingsparingly: Busy-polling (always returningPendingwithout registering a waker) wastes CPU. In production, you'd integrate with I/O or timer subsystems that call the waker.
A More Complete Example: A Future That Integrates With Tokio
Here's a future that properly uses the waker to notify tokio when it's ready:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::time::{sleep, Duration};
struct CountdownFuture {
count: u32,
}
impl Future for CountdownFuture {
type Output = u32;
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u32> {
if self.count == 0 {
Poll::Ready(self.count)
} else {
self.count -= 1;
// In a real example, register a waker via cx.waker().clone()
// to wake the task when ready; for simplicity, we skip that here.
Poll::Pending
}
}
}
#[tokio::main]
async fn main() {
let countdown = CountdownFuture { count: 5 };
let result = countdown.await;
println!("Result: {}", result);
}
Note: This example is educational. In practice, use async fn or tokio utilities like tokio::time::sleep() instead of rolling your own.
Why Use Futures Instead of Threads?
Futures enable high concurrency with low overhead:
- Threads: Each OS thread costs ~2 MB of memory and is preemptively scheduled, incurring context-switch overhead. Practical limit: ~thousands of threads.
- Futures: A future is a few hundred bytes. An async runtime runs thousands on a handful of threads. Because futures are cooperatively scheduled (they
awaitvoluntarily), context switches are minimal.
Example: A web server handling 100,000 concurrent connections:
- With threads: impossible (would need 100,000 threads ≈ 200 GB RAM).
- With futures: feasible (100,000 lightweight futures on ~8 threads, using tokio's event loop).
Best Practices and Common Patterns
Do: Use async fn and async { } blocks. The compiler handles polling automatically.
async fn fetch_data(url: &str) -> String {
// Compiler transforms this into a Future automatically
reqwest::get(url).await.unwrap().text().await.unwrap()
}
Don't: Manually implement Future unless you're building runtime infrastructure.
Do: Understand Poll and Context if you work with advanced async patterns or raw futures.
Don't: Use block_on() (blocking the thread) inside async code; it defeats the purpose of async.
// Bad: blocks the thread, preventing other futures from running
let data = tokio::task::block_in_place(|| blocking_io());
// Good: use async alternatives or spawn a blocking task
let data = tokio::task::spawn_blocking(|| blocking_io()).await;
Frequently Asked Questions
What's the difference between a Future and a Promise?
In other languages, a Promise is often an object that holds the result of an asynchronous computation and can be awaited. Rust's Future is similar but is lazy: it doesn't start until polled. Promises (in JavaScript, for example) are eager: they execute immediately when created. This makes Rust futures more efficient for high-concurrency scenarios.
Can I cancel a future?
Rust futures don't have built-in cancellation, but you can use tokio::select! to race futures: whichever completes first wins, and the others are dropped (and their resources cleaned up). This is more composable than explicit cancellation tokens.
tokio::select! {
result = some_future => { /* ... */ }
_ = timeout_future => { println!("Timeout!"); }
}
What does Pin do, and why is it necessary?
Pin ensures a future's memory location doesn't change after creation. This is critical for async functions that use &references across await points. Without Pin, you could move the future in memory, invalidating those references and causing undefined behavior. Pin provides a compile-time guarantee that the future won't move.
How does the async runtime know when to poll a future again?
Through wakers. When a future returns Poll::Pending, it should register a waker (from Context) with the underlying I/O or timer subsystem. When that subsystem becomes ready (socket is readable, timer expires), it calls the waker, which notifies the runtime to poll the future again. This is how the runtime avoids busy-polling.
Is it safe to call poll() directly?
Technically yes, but practically no. Don't call poll() manually; that's the runtime's job. Calling poll() directly bypasses the runtime's scheduling and can lead to subtle bugs. Always use await or the runtime's facilities.
Further Reading
- The Rust Async Book: The Future Trait
- Tokio Tutorial: Async Basics
- Futures Explained in 200 Lines of Rust
Glossary
- Future: A trait representing an asynchronous computation that may not complete immediately.
- Polling: The process of calling
poll()on a future to check if it's ready or to advance its progress. - Poll<T>: An enum with two variants:
Ready(T)(computation complete) andPending(not ready yet). - Waker: An object that notifies the runtime to poll a future again when it's ready to progress.
- Pin: A wrapper that ensures a value's memory location is stable, required for self-referential types like futures.
- Async runtime: Software (e.g., tokio) that manages and schedules futures on OS threads.
Challenge Yourself
- Implement a
Futurethat counts down from 10 to 0 and completes when it reaches 0. - Create another
Futurethat waits for a condition (e.g., a shared atomic boolean) to be settrue. - Use
tokio::select!to race both futures and print which one completes first. - Bonus: Integrate your futures with a waker so they properly notify the tokio runtime when ready (see tokio's
task::wakeutilities).
Next Steps
In the next article, Project: A Multi-threaded Web Server, we apply everything we've learned about concurrency — threads, async/await, and futures — to build a production-grade web server that handles thousands of concurrent connections efficiently.