Rust의 AsyncIterator 인터페이스에서 poll_next와 "async next" 접근의 차이를 비교하고, 취소 안전성, 객체 안전성, 최적화, 핀 고정 모델, 생성기와의 상호작용까지 고려해 저수준 API로서 poll_next가 갖는 이점을 논의한다. 또한 비동기 생성기와 함께하는 실용적 이행 경로와 잠재적인 출시 일정에 대해 제안한다.
이전 글에서, 나는 “레지스터(register)”라는 개념을 정립했다 — Rust의 코드는 서로 다른 레지스터에서 작성될 수 있고, 모든 레지스터를 적절히 지원하는 것이 중요하다는 것이다. 특히 현재 논쟁 중인 AsyncIterator 트레이트의 저수준 인터페이스에 대해 논의했다. 현재 인터페이스는 Future::poll과 같은 “poll” 메서드인 poll_next라는 메서드를 갖고 있다. poll 메서드는 매우 “저수준”이며, async 함수보다 올바르게 작성하기가 더 어렵다. 일부 사람들은 AsyncIterator를 단순히 Iterator 트레이트의 “비동기화된(asyncified)” 버전으로 만들어, async next 메서드를 갖도록 바꾸길 원한다.
나는 주로 철학적인 이유로 이에 반대했다: AsyncIterator의 인터페이스는 “저수준” 인터페이스이며, 사용하기 쉬움보다 사용자에게 완전한 제어권을 제공하는 것을 우선시해야 한다는 것이다. 그러나 이 인터페이스의 구체적인 세부 사항과 poll_next와 “async next”의 차이에 대해서는 들어가지 않았다. 결국, 손으로 직접 구현하더라도 AsyncIterator의 인터페이스가 일부러 더 어렵게 작성되도록 설계되어야 할 이유는 없다. 손으로 트레이트를 구현하는 것보다 더 쉬운 AsyncIterator 작성 방법이 있다 하더라도 말이다.
이 글에서는 poll_next와 “async next”의 차이를 보다 면밀히 살펴보겠다. 이 인터페이스들 사이에는 두 가지 핵심적인 차이가 있다:
poll_next에서는 상태를 저장할 수 있는 장소가 하나뿐이지만, “async next”에서는 둘이다 — 반복자의 더 오래 지속되는 상태와, 그 반복자의 상태를 참조하는 더 짧게 지속되는 future의 상태가 있다.poll_next에서는 그 단일 상태 저장소가 제자리에서 핀 고정(pinned)되지만, “async next”에서는 단기적인 future 상태만 핀 고정되고 장기 상태 저장소는 핀 고정되지 않는다.이는 사소한 차이가 아니다: 비동기 반복자를 작성하고 사용하는 방식에 근본적인 영향을 미친다. 저수준 레지스터를 위한 API를 작성할 때 가장 중요한 것은 코드를 최대한 쉽게 만드는 것이 아니라, 사용자에게 가장 유리한 표현을 제공하는 것이다. 물론 차이가 없다면 “가장 쉬운” API가 선호되어야 한다. 하지만 “고수준” 레지스터에서 대안이 존재한다면, 저수준 레지스터에서 저수준 제어력을 희생해서는 안 된다.
이러한 차이는 인터페이스의 양쪽 — next를 호출하는 소비자 측과, 비동기 반복자를 정의하는 구현자 측 — 모두에서 경험에 영향을 미친다.
next 호출하기next를 호출할 때의 주요 차이는 “async next”가 두 번째 상태 머신을 도입한다는 사실에서 비롯되며, 호출자는 그 두 번째 상태 머신을 기존 반복자의 상태 머신과 별개로 관리해야 한다는 점이다.
여기서 발생하는 큰 문제 하나는 “취소 안전성(cancellation safety)” 문제로 설명될 수 있다. 루프 안에서 두 비동기 반복자 중에서 선택(select)한다고 가정하자. 하나가 완료되었을 때 next future를 drop하면, 그 상태 머신에서 이루어진 진행은 취소된다. 루프를 다시 돌며 그 next를 다시 기다리면, 이전 상태에서 이어지는 것이 아니라 새로운 next future가 처음 상태에서 다시 시작한다. 이 동작을 올바르게 구현하려면, 루프 바깥에 next future들을 저장해 두고 완료될 때마다 교체해 주어야 한다. AsyncIterator는 거의 항상 이런 루프에서 반복적으로 poll되기 때문에, 이는 특히 AsyncIterator에서 큰 발목잡이가 된다(물론 이런 문제가 이미 다른 곳에서도 나타날 수는 있다).
반면 poll_next 버전에서는 모든 상태가 비동기 반복자 안에 저장된다. 따라서 next future를 drop하더라도 진행 중인 상태 전이에 영향을 주지 않는다: 다음에 next future를 생성할 때, 비동기 반복자는 이전에 멈춘 바로 그 상태에서부터 poll을 이어간다. 이것이 훨씬 더 사용하기 좋은 API다.
마찬가지로, 현재 futures의 Stream에 있는 poll_next 정의는 사소하게 객체 안전(object safe)하며, 실제로 그렇게 자주 사용된다. 두 번째 상태 머신이 없기 때문에 스트림의 상태를 저장하는 객체만 필요하지, next future의 상태를 저장하는 추가 객체는 필요 없다. 언젠가 async 메서드를 객체 안전하게 만들자는 논의가 있었지만, poll_next를 사용하면 추가 할당이나 성능에 영향을 줄 수 있는 코드 생성 없이도, 비동기 반복자를 지금 객체 안전하게 만들 수 있다.
좀 더 포괄적으로 보자면, 두 번째 상태 머신을 도입하면 최적화가 거의 확실히 약화될 것이다. 최적화가 첫 번째 상태 머신과 두 번째 상태 머신 사이의 간접화(indirection)를 “파고들어” 상태를 병합하고 배치를 최적화해야 하기 때문이다. 우리의 스택리스 코루틴에 대한 상태 머신 최적화는 이미 가능한 만큼 강력하지 않으며(그리고 바로 이 이유로 async Rust를 채택하지 않는 사람들도 보았다), 이는 비동기 반복자의 최적화를 더 어렵게 만든다.
이 모든 것이 “async next”를 사용할 때의 단점이며, 그에 비해 “async next”의 장점 하나는 next를 호출하기 전에 AsyncIterator를 핀 고정하지 않아도 된다는 점이다. 이는 사용자에게 특히 체감되는데, 비동기 반복자에는 “for 루프”가 없고: 현재는 사용자가 while 루프로 next 메서드를 반복 호출하는 것이 기대되기 때문이다. 그러나 이 문제의 해법은 async 반복자 위에서 동작하며 반복자를 대신 핀 고정해 주는 어떤 형태의 for await 루프를 제공하는 것이다.
next를 호출할 때 AsyncIterator를 핀 고정하지 않아도 되는 장점은 반복자 상태 자체가 핀 고정되지 않는다는 사실에서 비롯되지만, 이는 AsyncIterator를 구현할 때의 가장 큰 단점이기도 하다. 구현자는 반복자 상태가 핀 고정된다는 보장을 활용할 수 없기 때문이다. 결과적으로 비동기 반복자는 yield 지점 전체에 걸쳐 핀 고정되지 않고, 오직 _await 지점_에 걸쳐서만 핀 고정된다.
이는 정의할 수 있는 비동기 반복자의 범위를 제한한다. 일부 동기화 프리미티브(예: tokio의 것들)는 추가 힙 할당을 피하기 위해, 핀 고정 보장을 사용해 침투형(intrusive) 자료구조에 상태를 저장한다. “async next”에서는 반복자 상태를 장기간에 걸쳐 침투형 컬렉션에 저장할 수 없고, 단기적인 future 상태만 저장할 수 있으므로 제약을 받을 수 있다.
더 우려되는 것은 비동기 생성기에 대한 영향이다. 현재 정의된 인터페이스에서는, 비동기 생성기는 모든 지점에 걸쳐 자기 참조(self-referential)가 가능해져서, 비동기 생성기에서 참조를 사용하는 문제가 완전히 사라진다. 그러나 그 핀 고정 보장이 없으면, 비동기 생성기는 await 지점에 걸쳐서만 자기 참조가 가능하고, yield 지점에 걸쳐서는 불가능하다. 이는 사용자에게 매우 혼란스럽고 불규칙하게 느껴질 것이며, 이를 구현하려면 컴파일러 측의 추가 작업이 필요할 것이다.
poll_next가 더 근본적이다내가 본 잘못된 주장 하나는 “async next” 버전이 poll_next 버전보다 더 근본적이라는 것이다. 그 이유로는 poll_next 버전이 “async next” 버전을 바탕으로 정의될 수 있기 때문이라는 주장이 붙는다. 하지만 이 주장은 완전히 틀렸고, 실제로는 그 반대가 진실이다.
이런 주장이 나오는 이유는 future::poll_fn의 존재 때문이다. 이를 보면 사용자가 poll_fn을 통해 poll_next 메서드와 동등한 것을 얻어 “async next” 메서드를 구현할 수 있을 것처럼 보인다. 그러나 poll_fn은 사용자에게 poll_next와 동일한 인터페이스를 제공하지 못한다. 바로 앞서 논의했듯이 poll_fn은 반복자 상태를 핀 고정된 상태로 캡처할 수 없기 때문이다. 구현자 입장에서는 poll 메서드 작성 패턴의 _일부_를 가져올 수는 있지만(핀 고정 보장 없이), 호출자 입장에서는 앞서 논의한 poll 메서드의 장점을 얻을 수 없다. 구현자가 상태 저장(stateful) future를 사용하지 않았다는 보장이 없기 때문이다.
반면 “async next” 트레이트는 poll_next 버전으로 사소하게 변환할 수 있다. poll_next 버전은 모든 것을 핀 고정하여, next 메서드의 future 상태에서 더 오래 사는 반복자 상태로의 자기 참조를 포함할 수 있기 때문이다. 다른 이들이 이미, unsafe 코드를 써서 이 변환을 수작업으로 구현하고 라이브러리 차원의 안전한 추상화를 만들 수 있다고 언급했지만, 사실 비동기 생성기를 사용하면 완전히 안전하게 이 변환을 구현할 수 있다. 예시는 다음과 같다:
trait MyAsyncIterator {
type Item;
async fn next(&mut self) -> Option<Self::Item>;
}
async gen fn streamify<T>(mut iter: impl MyAsyncIterator<Item = T>) -> T {
while let Some(item) = iter.next().await {
yield item;
}
}
표준 라이브러리에는 더 간단한 인터페이스도 있을 수 있다 — iter::from_fn의 비동기화 버전으로, 기능적으로 동일하다(반복자 구조체가 클로저 상태에 캡처된다는 점에서):
async gen fn from_fn<T, F>(mut next: F) -> F) -> T
where F: FnOnce() -> impl Future<Output = Option<T>>
{
while let Some(item) = next().await {
yield item;
}
}
따라서, 몇 줄의 코드만으로 “async next”를 사용하는 모든 것을, “진짜” AsyncIterator의 구현으로 poll_next를 선택한 경우에도 poll_next 구현으로 변환할 수 있다. 이것은 또한 생성기가 next 메서드보다 얼마나 더 쉬운지를 엿보게 해 준다.
결국 “async next” 메서드를 이용해 비동기 생성기를 구현할 수 있고, 생성기 기능을 통해 “async next”가 주는 모든 사용성 상의 이점을 얻을 수 있다. 이렇게 되면 “async next”의 유일한 장점은 핀 고정 없이 next를 호출할 수 있다는 사실뿐이고, 단점으로는 (아직) 객체 안전하지 않으며, (결코) 취소 안전하지 않고, 최적화가 더 어렵고, 비동기 생성기가 yield 지점에 걸쳐 빌릴 수 없다는 점이 남는다. 이것이 실제 트레이드오프이며, 내게는 poll_next 쪽의 이점이 훨씬 분명하다.
마지막으로 한 가지 더: poll_next 인터페이스는 이미 존재한다. 생성기(비동기 생성기 포함)를 위한 기반 시설도 이미 존재하며, 마지막 마무리를 위한 약간의 통합만 필요하다. 프로젝트에서 우선순위를 두기만 한다면, 생성기(비동기 생성기 포함)는 대략 1년 정도의 일정으로 안정화될 수 있다고 나는 본다. 진정한 차단 요소는, 생성기에 사용할 키워드를 예약하기 위해 아마도 에디션 경계가 필요하다는 점이다(내 생각에는, 이는 2021년에 이미 해결하고 생성기를 출시했어야 했다).
async 메서드는 올해 출시될 수도 있지만, 출시되더라도 객체 안전하지 않을 것이다. poll_next 인터페이스를 사용하면 비동기 반복자는 지금 객체 안전할 수 있고, 비동기 생성기는 지금 모든 지점에 걸쳐 빌릴 수 있다. 그리고 async 메서드가 곧 출시될 수는 있지만, 키워드 제네릭 그룹은 AsyncIterator 트레이트 자체를 두지 않고 “async 효과 한정자(effect modifier)”를 선호하는 듯하다. 이것이 좋은 생각인지 여부는 제쳐두고(왜 동기가 약하다고 생각하는지는 이미 설명했다), 그게 언제 출시되겠는가? 2016년부터 개발돼 온, 수요가 매우 높은 이 기능을 이제 막 시작된 거대한 새 추상화에 발목 잡히지 않도록, Rust 프로젝트가 그렇게 하지 않기를 강력히 권하고 싶다.
생성기가 약 1년의 일정으로 출시될 수 있다는 내 주장을 진척시키기 위해, 다음 글에서는 생성기에 대한 남은 설계 질문들과 내 의견을 정리해 보겠다.