Async Rust의 핵심 과제로 ‘신뢰성’을 제시하고, 취소·기아·분리 실행 등 제어 흐름 문제의 원인과 해법(더 나은 콤비네이터와 명시적 계약, poll_progress, 스폰/임베디드 제약, async Drop, 구조적 동시성, 제너레이터 도입)을 논의한다.
지난해는 Async Rust에 있어 중요한 한 해였고, 그 정점은 러스트에서 오랫동안 기다려온 언어 기능 중 하나인 트레이트에서의 async fn 릴리스였습니다. 이 노력을 위해 Async 워킹 그룹이 쏟아부은 작업과 전문성, 그리고 릴리스를 다듬는 데 큰 역할을 한 커뮤니티의 피드백에 정말 자부심을 느낍니다.
올해에도 해야 할 중요한 일이 남아 있으며, 우선순위를 정하는 것이 지금 제 머릿속을 가장 많이 차지하고 있습니다.1 하지만 이 글에서는 한 걸음 물러나, 우리가 “끝났다”고 말할 수 있을 때 Async Rust가 어떤 모습이어야 하는지 생각해 보려 합니다.
많은 분들이 아직 async가 완성형과는 거리가 있다고 느끼는 데 동의하실 겁니다. 눈에 띄는 누락 기능들도 있지만, 그보다 더 근본적으로는 무엇이 있어야 정말 ‘완결성’을 느낄 수 있을지 말로 풀어내기가 어렵습니다. 이 글에서는 그 질문에 답하는 데 도움이 될 핵심 주제를 살펴보겠습니다.
다른 언어와 러스트를 갈라놓는 단 하나의 단어를 고르라면, 저는 “신뢰성”을 선택하겠습니다. 러스트는 가장 간결한 언어도, 가장 배우기 쉬운 언어도 아닙니다. 러스트는 빠르며, 그것은 중요하지만, 빠른 언어는 또 있습니다. 러스트의 가장 중요한 특징은, 러스트로 작성된 프로그램이 매우 다양한 상황에서 예측 가능하게 동작한다는 점입니다. 이 덕분에 러스트를 배우는 초기 비용이 결국은 가치가 있습니다. 더 익숙한 언어에 비해 러스트를 배우고 초기 구현을 작성하는 데 시간이 더 걸릴 수 있지만, 운영 환경에서 프로그램은 더 신뢰성 있게 동작하고, 리뷰어의 부담은 줄며, 버그 수정에 들이는 시간도 줄어듭니다.
async에 대해서도 저는 같은 이야기를 하고 싶습니다. 하지만 오늘날의 Async Rust 프로그래밍에는 일반 러스트보다 피해야 할 footgun과 facerake가 훨씬 더 많습니다. 일부는 문제 공간의 복잡성 때문에 불가피하지만, 상당 부분—어쩌면 대부분—은 그 문제 공간과 언어, 그리고 우리가 async 코드를 작성할 때 사용하는 라이브러리 간의 ‘상호작용’에서 비롯됩니다.
저는 무엇보다 ‘신뢰성’이 향후 몇 년간 Async의 지배적인 원칙이 되어야 한다고 믿습니다. Async가 러스트의 나머지 부분과 진정으로 동등한 수준에 오르려면 아직 갈 길이 멉니다. 하지만 저는 그게 가능하다고 믿습니다.
일반 러스트와 Async Rust의 이분법 때문에, async 자체가 실수였던 건 아닐까 하는 질문을 종종 보게 됩니다. 그 심정을 이해하지만, 맥락 속에서 봐야 합니다.
러스트가 처음 인기를 얻은 것은, 잘 연구된 언어 기능들을 시스템 프로그래밍이라는 새로운 맥락 속에 독특하게 조합했기 때문이었습니다. 그 결과로 “두려움 없는 동시성”, 미정의 동작과 메모리 취약점으로부터의 자유, unsafe 코드를 캡슐화하는 추상화를 구축할 만큼 강력한 타입 시스템 같은 가능성이 열렸습니다. 이는 수십 년간의 PL 연구와 산업 경험에서 비롯된 성공적인 조합이었지만, 1.0에 이르기까지 상당한 반복과 격변도 필요했습니다.
Async Rust가 등장했을 때, 우리는 기존 기능 조합에 약간의 문법 설탕과 표준 라이브러리의 추가만 더해 어디까지 갈 수 있는지 한계를 더 시험했습니다. 답은 ‘상당히 멀리’였습니다. 별도 할당 없이 직선적인 async 코드가 가능한, 실전 투입 가능한 시스템 프로그래밍 언어—제가 아는 한 오늘날 이 틈새를 메우는 다른 언어는 없습니다.
러스트는, 이전의 많은 새로운 언어들처럼, 가능한 것의 경계를 밀어붙이고 있습니다. 아마도 그중 가장 많이 미는 영역이 async일 겁니다. 그 결과는 자연스럽게 언어의 코어만큼 다듬어지고 사랑스러워 보이지 않을 수 있습니다. 여기에 도달하려면 무엇보다 ‘실험에 기반한 반복’이 필요합니다.
이것이 Async Rust의 도전이자 기회입니다. 제가 가장 고무적으로 보는 점은, 러스트가 업계에서 가장 사려 깊고 재능 있는 분들을 언어와 생태계 전반의 작업에 끌어들였다는 사실입니다. 우리는 해낼 수 있다고 믿습니다.
왜 Async Rust는 덜 신뢰성 있게 느껴질까요? 오늘날 Async Rust의 거의 모든 함정은 async 프로그램의 ‘제어 흐름’에 대한 직관이 어긋나기 때문입니다. 제어 흐름이 뜻밖에 다음과 같은 세 경우에 놓입니다.
러스트의 future는 이론상 어떤 await 지점에서도 취소되어 실행을 멈출 수 있습니다. 실무적으로는, 러스트 future의 취소 의미론은 async 피호출자와 호출자 사이의 암묵적 계약입니다.
많은 경우 async fn의 작성자는 취소의 파급효과를 충분히 고민하지 않고 코드를 씁니다. 다음 함수를 보세요:2
async fn read_send(file: &mut File, channel: &mut Sender<...>) {
loop {
let data = read_next(file).await;
let items = parse(&data);
for item in items {
channel.send(item).await;
}
}
}
이 함수는 전달받은 파일 핸들을 한 배치만큼 읽도록 전진시키고, 이어서 항목들을 하나씩 비동기로 처리합니다. 문제는, 호출자가 배치 처리 도중 future를 드롭하면, 남은 항목들이 이런 일이 있었다는 표시 없이 사라진다는 점입니다. read_next를 호출하면서 이미 file은 전진했습니다.
모든 호출자가 이 함수를 await하여 완료될 때까지 구동하고, 그 위의 호출자들 역시 예기치 않게 취소되지 않는다면, 이런 방식의 코드를 써도 문제가 없을 때가 많습니다. 문제는, 취소를 신중히 고려하지 않았거나, 함수의 하위 취소 계약을 모르는 호출자가 select 같은 콤비네이터를 사용할 때 보통 드러납니다:
let mut file = ...;
let mut channel = ...;
loop {
futures::select! {
_ = read_send(&mut file, &mut channel) => {},
some_data = socket.read_packet() => {
// ...
}
}
}
이 콤비네이터의 동작은, 셀렉트에 들어갈 때마다 read_send가 호출되고, 셀렉트를 빠져나갈 때마다 그 future가 드롭된다는 것입니다. 실제로는, 소켓에서 데이터가 들어올 때마다 마지막 배치에서 임의의 개수의 항목들이 사라지게 됩니다.
중요한 점은, 이런 facerake들을 사람들 발밑에 그대로 방치할 필요가 없다는 것입니다. Async Rust에는 암묵적인 취소 계약이 있으니, async 패턴을 표현하는 도구들이 여러분이 그 계약을 의식하도록 강제해야 합니다. 즉, select를 사용 중단(deprecate)하고, 요슈아 우이츠가 글로 소개했고 이후 boats가 다룬 merge 같은 다른 콤비네이터를 쓰는 것입니다.
let mut file = ...;
let mut channel = ...;
merge! {
repeated(|| read_send(&mut file, &mut channel)) => (),
some_data = socket.packet_stream() => {
// ...
}
}.await;
또한 AsyncIterator::next()를 트레이트의 구현 대상으로 만들어 next가 ‘상태’를 갖도록 허용하면, 이 문제가 더 악화될 수 있음을 언급할 가치가 있습니다. 오늘날 우리는, 좋든 싫든, next가 반환하는 future를 자유롭게 드롭할 수 있다고 가정하는 코드가 많습니다. 실용적인 차원에서, 더는 그렇게 가정할 수 없는 세계로 이 코드를 옮기는 것은 고통스러울 것이며, next future를 드롭할 수 없다는 제약은 현재 콤비네이터의 결함들과 불행하게도 맞물릴 것입니다.
이 문제의 또 다른 해법은, 취소를 언어에 내장된 future 계약의 명시적 일부로 만드는 것입니다. 이는 완료까지의 poll 이외의 의미론을 선택하려는 경우, 호출자와/또는 피호출자가 그 의미를 명시적으로 확인하도록 요구할 것입니다.
이런 변화가 어떤 형태든, 단지 다른 콤비네이터 라이브러리를 권장하는 것보다 생태계에 훨씬 더 파급력이 클 겁니다. 그럼에도 오늘날 보이는 암묵적 계약들은 러스트의 설계 철학과 어긋나 있으므로, 결국은 이 방향으로 가게 될 것이라 생각합니다.
이는 !Drop 타입 지원(여기에는 async Drop 지원을 포함할 수 있음)이나, completion 크레이트에서 실험했던 새로운 종류의 ‘완료까지 poll’ future를 통해 이뤄질 수 있습니다. 둘 중 어느 쪽이든, 오늘날과 같이 모든 await 지점에서의 암묵적 취소 지점을 포함하는, 하나 이상의 옵트인 취소 메커니즘과 결합될 수 있습니다.
저는 최근 글인 for await와 버퍼링 스트림의 전투에서 기아에 대해 다뤘습니다. 예기치 않은 기아의 주된 원인은, async 이터레이터와 그것을 처리하는 async 루프 본문 사이를 번갈아 오가는 제어 흐름입니다.
제 글에 대한 응답으로, boats는 poll_progress라는 해법을 소개했는데, 저도 이 문제가 가장 직접적으로 해결된다고 봅니다. 다만 이 접근은 for await가 생성하는 코드가 복잡해지고, AsyncIterator 트레이트도 더 복잡해진다는 단점이 있습니다.
제가 수용할 만하다고 보는 다른 유일한 해법은, 제 글에서 설명한 형태—백그라운드에서 활성 연결을 관리하는—의 이터레이터를 버그로 간주하는 것입니다. 대신 이터레이터 자체와 독립적으로 진행되는 태스크들을 스폰하면 됩니다.
큰 문제는, 이 접근에는 추가 할당이 필요하며, 따라서 할당자가 없는 임베디드 환경에서는 동작하지 않는다는 점입니다. 러스트가 이런 환경에서 async/await 틈새를 독자적으로 메울 수 있다는 점을 고려하면, 이 문제를 그곳에선 해결하지 못한 채로 두는 것은 아쉬울 것입니다.
세 번째 제어 흐름 문제는, 스폰된 태스크가 그 전이적 호출자들의 인지 없이 계속 실행을 이어갈 때 발생합니다.
가장 좋은 사례로는, 한 async sqlite 라이브러리가 소멸자에서 스폰한 태스크로 데이터베이스 핸들을 비동기적으로 닫았던 일이 있습니다. sqlite는 한 번에 하나의 핸들만 열 수 있기 때문에, 이전 핸들을 드롭한 직후 새 핸들을 여는 작업이 비결정적으로 실패할 수 있었습니다.
소멸자에서 스폰하는 이 패턴은 async Drop의 부재로 인해 필요했습니다. 저는 여기에 대한 해법이 필요하다고 봅니다. 이 문제를 완전히 일반적인 경우로 해결하는 것은 큰 도전이며, 사브리나 주손의 고전 블로그 글에서 잘 다뤄지고 있습니다.
핵심 문제는, async Drop 타입이 동기 컨텍스트—Vec 같은 단순한 제네릭 코드까지 포함—안으로 들어왔을 때 무엇을 해야 하느냐입니다. 일반적인 경우에 async Drop 타입의 동기 드롭을 컴파일 타임에 막으려면, 드롭 불가능한 선형 타입이 필요합니다.
저는 모든 경우에 통하는 해법을 찾을 수 있기를 바라지만, 반드시 그래야 한다고 보지는 않습니다. 동기 소멸자조차 실행이 보장되지 않으며, 그 결과 소멸자는 안전성과 무관한 리소스 관리에 사용됩니다. 남는 소수의 경우에 실패 모드가, 이를테면 오류 로그와 함께 리소스 누수 정도라면, 대다수의 경우를 잡아낼 수 있다면 충분히 실용적일 것입니다.
이 분리된 실행 문제는 일반 스레드에서도 발생합니다. 표준 라이브러리는 최근 스코프드 스레드 API를 도입했는데, 스코프를 빠져나오기 전에 스폰된 모든 스레드가 완료되었음을 보장합니다. 이 API의 가장 중요한 이점으로 자주 소개되는 것은, 아래 예시처럼 중첩된 스레드가 바깥 스코프에서 빌릴 수 있다는 점입니다.
let mut a = vec![1, 2, 3];
let mut x = 0;
thread::scope(|s| {
s.spawn(|| {
println!("a={a:?}");
});
s.spawn(|| {
x += a[0] + a[2];
});
println!("hello from the main thread");
});
// 스코프 이후에는 다시 변수들을 수정하고 접근할 수 있습니다:
a.push(4);
assert_eq!(x, a.len());
스코프드 스레드에서의 빌리기는 분명 장점입니다만,3 제 생각에 이 API의 가장 중요한 측면은, 각 스레드의 수명이 여러분의 프로그램 구조에—일반적인 제어 흐름처럼—직접 새겨진다는 점입니다. 어떤 스레드도 자신이 스폰된 스코프 s보다 오래 살지 않습니다. 덕분에, 스코프드 스레드를 사용한 제어 흐름에 대한 추론은 직선적인 코드의 제어 흐름에 대해 추론하는 것보다 단지 조금 더 복잡해질 뿐입니다.
이의 async 판본이 바로 구조적 동시성입니다. 지난 몇 년 사이 Swift와 Java 모두가 이를 채택했습니다.
구조적 동시성의 장점 중 일부는 프로그램의 모든 코드가 이를 사용할 때 나타나지만, 점진적으로 도입하는 것도 가능합니다. 구조적 스폰 API는 오늘날 러스트에서도 기존 실행기 위에 얇은 레이어로 작성할 수 있는데, 생태계에서 이런 시도가 거의 보이지 않는 점이 놀랍습니다.
러스트 표준 라이브러리에 언젠가 Spawn 트레이트가 들어간다면, 그 API의 일부로 구조적 동시성을 진지하게 고려해야 한다고 봅니다. 그동안은, 생태계에서 이런 API들을 실험해 보는 움직임이 활발해지길 바랍니다.
제어 흐름 이야기를 계속하자면, poll_next나 심지어 next를 수동으로 구현해야 할 때는 추론이 훨씬 더 어려워집니다. 제너레이터가 이 문제를 해결합니다. 이 주제에 대한 최근 boats의 글들이 도움이 되었습니다.
여기서 제가 더 할 말은 많지 않습니다. 제너레이터를 언어에 추가하되, 가장 필요로 하는 async부터 시작해야 한다는 것입니다. 우리는 아직 async 반복 트레이트에 대해 poll_progress를 포함할지 같은 세부사항을 결정해야 하지만, 이런 것들은 앞으로 1년 정도면 충분히 풀 수 있는 문제로 보입니다.
신뢰성만이 Async Rust에 대한 제 모든 야망을 담진 않지만, 가장 중요한 것들은 그 안에 담겨 있다고 믿습니다. 설령 이 글에서 다룬 문제들을 모두 해결하고 더는 async를 손대지 않는다 하더라도, 우리는 여전히 높은 레버리지와 최첨단 시스템 언어를 갖게 될 것이고, 이는 앞으로도 다양한 응용 분야에서 오랫동안 유용할 것입니다.
러스트를 특별하게 만드는 자질들과, 그것이 async의 진화에 주는 함의에 대해 더 말하고 싶지만, 일단은 여기서 마치겠습니다.
아직 읽지 않으셨다면, Niko의 2024년 async의 목표 글을 추천합니다. ↩︎
Tomaka의 글 A look back at asynchronous Rust에서 발췌 ↩︎
안타깝게도 오늘날 워크-스틸링 실행기에서는 스코프드 태스크에서의 빌리기가 불가능하지만, 그럼에도 명시적 제어 흐름의 이점은 충분히 가치가 있다고 생각합니다. ↩︎