Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save YangSiJun528/973e406e46f0f7520ed883bb3b03aa0e to your computer and use it in GitHub Desktop.

Select an option

Save YangSiJun528/973e406e46f0f7520ed883bb3b03aa0e to your computer and use it in GitHub Desktop.
Rust 비동기 실행 모델의 내부 원리.md

Rust 비동기 실행 모델의 내부 원리

Why async Rust? - without.boats를 참고하고 AI 분석하면서 이해한 내용.
Rust의 비동기가 어떤 식으로 동작하는지 가볍게 정리

Runtime 구현 이해는 tokio의 튜토리얼에서 mini tokio가 도움이 되었다. 기억 안나면 다시 보는걸 추천.

1. 왜 이런 설계를 선택했는가

Rust 비동기 모델의 특성

특성 Rust의 선택 의미
Stackful/Stackless Stackless 별도 스택 없이 컴파일러가 생성한 struct에 상태 저장
Explicit/Implicit Explicit await 키워드로 yield point가 코드에서 명시적으로 보임
Cooperative/Preemptive Cooperative 태스크가 자발적으로 yield, 스케줄러가 강제 중단 불가

Green Thread의 실패

Rust는 원래 Go처럼 Green Thread(Stackful 코루틴)를 가지고 있었다. 2014년 1.0 릴리스 직전에 제거되었다.

Green Thread의 문제:

  1. 스택 관리 문제: Green Thread는 별도 스택이 필요하다. 작게 시작해서 필요할 때 늘려야 하는데, 두 가지 방식 모두 문제가 있었다.

    • Segmented Stack: 스택을 링크드 리스트로 관리. 핫 루프에서 세그먼트 경계를 넘나들면 매 반복마다 할당/해제 발생.
    • Stack Copying: 스택이 꽉 차면 더 큰 곳으로 복사. 스택을 가리키는 포인터가 무효화된다. Go는 스택 내부 포인터만 스캔하면 되지만, Rust는 힙이나 다른 스레드 스택에서도 스택을 가리킬 수 있다. 이 포인터들을 추적하는 건 GC 문제와 같다.
  2. FFI 비용: C ABI는 OS 스택을 사용한다. Green Thread에서 C 코드를 호출하려면 OS 스택으로 전환해야 한다. Go는 이 비용을 감수하지만, Rust는 C/C++ 라이브러리와의 zero-cost FFI를 목표로 한다.

  3. 임베디드/no_std 환경: Green Thread 런타임을 돌릴 여유가 없는 환경도 지원해야 한다.

결국 Rust는 Green Thread를 제거하고, 대신 Stackless 코루틴 방식을 선택했다.

Iterator에서 Future로

Rust의 비동기 모델은 사실 Iterator 설계의 연장선이다.

초기 Rust의 Iterator는 콜백 기반이었다:

// 과거 방식 (internal iterator)
trait Iterator {
    fn iterate(self, f: impl FnMut(Item) -> ControlFlow);
}

2013년 Daniel Micay가 현재의 external iterator를 제안했다:

// 현재 방식 (external iterator)
trait Iterator {
    fn next(&mut self) -> Option<Self::Item>;
}

External iterator의 장점:

  • 여러 iterator를 교차로 순회 가능 (zip 등)
  • 컴파일러가 상태 머신으로 최적화 가능
  • Rust의 소유권/차용 시스템과 자연스럽게 통합

Future도 같은 패턴을 따른다. 초기에는 콜백(continuation) 기반이었다:

// continuation 기반 (다른 언어들의 방식)
trait Future {
    fn schedule(self, continuation: impl FnOnce(Self::Output));
}

문제: join 같은 combinator에서 continuation을 여러 Future가 공유해야 해서 할당이 필요했다.

Aaron Turon과 Alex Crichton이 readiness 기반으로 전환했다:

// readiness 기반 (Rust의 방식)
trait Future {
    fn poll(&mut self) -> Poll<Self::Output>;
}

Iterator의 external 전환과 Future의 readiness 전환은 본질적으로 같은 변환이다. 콜백을 외부 드라이버로 대체하고, combinator들이 단일 상태 머신으로 컴파일되게 한다.

