Skip to main content

Rust Async Traits: The Boundary Between async fn and Poll-Based I/O

Introduction

This document revisits an old note that copied an implementation of Tokio's AsyncRead and called it an "async trait," bringing it up to date with current Rust. The example did not implement an async fn in a trait. It manually built a poll-based I/O trait that provided poll_read and an adapter that wrapped it in an awaitable Future. The two approaches are related, but they are not the same abstraction.

  • An application's asynchronous behavioral contract can be expressed with an async fn in a trait.
  • Low-level I/O that directly meets an executor uses a contract based on Pin, Context, and Poll.
  • Tokio's AsyncReadExt::read() wraps the latter's poll_read with the former's .await ergonomics.

async fn in traits in current Rust

A Rust async function is conceptually transformed into a regular function that returns a Future.

async fn length(value: &str) -> usize {
value.len()
}

// Conceptually similar to the following.
fn length_desugared(value: &str) -> impl Future<Output = usize> + '_ {
async move { value.len() }
}

The same form can be used directly in traits. For an application contract consumed through static dispatch, it is the most readable starting point.

use std::io;

trait MessageSource {
async fn next_message(&mut self) -> io::Result<Vec<u8>>;
}

struct MemorySource {
message: Option<Vec<u8>>,
}

impl MessageSource for MemorySource {
async fn next_message(&mut self) -> io::Result<Vec<u8>> {
Ok(self.message.take().unwrap_or_default())
}
}

async fn consume<S: MessageSource>(source: &mut S) -> io::Result<Vec<u8>> {
source.next_message().await
}

There are two boundaries here.

  1. A trait with an async fn or a method returning impl Trait is not dyn-compatible as written. If you need Box<dyn MessageSource>, consider a separate design such as an object-safe adapter or boxed future.
  2. Whether the returned future must be Send depends on the call site. An API passed into a task on a multithreaded executor should state that requirement during design and verify it through the compiler.

Why Tokio AsyncRead uses poll_read

tokio::io::AsyncRead is the asynchronous counterpart of std::io::Read. When data is not immediately ready, it returns Poll::Pending instead of blocking the thread and registers the Context's waker so the task is awakened when the data becomes ready.

use std::{io, pin::Pin, task::{Context, Poll}};
use tokio::io::ReadBuf;

trait PollRead {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>>;
}

Each argument has a clear role.

  • Pin<&mut Self>: fixes internal state that must not move while it is being polled.
  • Context<'_>: carries the current task's waker.
  • ReadBuf<'_>: distinguishes the initialized region from the region still available to fill.
  • Poll::Pending: the operation is not complete yet, and the implementation has scheduled an appropriate wake-up.
  • Poll::Ready(Ok(())): the read is complete. Determine the number of newly filled bytes from the change in filled() length.

Both EOF and a zero-length output buffer may produce zero newly filled bytes, so the caller must consider its own buffer state together with the protocol context.

Wrapping a Poll contract with .await

A low-level trait implementer provides poll_read, but callers normally use Tokio's AsyncReadExt::read().

use tokio::io::{self, AsyncRead, AsyncReadExt};

async fn read_once<R>(reader: &mut R) -> io::Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut buffer = vec![0; 1024];
let count = reader.read(&mut buffer).await?;
buffer.truncate(count);
Ok(buffer)
}

At its core, the extension method creates a future that holds the reader and buffer, then calls the reader's poll_read when that future is polled. A manually written adapter has the following shape.

use std::{
future::Future,
io,
pin::Pin,
task::{Context, Poll},
};
use tokio::io::{AsyncRead, ReadBuf};

struct ReadOnce<'a, R> {
reader: &'a mut R,
buffer: &'a mut [u8],
}

impl<R> Future for ReadOnce<'_, R>
where
R: AsyncRead + Unpin,
{
type Output = io::Result<usize>;

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = &mut *self;
let mut read_buffer = ReadBuf::new(this.buffer);

match Pin::new(&mut *this.reader).poll_read(cx, &mut read_buffer) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(error)) => Poll::Ready(Err(error)),
Poll::Ready(Ok(())) => Poll::Ready(Ok(read_buffer.filled().len())),
}
}
}

This example is the smallest adapter that demonstrates the structure. Use the already validated AsyncReadExt in real code. Once you implement a future yourself, pinning, cancellation safety, repeated polling, waker registration, and EOF semantics all become part of the API contract.

Which approach to choose

SituationPreferred approach
Asynchronous behavior of a domain serviceasync fn in a trait
Combining various implementations genericallyStatic dispatch with S: Trait
A trait object is truly requiredConsider an object-safe adapter or boxed future
Low-level primitive such as an executor, socket, or streamPin + Context + Poll
Simply consuming a Tokio readerAsyncReadExt

Application code rarely needs a new poll-based trait. Follow that contract only when implementing an existing runtime trait, and avoid leaking Poll into the domain layer. Preserving the types → core → adapter → entry point boundary keeps executor details in the adapter.

Review checklist

  • Is this trait a domain behavior or a low-level primitive that meets the executor?
  • Is static dispatch sufficient, or is a trait object actually required?
  • Is there a call path where the returned future must be Send?
  • Is wake-up registered correctly before returning Poll::Pending?
  • Are pinning and Unpin assumptions explained at the type boundary?
  • Is it safe under cancellation and repeated polling?
  • Is it reimplementing an extension trait already provided by the runtime?

Official documentation