Rust의 AsyncIterator 안정화 논의를 다루며, poll_next 설계와 async fn next 설계를 성능, 핀(Pin), 취소(cancellation), 동적 디스패치, 객체 안전성, 사용성(어포던스) 측면에서 비교·분석하고, 지금 poll_next 설계를 받아들여 안정화해야 한다고 주장한다.
이전 글에서, 나는 Rust 프로젝트가 사용자들을 위해 할 수 있는 가장 좋은 일은 AsyncIterator를 안정화하는 것이라고 말했다. 나는 특히, 이미 표준 라이브러리에 존재하며 poll_next라는 메서드를 사용하는 인터페이스를 의미했다. 이상적으로는 이것이 몇 년 전에 일어났어야 하지만, 두 번째로 좋은 때는 바로 내일이다.
AsyncIterator 안정화를 가로막는 주요 요인은, 프로젝트의 영향력 있는 몇몇 기여자들이 대안 설계를 추구하겠다는 약속이다. 내가 “async next” 설계라고 부를 이 설계는, 오늘 구현돼 있는 “poll next” 설계의 poll 메서드 대신 async 메서드를 인터페이스로 사용하자고 제안한다. 내 의견으로는, 이 설계를 계속 추구하는 것은 실수다. 나는 이전에도 이에 대해 썼지만, Rust 프로젝트가 내 글을 충분히 받아들였다는 느낌이 들지 않는다.
async 작업 그룹의 주요 기여자인 Yosh Wuyts는 왜 async next 설계가 poll next보다 바람직한지에 대한 자신의 글을 썼다. 그의 글 상당 부분은, 나와 다른 사람들이 제기한 async next 설계의 문제점에 대한 반박 시도로 구성돼 있다. 나는 이 글의 주장을 설득력 있게 느끼지 못했고, 프로젝트가 무엇을 해야 하는지에 대한 내 입장은 변함이 없다. 나는 왜 프로젝트가 poll next 설계를 받아들이고 지금 AsyncIterator를 안정화해야 한다고 믿는지, 더 자세하고 단정적으로 다시 표현하기 위해 이 글을 쓴다.
poll next 설계와 async next 설계의 근본적인 차이는 비동기 반복을 위한 상태 머신의 표현 방식의 차이다. poll next 설계에서는 상태 머신이 하나뿐이다: 비동기 이터레이터 자체. async next 설계에서는 둘이다: 각 반복마다의 future 메서드(짧은 수명)와 그보다 더 오래 살아 있는 이터레이터(긴 수명)가 있다.
“타입 시스템” 관점에서 두 정의를 살펴보자. 차이를 더 명확히 느낄 수 있도록, async 트레이트 메서드를 궁극적인 형태로 디슈가(desugar)하겠다. 또한 두 설계에서의 for await 루프를 디슈가하여, 각각의 설계가 어떻게 동작하는지 이해를 돕겠다:
// POLL NEXT 설계
trait AsyncIterator {
type Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Option<Self::Item>>;
}
// ASYNC NEXT 설계
trait AsyncIterator {
type Item;
type Future<'a>: Future<Output = Option<Self::Item>> where Self: 'a;
fn next<'a>(&'a mut self) -> Self::Future<'a>;
}
// POLL NEXT 설계
let mut iter = pin!(iter);
'outer: loop {
let next = 'inner: loop {
match iter.as_mut().poll_next(cx) {
Poll::Ready(Some(item)) = break 'inner item,
Poll::Ready(None) => break 'outer,
Poll::Pending => yield Poll::Pending,
}
};
}
// ASYNC NEXT 설계
'outer: loop {
let mut future = pin!(iter.next());
let next = 'inner: loop {
match future.as_mut().poll(cx) {
Poll::Ready(Some(item)) = break 'inner item,
Poll::Ready(None) => break 'outer,
Poll::Pending => yield Poll::Pending,
}
};
}
두 설계의 두드러진 차이는 다음과 같다:
이런 설명은 글로만 하면 불분명할 수 있어, 요점을 분명히 하기 위해 시각적 다이어그램을 만들었다. 이 다이어그램에서 각 블록은 상태를 가진 객체를 나타내고, 화살표는 그에 대한 참조를 뜻한다.
╔═══════════════╗ ╔═══════════════╗
║ ║░░ ║ ║░░
║ POLL_NEXT ║░░ ║ ASYNC NEXT ║░░
║ ║░░ ║ ║░░
╚═══════════════╝░░ ╚═══════════════╝░░
░░░░░░│░░░░░░░░░░ ░░░░░░│░░░░░░░░░░
│ │
│ pin
─────────────────────── │ ───────────────────────────────── │ ────────────
│ │
│ ▼
│ ╔═══════════════╗
ALIVE FOR │ ║ ║░░
A SINGLE pin ║ FUTURE ║░░
ITERATION │ ║ ║░░
│ ╚═══════════════╝░░
│ ░░░░░░│░░░░░░░░░░
│ │
│ mut
─────────────────────── │ ───────────────────────────────── │ ────────────
│ │
▼ ▼
╔═══════════════╗ ╔═══════════════╗
ALIVE FOR ║ ║░░ ║ ║░░
THE ENTIRE ║ ASYNCITERATOR ║░░ ║ ITERATOR ║░░
LOOP ║ ║░░ ║ ║░░
╚═══════════════╝░░ ╚═══════════════╝░░
░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░
이러한 비동기 반복 표현의 차이는, 각 인터페이스가 제공하는 어포던스에 대해 광범위한 함의를 가진다.
먼저, 두 인터페이스의 성능적 함의를 말하고자 한다. Yosh는 자신의 글에서 간단한 예제(즉시 한 번만 값을 내놓고 끝나는 비동기 이터레이터)가 LLVM에 의해 동일한 코드로 최적화됨을 보여준다. 이 예제의 async 버전에서는 Future 상태 머신이 상태를 가지지 않으며, Iterator는 poll 버전의 AsyncIterator와 같은 상태(옵션 하나)를 가진다.
LLVM이 불필요한 간접 참조를 제거할 수 있다는 것은, 특히 이런 단순한 경우에 놀랄 일이 아니다. 그러나 상태 머신이 훨씬 더 복잡해졌을 때도 LLVM이 언제나 이 간접 참조를 제거한다고 가정할 수는 없다: 이는 많은 최적화 휴리스틱에 달려 있으며, 그 휴리스틱이 항상 작동한다고 보장할 수 없다. Rust 프로젝트가 인라이닝 실패를 버그로 취급할 것이라고 주장한다고 해서, 이것이 제로 코스트 추상화가 되는 것은 아니다. async next 설계에서는 더 오래 사는 상태 머신을 참조하는 두 번째, 단기 생명 상태 머신을 도입함으로써 간접층을 추가하고 있으며, 그 간접층을 제거하는 것은 보장되지 않는다.
이는 특히 동적 디스패치에서 문제인데, 이 경우 인라이닝 가능성이 사라지며, next나 poll_next가 가상 호출이 된다. Yosh는 객체 안전성에 대해 이야기하면서 이 점을 전혀 다루지 않았다. 그는 이렇게 말한다:
Box::new를 Boxed::new로 바꿔야 한다는 점은 큰 차이가 아니다.
여기서 그는 표현(레프리젠테이션)에 대한 언급 없이 오로지 어포던스만 이야기하고 있다. Boxed 어댑터가 하는 일은 async 메서드의 상태 머신을 힙에 할당하게 만드는 것이다. 그것이 API가 다른 이유다! async next에서는 비동기 루프의 매 반복마다, 이터레이터가 동적 디스패치된 객체라면 Future 상태 머신을 동적 할당해야 한다.
for await 루프의 경우, 이론적으로는(컴파일러가 이를 어떻게 판별할지는 확실치 않지만) alloca 같은 대체 동적 할당 방법을 쓰거나, 동일한 힙 할당을 재사용하는 방법도 가능할 수 있다. 하지만 이제 우리는 루프에서 반복 할당을 피하기 위해(심지어 비동기 메서드의 동적 디스패치를 기본 전제로 두는 것 외에도) 추가적인 가상의 컴파일러 기능을 계속 쌓아 올리는 셈이다. poll next 설계에서는, 메서드 호출의 일부로 동적으로 할당해야 하는 추가 상태 머신이 없기 때문에, 동적 디스패치가 즉시, 아무 추가 비용 없이 동작한다.
비동기 반복을 수행하기 위해 서로 다른 두 상태 머신을 수용해야 한다는 점은, LLVM이 일부 정적 경우에 간접 참조를 제거할 수 있더라도, 결코 더 최적의 표현이 될 수 없다. Rust의 Future 모델은 바로 이런 간접 참조와 동적 할당을 피하기 위해 큰 비용을 들여 설계되었다. 비동기 상태 머신을 구성하는 기본 추상화인 비동기 반복 역시 불필요한 간접 참조를 피해야 한다. 그렇지 않다면 그것은 제로 코스트 추상화가 아니며, Rust가 이전에 지켜 온 약속과도 일치하지 않는다.
두 개의 상태 머신이 있다는 사실과는 별개로, async next 설계에서는 수명이 더 짧은 상태 머신만이 pin으로 고정된다는 문제가 있다. 이는 단일 반복 동안에만 이동 불가성의 이점을 누릴 수 있고, 더 오래 사는 상태 머신은 반복 사이에서 이동될 수 있다고 가정해야 함을 의미한다.
이는 이론적인 문제가 아니다. 이미 tokio, smol, async-std의 동시성 프리미티브들은 동기화를 구현하기 위해 침습적(인트루시브) 연결 리스트를 사용한다: 어떤 이벤트가 발생하면, 그 이벤트를 기다리던 다른 핸들들을 알리기 위해 큐를 사용하고, 그 큐는 해당 동시성 프리미티브의 상태 머신에 저장된 노드들로 구성된 인트루시브 연결 리스트로 구현된다. 이는 그 상태 머신들이 pin으로 고정되어 있어야 함을 요구한다.
수명이 더 긴 상태 머신을 pin으로 고정할 수 없다면, 단일 반복 상태 머신만 큐에 들어갈 수 있다. 예를 들어, 다중 소비자 채널을 생각해 보자. 그 채널의 모든 수신자는 메시지가 전송될 때 깨울 수 있도록 알림 큐에서 대기하게 된다. 더 짧게 사는 상태 머신만 채널 안에 있을 수 있다면, 그 더 짧은 Future가 드롭될 때(예: 어떤 다른 Future와 경합하면서), 큐에서 자신의 위치를 잃게 된다. 이는 특정 수신자가 자신의 순서를 잃음으로써(애니캐스트 채널에서는 메시지의, 모든 경우에는 CPU 시간의) 기아(starvation)가 발생할 수 있다.
tokio는 불안정한 stream API에 직접 의존하지 않기 때문에, tokio의 broadcast 채널은 이러한 의미론(즉 Recv future가 드롭되면 큐에서 자신의 위치를 잃는다는 의미론)을 가진 recv 메서드만 제공한다. 반면, smol의 anycast 채널은 이런 동작을 하는 recv 메서드뿐 아니라, 줄에서 자신의 위치를 유지하는 Stream 구현도 제공한다.
이러한 차이는 중요하다. Rust의 핵심 추상화는 이런 모든 유스케이스를 지원할 수 있도록 정의되어야 한다. async next에서 수명이 긴 상태 머신을 pin으로 고정하는 유일한 방법은, 상태 머신 자체가 아니라 상태 머신에 대한 pin된 참조에 대해 AsyncIterator를 구현하는 것이다. 그 경우 다이어그램은 다음과 같다:
╔═══════════════╗ ╔═══════════════╗
║ ║░░ ║ ║░░
║ POLL_NEXT ║░░ ║ ASYNC NEXT ║░░
║ ║░░ ║ ║░░
╚═══════════════╝░░ ╚═══════════════╝░░
░░░░░░│░░░░░░░░░░ ░░░░░░│░░░░░░░░░░
│ │
│ pin
─────────────────────── │ ───────────────────────────────── │ ────────────
│ │
│ ▼
│ ╔═══════════════╗
ALIVE FOR │ ║ ║░░
A SINGLE │ ║ FUTURE ║░░
ITERATION │ ║ ║░░
pin ╚═══════════════╝░░
│ ░░░░░░│░░░░░░░░░░
│ │
│ mut
─────────────────────── │ ───────────────────────────────── │ ────────────
│ │
│ ▼
│ ╔═══════════════╗
│ ║ ║░░
│ ║ PIN ║░░
│ ║ ║░░
▼ ╚═══════════════╝░░
╔═══════════════╗ ░░░░░░│░░░░░░░░░░
ALIVE FOR ║ ║░░ │
THE ENTIRE ║ ASYNCITERATOR ║░░ pin
LOOP ║ ║░░ │
╚═══════════════╝░░ │
░░░░░░░░░░░░░░░░░ ▼
╔═══════════════╗
║ ║░░
║ ITERATOR ║░░
║ ║░░
╚═══════════════╝░░
░░░░░░░░░░░░░░░░░
Yosh는 대신에 “pinned iterator”와 “pinned async iterator” 트레이트가 있을 것이라고 제안한다. 이들은 일반 트레이트와 비슷하지만, self를 가변 참조가 아니라 pin된 참조로 받는다. 이것은 장기적으로 “pinned effect”를 도입하려는 더 긴 비전의 전주(前奏)다.
이를 “이펙트”로 생각하는 것(모든 추상화 축을 하나의, 모호하게 전개된 개념으로 붕괴시키는)은 적절하지 않고, 오히려 “가변성 다형성(mutability polymorphism)”으로 생각하는 편이 낫다. 즉 서로 다른 빌림/소유 변형(공유 참조, 가변 참조, pin된 참조, 소유 참조 등)을 추상화할 수 있어야 한다는 생각이다. 이 아이디어는 가끔 논의되었지만, 설계가 진척된 적은 없다. 나는 Rust에 새로운 추상화 축을 추가하는 것에 대해 그렇듯이, 이 아이디어를 추구하는 일의 실행 가능성과 신중함에 많은 의문을 가지고 있다.
이동 불가 이터레이터 문제를 해결하는 다른 방법도 있다. 하나는 에디션 경계를 넘어 Iterator 정의를 변경하는 것이다. 또 하나는 Move 트레이트를 추가하고 Pin을 완전히 없애는 것이다. 이들 각각은 두 설계를 같은 공간으로 이끌 것이다: 첫 번째에서는, 기본 Iterator 트레이트가 pin을 요구하게 되어 두 설계 모두 pinning을 특징으로 하게 된다. 두 번째에서는 Pin이 사라져 이러한 차이가 더 이상 의미를 가지지 않는다.
그러나 AsyncIterator의 경우, poll next 설계를 사용하면 이 문제를 피할 수 있다. 이터레이터가 이동 불가성을 지원하도록 바꾸는 일은 필연적으로 느리고 파괴적일 것이다. 대신, 프로젝트가 지금 즉시 immovability(이동 불가성)를 지원하는 AsyncIterator를 제공하도록, API로 poll next를 채택하자고 제안한다.
두 번째 상태 머신의 도입은 성능상의 함의뿐 아니라, 취소와의 상호작용에서 비롯되는 논리적 함의도 가진다. 이 문제를 분석하려면 “취소 안전성(cancellation safety)” 개념에 대한 다소 긴 논의가 필요하다.
문제는, next future를 드롭할 때마다 그 future가 취소되고, 다음번에 next를 폴링하기 시작하면 기본 이터레이터의 상태로부터 새로운 future가 준비되어야 한다는 점이다. 앞서 말한 동기화 프리미티브가 “큐에서 자신의 위치를 잃는” 문제는 사실 이 시나리오의 특수한 경우다. poll_next에서 next future를 취소하면 아무 일도 일어나지 않는다: 다음에 next를 호출할 때 같은 상태에 있을 것이다.
async Rust에서 흔한 문제는, 사용자가 future를 취소하고 있다는 사실이나, future를 취소했을 때의 함의를 인지하지 못하는 경우가 있다는 것이다. 다른 많은 언어에서는, 사용자가 기다리기를 멈춘 뒤에도 future가 계속 실행된다. Rust의 “drop=취소” 설계는 더 최적화되어 있지만 종종 사용자를 혼란스럽게 한다. 그래서 등장한 개념이 “취소 안전성”이다: “취소 안전한” future를 취소해도 눈에 띄는 영향이 없다.
Yosh는 자신의 글에서 Rust 트레이트 시스템을 사용해 이 “취소 안전성” 개념을 형식화하려 시도한다. “로컬 상태가 없는” future를 취소 안전하다고 정의한 것은 옳지만, 이를 “await 포인트가 하나뿐”이라고 풀어쓴 것은 로컬 상태가 없다는 의미를 충분히 포착하지 못한다(그도 이를 알고 있고, Mutex를 잠그는 예시가 자신의 정의가 실패하는 경우임을 암시한다).
await 포인트가 둘인 async 함수는 반드시 로컬 상태를 갖지만, “저수준”의 poll 기반 future에도 로컬 상태가 있는지 살펴봐야 한다. Mutex::lock의 경우, 그 로컬 상태는 앞서 pinning과 관련해 논의했던 알림 큐에서의 위치다. 궁극적으로, 어떤 future가 “취소 안전”하려면 그 상태가 다른 상태를 가진 객체들에 대한 참조만으로 구성되어야 한다. 그렇기 때문에, 그런 future를 취소한 후 같은 인자로 새로운 future를 구성하면, 취소된 것과 정확히 같은 상태의 상태 머신이 만들어져 눈에 띄는 효과가 없다.
그러나 “취소 안전성”을 타입 시스템으로 들여오려는 시도의 문제는, “취소 안전하지 않은” future를 취소하는 일이 항상 버그는 아니라는 점이다. tokio에서의 취소 안전성 개념은, 어떤 future를 취소하면 의미 있는 동작이 발생한다는 사실을 알려주려는 것이다. 하지만 그런 동작이 바로 원하는 동작일 수도 있다! 이는 데이터 레이스처럼 결코 올바르지 않은 행위와는 아주 다르다.
Yosh는 실제로 좋은 예시를 든다. 그는 read가 취소 안전한 연산이라고 제안한다. 이는 경우에 따라 다르다. epoll처럼 준비 상태 기반(readiness-based) 리액터에서는, 이후 read가 원래 읽혔을 데이터를 다시 읽을 것이므로 read는 “취소 안전”하다고 볼 수 있다. 그러나 io-uring처럼 완료 기반(completion-based) 리액터에서는 결코 그렇지 않다: 이미 발행한 read를 취소하면, 그 read가 완료될 수도 있지만 결과를 보지 못할 수도 있고, 따라서 이후의 read는 그 데이터를 잃는다.
그게 올바른 동작인가? 경우에 따라 다르다! 다음 read 호출에서의 데이터를 사용할 일이 없다면, 취소하는 것이 올바른 동작이다. 반면, 그 객체에서 계속 읽고 어떤 데이터도 놓치고 싶지 않다면, 비록 그 특정 read 연산을 취소하고 싶더라도, 그런 동작은 버그가 될 것이다.
의미 있는 취소가 바람직한 동작일 수 있기 때문에, 나는 전반적으로 “취소 안전성”의 틀로 취소를 다루는 것에 회의적이다. 하지만 여기에는 사실 “취소 안전성”이라는 개념이 주의를 다른 곳으로 돌리게 만드는 다른 문제가 있다: 어떤 API들은 사용자가 알아차리기 어렵게 future가 취소되도록 만든다. 사용자는 취소가 의미 있다는 사실을 놓칠 뿐 아니라, 취소가 일어나고 있다는 사실 자체를 놓친다. 여기에서 가장 큰 범인은 루프 안에서의 select!다.
내 경험상, 내부적으로 정해진 집합의 동시 작업을 반복적으로 수행하는 태스크를 자주 본다. 루프 안의 select는, 서로 다른 소스로부터의 이벤트에 대응할 때 공유 상태를 사용할 수 있게 해주므로, 이런 경우 아주 좋은 패턴이다. 예를 들어:
loop {
select! {
msg1 = rx1.recv() => {
// msg1 처리
}
msg2 = rx2.recv() => {
// msg2 처리
}
_ = async_function() => {
// async_function 종료 처리
}
}
}
이 패턴에는 심각한 문제가 있다: 루프의 모든 반복에서, 먼저 완료되지 않은 future들은 모두 취소되고, 다음 반복에서 다시 생성된다. 어떤 future의 취소가 의미 있는 경우, 이 동작은 가시적이다. 그러나 사용자는 각 select 분기가 루프에서 반복적으로 폴링된다고 생각하는 경향이 있으며, 매 반복마다 future를 생성하고 취소한다는 사실을 제대로 인지하지 못한다.
현재 루프에서 어떤 future도 취소하지 않으려면, 그 future를 “호이스트”하여 루프 밖으로 끌어올려야 한다. 하지만 이는 명백하지 않으며, 참조로 폴링할 수 있도록 pinning이 필요하고, 완료된 후 폴링해도 패닉이 나지 않도록 fuse 처리도 필요하다:
let mut future = pin!(async_function().fuse());
loop {
select! {
msg1 = rx1.recv() => {
// msg1 처리
}
msg2 = rx2.recv() => {
// msg2 처리
}
_ = &mut future => {
// async_function 종료 처리
}
}
}
async next 설계는 같은 함정을 제시한다. next가 의미 있는 취소를 가질 수 있는데(이는 next가 async 함수라는 사실에 내재한다), next 호출을 취소하는 사용자는 같은 함정에 빠질 수 있다. 이터레이터 자체도 상태 머신이라는 점을 고려하면, 사용자들이 next future 자체가 의미 있는 상태를 가질 수 있다는 사실을 특히 인식하지 못할 가능성이 높고, 실수로 이를 취소할 가능성이 높다.
내가 보기에는, 가장 큰 문제는 루프 안의 select가 너무 사용하기 쉬워 오용하기도 쉬운 API라는 점이다. 해결책은(이 부분에 대해 Yosh도 블로그에 썼다) 여기서 스트림과 merge 연산을 사용하는 것이다. merge는 select처럼 동작하지만, future가 아니라 스트림에 대해 동작한다. 반복적으로 future에 대해 동작하는 것이 아니라, 반복적으로 스트림에 대해 동작하는, 널리 사용되는 select와 매우 비슷한 매크로를 상상해 볼 수 있다:
merge! {
msg1 = rx1 => {
// msg1 처리
}
msg2 = rx2 => {
// msg2 처리
}
_ = once(async_function()) => {
// async_function 종료 처리
}
}
스트림을 병합함으로써, 미래를 반복적으로 선택하는 대신 전체가 더 단순해질 뿐 아니라, 어떤 분기에서 스트림 대신 future를 사용하고 싶다면, 그 의미론을 명시하는 생성자를 사용해 명시적으로 스트림으로 변환해야 한다. 예를 들어, 위 코드에서 once 대신, 완료한 뒤 다시 함수를 호출하고 싶다면 repeat_with 같은 것을 사용할 수 있다.
사실, future와 비동기 이터레이터 각각에 대해 직관적인 동시성 연산자 표를 만들 수 있다. 첫 번째 열에는 단일 항목에만 작동하는 연산자들이 있고, 두 번째 열에는 “합(sum)” 연산자들이 있으며 — 서브태스크 중 하나라도 준비되면 값을 낸다 —, 세 번째 열에는 “곱(product)” 연산자들이 있으며 — 모두가 준비되었을 때만 값을 낸다. 다음과 같다:
│ 단일(SINGLE) │ 합(SUM) │ 곱(PRODUCT)
───────────────┼─────────────┼───────────┼──────────
│ │ │
FUTURE │ await │ select! │ join!
│ │ │
ASYNCITERATOR │ for await │ merge! │ zip!
│ │ │
안타깝게도, 기본 트레이트가 안정화되지 않았기 때문에 AsyncIterator 기반 동시성 콤비네이터는 생태계에서 충분히 탐구되지 못했다. 그런 이유로, 쌍(pair) 단위의 merge 콤비네이터가 Stream 메서드로는 존재하지만, select!와 유사한 매크로 기반 접근은 내가 아는 한 생태계에 없다. 이것은 코어 언어가 기능을 내놓지 못한 탓에 더 넓은 생태계가 지체된 훌륭한 사례다.
이 모든 것은 약간의 곁길이다: merge 연산자는 poll next 설계든 async next 설계든 구현할 수 있다. 다만, async 설계에서는 merge 연산자가 별도로 next future들을 저장해야 하므로, 구현이 더 복잡해지고, (로컬에서 특수하게 구현하는 등) 누군가가 이를 잘못 구현할 가능성이 더 커진다는 점을 지적하고 싶다.
결국 여기에는 상충 관계가 있다. 한편으로, poll next 설계는 표현을 단순화하여, 루프 전체 동안 살아 있는 단일의 pin된 상태 머신만 갖게 한다. 이는 async next 설계에서는 불가능하거나, 쉽지 않거나, 제로 코스트가 아닌 몇 가지 동작을 가능하게 한다. 하지만 이는 AsyncIterator API에서 하나의 큰 어포던스를 없앤다: async next 메서드로 비동기 이터레이터를 정의할 수 없다는 점이다. 이 어포던스는 다른 곳에 미치는 부정적 영향에 비해 가치가 있을까?
Yosh가 이 어포던스를 지원할 가치가 있다고 보는 이유에 대한 논평은 흥미롭다:
대체로 모두가 첫눈에는 async fn next 기반 트레이트가 더 쓰기 쉬워 보인다고 동의할 것이라 생각한다. Pin이 무엇인지, Poll이 어떻게 동작하는지 생각할 필요 없이, 우리가 평소에 하듯 async 함수를 그냥 쓰면 되고, 그대로 동작한다. 꽤 멋지다!
Yosh가 이 어포던스가 제공하는 용이함으로 보는 것은 pinning이나 태스크 API를 다루지 않아도 된다는 점이 분명하다. 즉, 사용자가 “저수준” 레지스터의 난해한 API를 신경 쓰지 않아도 비동기 이터레이터를 작성할 수 있다는 장점이다. 하지만 이야기는 더 있다.
이러한 API 없이 AsyncIterator를 정의할 수 있게 하는 것은 async Rust의 사용성을 위해 매우 중요하다는 데 동의한다. 내가 이전에 썼듯, 사용자가 그렇게 할 수 있게 하는 방법으로 내가 Rust에 바라는 것은 비동기 제네레이터 문법을 제공하는 것이다. Yosh는 async next 설계가 poll next 설계보다 구현을 쉽게 만드는 사례로 “once”를 사용했다. 비동기 제네레이터로 once는 이렇게 보인다:
// 비동기 제네레이터 함수로서
async gen once<T>(value: T) yields T {
yield value;
}
// 비동기 제네레이터 블록으로서
async gen {
yield value;
}
하지만 async 제네레이터와 async next 사이에는 정말 강조해야 할 더 깊은 차이가 있다. Yosh는 poll_next가 다루는 어려운 API들을 “문제”의 중심에 놓지만, 내 시각에서 훨씬 더 도전적인 측면은 상태 머신을 수작업으로 작성하는 일이다. once보다 더 복잡한 예시들(여러 상태를 거쳐 전이하는 경우)을 다루기 시작하면, 이는 곧바로 분명해진다.
독자들의 주의를 최근의 curl CVE로 끌고 싶다. 이는 비동기 상태 머신을 구현하는 과정에서 발생한 실수로 빚어진 문제였다. 어떤 변수가 상태 머신을 폴링하는 함수의 스택에 저장되어, 상태 머신이 폴링될 때마다 리셋되었다. 이것이 바로 상태 머신을 수작업으로 작성할 때 발생할 수 있는 유형의 버그이며, 이 경우에는 파괴적이었다. async 함수나 제네레이터 같은 코루틴은 컴파일러가 상태 머신을 생성해 주므로 사용자가 이런 실수를 저지르지 않게 한다. 필요한 상태는 모두 저장되어, 코루틴이 yield했던 동일한 지점에서 재개(resume)된다.
AsyncIterator를 “수작업”으로 구현하는 진짜 문제는, 올바른 상태 머신을 구현할 책임이 사용자에게 있고, 이런 종류의 실수를 저지르기 쉽다는 점이다. async next 설계에서는 상태 머신이 두 개이며, 그중 하나는 컴파일러가 생성해 주지만 다른 하나는 수작업으로 작성해야 한다. 이는(상태 머신의 일부가 생성되므로) 복잡성이 줄어드는 것처럼 보일 수 있으나, 실제로는 그렇지 않다. 여전히 변수를 저장할 수 있는 장소가 두 곳(스택 vs 상태 머신이 아니라, 두 상태 머신)이고, 반복 사이에 상태를 유지해야 한다면, 이를 인지하고 해당 변수를 수작업으로 작성한 이터레이터 상태 머신에 옮기는 책임은 사용자에게 있다.
나는 이런 “혼합 레지스터” API가 특히 위험하다고 생각한다. 사용자가 고수준 async/await 문법을 쓸 수 있다는 사실에 안심하면서도, 어떤 상태가 반복 사이에 지속되어야 하는지 스스로 파악해야 하기 때문이다. 대신, “고수준” 레지스터에 머무르고 싶은 사용자는 비동기 제네레이터(명령형 코딩 스타일)나 콤비네이터(함수형 코딩 스타일)로 안내되어야 한다. 둘 다 수작업 상태 머신의 함정을 피한다.
poll_next 인터페이스는, 비동기 이터레이터의 레이아웃과 동작을 정밀하게 제어하고 싶은 “저수준” 레지스터의 사용자들을 위한 것이다. 이런 사용자들에게 async next 설계는 poll next 설계보다 확실히 나쁘다. Context에 접근하려면 poll_fn 같은 콤비네이터를 써야 하고, 서로 다른 두 상태 머신의 존재를 관리해야 하며, 수명이 긴 상태 머신을 pin으로 고정해야 한다면 추가 간접 참조가 필요하다.
마지막으로, 내가 이전 글에서 썼듯, 비동기 제네레이터가 있으면 poll next 설계에서도 정말로 “async fn next”를 갖고 싶어하는 사용자를 지원할 수 있다. 비동기 next 메서드를 가진 객체를 비동기 제네레이터로 바꾸는 데 필요한 코드는 다음의 작은 스니펫뿐이다:
// async next 기반 AsyncIterator를 poll_next 기반 AsyncIterator로 변환한다
async gen {
while let Some(item) = iter.next().await {
yield item;
}
}
두 설계를 좀 더 이론적인 관점에서 살펴보고 싶은 또 다른 각도가 있다. Yosh는 이렇게 말한다:
사람들이 “async iterator는 iterator의 async 버전이 아니다”라고 말할 때 그들은 옳다. … 대신에, async iterator가 “iterator의 async 버전”이어야 하는지 물어보는 것이 낫다 — 그리고 나는 확실히 그래야 한다고 믿는다.
우연히도, 이것이 바로 WG-async가 T-lang과 T-libs에 전달해 온 틀(frames)이기도 하며, 그들은 여기에 동의했다. 나는 이 결정이 우리를 구속해야 한다고 말하는 게 아니다(규칙 따위를 들먹이고 싶지 않다). 내가 보여주려는 것은, 이것이 수년간 설계가 달성해야 할 목표로 받아들여져 왔고, 우리는 이미 “async iterator(혹은 stream)가 그 자체의 특별한 무언가”라는 틀을 거부했다는 점이다. 물론 이는 다시 바꿀 수 있지만, 결코 새로운 통찰은 아니다.
이 틀은 아마도 내가 이전 블로그 글에서 한 코멘트를 오해한 데 기반해 있는 듯하다:
이름을 Stream에서 AsyncIterator로 바꾸는 것에 나는 반대하지 않는다. 그러나 그와 함께 내가 반대하는 이념적 약속이 묶여 있다고 생각한다 — 즉 AsyncIterator를 “그저” Iterator의 async 버전으로만 간주하는 태도다. AsyncIterator가 Future의 반복 버전이기도 하다는 사실을 잊지 말아야 한다.
프로젝트 내부의 사람들도 트레이트를 “중복”하고 싶지 않다고 비슷한 말을 하곤 한다. AsyncIterator와 Iterator를 모두 두는 것이 “그저” Iterator 하나만 두는 것보다 나쁘다고 믿는 것이다 — 결국 트레이트 하나가 둘보다 낫지 않은가? 하지만 내 반대는 오해되었다: 나는 AsyncIterator가 iterator의 async 버전이 아니다(명백히 async 버전이다!)라고 말하는 것이 아니라, 그것이 iterator의 async 버전이면서 동시에 future의 반복 버전이기도 하다고 말하고 있다. 이것은 두 가지 모두이다. 왜냐하면 AsyncIterator는 동시에 비동기적이면서 반복적인 코루틴을 표현하기 때문이다.
나는 이것을 문자 그대로 의미한다. AsyncIterator에는 async next 버전만큼 타당한 또 다른 표현이 있는데, 나는 이것을 “iterative poll(반복적 폴)” 버전의 인터페이스라고 부를 것이다. 이 버전은 Future를 받아 그 poll 메서드를 이터레이터로 만들며, async next 설계가 Iterator의 next 메서드를 future로 만드는 것과 같은 방식이다. 이 인터페이스를 IteratorFuture라고 부르자:
// 제네레이터 메서드가 있다면:
trait IteratorFuture {
type Item;
gen fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>)
yields Poll<Self::Item>;
}
// 디슈가:
trait IteratorFuture {
type Item;
type Iter<'a>: Iterator<Item = Poll<Self::Item>> where Self: 'a;
fn poll<'a>(self: Pin<&'a mut Self>, cx: &'a mut Context<'_>)
-> Self::Iter<'a>;
}
// for await 루프는 이렇게 보인다:
let mut iter_future = pin!(iter_future);
let mut iter = iter_future.poll(cx);
'outer: loop {
let next = 'inner: loop {
match iter.next() {
Some(Poll::Ready(item)) => break 'inner item,
Some(Poll::Pending) => yield Poll::Pending,
None => break 'outer,
}
};
}
이 설계에서, Future와 Iterator의 관계는 async next 설계와 반대로 뒤집힌다:
╔═══════════════╗
║ ║░░
║ ITERATOR POLL ║░░
║ ║░░
╚═══════════════╝░░
░░░░░░│░░░░░░░░░░
│
mut
────────────────────── │ ────────────────────────
│
▼
╔═══════════════╗
║ ║░░
║ ITERATOR ║░░
║ ║░░
╚═══════════════╝░░
░░░░░░│░░░░░░░░░░
ALIVE FOR │
THE ENTIRE pin
LOOP │
│
▼
╔═══════════════╗
║ ║░░
║ FUTURE ║░░
║ ║░░
╚═══════════════╝░░
░░░░░░░░░░░░░░░░░
이 설계에는 async next 설계에 비해 몇 가지 장점이 있다:
이 설계가 올바른 접근이라고 생각하느냐고? 절대 아니다! 이것은 모든 비동기 이터레이터를 future의 제네레이터 메서드로 작성하게 만드는, 기묘한 어포던스를 제공하기 위해 API를 비틀어 버리는 일이다. 내가 말하고자 하는 바는, Rust의 역사라는 구체적 경로 의존성이 없다면, AsyncIterator를 Iterator에서 “하나를 둘로 쪼갠다”라고 틀짓는 것이 Future에서 “하나를 둘로 쪼갠다”라고 틀짓는 것보다 특별히 더 타당하지 않다는 점이다.
실제로 AsyncIterator는 Iterator와 Future의 “곱(product)”이다. 각 인터페이스에서 동등하게 끌어온다. Iterator와 Future를 가진다는 것은 Rust가 이미 특정 코루틴 패턴(반복적, 비동기적)을 위한 여러 트레이트를 갖기로 약속했다는 뜻이다. 이 두 패턴을 결합하려면, 둘 다와 유사성을 가진 세 번째 트레이트가 필요하다. 반복 코루틴이 비동기 코루틴을 산출하도록 바꾸는 것도 임의적이며 잘못된 일이고, 비동기 코루틴이 반복 코루틴을 반환하도록 바꾸는 것도 임의적이며 잘못된 일이다.
이는 세 가지 코루틴 트레이트에 관련된 타입의 표를, 일반화된 Coroutine 인터페이스의 특수화로 상상해 봐도 분명해진다. AsyncIterator는 Iterator 및 Future 각각과 한 열을 공유하고, 한 열에서는 각자의 타입이 다르다:
│ YIELDS(산출) │ RETURNS(반환) │ RESUMES(재개)
──────────────┼─────────────────────┼─────────────────┼─────────────────
│ │ │
FUTURE │ () │ Self::Output │ &mut Context
│ │ │
ITERATOR │ Self::Item │ () │ ()
│ │ │
ASYNCITERATOR │ Poll<Self::Item> │ () │ &mut Context
│ │ │
종종, 두 가지 가능한 설계 사이에는 복잡한 상충 관계가 있으며, 내가 한쪽 접근을 선호하더라도 다른 쪽의 장점도 이해할 수 있다고 느낀다. 이번은 그런 경우가 아니다. 나는 poll next 설계에 대한 근거가 철벽이라고 생각한다. 표현이 더 단순하고, 런타임 표현을 더 잘 보장하며, 더 나은 동적 디스패치를 지원하고, pinning과 더 잘 상호작용하며, 더 나은 기본 어포던스를 전제로 하고, 이론적으로 더 정확하고, 정말로 async fn next 메서드로 이터레이터를 정의하고 싶어하는 사용자와도 호환된다.
추상적인 선호를 제쳐두고, 우리는 Rust라는 현실 세계의 맥락도 고려해야 한다. poll next 설계를 추진하려면, 명령형 스타일의 고수준 레지스터(권장 구현 방식)를 제공하기 위해 제네레이터를 구현하고 제공해야 한다. 나는 어차피 이것이 프로젝트의 우선순위가 되어야 한다고 믿지만, poll next 설계를 내놓는 함의 중 하나가 바로 제네레이터를 우선시해야 한다는 점이다.
async next 설계의 함의는 무엇인가? 내가 길게 다루진 않았지만, poll next 설계를 선호하는 이들이 원하는 것은 단지 async 메서드를 가진 AsyncIterator 트레이트가 아니다. 그들이 원하는 것은 Iterator의 메서드가 “어쩌면” async일 수도 있도록 정의하는 것이다. 다시 말해, 그들의 설계는 트레이트의 메서드가 비동기인지 여부를 추상화하는, Rust에 완전히 새로운 추상화 축을 함의한다. 이 개념은 이전에는 “키워드 제네릭스”로 불렸고, 지금은 “이펙트”로 재브랜딩되었다. 이런 제안은 커뮤니티에서 매우 논쟁적이었다. 또한 “키워드 제네릭스 이니셔티브”가 출범한 지 16개월이 지났지만, 그런 시스템이 실제로 어떻게 동작할지에 대한 구체적인 설계 제안은 아직 보지 못했다. 문법을 보여주는 모호한 코드 샘플만 있을 뿐이다.
“이펙트 시스템” 제공에 대한 의존을 넘어서, poll next 설계와 동일한 어포던스를 달성하려면 추가 기능을 계속 쌓아 올려야 한다. 객체 안전성을 위해서는 async 메서드가 객체 안전해야 하고, 루프에서 할당을 피하려면, 컴파일러가 async 이터레이터 트레이트 오브젝트를 특별한 방식으로 디슈가해야 한다. pinning을 위해, Yosh는 새로운 pinned 트레이트를 제안하는데, 이는 결국 언젠가 추가하려는 새로운 “이펙트”의 전주곡일 뿐이다. 이미 존재하는 API와 동등한 수준에 도달하는 데 필요한 이 모든 기능은 언제 제공될 것인가? 점점 더 많은 언어 기능 제안이 천천히 진화하기를 기다리는 동안, 사용자들은 이 매우 바람직한 API를 얻기 위해 앞으로도 몇 년을 더 기다려야 하는가?
나는 “키워드 제네릭스”라는 아이디어가 Rust를 확장하는 합리적인 방식인지에 대해 공개적으로 우려를 표했다. 하지만 여기서 그런 시스템을 절대 추구하지 말아야 한다고 말하는 것은 아니다. 사용자들의 관심사를 해결하는 설계가 개발될 수 있고, 그런 시스템이 순이익을 가져와 언젠가 구현될 가능성은 있다. 사람들이 그것을 향해 시간을 쓰고 싶다면, 그들의 선택이다.
내가 묻고 싶은 것은, 그런 추상화가 반복과 비동기의 교차점을 다루는 합리적인 방법인가, 그리고 사실상 무기한에 가까운 설계 아이데이션 과정 때문에 사용자에게 실질적인 가치를 제공하는 일을 막아 두는 것이 좋은 상충 관계인가 하는 점이다. 나는 Rust 프로젝트가 이 질문들을 진지하게 고려하고, 지금 당장 점진적인 가치를 제공하는 길을 택하기를 간곡히 요청한다.