Perfectly Sized Stack

이 설계의 핵심 결과: "perfectly sized stack"

Green Thread의 스택:
┌─────────────────────┐
│ (예약된 공간)       │  ← 실제로 쓸지 모르는 공간
│ ...                 │
│ 현재 사용 중        │
└─────────────────────┘
  런타임에 크기 결정, 동적 확장 필요

async fn의 상태 머신:
┌─────────────────────┐
│ ExampleFuture       │  ← 컴파일 타임에 크기 결정
│ ├─ state: enum      │
│ ├─ local_a: i32     │  ← 필요한 변수만 저장
│ └─ inner: InnerFut  │  ← 중첩 Future도 인라인
└─────────────────────┘
  • 컴파일 타임에 Future 크기가 결정됨
  • 스택이나 힙에 일반 struct처럼 할당
  • 소유권 시스템으로 수명 관리
  • Drop trait으로 자동 해제

GC 없이도 메모리 안전성 보장.

2. async fn과 상태 머신 변환

컴파일러가 하는 일

async fn을 작성하면 컴파일러가 Future trait을 구현하는 상태 머신 구조체로 변환한다.

// 작성하는 코드
async fn example() -> i32 {
    let a = fetch_a().await;
    let b = fetch_b().await;
    a + b
}

컴파일러가 생성하는 것 (개념적):

enum ExampleFuture {
    Start,
    WaitingA { fut_a: FetchAFuture },
    WaitingB { a: i32, fut_b: FetchBFuture },
    Done,
}

impl Future for ExampleFuture {
    type Output = i32;
    
    // 실제 시그니처는 Pin<&mut Self>지만, 상태 머신 설명과 무관하므로 생략
    fn poll(&mut self, cx: &mut Context) -> Poll<i32> {
        loop {
            match &mut self.state {
                Start => {
                    // fetch_a() 호출 → Future 생성, 아직 실행 안 됨
                    self.state = WaitingA { fut_a: fetch_a() };
                    // loop 계속 → 바로 WaitingA 처리
                }
                WaitingA { fut_a } => {
                    match fut_a.poll(cx) {
                        Poll::Ready(a) => {
                            self.state = WaitingB { a, fut_b: fetch_b() };
                            // loop 계속 → 바로 WaitingB 처리
                        }
                        Poll::Pending => return Poll::Pending,  // 여기서 멈춤
                    }
                }
                WaitingB { a, fut_b } => {
                    match fut_b.poll(cx) {
                        Poll::Ready(b) => {
                            let result = *a + b;
                            self.state = Done;
                            return Poll::Ready(result);
                        }
                        Poll::Pending => return Poll::Pending,  // 여기서 멈춤
                    }
                }
                Done => panic!("poll after completion"),
            }
        }
    }
}

핵심: 각 .await 지점이 상태 전이 지점이 된다. 필요한 로컬 변수만 구조체 필드로 저장된다.

모든 async fn은 저런 식으로 바뀐다고 보면 된다.

loop { match {...} } 구조인 이유:

  • 내부 Future가 Ready를 반환하면 → 다음 상태로 전이하고 loop를 계속 돌아 바로 다음 상태 처리
  • 내부 Future가 Pending을 반환하면 → Pending을 반환하고 poll 종료
  • 최종 상태에 도달하면 → Ready(결과)를 반환

즉, 한 번의 poll 호출에서 여러 상태를 연속으로 처리할 수 있다. 중간에 Pending이 나오면 거기서 멈추고, Ready가 나오면 다음 상태로 넘어가서 계속 진행한다.

Stackless의 의미

Stackless라는 것은 별도의 실행 스택을 보존하지 않는다는 의미다.

  • 일반 함수 호출은 콜 스택에 스택 프레임을 쌓음
  • async fn.await 시점에 상태를 구조체에 저장하고, 콜 스택은 unwind(풀다)됨
  • 나중에 재개할 때 구조체에서 상태를 복원

이로 인한 제약: function coloring - foo() → bar() → baz() 호출 체인에서 baz가 async면, barfoo도 async여야 한다. 중간에 있는 일반 함수의 스택 프레임은 보존되지 않기 때문.

