Why async Rust? - without.boats를 참고하고 AI 분석하면서 이해한 내용.
Rust의 비동기가 어떤 식으로 동작하는지 가볍게 정리
Runtime 구현 이해는 tokio의 튜토리얼에서 mini tokio가 도움이 되었다. 기억 안나면 다시 보는걸 추천.
| 특성 | Rust의 선택 | 의미 |
|---|---|---|
| Stackful/Stackless | Stackless | 별도 스택 없이 컴파일러가 생성한 struct에 상태 저장 |
| Explicit/Implicit | Explicit | await 키워드로 yield point가 코드에서 명시적으로 보임 |
| Cooperative/Preemptive | Cooperative | 태스크가 자발적으로 yield, 스케줄러가 강제 중단 불가 |
Rust는 원래 Go처럼 Green Thread(Stackful 코루틴)를 가지고 있었다. 2014년 1.0 릴리스 직전에 제거되었다.
Green Thread의 문제:
-
스택 관리 문제: Green Thread는 별도 스택이 필요하다. 작게 시작해서 필요할 때 늘려야 하는데, 두 가지 방식 모두 문제가 있었다.
- Segmented Stack: 스택을 링크드 리스트로 관리. 핫 루프에서 세그먼트 경계를 넘나들면 매 반복마다 할당/해제 발생.
- Stack Copying: 스택이 꽉 차면 더 큰 곳으로 복사. 스택을 가리키는 포인터가 무효화된다. Go는 스택 내부 포인터만 스캔하면 되지만, Rust는 힙이나 다른 스레드 스택에서도 스택을 가리킬 수 있다. 이 포인터들을 추적하는 건 GC 문제와 같다.
-
FFI 비용: C ABI는 OS 스택을 사용한다. Green Thread에서 C 코드를 호출하려면 OS 스택으로 전환해야 한다. Go는 이 비용을 감수하지만, Rust는 C/C++ 라이브러리와의 zero-cost FFI를 목표로 한다.
-
임베디드/no_std 환경: Green Thread 런타임을 돌릴 여유가 없는 환경도 지원해야 한다.
결국 Rust는 Green Thread를 제거하고, 대신 Stackless 코루틴 방식을 선택했다.
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"
Green Thread의 스택:
┌─────────────────────┐
│ (예약된 공간) │ ← 실제로 쓸지 모르는 공간
│ ... │
│ 현재 사용 중 │
└─────────────────────┘
런타임에 크기 결정, 동적 확장 필요
async fn의 상태 머신:
┌─────────────────────┐
│ ExampleFuture │ ← 컴파일 타임에 크기 결정
│ ├─ state: enum │
│ ├─ local_a: i32 │ ← 필요한 변수만 저장
│ └─ inner: InnerFut │ ← 중첩 Future도 인라인
└─────────────────────┘
- 컴파일 타임에 Future 크기가 결정됨
- 스택이나 힙에 일반 struct처럼 할당
- 소유권 시스템으로 수명 관리
- Drop trait으로 자동 해제
GC 없이도 메모리 안전성 보장.
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라는 것은 별도의 실행 스택을 보존하지 않는다는 의미다.
- 일반 함수 호출은 콜 스택에 스택 프레임을 쌓음
async fn은.await시점에 상태를 구조체에 저장하고, 콜 스택은 unwind(풀다)됨- 나중에 재개할 때 구조체에서 상태를 복원
이로 인한 제약: function coloring - foo() → bar() → baz() 호출 체인에서 baz가 async면, bar와 foo도 async여야 한다. 중간에 있는 일반 함수의 스택 프레임은 보존되지 않기 때문.
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한다.
상태 머신 변환의 한계가 명확히 드러나는 지점이 재귀 처리다.
// 컴파일 에러
async fn recursive(n: u32) {
if n > 0 {
recursive(n - 1).await;
}
}컴파일러가 상태 머신 크기를 계산할 수 없다. RecursiveFuture 안에 RecursiveFuture가 들어가므로 크기가 무한대가 된다.
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로 처리된 추상화가 깨진다는 문제가 있다.
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는 단순히 poll만 호출하는 게 아니다.
| 역할 | 설명 |
|---|---|
| Future 스케줄링 | 여러 Future를 관리하고 실행 순서 결정 |
| I/O 이벤트 처리 | epoll/kqueue 등으로 비동기 I/O 완료 감지 |
| Waker 관리 | Future가 깨어나야 할 때 알림 |
| 스레드 풀 | 멀티코어 활용을 위한 워커 스레드 관리 |
| Blocking I/O 스레드풀 관리 | Blocking I/O를 호출해야 하는 경우 작업에 영향을 주지 않도록 스레드풀을 관리하고 해당 풀에 요청을 위임 |
tokio 같은 라이브러리가 이 모든 것을 제공한다.
Rust 표준 라이브러리는 Future trait만 제공하고, Executor는 제공하지 않는다.
이유: Rust는 웹 서버부터 임베디드까지 다양한 환경을 지원해야 한다.
- 웹 서버: tokio (epoll 기반, 멀티스레드)
- 임베디드: embassy (인터럽트 기반, no_std)
- 동기 환경의 어댑터: pollster
환경마다 최적의 Executor 구현이 다르므로, 선택권을 사용자에게 넘긴다.
- async fn → 상태 머신: 컴파일러가
Future를 구현하는 enum/struct로 변환 - Stackless: 별도 스택 없이 필요한 변수만 구조체에 저장
- Explicit:
.await키워드가 상태 전이 지점 - poll 기반:
Future::poll이 호출되어야 실행됨 (lazy) - 재귀 한계: 크기를 알 수 없으므로
Box로 힙 할당 필요 - Executor 분리: 표준 라이브러리는 trait만 제공, 구현은 tokio 등 외부 라이브러리
- GC 불필요: 컴파일 타임 크기 결정 + 소유권 시스템으로 메모리 관리