Rust의 동시성에서 부모–자식 태스크 모델을 가정할 때, 동시성, 병렬화 가능성, 빌림이라는 세 가지 바람직한 속성 중 동시에 둘만 만족시킬 수 있다는 ‘스코프드 태스크 트릴레마’를 제시하고, 각 조합에 대응하는 기존 API들을 살피며 왜 셋을 모두 제공할 수 없는지와 그 함의를 논의한다.
Rust의 동시성에 관한 연재 글의 첫 번째 글이다. 최근의 제어 흐름 효과 시리즈와는 달리, 이 시리즈는 Rust 프로젝트가 무엇을 해야 한다는 특정한 비전을 향해 달려가지는 않는다. 대신, 문제 공간을 공개적으로 탐색하고 관련 이슈를 생각하는 도구를 쌓아 보려 한다. “정답”인 동시성 API가 무엇인지 나도 확신하지 못한다.
일부 커뮤니티에서는 잘 알려진 이야기가 있다. 샌프란시스코의 Mozilla 사무실에 “멀티스레드 코드를 쓰려면 이만큼 키가 커야 합니다”라는 팻말이 걸려 있었고(천장에서 약 3미터쯤), 이는 Rust의 기원 신화에서 거의 신화적 의미를 지닌다. Rust는 병렬·동시 시스템 소프트웨어 작성을 더 다루기 쉽게 만들기 위해 Mozilla 팀이 설계한 언어였다. 실제로 Rust는 멀티스레드 시스템을 작성할 때 사용자가 맞닥뜨리는 많은 문제들을 잘 해결했다. 하지만 문제가 하나 있다.
최근 Tyler Mandry가 “스코프드 태스크”에 대해 훌륭한 글을 썼다. 이는 스코프드 스레드 API를 async Rust로 “포팅”하려는 시도였는데, 그 과정에서 API의 몇몇 바람직한 속성들이 궁극적으로 서로 양립 불가능하다는 점을 발견했다. 나는 API의 구체를 걷어내고 보면, 그가 마주친 것이 바로 Rust의 현재 타입 시스템이 세 가지 바람직한 속성을 모두 갖춘 API를 지원할 수 없다는 보다 근본적인 한계라고 주장하고 싶다.
나는 이를 스코프드 태스크 트릴레마라고 부르겠다. 다만 이것은 스코프드 태스크에만 국한된 문제가 아니다. 우리는 Rust 타입 시스템으로 동시성 프로그래밍용 API를 만들고자 한다. 개념적으로 이 API에는 “부모 태스크”와 하나 이상의 “자식 태스크”가 있고, 자식이 여럿이면 서로 동시적으로 진행될 수 있다. 어떤 건전한 API도 아래의 세 가지 바람직한 속성 가운데 동시에 최대 두 가지만 제공할 수 있다.
여기서 “동시적”이라고 말할 때는 부모와 동시적으로 라는 뜻임을 분명히 한다. 자식 태스크가 여럿이면 그들끼리는 본질적으로 동시적이다. 또 “병렬화 가능”하다고 해서 반드시 병렬로 실행된다는 뜻은 아니다. 경우에 따라 순차적으로 실행될 수도 있지만, 원칙적으로는 병렬 실행이 허용된다는 뜻이다.
세 가지를 한 번에 제공할 수 없다는 사실이 바로 “스코프드 태스크” API가 건전하지 않은 이유이며, 원래의 스코프드 스레드 API가 건전하지 않았던 이유이고, io-uring 같은 것에 대해 보기에는 자명해 보이는 어떤 API들이 불가능한 이유이기도 하다.
Rust의 모든 건전한 동시성 API는 결국 이 세 가지 속성 중 둘만을 제공한다. 어떤 API가 어떤 해법에 해당하는지 살펴보는 것은 유익하다.
(예: thread::spawn, task::spawn.)
이들 API는 자식 태스크가 부모 태스크와 독립적으로, 그리고 동시적으로 진행하도록 허용한다. 자식 태스크의 작업은 다른 스레드에서 실행되어 부모와 병렬이 될 수 있으며, 부모의 작업도 동시에 끊김 없이 진행될 수 있다. 반면, 자식 태스크는 부모로부터 빌릴 수 없다. 그래서 두 API 모두 인자에 'static 제한이 붙어 있다.
이들 API는 자식 태스크를 조인하여 완료를 기다리는 수단을 제공하지만, 반드시 조인해야 하는 것은 아니다. 또한 비동기인 task::spawn의 경우, 그것을 기다리는 동안에도 같은 스레드에서 이 스코프 밖의 다른 작업을 동시적으로 실행할 수 있다.
(예: thread::scope, rayon::join)
이들 API는 자식 태스크가 부모 태스크와 독립적으로 진행하면서도 부모로부터 빌릴 수 있게 해 준다. 자식 태스크의 내부 작업은 부모 컨텍스트로부터 빌림을 유지한 채 다른 스레드에서 실행될 수 있다. 하지만 그 대가로 부모 스코프와 자식 스코프 사이에서 코드를 동시적으로 실행하는 능력을 잃는다. 이 작업이 진행되는 동안 부모는 이 스코프 밖의 코드를 실행할 수 없고(따라서 부모 스레드는 자식 태스크를 직접 돌리거나 블록되어야 한다).
이들 API는 호출자에게 제어를 반환하기 전에 자식 태스크를 반드시 조인하도록 요구한다. 이렇게 해서 스레드 간 빌림이 라이프타임 시그니처를 준수함을 보장한다.
(예: select!, FuturesUnordered.)
이들 API는 자식 태스크가 부모 태스크와 동시적으로 진행하면서 부모로부터 빌릴 수 있게 해 준다. 자식 태스크 내부의 코드(FuturesUnordered 스트림의 일부인 future나 select! 매크로 안의 future 등)는 부모 컨텍스트의 코드(그 구문들이 어떤 것을 await할 때 실행되는 것 등)와 동시적으로 실행될 수 있다. 그리고 자식 태스크는 동기화 없이 부모 API로부터 자유롭게 빌릴 수 있다.
자식 태스크를 부모 태스크와 독립적으로 실행할 수는 없고, 이들 API에는 “조인”이라는 개념이 없다. 선택되거나 스트리밍되는 futures는 부모와 같은 작업 단위의 일부로 실행되며, 실행 중에 부모와 하나의 단위로서가 아니라면 다른 스레드로 옮겨질 수 없다.
그렇다면 왜 한 API가 이 세 가지 바람직한 속성을 모두 제공하지 못하는가?
사실, 스코프드 스레드와 rayon API가 동기화 없이 빌림을 허용하면서도 병렬화 가능하다고 설명한 것은 약간 정직하지 못했다. 실제로는 동기화가 필요하다. 이 동기화는 호출자에게 돌아가기 전에 모든 하위 스레드를 조인할 때 발생한다. 다만 이 동기화는 서로 다른 태스크들 사이에서 모든 상태를 공유하거나 소유권을 전달하기 위해 각기 동기화를 요구하는 대신, 함수 끝의 단일 대기 연산으로 상쇄(amortize)된다.
유명하게도, 이전의 스코프드 스레드 API는 구조가 달랐다. 거기서는 조인이 조인 핸들 객체가 드롭될 때마다 일어났다. 이 API는 Rust의 모든 객체는 소멸자를 실행하지 않고도 누수시킬 수 있다는 사실 때문에 건전하지 않았다. 1.0 릴리스 직전, 이 API의 문제가 인지되었고, “leakpocalypse”라 불린 사건에서 어떤 객체든 누수될 수 있다고 결정되었다.
최근 이 결정을 재고하여 누수 불가능한 타입을 정의할 수 있게 하자는 논의가 있었다. Yoshua Wuyts가 이전 글들에 대한 링크와 함께 좋은 요약을 썼다. 이 글에서 세부에 들어가지는 않겠지만, 지금 와서 이 결정을 바꾸는 것은 역호환성 문제를 거대하고 혼란스럽게 만들 것이며, 개인적으로는 바뀔 것이라 낙관하지 않는다.
하지만 설령 바뀌었다고 하자, 혹은 그때 누수에 대한 결정이 반대로 내려졌다고 하자. 그러면 이 트릴레마가 해결되어 세 가지 바람직한 속성을 모두 가진 API를 얻을 수 있을까? 그렇기도 하고, 아니기도 하다. 옛 스코프드 스레드 API와 새 스코프드 스레드 API를 비교해 보면 교훈적이다.
새 스코프드 스레드 API에서는 “내부” 스코프(병렬화된 태스크들과 동시적으로 실행될 수 있는 부분)가 scope 함수에 전달되는 클로저로 렉시컬하게 정의되고, “외부” 스코프는 그 클로저 밖의 모든 것이다. 옛 스코프드 스레드 API에서는 “내부” 스코프가 더 암시적으로 정의되었는데, 자식 스레드의 조인 핸들이 아직 살아 있는 코드 구간이 그것이었다. leakpocalypse 결정이 내려졌을 당시에는 두 API가 표현력 면에서 동등했다. 하지만 그 이후 Rust에 추가된 기능들 덕에 소멸자 기반 접근이 더 매력적으로 보이게 되었다.
첫째는 비-렉시컬 라이프타임(NLL)이다(방금 문단에서 “렉시컬”이란 단어를 쓴 걸 눈치챘을 것이다). 특히 어떤 분기에서는 조인 핸들을 다른 분기보다 더 오래 보유할 수 있다. 다만 가장 흥미로운 몇몇 경우에는 컴파일러 성능상의 이유로 아직 안정화되지 않았다. 어떤 경우에는 이런 제어 흐름을 렉시컬 스코프로 다른 방식으로 표현할 수도 있지만, 어떤 경우에는 비-렉시컬 라이프타임이 가능하게 하는 진짜 표현상의 차이가 있어서, 강제적으로 렉시컬한 스코프드 스레드 API로는 영원히 표현할 수 없을 것이다.
다른 하나는(여기서 우리는 async 스코프드 태스크 API의 문제의 핵심에 다다른다) 자기-참조 코루틴 타입, 즉 async 함수에서 생성되는 futures와 같은 것이다. 이 타입들은 코루틴 내부 스택의 일부 구간을 사실상 가리키는 라이프타임을 “닫아” 캡처한다. 본질적으로 이는 스코프드 태스크가 빌리는 라이프타임에 외부 경계(outer bound)를 만들고, 그 경계 밖의 코드가 자식 태스크들과 동시적으로 실행될 수 있게 한다. 실무적으로 이는 모든 자식 태스크가 더 이상 할 일이 없을 때, 스레드가 부모 태스크의 형제 작업을 실행할 수 있음을 의미한다.
Rust에 선형 타입(linear types)을 추가하지 않는다고 가정하면, 각 API가 스코프드 태스크 트릴레마의 “각 축” 중 하나를 선택해야 하는 상황에서, 현재 사용자에게 제공되는 API들이 완전하고 일관된지 자문해야 한다. 이 기능 제공 방식에 결함은 없는가?
“빌림 + 동시성”에 관해서는 async API들이 존재하지만, 다른 API들과 매우 일관성이 떨어진다. 어떤 것도 자식 태스크를 명시적으로 “스폰”하는 모양새를 닮지 않았다. 이들 API는 사용자에게 가장 문제가 되기 쉬운 편이기도 해서, 잘못 사용할 여지가 많다. Niko Matsakis는 스코프드 스레드 API를 더 닮은 형태의 API를 제공하는 moro라는 라이브러리를 썼다. 이런 API가 사용자에게 더 직관적일까? 지난 몇 달 동안 내가 파악해 보려 했던 질문이고, async Rust의 보다 근본적인 질문들을 탐색한 뒤에 다시 돌아오고 싶다.
“병렬화 가능성 + 빌림”에 관해서는 비동기 API가 전혀 없다. 이는 놀랍지 않을 수 있다. 어차피 async는 동시성을 가능하게 하는 것이니까. 하지만 여기에는 내게는 분명해 보이는 빠진 API가 있다. 스레드 풀 전반에 걸쳐 익스큐터(executor)를 시작하고, 그 위에 스폰한 모든 태스크가 완료될 때까지 블록하는 API다. 이는 사실상 “async main”의 대안으로 작동하면서 메인 스레드의 스택에 장시간 지속되는 상태를 저장할 수 있게 해 준다. 현재 tokio로는 이를 재현할 방법이 없지만, smol의 async-executor 라이브러리를 스코프드 스레드 API와 조합하면 달성할 수 있다.
“동시성 + 병렬화 가능성”에 관해서는, async Rust와 비-async Rust 모두 사실상 동일한 spawn API를 제공한다. 이런 류의 API는 다른 언어들의 맥락에서 “구조적 동시성(structured concurrency)”의 옹호자들로부터 비판을 받아 왔다. 구조적 동시성은 스폰이 자식 태스크의 끝을 표시하는 명확한 조인과 쌍을 이룰 것을 요구한다. 다음 글에서는 이 “구조적 동시성”이라는 개념을 검토하고 Rust의 맥락에서 어떻게 적용되는지 살펴보고자 한다.