3. Future trait과 poll 기반 실행

Future trait

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

pub enum Poll<T> {
    Ready(T),
    Pending,
}
  • poll이 호출되어야 실행됨 (lazy evaluation)
  • Pending 반환 시 아직 완료되지 않음
  • Ready(value) 반환 시 완료

중첩 구조

.await는 내부적으로 중첩된 poll 호출이 된다.

async fn outer() {
    inner().await;  // inner의 Future를 poll
}

outer의 상태 머신은 inner의 Future를 필드로 가지고, outer가 poll될 때 inner를 poll한다.

4. 재귀 async fn의 한계

상태 머신 변환의 한계가 명확히 드러나는 지점이 재귀 처리다.

문제

// 컴파일 에러
async fn recursive(n: u32) {
    if n > 0 {
        recursive(n - 1).await;
    }
}

컴파일러가 상태 머신 크기를 계산할 수 없다. RecursiveFuture 안에 RecursiveFuture가 들어가므로 크기가 무한대가 된다.

해결: Box로 힙 할당

fn recursive(n: u32) -> Pin<Box<dyn Future<Output = ()>>> {
    Box::pin(async move {
        if n > 0 {
            recursive(n - 1).await;
        }
    })
}
  • async fn 문법 대신 fn으로 선언
  • Future를 Box로 감싸서 힙에 할당
  • 크기가 고정되므로 컴파일 가능

그러나 async fn로 처리된 추상화가 깨진다는 문제가 있다.

5. Executor와 Runtime

poll은 누가 호출하는가?

Future는 스스로 실행되지 않는다. 누군가 poll을 호출해야 한다.

// 최상위에서 Future 실행
fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        // async 코드
    });
}

tokio는 #[tokio::main] 매크로를 제공해서 이 boilerplate를 숨긴다.

// 작성하는 코드
#[tokio::main]
async fn main() {
    println!("Hello world");
}

// 매크로가 변환한 코드
fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            println!("Hello world");
        })
}

block_on이 Future를 받아서 완료될 때까지 현재 스레드를 블로킹하며 poll을 호출한다.

Executor의 역할

Executor는 단순히 poll만 호출하는 게 아니다.

역할 설명
Future 스케줄링 여러 Future를 관리하고 실행 순서 결정
I/O 이벤트 처리 epoll/kqueue 등으로 비동기 I/O 완료 감지
Waker 관리 Future가 깨어나야 할 때 알림
스레드 풀 멀티코어 활용을 위한 워커 스레드 관리
Blocking I/O 스레드풀 관리 Blocking I/O를 호출해야 하는 경우 작업에 영향을 주지 않도록 스레드풀을 관리하고 해당 풀에 요청을 위임

tokio 같은 라이브러리가 이 모든 것을 제공한다.

표준 라이브러리에 Executor가 없는 이유

Rust 표준 라이브러리는 Future trait만 제공하고, Executor는 제공하지 않는다.

이유: Rust는 웹 서버부터 임베디드까지 다양한 환경을 지원해야 한다.

  • 웹 서버: tokio (epoll 기반, 멀티스레드)
  • 임베디드: embassy (인터럽트 기반, no_std)
  • 동기 환경의 어댑터: pollster

환경마다 최적의 Executor 구현이 다르므로, 선택권을 사용자에게 넘긴다.

6. 핵심 요약

  1. async fn → 상태 머신: 컴파일러가 Future를 구현하는 enum/struct로 변환
  2. Stackless: 별도 스택 없이 필요한 변수만 구조체에 저장
  3. Explicit: .await 키워드가 상태 전이 지점
  4. poll 기반: Future::poll이 호출되어야 실행됨 (lazy)
  5. 재귀 한계: 크기를 알 수 없으므로 Box로 힙 할당 필요
  6. Executor 분리: 표준 라이브러리는 trait만 제공, 구현은 tokio 등 외부 라이브러리
  7. GC 불필요: 컴파일 타임 크기 결정 + 소유권 시스템으로 메모리 관리
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment