Tech Wiki

[Rust 실전 로드맵 23] Rust Future·Poll·Pin: async/await 아래에서 일어나는 일

async fn을 호출했다고 함수 본문이 곧바로 실행되지는 않습니다. 호출 결과는 Future이며, 누군가 poll해야 계산이 진행됩니다. await가 가려 주는 밑바닥에는 Poll::PendingPoll::Ready, 다음 poll을 요청하는 Waker, 그리고 이동 제약을 타입으로 표현하는 Pin이 있습니다.

이 글은 Rust 2024 edition과 rustc·Cargo 1.98.1을 기준으로 합니다. 표준 라이브러리만 쓰는 작은 예제를 한 번씩 직접 poll해 각 경계를 확인합니다. 여기서 만드는 polling driver는 개념 확인용입니다. 운영 executor나 비동기 런타임을 구현하지 않으며 네트워크, sleep, 시간 측정도 사용하지 않습니다.

1. Future는 poll되기 전까지 멈춰 있습니다

Future는 완료될 수도 있는 비동기 계산을 나타냅니다. 생성과 실행은 분리됩니다. future를 만들거나 변수에 담는 일만으로는 그 계산이 진행되지 않습니다. future 자체는 inert하며, 진행하려면 능동적으로 poll해야 합니다.

1.1. Future::poll의 입력과 출력

핵심 메서드는 다음 형태입니다.

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

호출자는 future를 Pin<&mut Self>로 건네고 현재 task의 Context를 함께 전달합니다. 반환값이 Poll::Pending이면 아직 결과가 없습니다. Poll::Ready(value)이면 계산이 끝났고 value가 최종 출력입니다.

예제의 TraceFuture::new()는 poll 횟수를 기록하는 카운터와 함께 future를 만듭니다. 생성 직후 카운터는 0이고 첫 poll_once 뒤에만 1이 됩니다. 생성자는 계산을 몰래 시작하지 않습니다.

1.2. PendingReady 뒤의 계약

Pending은 실패나 취소가 아닙니다. 지금은 완료할 수 없으니 진행 가능해졌을 때 현재 task가 다시 poll되도록 준비했다는 응답입니다. 반대로 Ready는 이 future의 완료 경계입니다.

완료된 future를 다시 poll해서 같은 값을 얻을 수 있다고 가정하면 안 됩니다. Future 계약은 Ready 이후 재poll의 효과를 보장하지 않습니다. panic이 날 수도 있고 영원히 멈추거나 다른 문제가 생길 수도 있습니다. 예제도 첫 Ready까지만 poll한 뒤 해당 future를 더 사용하지 않습니다.

2. Waker는 poll하지 않고 다시 poll해 달라고 알립니다

첫 poll이 Pending을 반환하면 누가 다음 poll을 시작할까요? Waker는 task를 깨울 수 있는 handle입니다. wake는 executor에 해당 task가 다시 실행될 준비가 되었다고 알립니다. 여기서 경계를 분명히 해야 합니다. WakerFuture::poll을 직접 호출하는 것은 아닙니다.

2.1. readiness 신호와 wake 요청 분리

예제는 ReadySignal로 readiness를 직접 바꿉니다. 상태는 mutex 하나에 묶여 있습니다.

#[derive(Debug, Default)]
struct SignalState {
    ready: bool,
    registered: Option<Waker>,
}

#[derive(Debug, Default)]
pub struct ReadySignal {
    state: Mutex<SignalState>,
}

ready는 평범한 bool이고 최신 Waker도 같은 Mutex<SignalState> 안에 있습니다. 원자 타입은 poll 횟수와 wake 요청 횟수를 세는 카운터에만 씁니다. readiness 자체를 atomic flag로 두지 않았기 때문에 상태 변경과 등록된 waker 회수를 한 임계 구역에서 처리할 수 있습니다.

mark_readyready = true로 바꾸고 등록된 waker를 꺼낸 다음, mutex guard를 놓은 뒤 wake를 호출합니다. 테스트에서 wake 요청 횟수는 1로 늘지만 poll 횟수는 그대로 1입니다. 이후 polling driver가 명시적으로 두 번째 poll을 호출해야 Ready("signal observed")가 나옵니다.

