가상 시간 시뮬레이션을 만들다 곁길로 새며, 가능한 한 적은 보일러플레이트로 저수준 async Rust의 Future, Pin, Context/Waker, 프리미티브 연산을 직접 다뤄 본 기록과 남은 질문들.
URL: https://dotat.at/@/2026-02-16-async.html
Tony Finch – blog
시뮬레이션을 하나 쓰고 있다. 아니, 정확히는 미루고 있다. 그리고 이 블로그 글은 메인 퀘스트에서 옆길로 새 버린 결과물이다.
그 시뮬레이션에는 여러 태스크가 있고, 각 태스크는 중간중간 지연(delay)이 있는 여러 단계의 과정을 거친다. 그리고 각 단계는 어떤 공유 상태(shared state)에 영향을 줄 수 있다. 나는 이것이 가짜 가상 시간(fake virtual time)으로 돌아가길 원한다. 그래서 지연은 실제로 sleep()을 호출하는 게 아니라 변수에 대한 행정적 업데이트일 뿐이어야 한다. 그리고 변이가 올바른 순서로 일어나도록 보장하고 싶다.
처음에는 각 태스크를 enum State로 표현하고, 큰 match state로 각 단계를 처리하는 식을 생각했다. 그런데 곧 이런 생각이 들었다. async라면 enum State와 match state를 내가 직접 쓰지 않아도, 대신 써 주는 거 아닌가? 그리고 async로 시뮬레이션을 쓰면 보일러플레이트가 얼마나 많아질지 궁금해졌다.
내 문제를 풀어 주는 크레이트를 찾아 뒤지기보다는, 이걸 저수준 async Rust를 조금 배울 기회로 삼기로 했다.
가능한 한 많은 것을 걷어내고 보니, 보일러플레이트가(보통 글꼴 크기로 인쇄했을 때) 종이 한 장 한 면에 들어갈 정도였다. 그렇게 나쁘지 않다!
하지만 질문이 있다…
출발점은 이것을 쓰는 것이었다:
rustasync fn deep_thought() -> u32 { 42 } fn main() { deep_thought(); }
deep_thought()를 호출하면 즉시 Future<Output = u32>를 얻는다. 컴파일러가 경고하듯, deep_thought() 안의 코드는 아무 것도 실행되지 않는다. 그저 deep_thought()의 상태 머신 초기 상태를 담고 있는, 정체를 딱 집어 말하기 어려운(ineffable) 타입의 값을 구성할 뿐이다.
실제로 실행하려면 poll()해야 한다. Future::poll() 메서드는 시그니처부터 곧바로 여러 장애물을 드러낸다:
rustfn poll( self: Pin<&mut Self>, ctx: &mut Context<'_>, ) -> Poll<Self::Output>
일반적인 Rust 자료구조와 달리, Future는 자기 자신을 참조하는 참조를 담을 수 있다. (Rust 함수에서 변수는 다른 변수를 참조할 수 있고, Future는(대략 말해) 함수의 활성 프레임을 담고 있으므로 자기참조가 가능해진다.) 그래서 일반적인 Rust 데이터 타입은 이동(move)될 수 있지만, Future는 빌려진 상태가 아닐 때조차도 같은 주소에 머물러 있어야 한다. Pin 타입은 Future를 움직이지 못하게 고정시키는 데 사용된다.
내 목적에는, 힙에 있는 Box 안에 Future를 Pin하는 게 가장 쉽다. Pin된 Future를 감싸는 struct Task를 정의해서 몇 개의 메서드를 붙이려고 한다. (더 정교한 async 프레임워크는 보통 자기네 Task와 그 Future 사이에 더 많은 레이어를 둔다.)
이 래퍼는 정체를 딱 집어 말하기 어려운 Fut 타입과, 궁극적 반환 타입 Out(deep_thought()의 경우 u32)에 대해 제네릭이다.
ruststruct Task<Fut> { future: Pin<Box<Fut>>, } impl<Fut, Out> Task<Fut> where Fut: Future<Output = Out>, { fn spawn(future: Fut) -> Self { let future = Box::pin(future); return Task { future }; } }
Task를 구성하는 모습은 이렇다:
rustlet mut task = Task::spawn(deep_thought());
poll()의 두 번째 인자는 Context인데, 이는 Waker를 감싼 래퍼다.
Context를 만드는 가장 단순한 방법은 Waker::noop()를 사용하는 것이다. 이는 deep_thought()가 실제로 실행되게 하는 데 충분하다. 내가 미루고 있는 가짜 시간 시뮬레이션에서도 그렇듯, 750만 년이 눈 깜짝할 사이에 지나간다.
rustlet mut ctx = Context::from_waker(Waker::noop()); match task.future.as_mut().poll(&mut ctx) { Poll::Pending => { todo!(); } Poll::Ready(answer) => { println!("the answer is {answer}"); } }
async 함수는 다른 async 함수를 호출할 수 있고, (async 코드에서도 일반 코드와 마찬가지로) 호출된 async 함수는 아무 것도 하지 않고 Future를 반환한다. 그것이 무언가를 하게 하려면 호출자가 그 Future를 .await해야 한다. 내부적으로 .await는 Future를 poll()하는 코드로 컴파일된다.
async .await 호출의 사슬은 바깥 세상과 상호작용하는 원시(primitve) 연산에서 바닥을 친다. 원시 async 연산은 impl Future 상태 머신 자료구조인데, 컴파일러의 트릭에 기대지 않고 수동으로 작성된다.
보통 원시 Future는 두 번 poll()된다:
첫 번째에는 연산이 일어나도록 준비한 다음 Poll::Pending을 반환한다. async 실행기는 그 연산이 진행되는 동안 이 Task를 중단(suspend)한다.
연산이 완료된 뒤 async 실행기는 poll()로 Task를 재개(resume)하는데, 이는 곧 원시 Future에 대한 두 번째 poll()이 된다. 이번에는 연산 결과를 담은 Poll::Ready()를 반환하고, 이는 .await가 반환하는 값이 된다.
원시 Future는 더 많은 poll()이 필요한 복잡한 상태 머신을 구현할 수도 있지만, Task를 실제로 중단시키려면 최소 두 번이 필요하다.
한 가지 요점을 설명하기 위해 가능한 한 최소한만 하는 내 접근을 이어서, 여기 가짜로 잠드는 척하는 스텁 Future가 있다. 아주 사소한 상태 머신은 delay 값에 인코딩되어 있다. 0이면 Future는 중단 없이 진행하고, 0이 아니면 Future는 중단하되 먼저 delay를 리셋해서 다음 번에는 진행되게 한다.
ruststruct Sleep(u32); impl Future for Sleep { type Output = (); fn poll( mut self: Pin<&mut Self>, _: &mut Context<'_> ) -> Poll<()> { if self.0 > 0 { self.0 = 0; return Poll::Pending; } else { return Poll::Ready(()); } } }
사용 예로, deep_thought()는 Sleep() 객체(우리의 최소 상태 머신)를 만든 다음 .await하여 poll()이 호출되게 함으로써 오랜 시간을 보내는 척할 수 있다.
rustasync fn deep_thought() -> u32 { Sleep(7_500_000).await; 42 }
이제 메인 루프는 완료까지 실행하려면 Task를 두 번 poll()해야 한다.
rustloop { match task.future.as_mut().poll(&mut ctx) { Poll::Pending => { println!("sleeping for 7.5 million years..."); } Poll::Ready(answer) => { println!("the answer is {answer}"); return; } } }
그 최소한의 개념 증명에서는 가짜 Sleep 프리미티브는 Task를 중단시키는 것 외엔 실제로 아무 것도 하지 않으며, 최상위 async 실행기 루프는 왜 Task가 중단되었는지 알고 있다고 태연하게 가정한다.
Context와 그 안의 Waker의 목적은 원시 Future가 async 실행기 루프와 의사소통하게 하는 것이다. 즉, 연산이 일어나도록 준비하고, 연산이 진행되는 동안 Task를 중단시키는 것이다.
그러니 내 가짜 Sleep이 가짜 시간의 흐름을 반영하려면, Waker::noop()보다 더 유용한 일을 하는 내 Waker를 만들어야 한다.
내가 이해하기로 설계 의도는 대략 이런 것이다: Waker는 현재 Task를 가리키는 스마트 포인터를 감싼 래퍼다. 원시 Future가 태스크를 중단시킬 때, 진행 중인 연산에 그 Waker를 저장해 둔다. 연산이 끝나면 Waker가 wake()를 통해 자기 Task를 깨우고, 그것이 async 실행기 루프에 다시 올라가 poll()된다.
Waker를 만들려면 RawWaker를 만들어야 한다:
rustpub const unsafe fn Waker::from_raw( waker: RawWaker ) -> Waker; pub const fn RawWaker::new( data: *const (), vtable: &'static RawWakerVTable ) -> RawWaker;
이건 절망적이다. 마치 손으로 굴린 객체지향 C 같다. 타입 안전한 dyn Trait 대신, raw pointer 하나와, 구조체 안의 함수 목록, 그리고 unsafe 코드로 뭔가를 덕지덕지 만들어야 한다.
(편집 추가, 2026-02-18) Arthur Carcano가 Lobsters에서 지적해 줬는데, 나는 Wake 트레잇을 알아차리지 못했다. 이는 Waker를 안전하게 구성하는 방법을 제공한다. 하지만 안전한 Wake 트레잇은 원시 Future가 동작하는 방식에 불편한 제약을 걸어서, 내가 Wake 트레잇 없이 찾은 해법보다 더 복잡해진다.
여기서 나는 막혔다. 내 Task들과 실행기 루프가 서로를 어떻게 참조해야 하는지, 그리고 어떤 스마트 포인터를 raw *const() 포인터로 몰래 통과시킬 수 있는지 우울하게 따져 보고 있었다. 그러다 결국 더 간단한 방법이 있음을 깨달았다.
원시 Future가 연산이 일어나도록 준비하는 방법은 몇 가지가 있다:
시스템 콜을 즉시 호출하고 전역 자료구조를 변이시켜 연산을 발사한 다음, Poll::Pending을 반환해서 스스로를 중단시키는 방법. 이 경우 스마트 포인터의 어려운 얽힘이 필요하다.
또는 먼저 스스로를 중단시키고, async 실행기가 Task를 대신해 수행할 커맨드를 반환하는 방법. 이는 Poll::Pending에 페이로드를 실어 보낼 수 없어서 어색하다. 하지만 Context는 사이드 채널을 제공하므로, 이를 통해 반환 값을 몰래 빼낼 수 있다.
가상의 안전한 Rust에서는, Task가 대략 다음과 같이 Command를 반환할 수 있다:
rustlet mut cmd = Command::Run;
Task를 poll()하며 커맨드에 대한 가변 대여를 넘긴다.rustlet p = task.future.as_mut().poll(&mut cmd);
Future가 어떤 연산을 수행하고 싶을 때, Task를 중단시키기 전에 커맨드를 덮어쓴다.rustfn poll( mut self: Pin<&mut Self>, cmd: &mut Command ) -> Poll<()> { *cmd = Command::Example; return Poll::Pending; }
poll()로부터 Poll::Pending을 받으면, 커맨드를 보고 Task를 어떻게 처리할지 결정한다.현실의 Rust에서는 빌린 &mut cmd를 RawWaker의 raw *const() 포인터를 통해 몰래 전달해야 한다.
나는 연산이 끝났을 때 Waker로 Task를 되살리는 일을 하지 않으므로, Waker::noop()의 RawWakerVTable을 재사용할 수 있다.
원시 커맨드들과 Poll::Ready()를 하나의 enum으로 합친 Yield 타입을 정의하겠다. 그리고 최상위 태스크의 반환 타입은 Future<Output = ()>로 고정하겠다. (Output 타입을 제네릭으로 유지하려면 보일러플레이트가 너무 많이 필요하다.)
“Yield”에는 두 가지 의미가 있다. 활동(activity)에서 반환(yield)되는 결과라는 의미와, 태스크가 CPU를 양보(yield)한다는 의미.
rust#[derive(Copy, Clone, Debug)] enum Yield { Run, Sleep(u32), // maybe other commands here Done(), }
async 실행기 루프는 Task의 poll()을 호출한다. 그러면 Yield 플레이스홀더를 만들고, 그에 대한 포인터를 새로운 Context에 저장한다.
rustimpl<Fut> Task<Fut> where Fut: Future<Output = ()>, { fn poll(&mut self) -> Yield { let mut yld = Yield::Run; let data = &mut yld as *mut Yield as *const (); let vtable = Waker::noop().vtable(); let waker = unsafe { Waker::new(data, vtable) }; let mut ctx = Context::from_waker(&waker); match self.future.as_mut().poll(&mut ctx) { Poll::Pending => yld, Poll::Ready(()) => Yield::Done(), } } }
Yield 타입은 원시 Future의 직접적인 표현으로도 쓰인다. async 함수는 Yield를 구성하고 .await하는데, 그러면 Context를 통해 Yield가 async 실행기 루프로 반환된다. 중단하기 전에 Future/Yield는 Yield::Run으로 리셋되어 다음번 Task가 poll()될 때 실행이 계속된다. (이전 예제에서 Sleep delay를 0으로 리셋하는 것과 유사하다.)
rustimpl Future for Yield { type Output = (); fn poll( mut self: Pin<&mut Self>, ctx: &mut Context<'_>, ) -> Poll<Self::Output> { if let Yield::Run = *self { return Poll::Ready(()); } else { let yld = ctx.waker().data() as *mut Yield; let yld = unsafe { yld.as_mut().unwrap() }; *yld = *self; *self = Yield::Run; return Poll::Pending; } } }
unsafe 코드에 대한 논의는 아래에 더 있다.
async 실행기 루프는 자신의 태스크들이 Yield한 커맨드들을 수행해야 한다. 타이머에 대한 전형적인 자료구조는 기상 시각을 키로 하는 최소 힙(min-heap)이다. 가짜 시간은, 실제 슬립이나 기상 시각 사이의 지연 없이, 그냥 일반적인 타이머 큐일 뿐이다.
내 Task 타입에 기상 시각을 추가한 뒤, 메인 프로그램은 대략 이런 스케치가 된다:
rustlet mut tasks = BinaryHeap::new(); for i in 1..=TASKS { tasks.push(Task::spawn(activity(i, LIMIT))); } while let Some(mut task) = tasks.pop() { match task.poll() { Yield::Sleep(delay) => { task.wake_up += delay; tasks.push(task); } Yield::Done() => { // drop completed task } yld => panic!("unexpected {yld:?}"), } }
메인 프로그램은 서로 다른 시간만큼 루프에서 잠드는 activity()들을 몇 개 스폰한다. 이들은 진행 상황을 stdout에 보고한다. 이 데모에서는 태스크들이 동기적으로 출력하길 원한다(비동기 IO 금지!). 상태 머신의 진행을 보여 주기 위해서다.
이 데모는 매우 단순화되어 있지만, 내가 미루고 있는 가짜 시간 시뮬레이션과 대략 같은 형태다.
rustasync fn activity(delay: u32, stop: u32) { let mut now = 0; println!("{now} {delay} start"); loop { Yield::Sleep(delay).await; now += delay; if now < stop { println!("{now} {delay} continue"); continue; } else { println!("{now} {delay} return"); return; } } }
완전한 데모는 Rust playground에서 동작하는 것을 볼 수 있다. 태스크 1은 매 tick마다 깨어나고, 태스크 2는 한 tick씩 건너뛰어 깨어나는 식이다.
Waker가 그냥 어떤 트레잇 바운드를 가진 추상 제네릭 타입 파라미터가 아니라서, 안전 코드로 정의할 수 없는 이유를 모르겠다. 내가 보기엔 언어와 표준 라이브러리는 Waker의 정확한 모양에 의존하지 않는 것처럼 보이므로, 세부사항은 async 런타임 라이브러리로 미뤄질(punt) 거라 예상했을 것이다. 아마 표준 라이브러리가 Waker의 모양을 부분적으로 제한해야 하는 뭔가를 내가 놓치고 있는 것 같다.
내 unsafe 코드에는 몇 가지 약점이 있다. Miri는 코드가 괜찮다고 말하는데, 이는 가변 대여와의 유추에 기반한 내 손흔들기식(handwavy) 정당성 주장과 일치한다. 하지만 컴파일러가 poll()에 의해 yld가 변이될 수 있음을 보장적으로 아는지 확신이 없다.
대안으로는, Yield::poll()과 같은 방식으로 Context에서 &mut Yield 참조를 재구성해, 변이된 Yield를 Task::poll()에서 반환하는 방법이 있을 수 있다. 하지만 그러면 컴파일러가 빌린 yld가 함수 끝까지 살아 있어야 함을 알지 확신이 없다.
지금은 더 짧은 코드를 선택했다.
직접 하는 법을 배웠으니, 이 문제를 이미 해결하는 크레이트가 있다면 듣고 싶다.
댓글 환영: •Dreamwidth•Fediverse•Lobsters•
⇐ 2026-01-15 ⇐ GCRA vs leaky / token buckets ⇐⇒ ☆ ⇒ ☆ ⇒