스레드와의 대비 속에서 Rust의 futures 모델이 주는 고유한 이점—특히 태스크 내 동시성—을 중심으로, 함수 색칠 문제, 블로킹 API의 함정, maybe(async) 제안의 한계, 코루틴/효과 핸들러와의 관련성 등을 검토하며 “Future는 Future답게” 활용하자는 제안.
2010년대 초중반, 다양한 언어가 새로운 동시성 방식을 탐구하는 르네상스가 있었다. 그 한가운데서, 동시 연산을 달성하기 위한 추상화로 “future” 또는 “promise”가 발전했다. 이는 언젠가(어쩌면) 완료될 작업 단위를 표현하고, 이를 통해 프로그램의 제어 흐름을 조작할 수 있게 해준다. 여기에 더해 “async/await”라는 문법 설탕이 도입되어, futures를 가장 흔한 형태인 평범하고 선형적인 제어 흐름으로 빚어냈다. 이 접근법은 많은 주류 언어가 채택했는데, 실무자들 사이에서는 논쟁을 불러왔다.
그 시기 나온 훌륭한 글 두 편이 각각의 주장을 아주 잘 대변한다. 두 글 모두 반드시 정독하길 강력히 권한다.
Eriksen의 글의 요지는 futures가 스레드와는 근본적으로 다른 동시성 모델을 제공한다는 것이다. 스레드는 프로그램의 실행을 함수 호출 스택으로 모델링하기 때문에 모든 연산이 “동기적”으로 일어난다. 즉, 동시에 실행되는 연산이 끝나길 기다려야 할 때 호출 지점에서 블로킹한다. 반대로, 동시에 실행되는 연산을 비동기적으로 완료되는 “future”로 표현하면, Eriksen이 제시한 여러 장점이 생긴다. 특히 내가 설득력 있게 느끼는 점은 다음과 같다.
flatMap
같은 예를 든다.Nystrom은 반대 입장이다. 그는 모든 함수가 BLUE
또는 RED
로 “색칠”된 언어를 상상하며 글을 시작한다. 그의 가상 언어에서 중요한 차이는 RED
함수는 오직 다른 RED
함수에서만 호출될 수 있다는 점이다. 그는 이 구분이 언어 사용자에게 큰 좌절을 준다고 가정한다. 왜냐하면 두 종류의 함수를 추적하는 게 성가신 데다, 그의 언어에서 RED
함수 호출 문법은 번거롭게 현학적이기 때문이다. 물론 여기서 말하는 건 동기 함수와 비동기 함수의 차이다. Eriksen이 futures의 장점으로 꼽은—future를 반환하는 함수가 future를 반환하지 않는 함수와 다르다는 점—바로 그 특징이 Nystrom에게는 가장 큰 약점이다.
Nystrom의 말 중 일부는 async Rust와 무관하다. 예컨대, 그는 한 색의 함수를 다른 색의 함수로 호출하면 끔찍한 일이 생길 수 있다고 말한다.
함수를 호출할 때는 그 색에 맞는 호출을 써야 한다. 만약 틀리면 … 무언가 나쁜 일을 한다. 어린 시절의 악몽을 끄집어내 보라. 침대 아래에 뱀 팔을 가진 광대가 숨어 있다가 모니터 밖으로 튀어나와 유리체 액을 빨아내는 그런 악몽 말이다.
이는 정적 타입이 아니고 황당한 의미론으로 유명한 JavaScript에서는 그럴듯하지만, Rust 같은 정적 타입 언어에서는 컴파일 오류가 나고, 고치고 넘어가면 된다.
그의 주요 주장 중 하나는 RED
함수를 호출하는 일이 BLUE
함수를 호출하는 것보다 훨씬 “고통스럽다”는 것이다. 글 뒷부분에서 그는 2015년 당시 JavaScript에서 흔하던 콜백 기반 API를 염두에 두고 말하며, async/await 문법이 이 문제를 해결한다고 말한다.
[Async/await] 덕분에 비동기 호출을 동기 호출만큼 쉽게 할 수 있다. 거기에 귀여운 작은 키워드를 살짝 얹기만 하면 된다.
await
호출을 표현식 속에 중첩하고, 예외 처리 코드에서도 쓰고, 제어 흐름 안에 쑤셔 넣으면 된다.
물론 그는 이렇게도 말한다. 이것이 바로 “함수 색칠 문제” 논쟁의 핵심이다.
하지만… 세계는 여전히 둘로 나뉘어 있다. 그 비동기 함수들은 쓰기 쉬워졌지만, 여전히 비동기 함수들일 뿐이다.
여전히 두 가지 색이 남아 있다. Async-await은 성가신 규칙 #4—빨간 함수를 파란 함수보다 많이 나쁘지 않게 호출하게 해 주는—를 해결했을 뿐이다. 하지만 나머지 모든 규칙은 그대로다.
Futures는 비동기 연산을 동기 연산과 다르게 표현한다. Eriksen에게 이것은 추가적인 “손잡이(affordance)”를 제공하는 것이고, 이것이 futures의 핵심 장점이다. Nystrom에게 이것은 블로킹 대신 future를 반환하는 함수를 호출할 때 또 하나의 장애물일 뿐이다.
이 블로그를 아는 사람이라면 예상하겠지만, 나는 Eriksen 쪽에 꽤 확고히 선다. 그래서 해커 뉴스 댓글러나 인터넷에 화난 확신범들이 쓴 글에서 Nystrom의 견해가 훨씬 인기 있는 것을 발견하는 일은 쉽지 않았다. 몇 달 전, Rust가 어떻게 futures 추상화와 그 위의 async/await 문법을 갖게 되었는지의 역사를 더듬는 글을 썼고, 이어서 async Rust를 더 쓰기 쉽게 만들기 위해 추가되었으면 하는 기능을 설명한 후속 글을 썼다.
이제 한 걸음 물러서서, futures 모델의 유용성이라는 질문의 맥락에서 async Rust의 설계를 다시 검토하고 싶다. futures를 사용함으로써 async Rust에서 실제로 얻은 것이 무엇인가? 나는, futures 사용의 어려움이 완화되거나 해결되었고 그들이 제공하는 추가적 affordance 덕분에 async Rust가 단지 비-async Rust만큼 쓰기 쉬울 뿐 아니라 실제로 더 좋은 경험이 되는 세계를 상상해 보고 싶다.
보통 futures의 이점은 사용자에게 성능 향상의 관점으로 설명된다. 스레드를 시작하는 것도, 스레드 간 전환도 비용이 크기 때문에, 단일 스레드에 여러 동시 연산을 멀티플렉싱하면 한 머신에서 더 많은 동시 연산을 수행할 수 있다는 것이다. 나는 Eriksen처럼, “스레드 기반 IO”와 “이벤트 기반 IO” 사이의 성능 양분법에 초점을 맞추는 건 미끼라고 본다. 코드 구조화 측면에서 futures의 이점에 대한 Eriksen의 주장들은 Rust에도 똑같이 적용된다.
Eriksen은 그의 글에서 future 추상화의 기반으로 연속 전달 스타일(continuation-passing style, CPS)을 사용하는 언어의 맥락에서 이야기했다. 내가 async Rust의 역사에서 썼듯이, Rust는 이 접근을 취하지 않았다. 독특하게도, Rust는 CPS를 뒤집은 시스템을 채택했다. 즉, future가 완료되면 연속(continuation)을 호출하는 대신, 외부에서 폴링해 완료를 이끈다. 이 점의 관련성을 이해하려면 “태스크(task)”에 대해 한 걸음 물러나 이야기해야 한다.
여기서 태스크라고 할 때 단지 “작업 단위”를 말하는 게 아니다. async Rust에서 “태스크”는 특정 용어다. 비동기 작업의 근본 추상화는 future, 즉 Future 트레이트를 구현하는 타입이며, 대부분은 async 함수나 async 블록으로 구현된다. 하지만 어떤 비동기 코드든 실행하려면 “태스크”를 실행할 수 있는 “실행기(executor)”가 필요하다. 보통 이것은 “런타임(runtime)”이 제공하며, 비동기 IO 타입 같은 것도 함께 제공한다. 가장 널리 쓰이는 런타임은 tokio다.
정의가 좀 꼬여 보일 수 있다. “태스크”란 실행기에 스케줄된 어떤 future다. 대부분의 실행기(예: tokio)는 여러 태스크를 동시에 실행할 수 있다. 하나의 스레드만 쓸 수도 있고, 여러 스레드를 써서 태스크를 분산할 수도 있다(무엇이 더 나은지는 별도의 논쟁이 있었다). 동시에 여러 태스크를 실행하는 실행기는 보통 spawn
같은 API를 노출한다. 이는 “태스크를 스폰(spawn)”한다. 반대로 pollster처럼 한 번에 하나의 태스크만 실행하는 실행기도 있는데, 이런 경우 block_on
같은 API를 제공한다.
모든 태스크는 future이지만, 모든 future가 태스크인 것은 아니다. future는 실행기에 넘겨 실행될 때 태스크가 된다. 대개 하나의 태스크는 여러 작은 future를 합성해서 구성된다. async 스코프 안에서 어떤 future를 await
하면, 그 future의 상태는 해당 async 스코프가 평가되어 생기는 상위 future의 상태에 직접 합쳐진다. 보통 우리는 spawn
을 쓰는 것보다 이런 식으로 더 많이 합성하므로, 대부분의 future는 내가 말한 의미의 태스크가 아니다. 하지만 future와 태스크 사이에 타입 수준의 구분은 없다. 어떤 future든 실행기에 올리면 태스크가 될 수 있다.
future와 태스크의 이 구분에서 중요한 점은, 태스크를 실행하는 데 필요한 모든 상태가 하나의 객체로 할당되어 메모리에 함께 배치된다는 것이다. 태스크 내부에서 사용되는 개별 future는 각각 별도의 할당을 요구하지 않는다. 우리는 이 상태 머신을 태스크의 “완벽히 맞춰진 스택(perfectly sized stack)”이라고 종종 설명해 왔는데, 태스크가 양보(yield)할 때 필요할 수 있는 모든 상태를 담기에 딱 맞는 크기라는 뜻이다.
(이 설계의 또 다른 함의는, 명시적으로 재귀 호출을 박싱(boxing)하지 않으면 재귀 async 함수를 작성할 수 없다는 점이다. 재귀적으로 포함된 타입을 박싱하지 않으면 재귀 구조체 정의를 쓸 수 없는 이유와 같다.)
이는 async Rust에서 동시 연산을 표현하는 데 흥미로운 시사점을 준다. 태스크 모델로 달성할 수 있는 두 가지 동시성, 즉 _멀티 태스크 동시성_과 _태스크 내 동시성_을 구분해 두고 이야기하고 싶다. 전자는 각 연산을 별도의 태스크로 표현하는 것이고, 후자는 하나의 태스크가 여러 연산을 동시에 수행하는 것이다.
두 연산이 동시에 일어나길 원한다면, 각 연산마다 별도의 태스크를 스폰하는 방식이 한 가지 방법이다. 이것이 “멀티 태스크 동시성”으로, 여러 동시 태스크를 사용해 얻는 동시성이다.
많은 사용자에게 멀티 태스크 동시성은 async Rust에서 가장 접근하기 쉬운 방식이다. 비-async Rust에서 스레드를 스폰하여 동시성을 달성하는 것과 가장 비슷하기 때문이다. async Rust에서도 태스크를 스폰할 수 있으며, 이는 이미 스레드 기반 동시성에 익숙한 사용자에게 친숙하다.
여러 비동기 태스크가 생기면, 그 사이에 정보를 전달할 방법이 필요해질 것이다. 이런 “태스크 간 통신”은 락(lock)이나 채널(channel) 같은 동기화 프리미티브로 달성한다. 비동기 태스크에는 블로킹 동기화 프리미티브에 해당하는 비동기 버전이 모두 있다. 예컨대 async Mutex
, async RwLock
, async mpsc
채널 등이다. 많은 런타임은 표준 라이브러리에 없는 비동기 동기화 프리미티브도 제공한다. 표준 라이브러리에 아날로그가 있을 때는, 두 프리미티브의 인터페이스가 매우 유사한 경우가 많다. affordance 관점에서 보면, async Mutex
는 블로킹 Mutex
와 거의 같되, 잠금 메서드가 블로킹 대신 async라는 점만 다르다. 이것이 async-std 런타임의 개념적 기반이었다.
하지만 구현은 완전히 다르다는 점은 짚고 넘어갈 가치가 있다. async 태스크를 스폰할 때 동작하는 코드는 스레드를 스폰하는 것과는 전혀 다르다. 예를 들어, async 락의 정의와 구현은 블로킹 락과 매우 다르다. 보통 내부적으로는 원자적(atomic) 락을 사용하면서, 락을 기다리는 태스크의 큐를 추가로 둔다. 스레드를 블로킹하는 대신, 그 태스크를 큐에 넣고 양보한다. 락이 풀리면, 큐의 첫 태스크를 깨워 락을 다시 잡게 한다.
이러한 API의 사용자 입장에서는 멀티 태스크 동시성이 멀티 스레드 동시성과 매우 비슷하다. 그러나 futures 추상화가 가능케 하는 동시성은 이것뿐이 아니다.
멀티 태스크 동시성이 (async와 await 키워드가 흩뿌려진 점을 빼면) 멀티 스레드 동시성과 같은 API 표면을 갖는 반면, futures 추상화는 스레드 문맥에는 아날로그가 없는 또 다른 동시성을 가능케 한다. 바로 “태스크 내 동시성”이다. 이는 동일한 태스크가 여러 비동기 연산을 동시에 수행한다는 뜻이다. 각 동시 연산마다 별도의 태스크를 할당하는 대신, 동일한 태스크 객체로 그 연산들을 수행할 수 있어, 메모리 지역성을 개선하고, 할당 오버헤드를 줄이며, 최적화 기회를 늘린다.
구체적으로는, 태스크 내 동시성 프리미티브(예: select!
)를 사용할 때, 조작 중인 두 future의 상태가 그것들을 조작하는 상위 future 안에 직접 내장된다. 널리 알려진 태스크 내 동시성 프리미티브는 내가 이전 글들에서 논의한 async 프리미티브들의 표에 대응한다. Future
에 대해 select와 join, AsyncIterator
에 대해 merge와 zip이다.
│ SUM │ PRODUCT
───────────────┼─────────────┼──────────
│ │
FUTURE │ select! │ join!
│ │
ASYNCITERATOR │ merge! │ zip!
│ │
스레드로도 이런 API를 제공할 수는 있지만, 그 경우 새 스레드를 스폰하고 채널이나 조인 핸들을 통해 결과를 상위 스레드로 다시 전달해야 한다. 이는 큰 오버헤드를 도입한다. 반면 Rust의 태스크 내 구현은 가능한 한 저렴하다.
사실 이러한 조합자(combinator)의 오버헤드를 제거하는 것이, Rust가 CPS에서 준비(readiness) 기반 접근으로 옮긴 전부의 이유였다. Aaron Turon이 CPS에서는 힙 할당이 필요해지는 future에 대해 적었을 때, 그의 예가 join
이었던 것은 우연이 아니다. 바로 동시 연산을 내장하는 future들이 연속의 공유 소유권(여러 동시 연산 중 어느 것이 끝나더라도 연속을 호출할 수 있도록)이 필요하기 때문이다. 즉, 태스크 내 동시성을 위한 이 조합자들이 준비 기반 future가 최적화하도록 설계된 대상이었다.
과거 Rain이 설득력 있게 주장했듯이, “이질적(heterogeneous) select가 async Rust의 요체”다. 특히, 서로 다른 타입의 다양한 future를 단일 태스크 내에서 추가 할당 없이, 어느 것이 먼저 끝나든 기다렸다가(await) 이어서 처리할 수 있다는 점은 비-async Rust에는 없는 고유한 속성이며, 매우 강력한 기능이다.
async Rust 서버의 흔한 아키텍처는 소켓마다 하나의 태스크를 스폰하는 것이다. 이 태스크들은 종종 그 소켓의 수신/송신 읽기/쓰기를 내부적으로 다중화하면서, 다른 태스크들에서 그 소켓의 반대편 서비스로 향하는 메시지까지 함께 다룬다. 이를 위해 라이프사이클의 구체에 따라 몇 개의 future 사이를 select하거나, 이벤트 스트림을 병합(merge)하기도 한다. 이 코드는 매우 고수준처럼 보이고, 많은 면에서 비동기 동시성의 액터 모델과 닮았다. 하지만 태스크 내 동시성 덕분에, 이것은 소켓당 하나의 상태 머신으로 컴파일되며, 이는 C 같은 언어로 손수 작성한 비동기 서버의 런타임 표현과 매우 비슷하다.
이 아키텍처(및 유사한 다른 아키텍처)는, 멀티 태스크 동시성을 그에 적합한 경우에 사용하고, 태스크 내 동시성을 그에 더 나은 경우에 사용해 결합한다. 이 둘의 차이를 인지하는 것이 async Rust를 마스터하는 핵심 역량이다. 태스크 내 동시성에는 몇 가지 제약이 있다. 알고리즘이 이 제약을 감내할 수 있다면, 그 경우에는 아마도 좋은 적합이다.
첫 번째 제약은, 태스크 내 동시성으로는 정적인 동시성 차수(arity)만 달성할 수 있다는 점이다. 즉, 임의의 개수의 future를 조인(또는 select 등)할 수 없고, 개수가 컴파일 타임에 고정되어야 한다. 이는 컴파일러가 각 동시 future의 상태를 상위 future의 상태에 배치해야 하며, 각 future는 정적으로 결정된 최대 크기를 가져야 하기 때문이다. 이는 동적으로 크기가 변하는 객체 컬렉션을 스택에 둘 수 없고, 동적 개수의 객체에는 힙 할당된 Vec
같은 것을 써야 하는 것과 정확히 같은 이유다.
두 번째 제약은, 이 동시 연산들이 서로 또는 그들을 기다리는 상위와 독립적으로 실행되지 않는다는 점이다. 두 가지 의미가 있다. 첫째, 태스크 내 동시성은 어떤 병렬성도 달성하지 못한다. 궁극적으로 하나의 태스크에 하나의 poll
메서드만 있으며, 여러 스레드가 그 태스크를 동시에 폴링할 수 없다. 따라서 태스크 내 동시성은 계산 바운드 작업에는 적합하지 않다. (널리 쓰이는 async 런타임들 역시 계산 바운드 작업에는 적합하지 않다. 이것이 async 모델의 본질 때문이라고는 생각하지 않지만, 현재 사용 가능한 라이브러리들에 관한 사실이긴 하다.) 둘째, 사용자가 이 태스크에 대한 관심을 취소하면, 모든 하위 연산은 필연적으로 취소된다. 전부 같은 태스크의 일부였기 때문이다. 따라서 이 작업이 취소되더라도 연산을 계속하고 싶다면, 별도의 태스크로 스폰해야 한다.
잠시 Nystrom의 글로 되돌아가, 전혀 다른 줄기를 하나 덧붙이고자 한다. 이 줄기들은 나중에 다시 합쳐질 것이고, 가능하다면 서로 응집되기를 바란다.
색칠된 함수가 있는 언어에 대한 사고 실험을 계속해 보자. 언어 설계자가 Nystrom의 비판을 읽고 RED
와 BLUE
함수의 고통을 줄이려 했다고 하자. 멈출 줄 모르는 언어 설계자들의 고전적 경향에 따라, 모두의 불만을 끝내기 위해 세 번째 색인 GREEN
함수를 추가했다. 물론 이들도 자신들만의 규칙 세트를 갖고 있다.
GREEN
함수는 BLUE
함수처럼 호출할 수 있다.RED
함수와 달리, GREEN
함수에는 특별한 문법이 없다. 어디서든 BLUE
함수와 정확히 같은 문법으로 호출할 수 있다. 실제로, 시그니처와 사용법만 보면 차이를 전혀 알아챌 수 없다. 문서에 그 함수가 GREEN
이라는 메모가 있을 것이다(함수 작성자가 그 정보를 굳이 적어두지 않았다면 없을 수도 있다).
훌륭하다! BLUE
와 GREEN
함수만 쓰는 한, 함수의 색을 더는 걱정하지 않아도 된다.
RED
함수에는 GREEN
등가가 있다.물론 이를 성취하려면, RED
함수를 호출하지 않고도 프로그램을 구현할 수 있어야 한다. 그래서 언어 저자들은 표준 라이브러리에, 원래는 오직 RED
함수로만 가능했던 각 연산마다 GREEN
함수를 추가했다.
구현은 성능과 관련된 어떤 이유로 서로 다르다. 그게 당신의 사용 사례에 의미가 있을 수도, 없을 수도 있다. 하지만 이 사고 실험에서는 코드 의미론 같은 것을 무시하기로 했으니, 적어도 지금은 더 파고들지 말자.
RED
함수든 감싸 호출하는 GREEN
함수가 있다.표준 라이브러리에 GREEN
함수가 있음에도, 사용자들은 여전히 RED
함수로 작성된 라이브러리를 만날 수 있다. 그래서 언어 설계자들은 영리한 우회로를 고안했다. RED
함수를 인자로 받는 고차 GREEN
함수가 있다. 기술적 세부를 제쳐두면, 실상은 그 RED
함수를 그냥 호출할 뿐이다. GREEN
함수는 어디서든 호출 가능하므로, BLUE
함수 안에서 RED
함수를 호출하지 못하는 문제를 해결해 준다.
RED
함수 안에서 GREEN
함수를 호출하는 건 매우 나쁘다.물론 언제나 단점은 있다. RED
함수 안에서 GREEN
함수를 호출해서는 안 된다. “코에 악마(nasal demons)가 튀어나오는” 미정의 동작만큼 나쁘거나, “뱀 팔을 가진 광대” JavaScript만큼 나쁘지는 않지만, 프로그램이 확실히 느려지고, 최악의 경우 교착 상태를 불러올 수도 있다. 사용자는 절대로 그러면 안 된다. RED
함수에 만족하는 프로그래머는 어떤 대가를 치르더라도 GREEN
함수를 피해야 한다.
하지만 이 언어가 GREEN
함수를 추가한 방식에는 문제가 있다. GREEN
함수는 BLUE
함수와 동일해 보이기 때문에, 호출할 때 이를 식별할 방법이 없다! 어떤 함수들이 GREEN
인지 문서로만 알아야 하고, RED
함수 내부에서는 절대로 호출하지 않도록 스스로 조심해야 한다.
이제 블로그를 크리스마스트리처럼 색칠했으니, 다시 Rust 이야기로 돌아가자. 아마 GREEN
함수가 무엇인지 눈치챘을 것이다. GREEN
함수는 현재 스레드를 블로킹하는 모든 함수다. 동시에 일어나는 일을 기다리기 위해 스레드를 블로킹하는 함수를 구분하는 특별한 문법이나 타입은 없다. 이것이 Nystrom이 블로킹 함수의 장점으로 주장한 바로 그것이다. 많은 비동기 함수를 가진 언어와 달리, Rust는 블로킹 함수도 지원한다. 어떤 종류의 IO나 스레드 동기화를 블로킹으로 수행하는 API가 있고, 임의의 Future
를 받아 그것이 준비될 때까지 이 스레드를 블로킹하는 block_on
API도 있다. 덕분에 비동기 라이브러리를 마치 블로킹처럼 호출할 수 있다.
블로킹 연산을 지원하지 않는 언어에는 이런 문제가 없다. 대신 Nystrom이 불평한 문제—비동기 함수와 비-비동기 함수를 구분해서 알아야 하는 문제—가 있다. 하지만 Rust에서는 모든 것이 가능하므로, futures를 쓰고 싶지 않은 사용자는 거의 완전히 피할 수 있다. 때때로 오픈소스 라이브러리(무상 보증 없이 제공되는!)가 async Rust를 사용하기 때문에, 본인 코드에서 이를 사용하려면 block_on
이 필요하다는 정도가 문제다. 그래도 이에 대해 자주, 격렬히 불평하는 사람은 있을 것이다.
이 상황에서 최악의 대우를 받는 사람들은 async Rust 사용자다. 그들은 async Rust를 다뤄야 할 뿐 아니라, 비동기 코드 안에서는 블로킹 함수를 절대 호출해서는 안 된다는 사실도 감당해야 한다. 그런데 블로킹 함수는 평범한 함수와 완전히 구분이 안 된다! Nystrom에 따르면, 바로 그게 좋은 점이었다.
오래 전(바로 async/await이 나온 직후), 나는 블로킹 함수에 속성을 달아 async 문맥에서 이를 호출할 때 린트로 검출하게 하자는 제안을 했다. 사용자가 이런 실수를 잡는 데 도움이 되도록 말이다. 이 아이디어는 왜인지 Rust 프로젝트에서 추진되지 않았다. 이 오류를 잡도록 돕는 더 많은 노력이 있었으면 한다.
async IO에서 블로킹 API 중 가장 교활한 것은 블로킹 Mutex
다. async 함수 안에서 블로킹 Mutex
를 사용하는 것이 특정하지만 꽤 흔한 조건에서는 괜찮다.
await
지점을 넘어서 락을 잡은 채로 있지 않을 때.그러나 정말 나쁜 지점은, 만약 Mutex
가 await
지점을 넘어서도 보유된다면, 같은 스레드에서 실행되는 다른 태스크들이 락을 잡으려 시도하는 동안 보류 중인 태스크가 락을 쥔 채로 있어서, 스레드가 쉽게 교착 상태에 빠질 수 있다는 것이다(표준 라이브러리의 Mutex
는 재진입 가능하지 않다). 즉, 어떤 때는 완전히 괜찮고, 어떤 때는 나쁠 뿐만 아니라 파괴적일 정도로 해롭다. 좋은 결말이 아니다!
앞선 두 절은 서로 꽤 독립적인 두 아이디어를 탐색했다.
이 두 논의를 관통하는 것은, 비동기 함수와 블로킹 함수의 차이가 _Future 트레이트라는 추가 affordance_에 있다는 사실이다. 이것이 비동기 태스크가 하나의 태스크 안에서 여러 연산을 동시에 수행하게 해 주는 반면, 스레드는 그러지 못한다. 그리고 이 affordance가 없기 때문에 블로킹 함수를 async 코드에서 호출하는 것이 문제다. 블로킹 함수는 양보할 수 없고(block), 차단만 하기 때문이다. 내 async Rust 설계 원칙은 이렇다. 우리는 아주 좋은 이유로 이 affordance를 도입했고, 그것을 최대한 활용해야 한다. Henry de Valence가 트위터에서 썼듯이: “난 빠른 스레드를 원하는 게 아니라, futures를 원한다.”
이 생각은 전혀 새롭지 않다. Rust에서 그린 스레딩 라이브러리를 제거한 RFC에서, Aaron Turon은 비동기 IO와 블로킹 IO의 API를 동일하게 만들려는 시도가 async Rust의 잠재력을 제한한다고 주장했다.
현재 설계에서는, 그린 스레드와 네이티브 스레드 모델이 언제나 같은 I/O API를 제공해야 한다. 하지만 오직 특정 스레딩 모델에서만 적절하거나 효율적인 기능들이 있다.
예를 들어, 가장 경량의 M:N 태스크 모델은 본질적으로 클로저들의 모음에 불과하며, 특별한 I/O 지원을 제공하지 않는다. 이런 종류의 경량 태스크는 Servo에서 사용되며, java.util.concurrent의 executor나 Haskell의 par monad 등에서도 볼 수 있다. 이 가벼운 모델은 현재 런타임 시스템에 잘 맞지 않는다.
Turon은 이어서 현재 Rust에 존재하는 준비 기반 futures API를 발전시켰는데, 그 기원을 위 인용에서 볼 수 있다. async/await 문법을 future 추상화 위에 겹치면서(또 Rust에 기여하던 사람들이 교체되면서) 이 생각은 강조가 약해지고 다소 잊힌 듯하다. 지금의 사고방식은 이렇다. async Rust와 블로킹 Rust는 가능한 한 비슷해야 한다. 하지만 이는, 사용자 공간 스케줄링에서의 잠재적 성능 향상을 제외하면, async Rust의 추가 affordance를 버리는 셈이다.
async/await이 이 세계에서 어떤 역할을 하는지, 그리고 그것이 전부가 아님을 이해하는 것이 중요하다. futures는 하나의 태스크 안에 동시 연산을 멀티플렉싱할 수 있는 “선택권”을 준다. 하지만 “의무”로 만들지는 않는다. 이 선택권이 필요한 때가 드물지만, 꼭 필요한 순간이 있다. 대부분의 시간에는 코드가 “한 번에 한 가지씩(one damn thing after another)” 진행되면 충분히 행복하다. await
연산자는 중첩된 콜백이나 조합자 체인을 쓰지 않고 정확히 그렇게 하게 해 준다. 이는 Futures의 선택권이 야기하는 비용을, 세계를 비동기 함수와 비-비동기 함수로 나누는 것에 한정시켜, 추가 사용 난점을 없앤다. 하지만 바로 그 선택권을 실행하는 지점—즉 어떤 future를 곧장 await
하지 않는 지점—이야말로 가장 중요하다!
futures는 단일 스레드에 임의 개수의 “완벽히 맞춰진” 태스크를 멀티플렉싱할 수 있게 해 주고, 하나의 태스크 안에서 정적 개수의 동시 연산을 멀티플렉싱할 수 있게 해 준다. 덕분에 사용자는 스레드를 스폰하는 보일러플레이트를 여기저기에 끼워 넣지 않고도, 동시 코드를 논리적으로 구조화할 수 있다. 그리고 훨씬 더 나은 성능 특성을 제공하는데, 매우 높은 동시성이 필요한 시나리오에서는 결정적일 수 있다. 이것만으로도 내게는 충분히 대가를 지불할 가치가 있지만, 더 많은 이점도 상상해 볼 수 있다.
락을 await
지점에 걸쳐 보유하는 문제로 돌아가 보자. 일부 사용자는 잠재적으로 장시간 걸릴 수 있는 비동기 연산(예: IO)을 수행하기 전에 반드시 락을 내려놓아, 다른 동시 연산들이 기다리지 않고 락을 잡도록 하려는 패턴을 사용한다. (주의가 필요하다. IO를 수행하는 동안 보호된 상태가 바뀔 수 있으므로, 코드가 그 변화에 견고해야 한다.) async/await은 이미 블로킹 IO보다 이 점에서 수월하다. 태스크가 장시간 작업을 수행할 수 있는 지점이 await
키워드로 표시되어 있기 때문이다. 블로킹 IO에서는 문법적으로 블로킹이 드러나지 않아, 락을 내려놓아야 할 지점을 놓치기 쉽다. 하지만 async Rust는 그보다 더 잘할 수도 있다.
David Barsky는 이른바 “lifecycle” 트레이트를 제안한 바 있다. 이는 Drop
과 유사하지만, 어떤 객체를 보유한 future가 양보할 때와 재개할 때 실행되는 인터페이스다. 그는 특히 tracing을 위해 이 개념에 관심이 있었다. tracing은 모든 로그 메시지에 실행 중인 태스크 정보를 포함하므로, 그 변화 시점을 알아야 하기 때문이다. 이 아이디어는, future가 양보할 때 자동으로 임대(lease)를 내려놓고, 재개할 때 다시 잡는 락킹 프리미티브를 가능하게 하는 데도 쓰일 수 있다. 그러면 사용자가 await
할 때 락을 내려놓는 것을 실수로 빠뜨릴 일이 없으며, 수동 버전보다 더 최적일 수도 있다. 태스크가 실제로는 양보하지 않는 경우(대상 future가 즉시 준비되었기 때문에) 락을 내려놓았다가 다시 잡을 필요가 없기 때문이다.
maybe(async)
여기까지와 완전히 역행하는, Rust 프로젝트 내 논의 중 하나를 언급하지 않으면 나태이겠다. 바로 maybe(async)
아이디어다. 이는 (문법은 미정) 어떤 코드가 async인지 아닌지에 대해 추상화하는 기능이다. 다시 말해, maybe(async)
함수는 두 변종으로 구체화될 수 있다. 하나는 async로, 안의 future들을 await
한다. 다른 하나는 비-async로, 그 경우에는 아마 async 버전에서 future를 반환하는 함수들이 블로킹으로 동작할 것이다.
이 아이디어의 가장 큰 문제는 멀티 태스크 동시성에만 작동할 수 있다는 점이다. 이미 썼듯이, 멀티 태스크 동시성으로 작성한 코드는 멀티 스레드 동시성과 직접적인 유비가 있다. 하지만 태스크 내 동시성은 스레드 기반 동시성 시스템에 상응하는 것이 없다. futures의 affordance에 의존하기 때문이다. 따라서 maybe(async)
를 사용하려는 모든 시도는, 엄격히 멀티 태스크 동시성만 사용하는 코드 조각으로 한정될 것이다. 문제는, 충분히 의미 있는 규모의 코드라면, 태스크 내 동시성의 이점을 활용하는 핵심 구간이 반드시 있으며, 그런 구간은 maybe(async)
로 같은 소스에서 추상화하기에 더는 적절하지 않다는 점이다.
최근 Mario Ortiz Manero가, 블로킹 IO와 비동기 IO를 모두 지원하는 라이브러리를 작성하려 할 때의 어려움에 대해 글을 썼다. 이 글은 내게 maybe(async)
의 가장 강력한 근거처럼 보였기에, 좀 더 자세히 분석하고 싶다.
그의 용례는, Rust 메서드 호출을 Spotify API로의 HTTP 요청으로 변환해 주는 래퍼였다. 그는 같은 소스 코드에서 블로킹과 비동기 두 버전을 모두 지원하고 싶었고, 비동기 HTTP 클라이언트로는 reqwest, 블로킹 HTTP 클라이언트로는 ureq를 사용하려 했다. 지금 이 작업이 매우 어렵다는 그의 말은 사실이다.
먼저 흥미로운 점은, reqwest 라이브러리에는 비동기 클라이언트뿐 아니라 자체 블로킹 HTTP 클라이언트도 포함되어 있다는 것이다. 이를 구현하기 위해, reqwest는 백그라운드 스레드를 하나 스폰하고, 그 클라이언트에 대한 모든 요청을 그 스레드에서 비동기적으로 수행하며, 같은 스레드에서 멀티플렉싱한다. Ortiz Manero는 이 접근을 다음 이유로 거부했다.
불행히도, 이 해결책에는 꽤 오버헤드가 있다.
futures
나tokio
같은 큰 의존성을 끌어오고, 바이너리에 포함시킨다. 그 모든 것을 하면서… 결국 블로킹 코드를 쓰게 된다. 그래서 런타임뿐 아니라 컴파일 타임에서도 비용이 든다. 내게는 그냥 잘못된 것처럼 느껴진다.
여기서 Ortiz Manero가 말하는 “오버헤드”는 런타임이 아니라 빌드 타임 의존성의 무게를 뜻하는 듯하다. 하지만 reqwest가 왜 이런 의존성을 끌어오는지 물어봐야 한다. reqwest의 블로킹 모드에서는, 하나의 클라이언트에 대한 모든 요청을 단일 스레드에서 멀티플렉싱하기 위해 tokio를 사용한다. 블로킹 reqwest와 ureq의 아키텍처 차이(ureq는 요청을 만든 스레드에서 블로킹 IO를 수행하는 반면)가, 무엇이 의존성 트리에 들어 있는지보다 내게는 더 중요해 보인다. 다양한 워크로드에서 두 접근을 비교하는 벤치마크를 보고 싶다. 단지 의존성 목록에 무엇이 있는지를 이유로 하나를 배제하기보다는.
reqwest가 지원하고 ureq가 지원하지 않는 기능으로 HTTP/2가 있다. HTTP/2는 사용자가 서로 다른 요청들을 동일한 TCP 연결 위에서 멀티플렉싱하도록 설계되었다. 반면 ureq는 오직 (파이프라이닝 없는) HTTP/1만 제공한다. 그리고 현재 아키텍처로는 이를 지원할 방법이 없다. 사용자가 TCP 연결 위에서 요청을 보낼 때마다, 그 요청이 완료될 때까지 스레드가 블로킹되기 때문이다. 따라서 ureq를 쓰면, 어떤 서비스로 동시에 보낼 수 있는 네트워크 요청 수는 그 서비스가 허용하는 동시 TCP 연결 수로 제한된다. 새 연결마다 새로운 TCP(그리고 아마 TLS) 핸드셰이크가 필요하기도 하다.
만약 ureq가 HTTP/2와 그 멀티플렉싱을 지원하고 싶다면, 동일한 TCP 연결 위에서 요청을 멀티플렉싱하는 방식을 어떻게든 구현해야 할 것이다. 꼭 async Rust로 하지 않더라도, 블로킹 IO를 유지하면서도 지금과 같은 API를 제공하려면, 여전히 백그라운드 스레드를 돌리고 채널을 사용해, 여러 스레드의 동시 요청을 단일 TCP 연결 위에서 멀티플렉싱해야 한다. 즉, 아키텍처는 결국 reqwest의 아키텍처와 매우 비슷해질 것이다. async Rust를 사용하면, reqwest는 HTTP/1에서 다중 연결로 요청을 멀티플렉싱하는 것과 HTTP/2에서 단일 연결로 멀티플렉싱하는 차이를 더 쉽게 추상화할 수 있다. 이것은 큰 장점이다. 사용자는 종종 자신이 통신하려는 서비스가 HTTP/2를 지원하는지 모른다.
그럼에도, ureq 대신 reqwest의 블로킹 API로 전환하더라도, 이 저자에게 maybe(async)
가 일정 효용을 준다고 말할 수 있을 것이다. 라이브러리의 async 버전과 그 위에 블로킹 API를 구현할 때 드는 보일러플레이트를 덜어줄 수 있기 때문이다. 하지만 maybe(async)
로 추상화할 수 있는 것에는 한계가 있으므로, 이는 오로지 하위 라이브러리의 의미론을 상태 없이 “사상(mapping)”하는 특정 종류의 라이브러리에만 해당된다. 이 글의 예처럼 HTTP RPC 호출을 Rust 객체와 메서드로 변환하는 라이브러리, 또는 TCP 같은 바이트 단위 인터페이스 위에 와이어 프로토콜을 정의하는 라이브러리가 그러하다. 라이브러리가 자체적으로 변화하는 상태를 관리하기 시작하는 순간(HTTP나 IO 라이브러리처럼), 두 구현은 의미 있게 갈라지며 maybe(async)
로는 같은 소스에서 구현할 수 없게 된다.
그런 라이브러리들에게 두 버전을 유지하는 일은 단지 보일러플레이트이므로, 새 추상화를 추가하는 것보다 더 나은 지원 방법이 있을 수 있다. 한 가지는 매크로 시스템을 사용하는 것이다. 예를 들면, async 인터페이스로부터 reqwest의 블로킹 인터페이스 같은 것을 생성할 수 있다(백그라운드 스레드를 스폰하고, 블로킹 함수를 그 스레드로 보내는 메시지로 매핑하는 코드를 생성). Spotify 클라이언트 같은 라이브러리는, 구현에 async 런타임을 백그라운드 스레드에서 사용한다는 대가만 치르면, 그 매크로를 사용해 블로킹 API 지원에 드는 보일러플레이트를 피할 수 있다. 그러나 이는 maybe(async)
와 달리 상태 없는 라이브러리뿐 아니라 상태가 있는 라이브러리에도 똑같이 적용된다.
또 다른 접근은 이른바 “sans-IO”다. 예컨대 ureq의 저자는 str0m이라는 WebRTC 라이브러리도 유지하고 있는데, 이 스타일로 작성되어, 라이브러리 내부에서 실제 IO를 전혀 다루지 않으므로 블로킹/비블로킹 IO 문제를 피한다. 비슷한 라이브러리로 Cloudflare의 quiche가 있는데, QUIC의 상태 머신을 구현하되 IO는 수행하지 않는다. 이 개념을 더 발전시켜, IO의 문제를 완전히 라이브러리 바깥으로 “들어 올려” UDP, TCP, HTTP 등 자신이 의존하는 것의 어떤 구현에서도 실행될 수 있는 추상 인터페이스를 상대로 작성하는 방법을 상상해 볼 수 있다. 정확히 어떻게 일반화할지는 아직 남은 과제다.
이 글은 이미 너무 길다. 그래도 Rust 커뮤니티 바깥에서 주목을 받을 수도 있고, 특정 부정적 반응을 예측한다. 내가 말한 futures의 affordance는 futures만으로 달성되는 것이 아니다! 어떤 종류의 코루틴이든 이 affordance를 제공할 수 있다. Rust는 스택리스(stackless) 코루틴을 사용하며, 약간 불쾌한 제약들이 있지만, 스택풀(stackful) 코루틴이 있는 언어라면 더 적은 골칫거리로 동일한 affordance를 제공할 수 있다.
나도 실제로 동의한다. 다시 상상의 언어 세계로 돌아가 보자. 모든 함수가 코루틴인 언어를 상상할 수 있다. 즉, 모든 함수가 양보할 수 있다. 함수 색칠이 없다! “순수” 함수는 Never
를 양보하는 함수(즉, 실제로는 전혀 양보하지 않는 함수)이고, “불순” 함수는 Pending
(또는 외부 이벤트를 기다린다는 의미의 런타임의 다른 마법 타입)을 양보하는 함수다. 불순 함수가 기본이며 모든 함수가 코루틴이므로, 호출 연산자는 Pending
값을 자동으로 바깥으로 전달(forward)할 것이다. 순수함을 보장하고 싶을 때를 위해 순수 함수를 표시하는 방법은 여전히 있을 것이다.
언어는 또한 코루틴 객체를 인스턴스화하고 재개(resume)하는 방법도 가져야 한다. 그 연산자를 사용해 select와 join 같은 동시성 조합자를 구현할 수 있다. 그리고 코루틴을 완전히 새로운 동시 태스크로 스폰하는 방법도 필요하다. 이 모든 것을 async/await 없이 할 수 있다. 이것이 스택풀 코루틴이 주는 것이다.
이 코루틴 기능을 다른 것들도 표현하는 데 확장할 수도 있다. 예를 들어, 이터러블을 의미 있는 값을 양보하는 코루틴으로 표현할 수 있다. for
루프는 그 코루틴 객체를 받아, 그런 값들을 차례로 처리할 것이다. 비동기 이터러블은 그 값을 또는 Pending
을 양보할 뿐이다. 예외도 같은 방식으로 모델링할 수 있다. 오류를 양보하는 식으로(아마 양보와 반환과는 별도의 “경로”를 두어야 할 것이다. 예외를 던진 함수는 재시작될 수 없다는 사실을 반영하기 위해). 모든 설계를 다 그린 건 아니지만, 충분히 그럴듯해 보인다.
(꼭 코루틴으로만 해야 하는 것도 아니다. 이를 뒤집어, 스택의 각 지점에 되돌아갈 지점을 등록하는 방식으로 모델링할 수도 있다. 보류 중인 IO 연산용 하나, 던져진 예외용 하나, 이터러블에서 양보된 항목용 하나 등으로 말이다. 그 스택의 지점을 해당 “효과(effect)”의 “핸들러(handler)”라고 부를 수 있다. 즉, 일종의 “대수적 효과 핸들러(algebraic effect handler)”다. 요컨대, 이 두 언어 개념—효과 핸들러와 코루틴—은 적어도 부분적으로는 서로 동형(isomorphic)이다.)
나는 또한(확신할 수는 없지만) 그런 언어가 Rust와 같은, 참조가 동시에 가변적이면서 별칭(alias)되는 일이 없다는 보장을, 표면 문법에 라이프타임을 추가하지 않고도 달성할 수 있다고 믿는다. 코루틴이 참조를 들고 양보하고 재개될 수만 있다면, 참조는 객체 타입에 내장될 수 없는 한정자(modifier)가 되고, 라이프타임은 전적으로 추론될 수 있다. 정확히 Rust만큼 최적의 코드 표현을 허용하지는 않겠지만(이전 글의 용어로는 “저수준 레지스터”에는 접근할 수 없겠지만), 동일한 정확성 보장은 여전히 제공할 수 있다.
Rust는 왜 이런 것을 하지 않았을까? 처음엔 그랬다! 하지만 다른 요구 사항에 밀려났다. 지난주 lobste.rs에 정말 좋은 댓글이 있었는데, 내가 하는 말보다 더 잘 정리돼 있었다.
Async 스타일 언어 기능은, 실행 모델이 1:1 C ABI, C 표준 라이브러리, C 런타임과 네이티브하게 호환되는 것과 M:N 실행 모델 사이의 절충안이다. C++ async도 같은 문제를 겪는다. 다만 수명 안전성에 덜 엄격할 뿐(좋은 일이 아니다). C/시스템 런타임과의 네이티브 호환성의 대가는 바로 “함수 색칠” 문제다.
Rust는 기존 C 런타임과의 호환성에 우선순위를 둔다. 이는 Rust 코드가 서브루틴 스택으로 구성되며, 스택의 항목 주소를 취할 수 있고, 그 주소를 스택뿐 아니라 프로그램의 다른 메모리 영역에도 저장할 수 있음을 의미한다. Rust는 이 접근을 택해, 그 모델로 작성된 기존 C/C++ 코드 방대한 양과의 제로 코스트 FFI를 얻었고, C 런타임은 모든 주류 플랫폼의 공통 최소치이기도 하다. 하지만 이 런타임 모델은 스택풀 코루틴과 양립하지 않는다. 그래서 Rust는 대신 스택리스 코루틴 메커니즘을 도입할 필요가 있었다. async/await을 가진 모든 주요 언어는 비슷한 기존 런타임에 얽매여 있다. C든, 어떤 VM 런타임이든. C 런타임만이 유별난 것이 아니라, 너무 널리 퍼져서 많은 프로그래머들이 그것이 존재한다는 사실조차, 자연발생이 아니라는 사실조차 잊고 있을 뿐이다.
한 마디만 더.
만약 당신이 저명한 언어 설계자라면, 큰 부를 가진 기술 회사가, C 런타임에 덜 얽매인 새로운 언어 작업을 후원하게 설득할 수도 있을 것이다. 특히 당신이 C와 유닉스에 대한 깊은 지식을 지닌 걸출한 시스템 엔지니어로 명성을 떨쳤고(또 회사의 명성과 함께) 당신의 언어를 빠르게 채택하도록 만들 수 있다면 말이다. 그렇게 영향력 있는 위치에 오른 다음, 스택풀 코루틴이나 효과 핸들러 같은 새로운 패러다임을 도입해, 프로그래머들을 스레드와 futures 사이의 가짜 선택지에서 해방시킬 수도 있을 것이다. 라이프니츠가 말한 대로 우리가 가능한 세계들 중 최선의 세계에 산다면, 일생에 한 번 올 그 기회를 그렇게 쓰지 않겠는가.
(그렇게 했다면, 적어도 무대에 올라, 당신의 언어가 그런 접근을 택한 이유가 “사용자들이 훌륭한 언어를 이해할 능력이 없어서”라고 말하지는 않기를!)
덜 최선의 세계에서는, 그보다 덜 영감적인 일을 선택할 수도 있다. C 런타임으로부터의 이탈을 선언한 뒤, 결국 다시 스레드를 구현할 수도 있다. 사실상 똑같은 의미론을 갖되, 사용자 공간에서 스케줄링한다는 점만 다르게 말이다. 사용자들은 과거와 똑같이, 스레드/락/채널로 동시성을 구현해야 할 것이다. 또 당신의 언어는 널 포인터, 디폴트 생성자, 데이터 레이스, GOTO 같은 고전 기능을 이유는 오직 당신만 아는 채로 품게 될지도 모른다. 제네릭 추가를 위해 사용자들이 수년간 요청하는데도 질질 끌지도 모른다. 덜 최선의 세계라면, 그렇게 할 수도 있을 것이다.
아아. 내가 비관적일 때, 우리 업계는 어떤 침체에 빠져 있어, 매 10년마다 같은 의미론을 가진 새 언어로 같은 동작을 하는 새 프로그램을 다시 쓰게 되고, 현재 하드웨어 사정에 좀 더 맞는 성능 특성만 약간 다를 뿐이라고 생각한다. 슬픈 운명이지만, 곧 프로그래머가 아니라 대규모 언어 모델들의 몫이 될지도 모른다. 내가 Rust의 열렬한 지지자인 이유는, Rust가 프로그래밍에 대해 낙관하게 만들기 때문이다. 주류 프로그래밍 언어는 의미 있게 더 나아질 수 있다고 Rust는 믿고 있다.
진전이긴 하지만, Rust는 C 런타임으로부터 이탈한 그 언어가 아니다. Rust는 그 언어의 더 저수준이고 더 어려운 사촌이다. 같은 보장을 얻을 수 있지만, “약간의 조립(assembly)이 필요하다.” 주어진 엄격한 요구 사항 속에서 필요한 조립을 가능한 줄이는 데 우리가 할 수 있는 일을 모두 해야 한다. 그리고 async/await으로 이미 그 토대를 깔아 두었다. 우리는 futures와 스레드의 차이를 숨겨서 시스템을 단순화하려 들지 말고, 오히려 futures의 affordance 위에 쌓아, 예전보다 더 다양한 엔지니어링을 가능하게 하는 올바른 API와 언어 기능의 집합을 찾아야 한다. 지금 우리에게는 토대만 있다. 하지만 이것만으로도 과거의 손수 상태 머신을 만들고 이벤트 루프를 직접 관리하던 세계에서 거대한 도약이다. 우리가 Future를 Future답게 두고 그 토대 위에 쌓아 나간다면, 더 많은 것이 가능해질 것이다.