Rust가 async/await를 채택한 기술적·역사적 맥락을 정리한다. 그린 스레드와 스택풀/스택리스 코루틴의 대안들을 비교하고, 이터레이터·Future·Pin 등의 설계가 어떻게 연결되는지, 그리고 생태·조직적 배경 속에서 왜 Rust에는 다른 선택지가 사실상 없었는지를 설명한다.
Rust의 async/await 문법은 처음 등장했을 때 큰 환영과 기대를 받았다. 당시 Hacker News의 한 댓글을 인용하자면:
이건 홍수가 터질 겁니다. 분명 많은 사람들이 Rust를 도입하려고 이 순간만 기다렸어요. 저도 그중 한 명이었고요.
게다가 이건 모든 좋은 점들을 갖췄죠: 오픈소스, 높은 품질의 엔지니어링, 공개적 설계, 복잡한 소프트웨어에 대한 많은 기여자들. 정말 영감이 됩니다!
최근에는 반응이 조금 더 엇갈린다. 같은 주제를 다룬 최근 블로그 글을 이야기하는 Hacker News의 또 다른 댓글을 인용하자면:
저는 정말로 이해할 수가 없습니다. Rust의 async가 만들어낸 난장판을 보고도, 이미 쓰기 복잡하기로 악명 높은 언어에 이런 설계가 좋다고 생각할 수 있다니요.
이해해 보려고 노력했어요, 정말로요. 그런데 세상에 이건 너무나도 거대한 혼란입니다. 그리고 닿는 모든 것을 오염시켜요. 저는 Rust를 정말 사랑하고 요즘 대부분의 코드를 Rust로 짭니다. 그런데 async가 잔뜩 섞인 Rust 코드를 마주할 때마다 턱이 굳어지고 시야가 흐려져요.
물론 어느 쪽 댓글도 전체를 온전히 대표하지는 않는다. 4년 전에도 이미 몇몇 사람들은 예리한 우려를 제기했다. 그리고 턱이 굳고 시야가 흐려진다는 그 스레드 안에서도 async Rust를 열정적으로 옹호하는 사람들이 많았다. 다만 시간이 흐르면서 회의론자들의 수가 늘고 어조가 더 강해졌다고 말해도 무리는 아닐 것 같다. 어느 정도는 자연스러운 하이프 사이클의 진행이기도 하지만, 동시에 원래의 설계 과정에서 멀어지며 맥락이 일부 사라진 탓도 있다고 생각한다.
2017년부터 2019년 사이, 나는 다른 이들과 협업하고 선구자들의 작업 위에 쌓아 올리며 async/await 문법의 설계를 주도했다. 누군가 그 “난장판”을 보고 “어떻게 그게 좋은 설계라고 생각할 수 있느냐”고 말할 때 약간 마음이 상하는 것을 이해해 주기 바란다. 그리고 이 글이 다소 산만하고 너무 길더라도, async Rust가 어떻게 탄생했고 무엇을 목적으로 했으며 왜 내 의견으로는 Rust에는 실질적인 대안이 없었는지 설명하려는 시도를 관대히 봐 주길 바란다. 그 과정에서 Rust의 설계를 더 넓고 깊게 이해하는 데 조금이라도 도움이 되기를, 과거의 정당화만 되풀이하지 않기를 바란다.
이 논쟁의 핵심 이슈는 사용자 공간 동시성(user-space concurrency)을 구현하는 데 Rust가 “스택리스 코루틴(stackless coroutine)” 접근을 채택했다는 점이다. 이 주제를 둘러싼 논의에서는 여러 용어가 난무하고, 모두가 그 모든 용어에 익숙할 필요는 없다.
먼저 기능의 목적부터 분명히 하자: “사용자 공간 동시성.” 주요 운영체제들은 동시성을 위해 꽤 유사한 인터페이스를 제공한다. 스레드를 생성하고, 그 스레드에서 시스템 콜로 IO를 수행하면 완료될 때까지 해당 스레드가 블록된다. 이 인터페이스들의 문제는 특정 성능 목표를 달성하려고 할 때 병목이 될 수 있는 오버헤드가 있다는 점이다. 크게 두 가지다:
이 한계들은 어느 정도 규모까지는 문제가 되지 않지만, 대규모 동시성 프로그램에는 맞지 않는다. 해결책은 논블로킹 IO 인터페이스를 사용하고, 단일 OS 스레드 위에서 많은 동시 작업을 스케줄하는 것이다. 이는 프로그래머가 “손으로”도 할 수 있지만, 현대 언어들은 이를 쉽게 해 주는 기능을 제공하곤 한다. 추상적으로, 언어는 작업을 태스크로 나누고 그 태스크를 스레드에 스케줄하는 방식을 제공한다. Rust의 시스템이 바로 async/await이다.
이 설계 공간에서 첫 번째 선택 축은 협력적(cooperative) 스케줄링과 선점형(preemptive) 스케줄링 사이의 선택이다. 태스크가 스케줄러에게 “협력적으로” 제어권을 돌려줘야 하는가, 아니면 태스크가 모르는 사이에도 실행 도중 “선점적으로” 중단될 수 있는가?
이 논의에서 자주 등장하지만 다소 모호하게 쓰이는 용어가 코루틴(coroutine)이다. 코루틴은 일시 중단되었다가 나중에 재개될 수 있는 함수다. 큰 모호성은 어떤 사람들은 코루틴을 “중단과 재개를 위한 명시적 문법이 있는 함수”(협력적 스케줄 태스크에 해당)로 쓰고, 또 어떤 사람들은 런타임이 암묵적으로 중단을 수행할 수 있는 함수(선점형 스케줄 태스크까지 포함)로 쓰는 데 있다. 나는 첫 번째 정의를 선호한다. 그래야 의미 있는 구분이 생기기 때문이다.
한편 고루틴(goroutine)은 Go 언어의 기능으로, 동시적이고 선점형으로 스케줄되는 태스크를 가능하게 한다. API는 스레드와 같지만 OS 원시 기능이 아니라 언어의 일부로 구현되며, 다른 언어에서는 종종 가상 스레드(virtual threads) 또는 그린 스레드(green threads)라 부른다. 그래서 내 정의에 따르면 고루틴은 코루틴이 아니지만, 더 넓은 정의를 쓰는 사람들은 고루틴도 일종의 코루틴이라 말한다. 나는 이 접근을 그린 스레드라 부르겠다. Rust에서 사용된 용어이기도 하다.
두 번째 선택 축은 스택풀(stackful) 코루틴과 스택리스(stackless) 코루틴 사이의 선택이다. 스택풀 코루틴은 OS 스레드처럼 프로그램 스택을 가진다. 코루틴에서 함수가 호출되면 그 프레임이 스택에 푸시되고, 코루틴이 양보(yield)하면 그 스택의 상태를 저장해 두었다가 같은 위치에서 재개한다. 반면 스택리스 코루틴은 재개에 필요한 상태를 연속체(continuation)나 상태 머신 같은 다른 방식으로 저장한다. 코루틴이 양보하면, 그때 사용 중이던 스택은 제어를 넘겨받은 연산이 사용하고, 재개할 때는 스택의 제어권을 다시 가져와 그 연속체나 상태 머신을 이용해 중단한 지점부터 이어간다.
async/await(특히 Rust 포함)에서 자주 제기되는 문제 중 하나가 “함수 색칠(function coloring) 문제”다. 즉, async 함수의 결과를 얻으려면 일반 호출이 아니라 다른 연산(예: await)을 써야 한다는 불만이다. 그린 스레드와 스택풀 코루틴 메커니즘은 이런 결과를 피할 수 있다. 스택리스 코루틴의 상태를 관리하기 위해 “특별한 일이 일어남”을 표시하는 특별한 문법이 사용되기 때문이다(구체적으로 무엇인지는 언어마다 다르다).
Rust의 async/await 문법은 스택리스 코루틴 메커니즘의 한 예다. async 함수는 Future를 반환하는 함수로 컴파일되고, 그 future가 코루틴이 제어권을 양보할 때 그 상태를 저장한다. 이 논쟁의 근본 질문은 Rust가 이 접근을 채택한 것이 옳았는지, 아니면 더 Go에 가까운 “스택풀” 혹은 “그린 스레드” 접근을 채택했어야 하는지(가능하면 함수에 “색”을 입히는 명시적 문법 없이)다.
세 번째 Hacker News 댓글은 이 논쟁에서 자주 보이는 발언을 잘 대변한다:
사람들이 원하는 대체 동시성 모델은, 스택풀 코루틴과 채널을 워크-스틸링 익스큐터 위에 얹은 구조적 동시성이죠.
누군가 그걸 구현해서 async/await와 futures와 비교해 보기 전까지는 생산적인 논의가 어렵다고 봅니다.
구조적 동시성, 채널, 워크-스틸링 익스큐터(완전히 직교하는 문제들)에 대한 언급은 잠시 접어두자. 이런 종류의 댓글이 당혹스러운 이유는 원래 Rust에 그린 스레드라는 형태의 스택풀 코루틴 메커니즘이 실제로 있었다는 점이다. 1.0 출시 직전인 2014년 말에 제거되었다. 그 이유를 이해하는 것이, 왜 Rust가 async/await 문법을 싣게 되었는지를 파악하는 데 도움을 준다.
어떤 그린 스레딩 시스템이든(Rust든 Go든 다른 언어든) 큰 이슈는 이 스레드의 프로그램 스택을 어떻게 할 것인가다. 사용자 공간 동시성의 목표 중 하나가 OS 스레드가 사용하는 큰 선할당 스택의 메모리 오버헤드를 줄이는 것임을 기억하자. 그래서 그린 스레드 라이브러리는 보통 작은 스택으로 스레드를 시작하고 필요할 때만 키우는 메커니즘을 시도한다.
이를 달성하는 한 가지 방법이 이른바 “세그먼트 스택(segmented stacks)”이다. 스택을 작은 스택 세그먼트들의 연결 리스트로 두고, 현재 세그먼트의 경계를 넘으면 새 세그먼트를 리스트에 추가하고, 줄어들면 그 세그먼트를 제거한다. 문제는 이 방식이 스택 프레임을 푸시하는 비용의 변동성을 크게 만든다는 점이다. 프레임이 현재 세그먼트에 들어가면 사실상 공짜다. 들어가지 않으면 새 세그먼트를 할당해야 한다. 특히 문제인 경우는 핫 루프 안의 함수 호출이 새 세그먼트 할당을 요구하는 경우다. 루프의 각 반복마다 할당과 해제가 추가되어 성능에 큰 영향을 준다. 그리고 호출 시점의 스택 깊이를 사용자가 알 수 없기 때문에 완전히 불투명하다. Rust와 Go 모두 세그먼트 스택으로 시작했다가 이런 이유로 포기했다.
또 다른 접근은 “스택 복사(stack copying)”다. 이 경우 스택은 연결 리스트라기보다 Vec에 가깝다. 스택이 한계에 닿으면 재할당으로 키워서 다시 한계에 닿지 않도록 한다. 이렇게 하면 스택을 작게 시작해 필요할 때만 키울 수 있고, 세그먼트 스택의 단점을 피할 수 있다. 문제는 스택을 재할당하려면 복사가 필요하고, 그러면 스택이 메모리의 새로운 위치로 이동한다는 점이다. 스택을 가리키던 모든 포인터가 무효가 되므로 이를 갱신하는 메커니즘이 필요하다.
Go는 스택 복사를 사용하며, Go에서는 스택 내부로의 포인터가 같은 스택 내부에만 존재할 수 있다는 점의 이점을 이용한다. 따라서 해당 스택만 스캔하여 포인터를 다시 쓸 수 있다. 이마저도 Rust가 보관하지 않는 런타임 타입 정보를 필요로 한다. 게다가 Rust에서는 스택 내부로의 포인터가 그 스택 내부뿐 아니라 힙이나 다른 스레드의 스택 어딘가에도 존재할 수 있다. 이런 포인터를 추적하는 문제는 궁극적으로 가비지 컬렉션의 문제와 동일하다. 메모리를 해제하는 대신 이동한다는 점만 다르다. Rust는 가비지 컬렉터가 없기 때문에 스택 복사를 채택할 수 없었다. 결국 세그먼트 스택 문제를 해결하기 위해 Rust는 그린 스레드의 스택을 OS 스레드처럼 크게 만들었다. 하지만 이는 그린 스레드의 핵심 장점 중 하나를 없애 버렸다.
스택을 리사이징할 수 있는 Go 같은 상황에서도, 다른 언어로 작성된 라이브러리와의 통합을 시도할 때 그린 스레드에는 피할 수 없는 비용이 있다. OS 스택을 전제로 한 C ABI가 모든 언어의 공통 최소치다. 그린 스레드에서 OS 스레드 스택으로 실행을 전환하는 것은 FFI에 지나치게 비쌀 수 있다. Go는 이 FFI 비용을 감수한다. C#은 최근 이러한 이유로 그린 스레드 실험을 중단했다.
이 문제는 특히 Rust에 치명적이었다. Rust는 다른 언어로 작성된 바이너리에 Rust 라이브러리를 내장(embedding)하는 용도, 그리고 가상 스레딩 런타임을 돌릴 시계 사이클이나 메모리가 부족한 임베디드 시스템에서도 돌아가도록 설계되어 있기 때문이다. 이를 해결하기 위해 그린 스레딩 런타임을 선택 사항으로 만들었고, Rust를 네이티브 스레드와 블로킹 IO로도 컴파일할 수 있도록 했다. 최종 바이너리에서 컴파일 타임 결정으로 고르게 하려 했다. 그 결과 한동안 Rust에는 두 가지 변종이 있었다. 하나는 블로킹 IO와 네이티브 스레드를 사용했고, 다른 하나는 논블로킹 IO와 그린 스레드를 사용했다. 그리고 모든 코드는 두 변종에서 모두 호환되도록 의도되었다. 결과는 좋지 않았고, RFC 230에 열거된 이유들로 그린 스레드는 Rust에서 제거되었다:
그린 스레드를 제거한 뒤에도 고성능 사용자 공간 동시성 문제는 남아 있었다. 이를 해결하기 위해 Future 트레이트가 도입되었고, 이후 async/await 문법이 개발되었다. 하지만 그 과정을 이해하려면 한 걸음 더 물러나, Rust가 다른 문제를 어떻게 풀었는지를 봐야 한다.
async Rust 여정의 진짜 시작은 2013년 Daniel Micay라는 기여자가 올린 오래된 메일링 리스트 글에서 찾을 수 있다고 생각한다. 이 글은 async/await이나 futures, 논블로킹 IO와는 아무 관련이 없었다. 이터레이터에 대한 글이었다. Micay는 Rust를 “외부(external)” 이터레이터를 쓰는 방향으로 전환할 것을 제안했고, 그 전환—그리고 Rust의 소유권·차용 모델과의 궁합—이 Rust를 async/await으로 가는 길로 되돌릴 수 없게 만들었다. 물론 당시에는 아무도 그렇게 몰랐다.
Rust는 오래전부터 한 바인딩이 다른 변수와 별칭(alias)인 상태에서는 그 바인딩을 통해 상태를 변경하는 것을 금지했다. “가변 XOR 별칭”이라는 규범은 초기 Rust에서도 지금만큼이나 핵심적이었다. 다만 그때는 라이프타임 분석이 아니라 다른 메커니즘으로 이를 강제했다. 당시 참조는 그저 “인자 수식어”였고, Swift의 inout 수식어 같은 개념에 가까웠다. 2012년, Niko Matsakis가 Rust의 라이프타임 분석 첫 버전을 제안하고 구현했으며, 참조를 진짜 타입으로 승격해 구조체 안에 담을 수 있게 만들었다.
라이프타임 분석으로의 전환이 오늘날의 Rust를 만든 데 미친 엄청난 영향은 널리 인정받아 마땅하다. 하지만 그것이 외부 이터레이터와 공생적으로 상호작용한 점, 그리고 그 API가 Rust를 현재의 틈새 시장에 정착시키는 데 얼마나 근본적으로 중요했는지는 충분히 조명받지 못했다. “외부” 이터레이터 도입 전의 Rust는 콜백 기반 접근을 사용했다. 현대 Rust로 쓰면 대략 다음처럼 생겼을 것이다:
enum ControlFlow {
Break,
Continue,
}
trait Iterator {
type Item;
fn iterate(self, f: impl FnMut(Self::Item) -> ControlFlow) -> ControlFlow;
}
이런 방식의 이터레이터는 각 원소마다 콜백을 호출하고, 콜백이 ControlFlow::Break를 반환하면 순회를 멈춘다. for 루프의 본문은 순회 대상 이터레이터에 넘겨지는 클로저로 컴파일되었다. 이런 이터레이터는 외부 이터레이터보다 작성하기 훨씬 쉬웠지만, 두 가지 핵심 문제가 있었다:
zip 같은 여러 이터레이터를 교차(interleave)하는 제네릭 컴비네이터를 구현할 수 없었다. API가 하나의 이터레이터와 다른 이터레이터를 번갈아 순회하는 것을 지원하지 않았기 때문이다.대신 Daniel Micay는 Rust를 “외부” 이터레이터로 전환하자고 제안했다. 이는 위 문제들을 완전히 해결했고, 오늘날 Rust 사용자가 익숙한 인터페이스를 제공한다:
(아주 잘 아는 독자들은 Rust의 Iterator에 try_fold라는 제공 메서드가 있어 내부 이터레이터 API와 기능적으로 매우 비슷하고, 더 나은 코드 생성을 위해 몇몇 다른 이터레이터 컴비네이터의 정의에 사용된다는 사실을 알고 있을 것이다. 하지만 그것이 모든 이터레이터의 정의를 받치는 핵심 기반 메서드는 아니다.)
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
외부 이터레이터는 Rust의 소유권·차용 시스템과 완벽하게 맞물렸다. 순회 상태를 자기 자신 안에 담는 구조체로 사실상 컴파일되기 때문에, 순회 대상 자료구조에 대한 참조를 다른 구조체처럼 담을 수 있었다. 그리고 단형화(monomorphization) 덕분에 여러 컴비네이터를 조합해 만든 복잡한 이터레이터도 하나의 구조체로 컴파일되어 최적화기에게 투명했다. 단 하나의 문제는, 순회에 쓰일 상태 머신을 직접 정의해야 하므로 손으로 쓰기 어렵다는 점뿐이었다. 미래의 전개를 암시하듯, Daniel Micay는 그때 이렇게 썼다:
미래에 Rust는 C#처럼
yield문을 사용하는 제너레이터를 가질 수 있다. 컨텍스트 스위치, 가상 함수, 심지어 클로저도 없이 빠른 상태 머신으로 컴파일될 것이다. 그러면 외부 이터레이터로는 손코딩하기 어려운 재귀 순회를 쉽게 작성할 수 있다.
제너레이터는 빠르게 진척되지 않았지만, 최근 흥미로운 RFC가 나와 곧 이 기능을 볼 수 있을지도 모른다.
제너레이터 없이도 외부 이터레이터는 대성공이었고, 그 기법의 일반적 가치가 인정되었다. 예컨대 Aria Beingessner는 맵 엔트리에 접근하는 “Entry API”에 비슷한 접근을 사용했다. 의미심장하게도, 해당 API의 RFC에서 그녀는 이를 “이터레이터와 유사(Iterator-like)”하다고 부른다. 의미는 이렇다. 이 API는 일련의 컴비네이터로 상태 머신을 구성하고, 그 결과 컴파일러에 매우 읽기 쉬운 형태로 나타나 최적화 가능해진다는 것이다. 이 기법은 통했다.
그린 스레드를 대체해야 할 때, Aaron Turon과 Alex Crichton은 먼저 다른 많은 언어에서 쓰이던 API를 복사해 왔다. 훗날 퓨처나 프라미스라 불리게 된 것이다. 이런 API는 연속체 전달 방식(continuation-passing style, CPS)에 기반한다. 이런 식으로 정의된 future는 연속체라 불리는 콜백을 추가 인자로 받는다. future가 완료되면 마지막 동작으로 그 연속체를 호출한다. 대부분의 언어에서 이 추상화가 이렇게 정의되며, 대부분의 언어에서 async/await 문법은 이런 연속체 전달 방식으로 컴파일된다.
Rust에서 그런 API는 대략 이렇게 생겼을 것이다:
trait Future {
type Output;
fn schedule(self, continuation: impl FnOnce(Self::Output));
}
Aaron Turon과 Alex Crichton은 이 접근을 시도했지만, 곧 연속체 전달 방식을 쓰면 콜백을 할당해야 하는 경우가 너무 자주 생긴다는 문제에 부딪혔다. Turon은 join의 예를 든다. join은 두 개의 future를 받아 둘 다 동시에 실행한다. join의 연속체는 두 자식 future 모두가 소유해야 한다. 둘 중 늦게 끝난 쪽이 이를 실행해야 하기 때문이다. 이 구현에는 참조 카운팅과 할당이 필요했고, Rust에는 용납되기 어려웠다.
대신 C 프로그래머들이 비동기 프로그래밍을 어떻게 구현하는지 살펴봤다. C에서는 논블로킹 IO를 상태 머신을 만들어 다룬다. 그들이 원한 것은 C 프로그래머가 손으로 쓸 상태 머신으로 컴파일될 수 있는 Future의 정의였다. 몇 차례 실험 끝에 그들은 “준비 기반(readiness-based)” 접근에 도달했다:
enum Poll<T> {
Ready(T),
Pending,
}
trait Future {
type Output;
fn poll(&mut self) -> Poll<Self::Output>;
}
연속체를 저장하는 대신, future는 외부의 executor에 의해 폴링된다. future가 대기 중(pending)일 때는 자신을 다시 폴링할 준비가 되었을 때 그 executor를 깨울 방법을 저장했다가, 준비되면 그것을 실행한다. 이렇게 제어를 뒤집음으로써, future 완료 시 호출할 콜백을 저장할 필요가 없어졌고, future를 하나의 상태 머신으로 표현할 수 있게 되었다. 그들은 이 인터페이스 위에 라이브러리 컴비네이터를 구축했고, 모두가 하나의 상태 머신으로 컴파일되었다.
콜백 기반 접근에서 외부 구동(external driver)으로 전환하기, 여러 컴비네이터를 하나의 상태 머신으로 컴파일하기, 두 API의 정확한 명세까지—앞 절을 읽었다면 모두 매우 익숙하게 느껴질 것이다. 바로 그렇다. 연속체에서 폴링으로의 전환은 2013년에 이터레이터에서 수행한 전환과 정확히 같았다! 다시 한 번, 라이프타임을 가진 구조체를 다루고, 따라서 외부 상태를 차용하는 스택리스 코루틴을 다룰 수 있는 Rust의 능력이, 메모리 안전을 해치지 않고 상태 머신으로서 future를 최적으로 표현하게 했다. 이처럼 작은 구성요소로부터 단일 객체의 상태 머신을 조립하는 패턴은, 이터레이터든 future든, Rust가 작동하는 핵심 방식이다. 언어에서 거의 자연스럽게 흘러나온다.
이터레이터와 future 사이의 한 가지 차이를 잠시 강조하자. 두 이터레이터를 교차하는 Zip 같은 컴비네이터는 콜백류 접근으로는 아예 불가능하다. 당신의 언어가 그 위에 구축할 수 있는 어떤 네이티브 코루틴 지원을 가지고 있지 않은 한 말이다. 반면 두 future를 교차하는 Join을 원한다면, 연속체 기반 접근도 이를 지원할 수는 있다. 다만 런타임 비용이 따른다. 이것이 왜 다른 언어에서는 외부 이터레이터가 흔하지만, future에 이 변환을 적용한 것은 Rust가 유일한지를 설명해 준다.
초기 버전에서 futures 라이브러리는, 사용자가 이터레이터를 구성하듯 future를 구성한다는 원칙으로 설계되었다. 저수준 라이브러리 작성자는 Future 트레이트를 사용하고, 애플리케이션을 작성하는 사용자는 futures 라이브러리가 제공하는 일련의 컴비네이터로 더 간단한 구성요소로부터 복잡한 future를 만들어 쓰는 방식이다. 안타깝게도 사용자가 그렇게 시도하자마자 성가신 컴파일러 에러에 직면했다. 문제는 future가 스폰될 때 주변 컨텍스트를 “벗어나”야 하므로, 그 컨텍스트에서 상태를 차용할 수 없었다는 점이다. 태스크는 자신의 모든 상태를 소유해야 했다.
이것은 future 컴비네이터에 문제가 되었다. 그 상태는 종종 future를 이루는 연쇄된 여러 컴비네이터에서 접근되어야 했기 때문이다. 예컨대 어떤 객체에 대해 하나의 “비동기” 메서드를 호출한 뒤 이어서 다른 메서드를 호출하는 것은 흔했고, 다음처럼 썼다:
foo.bar().and_then(|result| foo.baz(result))
문제는 foo가 bar 메서드에서도, and_then에 넘긴 클로저 안에서도 차용된다는 점이었다. 본질적으로 사용자가 하고 싶었던 것은 “await 지점”을 가로질러 상태를 저장하는 것이었다. 여기서 await 지점은 future 컴비네이터들 간의 체이닝으로 형성된다. 이는 대개 난감하고 혼란스러운 빌림 검사기 에러로 이어졌다. 가장 접근성 있는 해결책은 그 상태를 Arc와 Mutex에 넣는 것이었는데, 제로 코스트가 아니고 시스템이 복잡해질수록 매우 불편하고 어색했다. 예를 들어:
let foo = Arc::new(Mutex::new(foo));
foo.clone().lock().bar()
.and_then(move |result| foo.lock().baz(result))
초기 실험에서 futures가 보여준 멋진 벤치마크에도 불구하고, 이 제약의 결과 사용자들은 복잡한 시스템을 구축하는 데 futures를 제대로 쓸 수 없었다. 바로 여기서 내가 이야기에 등장한다.
2017년 말, 사용자 경험 문제로 futures 생태계가 이륙에 실패하고 있음이 분명해졌다. 애초에 futures 프로젝트의 최종 목표는 이른바 “스택리스 코루틴 변환(stackless coroutine transform)”을 구현하는 것이었다. async/await 문법 연산자를 사용하는 함수를 future로 평가되는 함수로 변환해, 사용자가 손으로 future를 쓰지 않게 하는 것이다. Alex Crichton은 라이브러리로 매크로 기반 async/await 구현을 개발했지만 거의 주목받지 못했다. 뭔가 바뀌어야 했다.
Alex Crichton의 매크로의 가장 큰 문제 중 하나는, 사용자가 await 지점을 넘어 유지되는 future 상태에 대한 참조를 가지려고 하면 에러를 낸다는 점이었다. 이는 사실 컴비네이터에서 겪던 빌림 문제와 똑같은 이슈가 새 문법에서 다시 나타난 것이다. await 도중 future가 자신의 상태에 대한 참조를 보유할 수 없었다. 그렇게 컴파일하려면 자기참조 구조체(self-referential struct)가 필요했는데, Rust는 이를 지원하지 않았다.
이를 그린 스레드 문제와 비교하는 것은 흥미롭다. future를 상태 머신으로 컴파일한다는 설명 방식 중 하나는, 그 상태 머신이 “완벽히 크기가 맞는 스택”이라는 것이다. 그린 스레드의 스택은 어떤 스레드 스택이든 알 수 없는 크기의 상태를 수용하기 위해 커져야 하지만, 손으로 구현하든, 컴비네이터로 짜 맞추든, async 함수로 만들든 컴파일된 future는 필요한 만큼만 정확히 크다. 따라서 런타임에 이 스택을 키워야 할 문제는 없다.
하지만 이 스택은 구조체로 표현되며, Rust에서 구조체를 옮기는(move) 것은 언제나 안전하다. 즉, future를 실행 중에 굳이 옮길 필요는 없지만, Rust의 규칙상 옮길 수는 있어야 한다. 그리하여 그린 스레드에서 마주쳤던 스택 포인터 문제가 새 시스템에서도 다시 나타났다. 다만 이번에는 future를 실제로 옮길 필요는 없었고, 그저 future가 옮겨질 수 없음을 표현하기만 하면 되었다.
이를 구현하기 위한 초기 시도는 Move라는 새로운 트레이트를 정의해, 코루틴을 이동시킬 수 있는 API에서 제외하는 것이었다. 이는 내가 예전에 문서화한 바 있는 하위 호환성 문제에 부딪혔다. async/await에 대한 나의 테제는 세 가지였다:
이 세 가지를 결합하면, 언어에 큰 파괴적 변화를 주지 않고 구현할 수 있는 Move 트레이트 외의 대안을 찾아야 했다.
내가 처음 계획한 방안은 지금 우리가 갖게 된 것보다 훨씬 나빴다. 그냥 poll 메서드를 unsafe로 만들고, 한 번 future의 폴링을 시작하면 다시는 옮길 수 없다는 불변식을 포함하자는 제안이었다. 간단하고 즉시 구현 가능하며 극단적인 방법이었다. 모든 손으로 쓴 future를 unsafe로 만들고, 컴파일러의 도움 없이 검증하기 어려운 요구사항을 부과하게 된다. 아마 언젠가 건전성 문제에 좌초했을 것이고, 분명히 매우 논쟁적이었을 것이다.
다행히 Eddy Burtescu가 몇 마디 힌트를 주었고, 덕분에 훨씬 더 나은 API 방향으로 나아갈 수 있었다. 요구되는 불변식을 훨씬 미세한 단위로 강제할 수 있는 방식이었다. 이것이 결국 Pin 타입이 되었다. Pin 자체도 꽤 많은 불만의 원천이었지만, 당시 고려하던 다른 선택지들에 비해 부정할 수 없는 개선이었다. 목표 지점에 맞고, 강제 가능하며, 제때 배송 가능했다.
돌이켜보면, 핀닝 접근에는 두 부류의 문제가 있었다:
Pin을 다룰 일이 없도록 하는 것이 우리의 의도였다. 대부분은 그렇게 됐지만 눈에 띄는 예외가 몇 가지 있다. 거의 모두 약간의 문법 개선으로 고칠 수 있다. 정말 나쁘고(그리고 내게 개인적으로 민망한) 유일한 예외는, future 트레이트 객체를 await하려면 핀을 박아야 한다는 점이다. 이는 굳이 저지른 실수였고, 이제는 고치면 깨지는 변경이 된다.async/await에 관한 다른 결정들은 문법적인 것이었고, 이미 너무 긴 이 글에서 더 건드리지는 않겠다.
이 모든 역사를 돌아보는 이유는 일련의 사실들이 우리를 필연적으로 특정 설계 공간으로 이끌었음을 보이기 위해서다. 첫째, Rust에는 런타임이 없기 때문에 그린 스레드는 실행 가능한 해법이 아니었다. Rust는 내장(다른 애플리케이션에 내장되거나 임베디드 시스템에서 실행)도 지원해야 하고, 그린 스레드에 필요한 메모리 관리를 수행할 수도 없었다. 둘째, Rust는 메모리 안전을 유지하면서도 고도로 최적화 가능한 상태 머신으로 컴파일되는 코루틴을 자연스럽게 표현할 수 있었고, 이는 future뿐 아니라 이터레이터에도 활용되고 있다.
하지만 또 다른 측면이 있다. 왜 사용자 공간 동시성을 위한 런타임 시스템을 추구했나? 애초에 왜 future와 async/await가 필요한가? 이 논증은 보통 두 가지 형태로 나온다. 한편에는 epoll 같은 인터페이스를 직접 써서 사용자 공간 동시성을 “손으로” 관리하던 사람들이 있다. 이들은 종종 async/await 문법을 “웹잡것”이라며 비웃는다. 다른 한편에는 “그런 건 필요 없다”고 하며 더 단순한 OS 동시성(스레드와 블로킹 IO)을 쓰자고 하는 사람들이 있다.
사용자 공간 동시성 기능이 없는 C 같은 언어로 고성능 네트워크 서비스를 구현하는 사람들은 손으로 상태 머신을 작성해 구현하는 경향이 있다. 이것이 바로 Future 추상이 손으로 상태 머신을 쓰지 않고도 그 형태로 컴파일되도록 설계된 이유다. 코루틴 변환의 핵심은 “함수가 결코 양보하지 않는 것처럼” 명령형 코드를 작성하게 하면서, 블록될 시점에 일시 중단하도록 컴파일러가 상태 전이를 생성하게 하는 것이다. 그 이점은 적지 않다. 최근 curl의 한 CVE는 궁극적으로 상태 전이 중에 저장해야 할 상태를 인지하지 못해서 생겼다. 상태 머신을 손으로 구현할 때 이런 논리적 오류는 쉽게 발생한다.
Rust에서 async/await 문법을 배송한 목표는, 이런 버그를 피하면서도 동일한 성능 프로파일을 갖는 기능을 제공하는 것이었다. 이런 시스템은 대부분 C나 C++로 작성되며, 우리가 제공하는 제어 수준과 메모리 관리 런타임이 없다는 점을 고려할 때 충분히 우리의 잠재 사용자군에 포함된다고 보았다.
2018년 초, Rust 프로젝트는 그해 새로운 “에디션(edition)”을 출시해 1.0에서 드러난 문법적 문제들을 고치기로 했다. 또한 이 에디션을 Rust가 이제 본격적으로 쓸 준비가 되었음을 알리는 기회로 삼기로 했다. Mozilla 팀은 주로 컴파일러 해커와 타입 이론가들이었지만, 마케팅에 대해서도 기본적인 감은 있었고 에디션이 제품에 시선을 끌 기회임을 인지하고 있었다. 나는 Aaron Turon에게 Rust의 성장 기회로 보이는 네 가지 기본 사용자 시나리오에 집중하자고 제안했다. 다음과 같았다:
이 제안은 “도메인 워킹 그룹”의 출발점이 되었다. 이는 특정 “도메인”에 초점을 맞춘, 기능横断적 그룹이었다(기존의 기술·조직적 영역을 관할하는 “팀”과 대조적). 이후 Rust 프로젝트의 워킹 그룹 개념은 변형되어 대부분 이런 의미를 잃었지만, 이야기가 샌다.
async/await 작업은 “네트워크 서비스” 워킹 그룹에서 시작되었고, 결국 단순히 async 워킹 그룹으로 알려지게 되었다(지금도 이 이름으로 존재한다). 그러나 런타임 의존성이 없다는 점을 고려할 때, async Rust는 다른 도메인, 특히 임베디드 시스템에도 크게 기여할 수 있음을 우리는 잘 알고 있었다. 두 용례 모두를 염두에 두고 기능을 설계했다.
분명하지만 대개 말로는 하지 않던 사실이 있다. Rust가 성공하려면 산업계의 도입이 필요했다. 그래야 Mozilla가 더 이상 실험적 새 언어에 자금을 대지 않게 되었을 때도 지원을 받을 수 있다. 그리고 단기적 산업 도입으로 가장 가능성이 큰 경로는 네트워크 서비스, 특히 그 당시 C/C++로 작성할 수밖에 없던 성능 프로파일을 가진 분야였다. 이 용례는 Rust의 틈새와 완벽히 맞았다. 이런 시스템은 성능 요구를 충족하기 위해 높은 수준의 제어가 필요하고, 네트워크에 노출되므로 취약한 메모리 버그를 피하는 것도 중요하다.
네트워크 서비스의 또 다른 장점은, 이 업계가 Rust 같은 신기술을 빠르게 도입할 유연성과 식욕을 가진다는 점이다. 다른 도메인들은—지금도 그렇지만—Rust에 장기적으로 유망한 기회다. 하지만 신기술 도입 속도가 그리 빠르지 않다(임베디드). 자체적으로도 아직 널리 도입되지 않은 새 플랫폼(WebAssembly)에 의존하거나, 언어에 자금을 댈 만큼 수익성 있는 산업적 응용이 아니다(CLI). 나는 Rust의 생존이 이 기능에 달려 있다는 절박한 마음으로 async/await을 밀어붙였다. (악의적이고 문해력 없는 바보들이 Hacker News 같은 사이트에서 이 문장을 문맥에서 떼어, Rust가 기술적 이유가 아니라 자바스크립트 사용자에게 어필하기 위해 async/await을 채택했다고 주장하려 한다. 이 글은 Rust가 async/await을 채택한 기술적 이유를 자세히 설명하는데도 이런 주장을 한다면, 그런 사람은 거짓말쟁이거나 바보다. 그런 발언은 무시하라. 나는 그런 악의에 예의의 가장자리조차 남겨 두지 않겠다.)
그 점에서 async/await은 대단히 성공적이었다. Rust 재단의 가장 저명한 스폰서들—특히 개발자 급여를 지급하는 곳들—은 Rust로 고성능 네트워크 서비스를 작성하는 주요 용례 중 하나로 async/await에 의존하며, 그 사실이 그들의 후원을 정당화한다. 임베디드 시스템이나 커널 프로그래밍에서 async/await을 쓰는 것도 밝은 미래를 가진 성장 분야다. async/await이 너무 성공한 나머지, 가장 흔한 불만은 생태계가 “보통” Rust보다 이것에 너무 중심을 두고 있다는 것이다.
스레드와 블로킹 IO만 쓰고 싶어 하는 사용자에게 뭐라 말해야 할지 모르겠다. 분명 그런 접근이 합리적인 시스템이 많다고 본다. 그리고 Rust 언어는 그런 일을 막지 않는다. 그들의 불만은 crates.io 생태계, 특히 네트워크 서비스를 작성할 때, async/await 사용을 중심으로 돌아간다는 데 있는 듯하다. 가끔 “카고 컬트” 식으로 async/await을 쓰는 라이브러리를 보기도 하지만, 대부분은 라이브러리 작성자가 실제로 논블로킹 IO를 수행해 사용자 공간 동시성의 성능 이점을 얻고자 한다고 보는 것이 안전해 보인다.
우리는 모두 다른 사람들이 무엇에 시간을 쓰는지를 통제할 수 없다. 현실은, crates.io에 네트워킹 관련 라이브러리를 공개하는 많은 사람들이 비즈니스적 이유든 관심사 때문이든 async Rust를 쓰고 싶어 한다는 것이다. 나는 그런 라이브러리를 비동기 아닌 맥락에서도 더 쉽게 쓰게 만들고 싶다(예: pollster 같은 API를 표준 라이브러리에 들여오기). 하지만 공짜로 코드를 올리는 사람들이 당신과 정확히 같은 용례를 가지고 있지 않다고 불평하는 사람들에게 뭐라고 말해야 할지는 어렵다.
_Rust_에는 대안이 없었다고 주장하지만, 모든 언어에 대해 async/await이 정답이라고 믿지는 않는다. 특히 Rust가 제공하는 것과 같은 신뢰성 보장을 제공하지만 값의 런타임 표현에 대한 제어는 덜하는 언어가, 스택리스 대신 스택풀 코루틴을 사용할 가능성이 있다고 본다. 심지어—그런 언어가 이터레이션과 동시성 모두에 쓸 수 있을 정도로 코루틴을 지원한다면—라이프타임 없이도, 별칭된 가변성에서 비롯되는 오류를 제거할 수 있을지도 모른다. 그의 노트를 읽어 보면, 이런 언어가 바로 Graydon Hoare가 원래 지향하던 바였고, Rust가 C와 C++과 경쟁할 시스템 언어로 진로를 바꾸기 전의 모습이었음을 알 수 있다.
그런 언어가 있다면 기꺼이 쓸 Rust 사용자도 분명 있을 것이다. 그리고 그들이 저수준 세부사항의 본질적 복잡성을 감당해야 하는 것을 싫어하는 것도 이해한다. 예전에는 이런 사용자들이 수많은 문자열 타입에 대해 불평했다면, 이제는 async에 대해 불평하는 편이다. 그런 용례를 위한, Rust와 같은 종류의 보장을 제공하는 언어가 존재하기를 바라지만, 여기서 문제는 Rust가 아니다.
그리고 async/await이 Rust에 맞는 접근이라고 믿지만, 오늘의 async 생태계 상태에 불만을 가지는 것도 합리적이라고 본다. 우리는 2019년에 MVP를 배송했고, tokio는 2020년에 1.0을 냈다. 그 이후로는 관련자 누구의 기대보다도 정체되어 있었다고 생각한다. 후속 글에서는 오늘의 async 생태계 상태와, 사용자의 경험을 개선하기 위해 프로젝트가 할 수 있는 일을 논하고 싶다. 하지만 이 글은 이미 내가 출간한 것 중 가장 길다. 일단은 여기서 마치겠다.