Rust 비동기 trait: async fn과 poll 기반 I/O의 경계
들어가며
이 문서는 과거 Tokio의 AsyncRead 구현을 옮겨 적으며 "async trait"이라고 부르던 메모를
현재 Rust 기준으로 다시 정리한다. 당시 예제는 trait의 async fn을 구현한 것이 아니라,
poll_read를 제공하는 poll 기반 I/O trait과 그것을 .await 가능한 Future로 감싸는
adapter를 직접 만든 코드였다. 두 방식은 관련되어 있지만 같은 추상화가 아니다.
- 애플리케이션의 비동기 동작 계약은 trait의
async fn으로 표현할 수 있다. - executor와 직접 맞닿는 저수준 I/O는
Pin,Context,Poll기반 계약을 사용한다. - Tokio의
AsyncReadExt::read()는 후자의poll_read를 전자의.await사용감으로 감싼다.
현재 Rust의 async fn in trait
Rust의 async 함수는 개념적으로 Future를 반환하는 일반 함수로 변환된다.
async fn length(value: &str) -> usize {
value.len()
}
// 개념적으로 다음과 비슷하다.
fn length_desugared(value: &str) -> impl Future<Output = usize> + '_ {
async move { value.len() }
}
Trait에서도 같은 형태를 직접 쓸 수 있다. 정적 dispatch로 사용할 애플리케이션 계약이라면 가장 읽기 쉬운 출발점이다.
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
}
여기에는 두 가지 경계가 있다.
async fn이나impl Trait을 반환하는 method가 있는 trait은 그대로는 dyn-compatible하지 않다.Box<dyn MessageSource>가 필요하다면 object-safe adapter나 boxed future 같은 별도 설계를 검토해야 한다.- 반환 future에
Send가 필요한지는 호출 위치에 따라 달라진다. 멀티스레드 executor의 task로 넘길 API라면 설계 시점에 그 요구를 명시하고 컴파일러로 검증해야 한다.
Tokio AsyncRead는 왜 poll_read를 쓰는가
tokio::io::AsyncRead는 std::io::Read의 비동기 대응물이다. 데이터가 즉시 준비되지 않았을
때 스레드를 막는 대신 Poll::Pending을 반환하고, 전달받은 Context의 waker가 준비 시점에
task를 다시 깨우도록 등록한다.
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<()>>;
}
각 인자의 역할은 분명하다.
Pin<&mut Self>: polling 중 움직이면 안 되는 내부 상태를 고정한다.Context<'_>: 현재 task의 waker를 전달한다.ReadBuf<'_>: 초기화된 영역과 아직 채울 수 있는 영역을 구분한다.Poll::Pending: 지금은 완료되지 않았으며, 구현체가 적절한 wake-up을 예약했다는 뜻이다.Poll::Ready(Ok(())): 읽기가 완료됐다. 새로 채운 바이트 수는filled()길이 변화로 구한다.
EOF와 길이가 0인 출력 버퍼는 모두 새로 채운 바이트가 0일 수 있으므로, 호출자는 자기 버퍼 상태와 프로토콜 문맥을 함께 봐야 한다.
Poll 계약을 .await로 감싸는 adapter
저수준 trait 구현자는 poll_read를 제공하지만, 호출자는 보통 Tokio가 제공하는
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)
}
이 extension method가 내부적으로 하는 핵심은 "reader와 buffer를 보관한 future를 만들고,
future가 poll될 때 reader의 poll_read를 호출한다"는 것이다. 직접 adapter를 만들면 구조는
다음처럼 된다.
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())),
}
}
}
이 예제는 구조를 설명하기 위한 최소 adapter다. 실제 코드에서는 이미 검증된
AsyncReadExt를 사용한다. 직접 future를 구현하면 pinning, cancellation safety, 반복 poll,
waker 등록과 EOF 의미까지 모두 API 계약이 되기 때문이다.
어떤 방식을 선택할 것인가
| 상황 | 우선할 방식 |
|---|---|
| 도메인 service의 비동기 동작 | trait의 async fn |
| 다양한 구현을 generic으로 조합 | S: Trait 정적 dispatch |
| trait object가 반드시 필요 | object-safe adapter 또는 boxed future 검토 |
| executor·socket·stream 같은 저수준 primitive | Pin + Context + Poll |
| Tokio reader를 단순히 소비 | AsyncReadExt |
애플리케이션 코드에서 poll 기반 trait을 새로 만드는 일은 드물다. 이미 존재하는 runtime trait을
구현해야 할 때만 그 계약을 따르고, 도메인 계층에는 Poll을 누출하지 않는 편이 낫다.
타입 → core → adapter → entry point 경계를 유지하면 executor 세부사항은 adapter에 머문다.
검토 체크리스트
- 이 trait은 도메인 동작인가, executor와 맞닿는 저수준 primitive인가
- 정적 dispatch로 충분한가, 실제로 trait object가 필요한가
- 반환 future가
Send여야 하는 호출 경로가 있는가 -
Poll::Pending을 반환하기 전에 wake-up을 올바르게 등록하는가 - pinning과
Unpin가정을 타입 경계에서 설명했는가 - cancellation과 반복 poll에 안전한가
- runtime이 제공하는 extension trait을 다시 구현하고 있지는 않은가
관련 문서
- Cargo workspace 가이드 — 비동기 adapter와 순수 core의 package 경계
- Rust workspace versioning — 멤버 버전 관리