Rust의 비동기 정리 문제를 다루며, 소멸자에서 await을 허용하는 접근의 한계를 분석하고, async 취소를 위한 poll_cancel과 블록 단위 정리를 위한 do … final 구문, 그리고 비동기 언와인딩을 제안한다. 또한 스코프드 태스크 트릴레마 등 사례를 통해 unforgettable 타입과 드롭 불가(undroppable) 타입 같은 ‘선형 타입’과의 관계를 설명하고, 이러한 타입 시스템 기능에 앞서 필수 전제조건으로서 위 두 메커니즘을 제시한다.
비동기 정리
async Rust 설계의 한 가지 문제는 비동기 정리(clean-up) 코드를 어떻게 할 것인가이다. 예컨대 어떤 객체나 연산(예: 비동기 IO 핸들)을 나타내는 타입이 있고, 더 이상 사용하지 않을 때 정리 코드를 실행해야 하는데, 그 정리 코드 자체가 논블로킹이며 제어를 양도할 수 있다고 하자. 오늘날의 async Rust에는 이 패턴을 잘 처리할 방법이 없다.
가장 그럴듯한 해법은 이미 존재하는 메커니즘, 즉 소멸자(destructor)를 사용하는 것이다. 소멸자 안에서만 await을 할 수 있다면 모든 게 해결될 것처럼 보인다. 안타깝게도 이는 여러 문제를 야기하며, 개인적으로는 현재의 소멸자 방식 그대로 Rust가 이 기능을 얻는 것은 현실적이지 않다고 본다.
첫 번째 문제는 다음과 같다. 만약 비동기가 아닌 스코프에서 그 값을 drop하면 어떻게 될까? 거기서는 await을 할 수 없다! 선택지는 둘이다. 비동기 소멸자가 아예 실행되지 않게 하거나(너무 실수하기 쉬운 것으로 간주됨), 아니면 비동기가 아닌 스코프에서 비동기 소멸자를 가진 값을 drop하는 것을 금지하는 타입 검사 규칙을 두는 것이다. 두 번째 해법은 곧 ‘드롭 불가 타입(undroppable types)’로 귀결되며, 이에 대해서는 글의 후반부에서 다룬다. 이 규칙은 사실상 드롭 불가 타입에 “비동기 스코프에서는 드롭을 허용한다”는 예외를 추가한 것에 지나지 않는다. 확실히 말할 수 있는 것은, 비록 예외가 있더라도 드롭 불가 타입을 Rust에 도입하는 일은 대단히 어렵다는 점이다.
두 번째 문제는 비동기 소멸자의 상태가 그것을 담고 있는 어떤 future의 상태에 미치는 영향 방식이다. 이는 사실 async 메서드에서 나타난 문제의 재현인데, 이제는 임의의 제네릭 타입에 적용된다(왜냐하면 제네릭 타입 T가 비동기 소멸자를 갖는지 알 수 없기 때문이다). 첫째, 임의의 트레이트 객체가 있다고 하자. 그것을 drop할 때 비동기 소멸자가 있다면 어떻게 되는가? 이는 async 메서드의 객체 안정성(object safety) 문제와 동일하다. 즉, 트레이트 객체의 비동기 소멸자가 반환하는 future를 저장할 곳이 없다. 둘째, 어떤 값을 다른 스레드로 보내고 싶다면 그 비동기 소멸자의 상태 역시 Send여야 한다. 이것이 바로 RTN을 동기 부여했던 문제와 동일한데, 이제는 명시적으로 async 메서드를 호출한 타입뿐만 아니라, 다른 스레드로 이동되는 모든 제네릭 타입에 대해 이 문제가 발생한다. 나는 이 문제에 대해 수년 전에 글을 썼지만, 이후 오해되거나 무시되어 온 듯하다.
세 번째 문제는, 사용자들이 자신도 모르는 사이에 future에 암묵적인 await 지점이 추가되는 것에 대해 우려한다는 점이다. 따라서 이러한 타입을 비동기가 아닌 스코프에서 drop할 수 없게 할 뿐만 아니라, 이미 명시적인 await 지점에서 파괴되도록 강제하는 제약이 필요하다. 이는 설령 일관되게 만들 수 있다 하더라도, 이러한 타입의 비동기 소멸자가 언제 실행되는지에 관한 규칙을 다른 소멸자와 매우 다르게 만들 것이다.
네 번째 문제는, 아마도 이전에 제기된 적이 없을 텐데, 비동기 소멸자를 무조건 순차적으로 실행하는 것이 최적의 코드 생성이 아니라는 점이다. 예를 들어 비동기적으로 drop하려는 값이 둘 있다면, 소멸자들을 join하여 동시 실행하고 싶을 수도 있다. 하지만 이를 암묵적으로 수행하는 것은 위험할 수 있는데, 실제로는 하나가 다른 것보다 먼저 실행되기를 조심스럽게 기대하고 있을 수도 있기 때문이다.
이 모든 문제는 비동기 정리 문제를 다른 방식으로 틀 지어 보라고 시사한다. 문제는 ‘async drop이 없다’가 아니라 ‘소멸자는 오직 ()를 반환하는 소멸자 함수만 잘 동작한다’는 데 있다. 비동기 정리는 단지 ()를 반환하지 않는 정리의 특수한 경우일 뿐이다. 이 경우에는 future를 반환하며, 다른 시나리오로는 예를 들어 Result를 반환하는 소멸자가 필요하지만 존재하지 않는 경우도 있다.
나는 여기서, 소멸자 자체에 초점을 맞추기보다는, 일반적으로 비동기 정리 및 값을 반환하는 정리 코드에 대한 설계 공간을 탐색하고자 한다. 여기서 정리해 제안하는 내용은(특히 Eric Holk와 Tyler Mandry의 작업에 크게 의존한다) 두 가지 별개의 기능, 즉 비동기 future 취소와 do … final 구문을 결합하여, 사용자가 일관되게 호출되는 비동기 정리 코드를 작성할 수 있게 하는 것이다. 또한 이러한 구성은 Rust에서 어떤 형태로든 ‘선형 타입(linear type)’ 메커니즘을 도입하는 데 필수적임을 보일 것이다. 따라서 이것들을 타입 기반의 비동기 정리 코드에 대한 대안으로 보기보다는, 가까운 시일 내에 구현 가능한 전제조건으로 보아야 한다.
future가 자신의 상태를 정리해야 하는 이유는 두 가지다. 첫째, future가 준비(ready)되어 최종 값을 반환할 때다. 둘째, future가 보류(pending) 중일 때 호출자가 더 이상 관심이 없어 취소(cancellation)했을 때다. 이 절에서는 비동기 취소 메커니즘을 도입해, 취소 과정에서 비동기 정리가 수행될 수 있도록 한다.
작업을 취소하는 문제는 동시성 프로그래밍의 큰 이슈이며, 서로 다른 동시성 시스템은 서로 다른 접근법을 취한다. 설계 공간을 구성하는 한 가지 방식은 취소 설계를 ‘협조성(cooperativeness)’의 스펙트럼으로 보는 것이다. 한쪽 끝에는 작업 단위를 그 내부의 어떠한 처리 없이도 취소할 수 있는 ‘비협조적 취소(non-cooperative cancellation)’가 있다. 반대쪽 끝에는 작업 단위를 취소할 수 없고, 완료될 때까지 반드시 실행되는 ‘협조적 취소(cooperative cancellation)’가 있다(이런 시스템에서 취소를 구현하려면 작업 단위가 언어에 따라 Option이나 null 가능 타입을 반환하고, 취소 메시지를 수신하는 메커니즘을 제공하면 된다).
Go의 goroutine은 협조적으로 취소된다. 즉, 명시적으로 취소를 받아들이지 않으면 취소할 수 없다. POSIX 스레드는 pthread_kill(3)로 SIGKILL을 보내 비협조적으로 취소할 수 있다(수정: 이는 틀렸다. POSIX 스레드에 SIGKILL을 보내면 전체 프로세스가 종료된다. 완전히 비협조적인 취소의 예시는 당장 떠오르지 않는다).
비협조적 취소의 문제는, 해당 작업 단위가 락을 잡고 있거나 힙 메모리를 소유하고 있을 수 있는데 그것들이 전부 누수될 수 있어 프로그램이 나쁜 상태에 빠질 수 있다는 점이다. 협조적 취소의 문제는, 작업 단위가 취소를 수용하지 않으면 그 작업이 더 이상 필요 없어도 끝날 때까지 계속 실행된다는 점이다.
이 극단 사이에는 ‘반(半)협조적 취소(semi-cooperative cancellation)’ 메커니즘이라 부를 수 있는 범주가 있다. 이러한 메커니즘에서는, 작업 단위의 협조 없이도 취소를 위한 제어 흐름 경로로 이행시킬 수 있지만, 취소되면서 자신의 상태를 ‘정리’하기 위해 특별히 지정된 코드를 실행할 수 있다. 이는 협조적 취소와 비협조적 취소 사이의 중간 지점을 찾기 위한 장치다.
Rust의 async 모델은 현재 실질적으로 반협조적 취소 모델을 채택하고 있다. 즉, future의 어떤 await 지점에서든 그 future가 취소될 수 있다. 제대로 구현된 런타임은 그 future의 소멸자를 실행하여 상태를 정리할 수 있게 한다. 그러나 이 취소 코드는 전적으로 동기적이어야 한다. 비동기 정리 코드를 지원하려면, ‘비동기 반협조적 취소(asynchronous semi-cooperative cancellation)’가 필요하며, 이하에서는 이를 간단히 ‘비동기 취소(async cancellation)’라 부르겠다.
더 복잡해지지 않았다면 덧붙일 뉘앙스가 조금 더 있다. 첫째, async Rust는 await 지점에서만 취소를 지원한다. await 지점이 아닌 곳에서는 future가 취소되지 않는다고 사용자는 보장받는다. 이는 async Rust가 협조적 스케줄링(cooperative scheduling)을 사용한다는 사실의 함의로, 작업 단위는 선점(preemption)되지 않으며 스스로 선택할 때만 스케줄러에 제어를 반환한다. 이는 장단이 있다. 둘째, 비록 async Rust의 취소가 실무적으로는 반협조적이긴 하지만, 코드 작성자는 안정성(soundness)을 위해 취소에 의존할 수 없다. 정리 코드를 실행하지 않고 future를 취소하는 것이 미정의 동작(undefined behavior)이 아니다. 이에 대해서는 뒤에서 다시 논의한다.
비동기 취소를 지원하려면, 비동기 작업 단위를 위한 트레이트에 이를 비동기적으로 취소하는 API를 추가해야 한다. 이것이 바로 Future 정의에 다음과 같이 추가되는 poll_cancel이다:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
fn poll_cancel(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// 기본적으로 future는 비동기 취소 코드가 없다
Poll::Ready(())
}
}
Eric Holk은 작년에 멋진 글에서 이 새 API를 길게 논의했다: https://theincredibleholk.org/blog/2023/11/14/a-mechanism-for-async-cancellation/ 내 관점에서의 몇 가지 메모를 덧붙인다.
첫째, 나는 즉시 Future에 대한 on_cancel 확장을 지원하지 않을 것이다. 일반적인 async Rust(하위 레벨의 poll 레지스터 방식이 아닌)에서는 ‘취소될 때에만’ 실행되고 정상 완료 시에는 실행되지 않는 정리는 말이 안 된다고 본다. 여기에는 버그 위험이 있다. 예를 들어 사용자가 취소 코드 안에서 결코 종료되지 않는 루프를 작성하면, 태스크는 실제로는 절대 취소되지 않는다. select!처럼 비동시적으로 취소하는 모든 것도 종료되지 않을 것이다. 이 코드는 취소 시에만 실행되므로 이러한 버그가 테스트에서 드러날 가능성도 낮다. 물론 취소 전용의 비동기 코드가 반드시 필요하다고 믿는 사례가 있다면 보고 싶다. 글의 후반부에서 취소 시와 정상 완료 시 모두 실행되는 비동기 정리 코드를 지원하는 방법을 보여주겠다.
이는 또한 “취소 future를 또 취소하면 어떻게 되나” 같은 질문을 고려할 필요를 덜어준다. 한 번 future가 취소에 들어가면, 그것을 ‘다시’ 취소하는 것은 멱등적이다. 이미 취소 중이며, 두 번째로 취소할 future가 따로 존재하지 않기 때문이다.
둘째, 핵심적인 추가 사항은 AsyncIterator에도 poll_cancel을 추가하는 것이다. 이렇게 하면 AsyncIterator(비동기 제너레이터 포함) 역시 취소 시 비동기 정리를 지원할 수 있다. 그 결과, async 블록이나 함수의 poll_cancel 코드 생성은 더 복잡해진다. 먼저, 현재 await 중인 future가 있다면 그것의 poll_cancel을 호출한다. 그런 다음, for await로 순회 중인 모든 AsyncIterator에 대해 역순으로 거슬러 올라가며 poll_cancel을 호출해 그들 역시 취소한다.
이 API들이 프레임워크의 최소 지원 Rust 버전에 안정화되면, 프레임워크는 자신이 취소하는 모든 future에 대해 poll_cancel을 호출하기 시작해야 한다. 이렇게 해서, 해당 메커니즘을 지원하는 버전으로 업데이트된 어떤 런타임에서도 async Rust는 비동기 취소를 지원하게 된다. 소멸자가 절대적 보장을 제공하지 않는 것과 같은 맥락에서, 이것도 철통 같은 보장은 아니지만, 좋은 런타임의 지원 버전을 사용한다면 future는 일관되게 취소 코드를 실행하게 될 것이다.
Eric Holk은 그의 블로그 글에서 언와인딩(unwinding)을 언급하지만, 나는 그 논의에 몇 가지 오류가 있다고 본다. 여기서는 비동기 언와인딩 동작을 내가 이해한 대로 설명한다.
future의 언와인딩 경로는 poll_cancel 역시 호출해야 한다. 유일한 복잡성은 Pending이 반환되는 경우를 어떻게 처리할지에 있다.
표준 라이브러리에 catch_unwind의 비동기 버전이 추가될 것이다. 이 API가 Pending을 반환하는 언와인딩을 포착했을 때, 자체적으로 Pending을 반환하고 패닉 상태를 해제한다. 이후 다시 폴링되면 패닉 상태를 설정한 뒤 자신을 폴링하여, 감싸고 있는 future의 poll_cancel을 더 진행한다. 결국 poll_cancel이 Ready를 반환하면, 비동기 catch_unwind는 패닉 에러와 함께 반환한다.
만약 비동기 catch_unwind 내부가 아닌 곳에서 언와인딩 중에 poll_cancel이 Pending을 반환한다면, 두 가지 선택지가 있다. 전체 프로세스를 중단(abort)하든가, 추가 언와인딩 코드를 전혀 실행하지 않고 가장 안쪽의 catch_unwind로 즉시 점프하든가다. 아마 전자가 더 안전한 선택일 것이다.
취소 지원으로 업그레이드하는 것에 더해, 런타임은 비동기 catch_unwind 사용으로도 업그레이드해야 한다.
비동기 정리를 작동하게 만드는 두 번째 단계는 비동기 취소 지원과는 완전히 별개다. 이는 소멸자 외의 다른 방식으로 정리를 수행하는 방법이다. 이로써 몇 가지 문제가 해결된다. 첫째, 앞서 논의한 ‘의미 있는 반환값을 갖는 정리’(Result나 Future를 반환하는) 문제를 해결한다. 둘째, 일회성의 임시(ad hoc) 정리를 위해, 지금까지는 소멸자를 가진 커스텀 가드 타입을 만들어야 했던 번거로움을 피하고, 간편히 임시 정리를 할 수 있게 해준다.
기본 개념은, 블록이 종료될 때마다 실행되는 코드를 지정할 수 있는 새로운 종류의 블록 구문을 추가하는 것이다. 나는 문법을 do … final로 선택했다. 둘 모두 Rust에서 아직 의미가 없는 예약어이기 때문이다. 다른 문법도 쉽게 상상할 수 있다. Go의 defer 블록이 사실상 같은 기능이지만, 여기서는 이 문법이 더 많은 블록 구조화를 제공한다.
예시:
do {
if fallible_call()? {
println!("successful and true")
} else {
panic!("successful but true")
}
} final {
println!("exiting block")
}
이 코드는 do 블록에서 세 가지 방식으로 빠져나올 수 있다. 정상적으로 종료하거나, fallible_call이 에러를 반환하거나, 블록이 패닉할 수 있다. 어떤 경우든, final 블록은 진행을 계속하기 전에 실행되며, 따라서 "exiting block" 메시지는 무슨 일이 일어나도 출력된다.
final 블록은 항상 ()로 평가되어야 하며, 전체 구문은 do 블록이 평가되는 타입으로 평가된다.
이는 ‘임시 가드(ad hoc guard)’ 패턴을 상당히 단순한 구문으로 축소한다. 다음 예를 보자.
foo.bar();
closure(&mut foo)?;
foo.unbar();
사용자의 의도가 클로저가 어떻게 종료되든 매번 unbar를 호출하는 것이라면, 위 코드는 실패한다. 클로저가 패닉하거나 에러를 반환하면 unbar는 호출되지 않는다. 이런 이유로 현재는 다음처럼 임시 가드 타입을 사용한다:
struct Guard<'a>(&'a mut Foo);
impl Drop for Guard<'_> {
fn drop(&mut self) {
self.0.unbar();
}
}
let mut guard = Guard(&mut foo);
guard.0.bar();
closure(&mut guard.0)?;
drop(guard);
이 경우에 추가해야 할 코드가 꽤 많다. do … final 블록을 사용하면 훨씬 단순해진다:
foo.bar();
do {
closure(&mut foo)?;
} final {
foo.unbar();
}
이 기능은 실패 가능한 정리에서의 에러 처리에도 유용하다. 파일을 닫는 경우를 생각해 보자:
let mut file = File::open(path)?;
operate_on_file(&mut file);
drop(file);
유닉스 시스템에서 파일은 drop 호출 시 close(2)를 호출하여 닫힌다. 그 호출이 에러를 반환하면 어떻게 되는가? 표준 라이브러리는 이를 무시한다. 이는 부분적으로 close(2)가 에러를 낼 때 이에 대응할 수 있는 방법이 많지 않기 때문이다. 표준 라이브러리의 주석은 이를 다음과 같이 설명한다:
// 파일 디스크립터를 닫을 때 발생하는 에러는 무시한다.
// 그 이유는 에러가 발생했을 때 실제로 파일 디스크립터가 닫혔는지
// 아닌지 알 수 없기 때문이다. 또한 (EINTR 같은 경우를 위해)
// 재시도하면, 우리가 닫은 이후에 열렸던 다른 유효한 파일
// 디스크립터를 닫아버릴 수도 있다.
“Worse is better.”
그럼에도 사용자는 이 경우에 대해 어떤 수준의 제어를 원할 수 있다. 최소한 에러가 발생했음을 로깅하여, 이후에 생길지도 모를 사고를 디버깅하는 데 도움이 되도록 말이다. 이를 위해 File은 Result를 반환하는 close 메서드를 추가로 가질 수 있다. 그러면 사용자는 final 블록에서 그 메서드를 호출하고, 만약 에러가 발생하면 원하는 방식으로 처리할 수 있다:
let mut file = File::open(path)?;
do {
operate_on_file(&mut file)?;
} final {
if let Err(err) = file.close() {
info!("Error occurred closing file at {path}: {err}");
}
}
위 두 사용 사례는 비동기 정리와는 완전히 별개지만, 이 기능은 비동기 정리에도 유용하다. final 블록 안에서 await 식을 사용할 수 있기 때문이다. 예를 들어:
do {
process_messages(&mut socket).await?;
} final {
socket.shutdown_graceful().await;
}
비동기 스코프의 poll_cancel 구현은, await 중인 future의 poll_cancel과 순회 중인 모든 비동기 반복자에 대한 poll_cancel을 호출하는 것에 더해, 해당 future가 현재 진입해 있는 모든 do 블록의 final 블록도 실행해야 한다.
따라서 비동기 취소와 do … final의 결합을 통해 비동기 정리 방법을 구현할 수 있다. 이는 비동기 소멸자와 달리 특정 타입에 묶여 있지 않다는 한계가 있다. 예를 들어 소켓 타입이 항상 ‘정상 종료(shutdown graceful)’됨을 보장할 수는 없다. 이는 뒤의 선형 타입 절에서 다룰 것이다.
선형 타입으로 넘어가기 전에, 다른 제어 흐름 연산자들과 final 블록의 관계에 대한 몇 가지 메모를 적는다. 이 부분에는 추론상의 오류가 있을 수 있으며, 제어 흐름 가능성을 완전히 올바르게 생각해 보았는지 자신하지 못하겠다.
final 블록의 특별한 특징 중 하나는, final 블록이 값과 함께 조기 종료(early exit)하려고 할 때 어떻게 할지다. 사용자가 이미 값과 함께 조기 종료하려는 중일 수도 있기 때문이다. 예를 들어:
do {
read(&mut file, &mut buffer)?;
} final {
close(&mut file)?;
}
read가 에러를 반환해 final 블록에 들어간 뒤, close도 에러를 반환하면 어떻게 될까? 두 에러를 모두 반환할 수는 없다. 내가 상상할 수 있는 선택지는 둘뿐이다.
후자의 경우, 이 final 블록의 나머지 코드는 실행하지 않지만, 더 아래에 있는 다른 final 블록들은 계속 실행한다.
final 블록에서의 조기 반환이 허용된다면, yield 역시 허용될 수 있음을 유의하라. 예를 들어:
gen {
do {
for elem in iter {
if elem.has_foo()? {
yield "elem has foo";
}
}
} final {
yield "final yield";
}
}
has_foo가 한 번도 실패하지 않는다면, iter 안에서 “foo를 가진” elem의 수만큼 "elem has foo"를 내보낸 뒤, 마지막에 한 번 "final yield"를 내보낼 것이다. 만약 실패한다면, 그 이전까지는 "elem has foo"를 내보내고, final 블록에 들어가 "final yield" 값을 yield한 뒤 종료한다.
일반적이지 않은 제어 흐름을 포함하는 final 블록을 통과해 언와인딩할 때 무엇이 일어나는지에 대해 특별한 주의가 필요하다.
await의 경우, 이는 이미 비동기 언와인딩 논의에서 다뤘다. 비동기 catch_unwind로 감싸져 있다면 정상적으로 진행되고, 그렇지 않으면 중단(abort)하거나 언와인딩을 멈춘다.
yield와 조기 return 연산자가 final 블록에서 허용된다면, 이들은 해당 final 블록을 ‘탈출’하되 그 값은 폐기해야 한다. 우리는 언와인딩을 계속 진행하되, 이 특정 final 블록의 남은 코드는 건너뛴다. yield였다면, 그 값을 실제로 yield하지 않고 루프를 빠져나와 언와인딩을 계속한다.
비동기 취소와 do … final 블록이 있으면 비동기 정리는 가능해지지만, 어떤 특정 비동기 정리 코드가 타입의 스코프 종료 시 반드시 실행된다고 보장할 수는 없다. 사실 오늘날에도, 비동기 여부와 무관하게 타입이 스코프에서 벗어날 때 정리 코드가 항상 실행된다는 보장은 없다. 이 문제에 대한 잠재적 해법은 둘이며, 흔히 단일 용어 ‘선형 타입’으로 혼동되곤 하므로, 여기서는 서로 다른 이름으로 구분해 부르겠다.
첫 번째 해법은, 타입이 자신의 계약의 일부로, 스코프에서 벗어날 때마다(비동기적이지 않은, 값을 반환하지 않는) 소멸자가 실행된다고 표현할 수 있게 하는 것이다. 나는 이를 잊히지 않는 타입(unforgettable types)이라 부른다. 이를 위해서는 Leak 오토 트레이트를 추가하고, 누수를 유발할 수 있는 모든 API(예: mem::forget)에 그 오토 트레이트 제약을 걸어야 한다.
두 번째 해법은, 아예 드롭될 수 없는 타입을 허용하는 것이다. 이는 사실 Rust 외의 맥락에서 ‘선형 타입’이 의미하는 바이지만, 혼동을 피하기 위해 여기서는 이를 드롭 불가 타입(undroppable types)이라 부르겠다. Niko Matsakis는 이를 ‘반드시 이동해야 하는 타입(must move types)’이라는 용어로 썼다. 그런 타입은 드롭될 수 없고 반드시 이동되어야 하기 때문이다. 여기서는 이들을 Rust에 추가하는 난점이나 다른 용례에 미치는 영향은 논외로 하고, 문제의 해법으로서만 간략히 살펴본다.
이 더 약한 제약은 비동기 소멸자 같은 기능을 가진 타입을 정의할 수 있게 해주진 않지만, 스코프드 태스크 트릴레마를 해결해 준다. 스코프드 태스크 API는 자신의 소멸자가 실행될 것을 보장하는 !Leak future를 반환할 것이다.
실무적으로는, (오늘날 소멸자가 거의 항상 실행되는 것과 마찬가지로) 거의 항상 비동기적으로 정리되도록 보장된다. 비동기 scope 함수는 대략 다음과 같을 것이다:
pub fn async scope<'env, F, T>(f: F) -> T
where
F: for<'scope> async FnOnce(&'scope Scope<'scope, 'env>) -> T
{
let scope = ...;
do {
f(&scope).await
} final {
scope.await_all_tasks().await;
}
}
Scope 타입이 !Leak이므로, 이 함수가 반환하는 future도 !Leak이다. 그 future의 poll_cancel이 호출되지 않으면, 동기 소멸자가 실행되며, 이는 ‘비상 백스톱’ 역할을 해 이 스레드를 블로킹하여 메모리 안전성을 보장한다. 하지만 제대로 구현된 런타임에서 실행되는 한, 대신 비동기 정리 코드가 실행되어, 자식 태스크들이 스레드를 블록하지 않고 모두 await되도록 보장한다.
드롭 불가 타입은 더 다양한 계약을 타입 수준에서 표현할 수 있게 해주지만, 올바르게 사용하려면 더 많은 코드가 필요하다. 이는 Drop을 구현하지 않는 타입을 정의할 수 있게 추가하는 것이며, 따라서 그런 타입은 드롭될 수 없다(오늘날과는 다르다. 지금은 Drop을 구현하지 않아도 드롭된다. 이 글에서는 마이그레이션 이야기는 피한다).
Leak을 추가하는 것은 소수의 API에만 영향을 주지만, !Drop은 훨씬 더 많은 곳에 영향을 준다. 어떤 함수의 어떤 코드 경로에서든 제네릭 값을 드롭한다면, 그 제네릭 타입에는 Drop 바운드가 필요해진다.
드롭 불가 타입은 드롭할 수 없으므로, 없애는 유일한 방법은 구조 분해(destructure)하는 것이다. 만약 그 필드가 private라면, 내부적으로 구조 분해하는 public 메서드를 호출하는 수밖에 없다. 이를 통해 드롭 불가 타입의 작성자는 반드시 호출해야 하는 하나 이상의 정리 메서드를 제공할 수 있고, 사용자는 이것을 호출하는 것을 잊을 수 없다. 잊으면 에러가 나기 때문이다.
예를 들어, 드롭 불가 File 타입은 close 메서드를 공개할 수 있으며, 이것이 그 File을 없애는 유일한 방법이다. 사용자가 코드에서 File을 명시적으로 닫는 것을 잊으면 에러가 발생한다.
Niko Matsakis는 이를 비동기 소멸자를 구현하는 방법으로 제안한다. 어떤 AsyncDrop 트레이트 같은 것을 도입하고, 비동기 소멸자를 가져야 하는 타입은 Drop은 구현하지 않고 이 트레이트를 구현한다. 그런 다음 이들 타입에 대해 정리 코드를 명시적으로 실행하기 위해 async_drop 함수를 호출하도록 한다. 이것이 내가 앞서 열거한 모든 문제를 피하는 유일한 ‘비동기 소멸자’ 제안이라고 믿는다. 암묵적 비동기 소멸자 대신, 정리 코드를 명시적으로 호출해야 하는 드롭 불가 타입을 사용하는 것이다.
앞서 보인 스코프드 태스크 API에서 await_all_tasks 메서드는 Scope 타입을 파괴하는 유일한 방법이며, Scope는 Drop을 구현하지 않는다. 이렇게 해서 이 메서드가 결국 호출됨을 보장하여, 스코프드 태스크 트릴레마를 해결한다.
드롭 불가 타입이 비동기 정리에 사용될 수 있다는 Niko Matsakis의 제안에는 한 가지 문제가 있다. 그 설명을 보자.
“가장 단순한 ‘async drop’을 달성하는 방법은
trait AsyncDrop { async fn async_drop(self); }같은 트레이트를 정의하고, 타입을 ‘must move’로 만드는 것이다. 이렇게 하면 호출자는 결국async_drop(x).await를 호출하게 강제된다.?를 더 쉽게 다룰 문법 설탕이 필요할 수 있지만, 그것은 나중 문제다.”
Matsakis가 이 코멘트에서 언급하지 않은 부분은, 바로 async_drop이 자체적으로 반환하는 future의 문제다. 만약 그 future를 드롭하면 어떻게 되는가? 이제 비동기 소멸자는 끝까지 실행되지 않으며, 계약을 위반하게 된다. 이는 async_drop이 반환하는 future 자체가 Drop을 구현하지 않아야 함을 의미한다.
그러나 Drop이 아닌 future는 어떻게 취소할 것인가? 소멸자를 수행하는 비동기 취소 경로가 필요하다. 런타임이 비동기 소멸자를 가진 태스크를 지원하고자 한다면, 모든 태스크를 완료될 때까지 폴링하거나, 아니면 취소될 경우 그 비동기 취소 경로를 완료될 때까지 실행함을 보장해야 한다.
다시 말해, poll_cancel은 드롭 불가 타입을 작동시키기 위한 전제조건이다. 그리고 Matsakis가 ‘?를 더 쉽게 다룰 문법 설탕’을 암시했을 때, 그것이 바로 do … final이 맡은 역할이다. 이것은 조기 반환이나 패닉이 있는 어떤 코드 블록을 가로질러도 드롭 불가 타입을 보유할 수 있게 해준다.
이 장치들은 잊히지 않는 타입(unforgettable types)에도 선행 조건이다. 스코프드 태스크 API가 기본적으로 모든 스코프드 태스크를 비동기적으로 await하게 만들려면, 비상 백스톱 같은 블로킹에 의존하지 않고도 비동기 취소와 do … final 블록의 조합이 필요하기 때문이다.
이는 곧, 이 글에서 다룬 다른 기능들이 드롭 불가 타입이든 잊히지 않는 타입이든 어떤 정의의 ‘선형 타입’을 도입하기 위한 필수 선행 조건임을 의미한다. 이러한 선형 타입 기능은 Rust에 대대적인 변화이며 2027년 이전에 실현될 가능성은 낮아 보인다. 따라서 당장 취할 수 있는 조치는 비동기 취소와 do … final을 추가하는 것이다. 이렇게 하면 선형 타입이 없더라도 가까운 시일 내에 사용자들이 어떤 형태로든 비동기 정리를 수행할 수 있게 된다.