비동기 Rust에는 모든 비동기 코드를 동일한 방식으로 중단할 수 있는 ‘보편적 취소 프로토콜’이 있다. 이 글은 취소 안전성과 취소 정합성의 차이, 실제로 발생하는 버그 사례, 이를 피하기 위한 실용적 기법과 향후 언어 차원의 제안을 정리한다.
LWN.net에 오신 것을 환영합니다
다음 구독자 전용 콘텐츠는 한 LWN 구독자의 공유로 이용 가능합니다. 수천 명의 구독자가 리눅스와 자유 소프트웨어 커뮤니티의 최고의 뉴스를 위해 LWN에 의존하고 있습니다. 이 기사를 즐겼다면 LWN을 구독하는 것을 고려해 주세요. 방문해 주셔서 감사합니다!
비동기 Rust 코드는 Rain Paharia가 “보편적 취소 프로토콜(universal cancellation protocol)”이라고 부르는 특성을 갖고 있는데, 이는 모든 비동기 코드를 동일한 방식으로 중단시킬 수 있음을 의미한다. Paharia는 이것이 의도적으로 사용하면 유용한 기능이지만, 우연히 그렇게 될 경우 오류의 근원이 되기도 한다고 말한다. 이 문제를 주제로 RustConf 2025에서 발표하며, 비동기 Rust 코드에 버그가 스며드는 것을 피하기 위한 몇 가지 기법을 소개했다.
Paharia는 발표를 (슬라이드) 청중의 Rust 프로그래머들에게 질문을 던지며 시작했다. tokio 라이브러리의 비동기 큐(채널)를 사용해 채널이 닫힐 때까지 루프로 읽되, 채널이 너무 오래 비어 있으면 에러를 발생시키고 싶다고 하자. 다음 코드는 그에 맞는 올바른 방법일까?
loop {
match timeout(Duration::from_secs(5), rx.recv()).await {
Ok(Ok(msg)) => process(msg),
Ok(Err(_)) => return,
Err(_) => println!("5초 동안 메시지가 없음"),
}
}
슬라이드에 나오는 코드에서 흔히 보듯, 이 예제는 다소 간결하다. 여기서 timeout()은 제한 시간에 도달하면 Err 값을 반환하고, 시간 제한을 적용한 연산의 반환값을 Ok로 감싸서 돌려준다. 이 경우 제한을 받는 연산은 rx.recv()이다. 이는 오류(채널이 예기치 않게 닫힐 때) 또는 채널에서 읽은 메시지를 담은 Ok 값을 반환한다. 실제 애플리케이션에서는 아마 채널이 닫히는 경우를 좀 더 명시적으로 처리하고 싶겠지만, 예제 코드로서는 채널 읽기를 타임아웃으로 감싸는 기본 아이디어를 잘 보여 준다.
발표자가 “이 코드가 맞을까요?”라고 물으면 대개 미묘한 문제가 있다고 지적하려는 거라는 걸 모두 알기 때문에, 청중은 대답을 머뭇거렸다. 하지만 잠시 긴장감이 흐른 뒤, Paharia는 “그렇다, 이 코드는 완전히 올바르다”고 밝혔다. 그렇다면 채널의 반대쪽, 쓰기 코드도 보자. 다음 슬라이드를 띄우고, 이것이 채널에 쓰는 올바른 방법인지 물었다:
loop {
let msg = next_message();
match timeout(Duration::from_secs(5), tx.send(msg)).await {
Ok(Ok(_)) => println!("전송 성공"),
Ok(Err(_)) => return,
Err(_) => println!("5초 동안 대기열에 공간 없음"),
}
}
이번에는 사람들이 조금 더 자신 있게 말했다. 아니다, 이 코드는 버그가 있다. 전송이 타임아웃되면(Err() 분기) 또는 채널이 닫히면(Ok(Err()) 분기) msg가 드롭되어 데이터가 유실될 수 있다. Paharia는 이것이 문제라고 말했다. 현재 비동기 Rust의 설계 방식 때문에, 이런 실수를 우연히 저지르기 너무 쉽다는 것이다.
Paharia는 이것이 “비동기 Rust가 나쁘다”는 말이 아니라는 점을 강조하고 싶다고 했다. “저는 async를 사랑합니다!” 심지어 RustConf 2023에서 비동기가 올바른 POSIX 시그널 처리 코드를 작성하는 데 아주 잘 맞는다는 발표도 했다. Oxide에서의 일에서도, 회사의 소프트웨어 스택 전반에 비동기 Rust를 사용한다. 비동기 Rust는 훌륭하다. 그래서 많이 썼고, 바로 그렇기 때문에 비동기 작업 취소와 관련된 많은 문제를 겪었고, 그 함정을 최소화하는 방법을 공유하고 싶다는 이야기다.
비동기 Rust는 Future 트레이트를 중심으로 구축되어 있는데, 이는 폴링되어 일시 중단될 수 있는 계산을 나타낸다. 프로그래머가 비동기 함수를 작성하면, 컴파일러는 그것을 상태 머신의 가능한 상태를 나타내는 열거형으로 바꾼다. 그 열거형은 Future를 구현하여 상태 머신을 진행시키는 공통 인터페이스를 제공한다. async 함수는 await를 사용해 작은 상태 머신들을 더 큰 상태 머신으로 인체공학적으로 결합할 수 있게 해 준다.
Paharia는 이 설계를 좋아하지만, JavaScript나 Go에서 Rust로 넘어오는 프로그래머에게는 놀랄 수 있는 부분이라고 말한다. 그 언어들에서는 동등한 언어 메커니즘이 그린 스레드로 구현된다. 그런 언어에서 Future의 동등물(프로미스)을 만들면, 그 즉시 별도의 스레드에서 코드가 실행되기 시작하고, 프로미스는 최종 결과를 회수하기 위한 핸들에 불과하다. Rust에서는 Future를 만든다고 해서 아무 일도 일어나지 않는다 — 메모리에 상태 머신이 만들어질 뿐이다. 실제로는 프로그래머가 .poll() 메서드를 호출하기 전까지 아무 일도 일어나지 않는다.
이런 설계는 임베디드 소프트웨어의 요구에서 비롯되었다고 설명했다. 임베디드 세계에는 스레드도 런타임도 없기 때문에 사실상 이렇게 작동해야 한다. 또한 Rust에서 비동기 작업이 그냥 일반 데이터라는 사실은 그것들을 쉽게 취소할 수 있게 해 주기도 한다. 비동기 함수를 취소하고 싶다면, 단지 .poll()을 더 이상 호출하지 않으면 된다. 그렇게 할 수 있는 방법은 아주 많다. Paharia는 자신이 실수로 Future를 드롭(해제)해 버린 실제 버그 사례 몇 가지를 예로 들었다:
// 처음에는 동기 코드로 구현된 연산
some_operation();
// Result를 반환하는데, 이를 무시하면 린터가 경고한다
// 하지만 여기서는 신경 쓰지 않으니 린터 경고를 잠재운다:
let _ = some_operation();
// 나중에 이 코드를 비동기로 리팩터링했다
let _ = some_async_operation();
// ... 이제 Future가 생성만 되고, 실행되지 않은 채 드롭되고 있다
이 시점에서 두 가지 용어를 정의하고자 했다. “취소 안전성(cancel safety)”과 “취소 정합성(cancel correctness)”이다. 취소 안전성은 Future를 안전하게 드롭할 수 있는지 여부를 의미한다. 위의 리더 예제는 안전하게 드롭할 수 있다 — 그냥 큐에서 읽기를 멈출 뿐이다. 반면 라이터 예제는 그렇지 않다 — 드롭하면 데이터도 함께 드롭될 수 있다. 전자는 “취소-안전”이고 후자는 아니다. Tokio의 문서에는 해당 비동기 API 함수가 취소-안전한지 여부에 대한 섹션이 있어, 보통은 함수의 로컬 정의만 보아도 파악할 수 있다.
반면 취소 정합성은 프로그램 전체의 전역적 성질이다. 프로그램이 Future 취소와 관련된 버그를 갖고 있지 않다면 그 프로그램은 취소-정합적이라고 할 수 있다. 취소-안전하지 않은 함수가 항상 정합성 문제를 야기하는 것은 아니다. 예를 들어 어떤 확인 응답과 재전송 메커니즘을 사용한다면, 예제처럼 메시지를 드롭하는 것이 실제로 버그가 아닐 수도 있다.
취소-정합성 버그가 존재하려면 세 가지가 모두 참이어야 한다고 Paharia는 말했다. 취소-안전하지 않은 Future가 존재해야 하고, 어느 시점에 그것이 실제로 취소되어야 하며, 그 취소가 시스템의 어떤 속성을 위반해야 한다. 취소-정합성 버그를 제거하는 대부분의 접근법은 이 세 기둥 중 하나를 겨냥한다. 안타깝게도 현재의 Rust에서는 어느 한 가지 기법만으로 완전한 해결을 할 수는 없다. 그래서 취소-정합성 버그를 없애는 데 도움이 되는 몇 가지 기법을 공유했다.
첫 번째 기법은 잠재적으로 위험한 연산을 더 작은 조각으로 분해하는 것이다. 앞서의 라이터 예제는 실제 전송 전에 tokio 큐의 .reserve() 메서드를 사용해 메시지를 위한 슬롯을 보장받을 수 있다. 다음 코드는 타임아웃이 발생하더라도 msg를 드롭하지 않는다:
loop {
let msg = next_message();
loop {
match timeout(Duration::from_secs(5), tx.reserve()).await {
Ok(Ok(permit)) => { permit.send(msg); break; }
Ok(Err(_)) => return,
Err(_) => println!("5초 동안 공간 없음"),
}
}
}
이 방법은 동작하지만, 그래도 여전히 주의를 기울여 정확히 작성해야 한다. 또 다른 기법은 부분 진행 상태를 기록하는 API를 사용하는 것이다. 이렇게 하면 어떤 데이터가 처리되었고 어떤 데이터가 처리되지 않았는지 알 수 있다. 예를 들어, tokio의 Writer::write_all() 메서드는 취소-안전하지 않지만, 대안인 Writer::write_all_buf()는 커서를 사용하여 어디에서 쓰기가 중단되었는지 확인하고 복구할 수 있게 해 준다.
마지막 기법은 Go와 JavaScript의 접근을 스레드로 에뮬레이션하는 것이다. Tokio에는 Future를 받아 새 스레드를 시작(또는 스레드 풀에서 가져오고)하여 Future를 완료될 때까지 실행하는 spawn() 함수가 있다.
“이거 참 별로입니다!”라고 Paharia는 요약했다. 이런 기법들은 모두 도움이 되지만, 그 어느 것도 완전하고 체계적인 해결책은 아니다. 언어가 이런 종류의 버그를 방지할 메커니즘을 아직 제공하지 않기 때문에, 이를 “Rust에서 가장 Rust답지 않은 부분”이라고까지 표현했다.
그래도 희망은 있다. Rust가 취소-정합성 버그를 원천적으로 배제할 수 있게 해 줄 세 가지 제안이 있다. 취소 시 비동기 코드가 정리 함수(클린업)를 실행할 수 있게 하는 Async drop, 모든 Future 값이 반드시 사용되도록 요구하는 Unforgettable types, 그리고 이와 밀접한 [Undroppable types] 제안이다.
앞으로 이들 제안 중 하나가 채택되고 구현된다면, 이 범주의 버그는 통째로 사라질 수 있다. 그때까지는, 비동기 코드를 취소-안전하게 만들고, 올바른 사용을 쉽게 해 주는 API를 설계하며, 중요한 Future 값이 실수로 드롭되거나 잊히지 않도록 스폰된 스레드 같은 기법을 사용하는 것을 권한다. 발표에서는 다 다루지 못한 내용이 있어, 이 주제에 대해 Paharia가 Oxide를 위해 작성한 문서에 더 자세한 내용이 담겨 있다.