2.2. 가장 최근 Context의 Waker를 보관하기

Context는 현재 task의 Waker에 접근할 통로를 제공합니다. 같은 future가 여러 번 Pending을 반환할 때는 가장 최근 Context에서 받은 waker가 다음 wakeup을 받아야 합니다. 이전 waker를 계속 들고 있으면 이미 다른 task 표현으로 교체된 등록을 깨울 수 있습니다.

fn poll_state(&self, cx: &Context<'_>) -> bool {
    let mut state = self.state.lock().expect("readiness mutex poisoned");
    if state.ready {
        true
    } else {
        state.registered = Some(cx.waker().clone());
        false
    }
}

예제는 첫 번째 waker로 한 번, 두 번째 waker로 다시 poll합니다. 두 번째 poll 뒤에는 첫 등록이 교체됩니다. mark_ready를 호출하면 첫 카운터는 0, 두 번째 카운터만 1이 됩니다. 이때도 future의 poll 횟수는 늘지 않습니다. wake는 재poll 요청이고 poll 실행은 별도 책임입니다.

3. 최소 polling driver가 보여 주는 것과 보여 주지 않는 것

교육용 helper인 poll_once는 borrowed WakerContext를 만든 뒤 정확히 한 번만 poll합니다.

pub fn poll_once<F: Future>(future: Pin<&mut F>, waker: &Waker) -> Poll<F::Output> {
    let mut context = Context::from_waker(waker);
    future.poll(&mut context)
}

이 코드는 Future, Context, Waker의 연결을 눈앞에 드러내지만 production executor는 아닙니다. task queue도 없고 wake 요청을 받아 자동으로 task를 예약하는 loop도 없습니다. 여러 task 사이의 공정성, 동시 실행, I/O readiness, 취소, panic 격리, shutdown도 다루지 않습니다.

예제에서는 signal.mark_ready() 다음에 두 번째 poll_once를 직접 호출합니다. 실제 executor라면 wake 알림을 스케줄링 정책에 반영하고 적절한 시점에 future를 다시 poll합니다. 이 글은 그 역할을 개념적으로만 구분합니다. 런타임과 Tokio task 구성은 다음 편의 범위입니다.

4. async fn과 async block도 Future를 만듭니다

async fn을 호출하면 함수의 반환형에 적힌 값이 즉시 나오는 것이 아니라 그 값을 출력할 future가 반환됩니다. async block 역시 평가되면 익명 future 타입을 만듭니다. future가 lazy하다는 규칙은 수동으로 구현한 TraceFuture와 동일합니다.

4.1. await가 없는 async fn도 poll이 필요합니다

예제의 함수 본문에는 await가 없습니다.

pub async fn async_answer() -> u8 {
    42
}

그래도 async_answer()의 결과는 u8이 아니라 Future<Output = u8>입니다. 호출 직후에는 body의 결과를 꺼내지 않습니다. Box::pin으로 고정하고 한 번 poll하면 이 단순한 본문은 Ready(42)를 반환합니다.

await가 있는 async code는 완료되지 않은 하위 future에서 Pending을 만날 수 있습니다. 다만 이 예제는 .await orchestration이나 runtime scheduling을 흉내 내지 않습니다. async fnasync block이 future를 만든다는 타입 경계까지만 확인합니다.

5. Pin은 안전한 API를 통한 이동을 제한합니다

Future::poll의 receiver가 &mut Self가 아니라 Pin<&mut Self>인 이유는 일부 future가 poll 사이에 자기 내부 위치가 유지된다는 보장에 의존할 수 있기 때문입니다. Pin<Ptr>는 pointer wrapper이며, safe API를 통해 그 pointee를 현재 메모리 위치에서 옮기거나 무효화하지 못하게 합니다.

표현을 과하게 넓히면 안 됩니다. Pin이 모든 종류의 메모리 이동을 우주적으로 금지하는 것은 아닙니다. pinning 계약을 깨는 unsafe code나 잘못된 구현까지 자동으로 막아 주지도 않습니다. 또한 Pin 자체가 값을 self-referential로 만들지 않습니다. 어떤 타입이 위치 안정성에 의존하는지는 그 타입의 구현과 불변식이 정합니다.

