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 fnin a trait. - Low-level I/O that directly meets an executor uses a contract based on
Pin,Context, andPoll. - Tokio's
AsyncReadExt::read()wraps the latter'spoll_readwith the former's.awaitergonomics.
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.
- A trait with an
async fnor a method returningimpl Traitis not dyn-compatible as written. If you needBox<dyn MessageSource>, consider a separate design such as an object-safe adapter or boxed future. - Whether the returned future must be
Senddepends 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 infilled()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
| Situation | Preferred approach |
|---|---|
| Asynchronous behavior of a domain service | async fn in a trait |
| Combining various implementations generically | Static dispatch with S: Trait |
| A trait object is truly required | Consider an object-safe adapter or boxed future |
| Low-level primitive such as an executor, socket, or stream | Pin + Context + Poll |
| Simply consuming a Tokio reader | AsyncReadExt |
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
Unpinassumptions explained at the type boundary? - Is it safe under cancellation and repeated polling?
- Is it reimplementing an extension trait already provided by the runtime?
Related documents
- Cargo workspace guide — package boundaries between asynchronous adapters and a pure core
- Rust workspace versioning — member version management