Skip to content

Understanding Async Traits in Rust: From Simple Code to Complex Memory Safety

This guide explores how a simple async trait in Rust leads us through some of the language's most complex features: trait objects, dynamic dispatch, pinning, Send/Sync bounds, and lifetimes.

The Starting Point: A Simple Async Trait

Let's begin with what seems like innocent Rust code:

trait UserService {
    async fn get_user(&self, id: &str) -> Option<User>;
}

fn use_service(service: Box<dyn UserService>) {
    // This won't compile!
}

This simple example will take us on a journey through Rust's most challenging concepts.

Polymorphism in Rust: Static vs Dynamic Dispatch

Rust provides two ways to achieve polymorphism:

Static Dispatch with Generics

fn use_service<T: UserService>(service: T) {
    // T is known at compile time
    // Rust generates specialized functions for each concrete type
}

Static dispatch has no runtime overhead because the compiler knows exactly which implementation to use at compile time.

Dynamic Dispatch with Trait Objects

fn use_service(service: Box<dyn UserService>) {
    // Concrete type is determined at runtime
}

Dynamic dispatch uses trait objects, which must always be behind a pointer (like Box, &, or Arc). Here's why:

// A Box<dyn Trait> contains two pointers:
// 1. Data pointer -> the concrete value on the heap
// 2. VTable pointer -> virtual table for method dispatch

The reason trait objects require pointers is that Rust doesn't know the size of the concrete type until runtime.

The First Problem: Object Safety

When we try to compile our async trait, we encounter:

error: the trait `UserService` is not object-safe

Understanding Object Safety

A trait is "object-safe" (or "dyn-compatible") if it can be used as a trait object. One key rule is:

A trait cannot have async functions because they contain hidden generic types.

Why Async Functions Break Object Safety

The async keyword is syntactic sugar. Our async method:

async fn get_user(&self, id: &str) -> Option<User>

Actually desugars to:

fn get_user(&self, id: &str) -> impl Future<Output = Option<User>>

The impl Future syntax is a form of generics using static dispatch. Since trait objects require dynamic dispatch, this creates a conflict.

How Async Rust Works: State Machines

To understand the solution, we need to understand how async Rust works under the hood.

From Async Functions to State Machines

async fn fetch_page_content() -> String {
    let response = http_get("https://example.com").await;
    let content = response.text().await;
    content
}

The Rust compiler transforms this into a state machine:

enum PageContentFuture {
    Start,
    WaitingForResponse { /* ... */ },
    WaitingForText { /* ... */ },
    Done,
}

impl Future for PageContentFuture {
    type Output = String;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // State machine logic here
    }
}

Each await point creates a new state where execution can pause and resume.

The Solution: Boxing Futures

To make our trait object-safe, we replace the generic return type with a trait object:

use std::future::Future;
use std::pin::Pin;

trait UserService {
    fn get_user(&self, id: &str) -> Pin<Box<dyn Future<Output = Option<User>>>>;
}

Now every implementation returns the same pointer type, making the trait object-safe.

The Second Problem: Pinning

After fixing object safety, we encounter a new error:

error: `dyn Future<Output = Option<User>>` cannot be unpinned

Understanding Pinning in Rust

Pinning addresses a crucial problem: some values must not move in memory.

State machines generated from async functions may contain self-references:

struct SelfReferencingFuture {
    data: String,
    ptr_to_data: *const String, // Points to the `data` field above
}

If this future moves in memory, the pointer becomes invalid, leading to undefined behavior.

The Pin Smart Pointer

Pin<T> guarantees that the value behind the pointer will not move in memory (unless it implements Unpin):

trait UserService {
    fn get_user(&self, id: &str) -> Pin<Box<dyn Future<Output = Option<User>>>>;
}

The Pin<Box<...>> tells Rust: "Put this future on the heap and ensure it cannot move if moving would be unsafe."

The Unpin Trait