5.1. PhantomPinned!Unpin 경계 드러내기

TraceFuturePhantomPinned 필드를 넣어 자동 Unpin 구현을 받지 않습니다.

#[derive(Debug)]
pub struct TraceFuture {
    polls: Arc<PollCount>,
    signal: Arc<ReadySignal>,
    _pin: PhantomPinned,
}

let future = Box::pin(TraceFuture {
    polls,
    signal,
    _pin: PhantomPinned,
});

이 예제는 실제 self-reference를 만들지 않습니다. 오직 !Unpin 타입을 안전한 API에서 어떻게 다뤄야 하는지 보여 주는 표지입니다. Box::pin은 값을 pinned heap allocation에 놓고 Pin<Box<TraceFuture>>를 만듭니다. 이후 poll에는 future.as_mut()으로 얻은 Pin<&mut TraceFuture>만 전달합니다.

5.2. Unpin 타입은 Pin을 통해서도 안전하게 옮길 수 있습니다

Unpin은 pinning 보장이 필요 없는 타입을 표시하는 auto trait입니다. pointee가 Unpin이면 Pin이 일반 pointer처럼 작동해도 위치 불변식을 깨지 않습니다. 그래서 &mut F에서 안전하게 Pin::new를 만들 수 있습니다.

pub fn poll_unpinned_once<F: Future + Unpin>(
    future: &mut F,
    waker: &Waker,
) -> Poll<F::Output> {
    poll_once(Pin::new(future), waker)
}

테스트는 std::future::ready("movable future")를 이 helper에 전달해 Ready("movable future")를 확인합니다. 반면 PhantomPinned가 든 TraceFuture에는 이 helper를 쓸 수 없고 이미 pinned된 pointer를 받아들이는 poll_once 경로를 사용해야 합니다.

6. 결정적인 테스트로 경계 확인하기

프로젝트 디렉터리에서 다음 명령을 실행합니다.

cd examples/article-23-future-poll-pin
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

Rust 1.98.1과 Cargo 1.98.1에서 다섯 명령은 종료 코드 0을 반환해야 합니다. 테스트 모음에는 통합 테스트 6개가 있습니다. 실행 출력은 다음과 같이 고정됩니다.

constructed: polls=0 wake_requests=0
first poll: Pending polls=1 wake_requests=0
signal: polls=1 wake_requests=1
second poll: Ready("signal observed") polls=2 wake_requests=1
async fn: Ready(42)
pin boundary: TraceFuture uses PhantomPinned; Ready<T> is Unpin

6.1. 숫자가 증명하는 경계

첫 줄의 polls=0은 future 생성만으로 계산이 진행되지 않았음을 보여 줍니다. 첫 poll은 Pending을 반환하고 poll 횟수만 1로 올립니다. readiness를 표시하면 wake 요청은 1이 되지만 polls는 여전히 1입니다. 두 번째 poll을 명시적으로 수행한 뒤에야 polls가 2가 되고 Ready가 나옵니다.

테스트는 최신 Context의 waker 교체, async fn future의 첫 poll, Unpin helper도 별도로 확인합니다. 모두 직접 호출과 카운터를 사용하므로 스레드 스케줄 순서나 경과 시간에 기대지 않습니다. 다만 이 결과로 executor의 처리량이나 공정성, 실제 I/O 통합을 주장할 수는 없습니다.

Future를 읽을 때는 세 질문을 나누면 됩니다. 누가 poll하는가, Pending일 때 누가 최신 waker를 저장하고 깨우는가, 해당 타입이 위치 안정성을 요구하는가. 첫 두 질문의 운영 답은 executor와 runtime에 속합니다. 이번 편에서는 그 아래 계약만 고정했고, Tokio를 이용한 task 구성은 다음 편에서 이어갑니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 23 프로젝트에서 확인할 수 있습니다.

출처


1개 응답

  1. […] 이전 글Rust Future·Poll·Pin: async/await 아래에서 일어나는 일 […]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.