Most types in Rust implement Unpin automatically, meaning they're safe to move. However, some futures (especially self-referential ones) explicitly do NOT implement Unpin, ensuring they remain pinned.

The Third Problem: Send and Sync

When building real applications, we often need to share services across threads:

use std::sync::Arc;

async fn use_service(service: Arc<dyn UserService>) {
    let user = service.get_user("123").await;

    // Spawn a background task
    tokio::spawn(async move {
        let another_user = service.get_user("456").await;
    });
}

This gives us thread safety errors about Send and Sync.

Understanding Send and Sync

  • Send: Safe to move ownership between threads
  • Sync: Safe to share references between threads

Both are auto-traits (implemented automatically unless opted out).

The Solution: Adding Bounds

trait UserService: Send + Sync {
    fn get_user(&self, id: &str) 
        -> Pin<Box<dyn Future<Output = Option<User>> + Send>>;
}

fn use_service(service: Arc<dyn UserService + Send + Sync>) {
    // Now it works!
}

We need: - Send + Sync on the trait itself - + Send on the returned future - + Send + Sync on the trait object

The Fourth Problem: Lifetimes

When we implement our trait, we encounter lifetime issues:

struct DbUserService;

impl UserService for DbUserService {
    fn get_user(&self, id: &str) -> Pin<Box<dyn Future<Output = Option<User>> + Send>> {
        Box::pin(async move {
            // Create and return a user
            Some(User { id: id.to_string() })
        })
    }
}

This produces:

error: lifetime may not live long enough

Understanding the Lifetime Problem

By default, trait objects have a 'static lifetime bound, but our async block captures id, which has a limited lifetime.

The Solution: Explicit Lifetime Parameters

trait UserService: Send + Sync {
    fn get_user<'service, 'id, 'future>(
        &'service self, 
        id: &'id str
    ) -> Pin<Box<dyn Future<Output = Option<User>> + Send + 'future>>
    where
        'service: 'future,
        'id: 'future,
        Self: 'future;
}

This establishes that: - The returned future cannot outlive the service ('service: 'future) - The returned future cannot outlive the id parameter ('id: 'future) - The service type itself must live at least as long as the future (Self: 'future)

The Complete Solution

Here's our final, fully-featured async trait:

use std::future::Future;
use std::pin::Pin;

trait UserService: Send + Sync {
    fn get_user<'service, 'id, 'future>(
        &'service self, 
        id: &'id str
    ) -> Pin<Box<dyn Future<Output = Option<User>> + Send + 'future>>
    where
        'service: 'future,
        'id: 'future,
        Self: 'future;
}

struct DbUserService;

impl UserService for DbUserService {
    fn get_user<'service, 'id, 'future>(
        &'service self, 
        id: &'id str
    ) -> Pin<Box<dyn Future<Output = Option<User>> + Send + 'future>>
    where
        'service: 'future,
        'id: 'future,
        Self: 'future 
    {
        Box::pin(async move {
            // Implementation here
            Some(User { id: id.to_string() })
        })
    }
}

The Elegant Solution: async-trait

Rather than writing all this complexity manually, we can use the async-trait macro:

use async_trait::async_trait;

#[async_trait]
trait UserService: Send + Sync {
    async fn get_user(&self, id: &str) -> Option<User>;
}

#[async_trait]
impl UserService for DbUserService {
    async fn get_user(&self, id: &str) -> Option<User> {
        // Simple, clean implementation
        Some(User { id: id.to_string() })
    }
}

The async-trait macro automatically generates all the complex type signatures we wrote manually.

Key Takeaways

  1. Async functions use hidden generics (impl Future), making them incompatible with trait objects by default
  2. Pinning prevents memory safety issues in self-referential futures
  3. Send and Sync bounds are crucial for multi-threaded async code
  4. Lifetime parameters ensure memory safety when futures borrow data
  5. The async-trait macro hides this complexity while generating the same underlying code

Understanding these concepts deeply helps you write better Rust code and debug complex async issues when they arise.