폴링될 준비가 된 future가 깨어 달라고 요청했는데도 폴링되지 않아 멈춰버리는 ‘스누징’이 async Rust에서 어떻게 지연과 데드락(‘futurelock’)을 만들고, 이를 피하기 위한 패턴과 규칙, 그리고 가능한 대안을 살펴본다.
2026년 3월 2일
음, 그건 혼란스럽네요. 그 경우라면 그 태스크가 다른 future들을 실행할 수 있어야 하는데 — 왜 그녀의 연결들은 진행을 만들지 못한 채로 멈춰 버리는 거죠?
어떤 future가 진행할 준비가 되었는데도 폴링되지 않는다면, 나는 그걸 “스누징(snoozing)”이라고 부른다. 스누징은 async Rust에서 많은 행(hang)과 데드락의 원인이며, Oxide 사람들이 작성한 최근의 “Futurelock” 사례 연구도 그중 하나다. 나는 스누징이 거의 항상 버그라고, 우리를 스누징에 노출시키는 도구와 패턴은 해롭다고 간주되어야 한다고, 그리고 신뢰할 수 있고 편리한 대체재가 가능하다고 주장하려 한다.
본격적으로 들어가기 전에, 스누징과 취소(cancellation)는 서로 다른 것임을 분명히 하고 싶다. 스누징과 기아(starvation)도 서로 다른 것이다. 기아는 무언가가 실행자를 독점해 다른 future들의 폴링을 방해하는 상황이다. 스누징은 모든 것이 매끄럽게 유휴(idle) 상태로 흘러갔는데도, 깨워 달라고 요청한 어떤 future가 여전히 폴링되지 않는 상황이다. 스누즈된 future가 결국 깨어난다면, 분명히 취소된 것은 아니었다. 반대로 취소된 future도 스누즈될 수 있는데, 마지막으로 폴링된 시점과 마침내 drop되는 시점 사이에 간격이 있을 때 그렇다. 우리는 흔히 future를 취소한다는 것이 곧 drop하는 것을 _의미_한다고 말하지만, 다시는 폴링되지 않을 future 역시 아직 drop되지 않았더라도 취소되었다고 할 여지도 있다. 어떤 정의가 더 나은가? 확신은 없다. 하지만 스누징이 버그라는 데 동의한다면, 그 차이는 버그가 있는 프로그램에서만 중요하다. 취소 버그는 async Rust에서 큰 주제이고 그에 대해 이야기하는 건 좋은 일이다. 하지만 취소 _자체_는 버그가 아니다. 스누징은 버그다. 그리고 나는 우리가 스누징에 대해 충분히 이야기하지 않는다고 생각한다.
단일 태스크가 여러 future를 동시에 폴링하는 경우가 있다면, 그 태스크가 이전에 폴링을 시작했던 future를 다시는 폴링하지 않게 되는 일이 없도록 극도로 조심하라.
스누징은 미스터리한 지연과 타임아웃을 유발할 수 있지만, 가장 분명하고 극적인 스누징 버그는 데드락(“futurelock”)이다. 몇 가지 예를 보자. 오늘의 실험 대상은 foo로, 프라이빗 async 락을 잡고 무언가 작업을 하는 척하는 장난감 함수다. foo 자체에는 아무 문제가 없다. 이런 예시는 어떤 형태의 async 대기에도 만들 수 있다. 세마포어, bounded 채널, 심지어 OnceCell도 가능하다. Tokio 문서에는 가능한 한 async 락 대신 일반 락을 쓰라는 흥미로운 조언이 있고, 좋은 조언이지만, 원래 “Futurelock” 버그는 tokio::sync::mpsc 채널이 내부에서 사용하는 세마포어에서의 데드락이었다. foo 말고는 LOCK을 건드릴 것이 없으니, foo의 본문으로 옮기는 편이 더 깔끔할 것이다. 나는 이 형태를 유지하겠다. 함수 로컬 static을 처음 보는 사람도 있고, 처음 보면 헷갈릴 수 있기 때문이다.
static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());async fn foo() { let _guard = LOCK.lock().await; tokio::time::sleep(Duration::from_millis(10)).await; // pretend work}
계속 진행하면서, foo가 당신이 들어본 적도 없는 어떤 의존성의 세 개 크레이트 아래 깊숙이 묻혀 있다고 상상해 보길 바란다. 현실에서 이런 일이 벌어질 때, 락, 그 락을 쥐고 있는 future, 그리고 그 future를 스누즈시키는 실수는 서로 아주 멀리 떨어져 있을 수 있다. 원래 “Futurelock” 조사에서는 버그를 좁히기 위해 Ghidra에서 코어 덤프를 봐야 했다. 그게 바로 우리가 말하는 “type 2 fun”이다. 이를 염두에 두고, 최소한의 futurelock을 보자.
Playground #1let future1 = pin!(foo());_ = poll!(future1);foo().await; // Deadlock!
여기에는 foo 호출이 두 번 있다. 첫 번째 호출에서 future1을 얻고 poll!로 폴링한다. poll! 매크로는 Future::poll을 정확히 한 번만 호출한다. 이는 사실상 Mutex::try_lock이나 Child::try_wait의 더 일반적인 버전, 즉 “잠재적으로 블로킹될 수 있는 작업을 시도하되, 정말로 블로킹이 필요하면 포기하라”에 해당한다. 같은 일을 poll_fn으로 하거나, “손으로” Future를 작성로도 할 수 있다. 이 코드는 LOCK을 획득하고 잠자기(sleep)를 시작하는 지점까지 실행된다. 그 다음 foo를 다시 호출하면 또 다른 future가 생기고, 이번에는 그것을 .await한다. 즉 우리는 두 번째 foo future를 완료될 때까지 루프에서 폴링한다.
루프는 존재하지만, 실제로 .await “안에” 있는 것은 아니다. 대신 그것은 런타임 안에 있다. 이런 “제어의 역전(inversion of control)”은 async/await의 핵심 중의 핵심이며, 이것이 하나의 스레드에서 여러 future를 동시에 실행할 수 있게 해준다. 이를 동작시키는 poll과 Waker 메커니즘을 아직 보지 못했다면, Async Rust in Three Parts의 최소한 1부는 읽어 보길 권한다. 하지만 두 번째 foo는 같은 락을 잡으려 시도하고, future1은 우리가 future1을 다시 폴링하거나 drop하기 전까지 그 락을 놓지 않는다. 우리의 루프는 둘 중 어느 것도 하지 않는다 — 우리는 future1을 “스누즈”해 버렸다 — 그래서 데드락이다.
이 예시는 멋지게 짧지만, 실제 프로그램에서는 poll! 매크로가 흔하지 않다. 현실에서는 select!로 이런 형태를 더 자주 보게 된다. Futurelock의 select! 예시는 루프를 포함하지 않지만, 버그를 고친 PR을 보면 이와 똑같은 루프가 있다. 루핑은 보통 참조로 select하도록 강제하지만, 가능할 때는 값으로 select해야 하고 또 그럴 수 있다. 그러면 취소된 future가 즉시 drop되고 이런 종류의 데드락을 막는다. 이에 대해서는 아래에서 더 이야기하겠다.
Playground #2let mut future1 = pin!(foo());loop { select! { _ = &mut future1 => break, // Do some periodic background work while future1 is running. _ = tokio::time::sleep(Duration::from_millis(5)) => { foo().await; // Deadlock! } }}
이 루프는 future1을 완료까지 몰고 가면서, 때때로 깨어나 백그라운드 작업을 하려 한다. select! 매크로는 &mut``future1과 Sleep future를 둘 다 폴링하다가 하나가 준비되면, 둘 다 drop하고 승자의 => 본문을 실행한다.
승자의 출력이 유용했다면, =의 왼쪽에 변수명(또는 일반적으로 어떤 “패턴”)을 두고 그것을 캡처할 수 있다. 여기서 두 출력은 모두 ()이므로, 무시하기 위해 _를 쓴다. 이는 할당, 함수 인자, match arm에서 _가 동작하는 방식과 동일하다. 루프는 매회 새 Sleep future를 만들지만, foo를 재시작하고 싶지는 않으므로 future1을 참조로 select한다. 하지만 그것은 future1을 살아 있게만 할 뿐, 계속 폴링된다는 뜻은 아니다. 의도는 다음 루프 반복에서 future1을 다시 폴링하는 것이지만, 백그라운드 작업 동안(그 작업에는 또 다른 foo 호출이 포함되어 있다) future1을 스누즈해 버리고, 다시 데드락이다.
스트림을 select하면 이 데드락을 또 유발할 수 있다.
Playground #3let mut stream = pin!(stream::once(foo()));select! { _ = stream.next() => {} _ = tokio::time::sleep(Duration::from_millis(5)) => {}}foo().await; // Deadlock!
이 경우 stream.next() future는 사실 참조가 아니라 값이며, sleep이 끝난 뒤 drop되기도 한다. 하지만 그것은 스트림에 대한 참조를 _포함_하고 있고, 우리는 next를 취소한 뒤에도 그 스트림 내부의 foo future를 스누즈하는 결과에 도달한다.
스트림을 스누즈하는 것이 무엇을 의미하는지는 다소 까다롭고, 최종적으로 안정화되기 전에 저수준 API 계약이 바뀔 가능성도 있다. (이름조차 확실하지 않다. 오늘날 우리는 futures 크레이트의 Stream 트레이트를 쓰지만, 표준 라이브러리의 nightly 전용 버전은 AsyncIterator라고 부른다.) 핵심은 Future::poll이 가능한 두 상태를 나타내는 반면, Stream::poll_next는 세 상태를 나타낸다는 점이다. future와 스트림은 각각 끝났을 때 Ready(_)와 Ready(None)을 반환한다. 그리고 둘 다 wakeup을 등록했고 나중에 다시 폴링되어야 할 때 Pending을 반환한다. async 함수 관점에서는 그것이 “await 지점”이며, 스누징이 발생할 수 있는 곳이다. 하지만 스트림에는 세 번째 상태가 있다. Ready(Some(_))는 스트림에서 값을 하나 내보내는 것으로, 스트림이 끝난 것은 아니지만 동시에 (전형적으로, 현재로서는) wakeup을 등록하지 않았다. 이것은 await 지점이 아니라 “yield 지점”이며, nightly 전용 gen / async``gen 문법에서의 yield 키워드에 대응한다. .next() 호출을 취소하면 스트림(그리고 그 안에 포함되었을지도 모르는 어떤 future들)을 임의의 await 지점에 남겨 두게 되며, 그것이 이 예제에서 foo를 스누즈하고 데드락을 얻는 방식이다. 하지만 .next() 호출을 완료하면 스트림은 await 지점이 아니라 yield 지점에 놓이게 되며, 아마 우리는 그것을 “스트림을 스누즈했다”고 세고 싶지 않을 것이다. 이에 대해서도 아래에서 더 이야기하겠다.
스트림 이야기가 나온 김에, 또 다른 futurelock 범주는 buffered 스트림에서 비롯된다. StreamExt의 대부분 메서드처럼, buffered도 입력 스트림을 받아 다른 스트림으로 어댑트한다. 하지만 다른 메서드들과 달리, buffered는 입력 아이템이 _그 자체로 future_라고 가정하고, 내부에서 그것들을 await하여 출력들을 모은다. 이 iter 스트림의 Item 타입은 foo future들로, 앞선 예제의 once 스트림 Item 타입인 ()과는 완전히 다르다.
Playground #4futures::stream::iter([foo(), foo()]) .buffered(2) .for_each(|_| foo()) // Deadlock! .await;
여기서 버퍼는 두 foo future를 동시에 폴링하기 시작한다. 첫 번째가 끝나면, 제어가 for_each 클로저로 넘어간다. 그 클로저가 실행되는 동안, 버퍼 안의 다른 foo는 스누즈된다.
이 경우 두 번째 buffered foo는 실제로 LOCK을 획득하는 지점까지 진행하지는 않는다. 하지만 여기서는 여전히 재현 가능한 데드락이 발생한다. Tokio의 Mutex는 “공정(fair)”하기 때문이다. Mutex::lock이 Mutex가 풀리기를 기다리며 블록될 때, 그것은 “줄서기”를 하고, 취소되지 않는 한 다른 호출자들이 새치기할 수 없다. 불공정한 mutex로 이 예제를 동작시키려면, foo에서 임계 구역 이후 1ms sleep을 추가하면 된다.
Buffered 스트림은 FuturesOrdered 또는 FuturesUnordered 중 하나를 감싼 래퍼이고, 둘 중 어느 쪽이든 직접 루프를 돌면 같은 데드락을 맞을 수 있다. 이 예시를 위의 stream::once 예시와 대비해 보라. 거기서는 yield 지점 사이에서 스트림을 스누즈한 것이 우리의 “잘못”이었지만, 여기서는 우리 프로그램이 FuturesUnordered를 성실히 yield 지점까지 몰고 가는데도 내부에서 다른 foo를 스누즈한다. 나는 결국 이 서로 다른 경우들에 대해 서로 다른 수정이 필요하다고 생각한다. 이에 대해서도 아래에서 더 이야기하겠다.
Playground #5let mut futures = FuturesUnordered::new();futures.push(foo());futures.push(foo());while let Some(_) = futures.next().await { foo().await; // Deadlock!}
데드락은 나쁘다. 하지만 더 나쁜 건, 이 예시들이 정확히 무엇을 잘못했는지 짚어내기가 어렵다는 점이다.
"여기서 우리가 가리키며 ‘절대 이걸 하지 마’라고 말할 수 있는 단 하나의 추상화, 구성 요소, 혹은 프로그래밍 패턴은 없다."
foo가 망가진 건가? select!와 buffered 스트림이 망가진 건가? 아니면 이 프로그램들이 “쥐는 법을 잘못” 쥔 건가?그 질문들에 대한 답을 바로 하기에 앞서, 나는 완전히 다른 질문을 던지고 싶다. 왜 일반 락과 스레드를 쓸 때는 이런 데드락이 없을까?
몇 번을 말해야 하죠: 절대로
TerminateThread를호출하지 마세요.
foo의 일반(비 async) 버전을 생각해 보자.
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());fn foo() { let _guard = LOCK.lock().unwrap(); thread::sleep(Duration::from_millis(10));}
이 foo가 이 LOCK을 건드리는 유일한 함수라고 가정하면, 여기서 데드락이 _가능_하기는 할까?
짧고 합리적인 대답은 “아니오”다. 하지만 길고 педан틱한 대답은 “예”인데, 시스템 프로그래밍의 오랜 규칙 하나를 깨고 foo를 실행 중인 스레드를 죽일 의지가 있다면 그렇다. Windows TerminateThread 함수는 이에 대해 경고한다. “대상 스레드가 critical section을 소유하고 있다면, 그 critical section은 해제되지 않는다.” 문서는 또한 이것을 “가장 극단적인 경우에만 사용해야 하는 위험한 함수”라고 부른다. 무엇이 극단적인 경우에 해당하는지에 대해서는 자세히 말하지 않는다. “원래 설계자들은 스레드를 종료하는 안전한 방법이 없으므로 그런 함수가 존재해서는 안 된다고 강하게 믿었다. 안전하게 호출될 수 없는 함수를 두는 건 의미가 없다.” - Raymond Chen 유닉스에서 이런 문제의 고전적 원인은 fork인데, 이는 프로세스의 전체 주소 공간을 복사하지만 실행 중인 스레드 중 하나만 복사한다. Playground 예시 “프로그래밍 가이드는 멀티스레드 프로세스에서 fork를 쓰지 말거나, 그 직후 즉시 exec를 호출하라고 조언한다. POSIX는 fork와 exec 사이에서 소수의 ‘async-signal-safe’ 함수만 호출 가능하다고 보장하는데, 그 목록에는 malloc()과 메모리를 할당하거나 락을 획득할 수 있는 표준 라이브러리의 다른 어떤 것들도 포함되지 않는다. fork를 하는 실제 멀티스레드 프로그램은 이 관행에서 비롯되는 버그로 시달린다. 이런 속성을 가진 새로운 syscall 제안이 제정신인 커널 메인터이너에게 받아들여질 것이라 상상하기는 어렵다.” - A fork() in the road foo 같은 함수가 현실적으로 자기 자신을 이로부터 보호할 수 있는 방법은 없다. 유닉스에서는 pthread_atfork와 pthread_cleanup_push로 이런 상황에서 정리를 할 수 있지만, 실용적이지 않다. 메모리 누수를 막으려면 모든 단일 할당마다 콜백을 등록해야 하고, 할당과 등록 사이에 취소나 포킹이 끼어들지 못하도록 어떤 방식으로든 원자적으로 해야 한다. (취소는 pthread_setcancelstate로 미룰 수 있지만, 포킹에는 동등한 것이 없다.) 또한 이 모든 것이 move semantics와 어떻게 상호작용하는지까지 알아내야 하는데, 아마도 컴파일러 자체의 변경이 필요할 것이다. 그래서 일반 규칙은 “스레드를 절대 죽이지 마라”이다.
스레드 취소가 역사적으로 얼마나 큰 난장판이었는지 생각하면, future 취소가 지금 정도로 잘 동작한다는 것은 놀랍다. 결정적인 차이는 Rust가 future를 drop해서 그것이 소유한 자원, 특히 락 가드를 정리하는 방법을 알고 있다는 점이다. Rust는 또한 우리가 어떤 객체를 drop하는 시점에는 그 객체의 어느 부분도 borrow되어 있지 않다는 것을 안다. OS는 프로세스가 종료하면 그 전체를 정리할 수 있지만, 그전까지는 어떤 스레드가 무엇을 소유하는지 알지 못한다.
이 버전의 foo는, 그것을 실행 중인 스레드를 _일시 정지_시키면 데드락될 수도 있다. Windows 문서도 이에 대해 경고한다. “mutex나 critical section 같은 동기화 객체를 소유하는 스레드에 SuspendThread를 호출하면, 호출 스레드가 일시 정지된 스레드가 소유한 동기화 객체를 얻으려 할 때 데드락으로 이어질 수 있다.” 유닉스에서 이런 문제의 고전적 원인은 시그널 핸들러로, 시그널 핸들러는 실행될 때마다 스레드를 하이재킹한다. Playground 예시 “시그널 핸들러를 등록하면, 그것은 당신이 실행 중인 어떤 코드의 한가운데에서 호출된다. 이는 시그널 핸들러가 할 수 있는 일에 매우 가혹한 제한을 건다. 어떤 락도 잠겨 있지 않다고 가정할 수 없고, 어떤 복잡한 자료구조도 신뢰할 수 있는 상태라고 가정할 수 없다, 등등. 이 제한은 스레드 안전 코드에 대한 제한보다 더 강하다. 왜냐하면 시그널 핸들러가 원래 코드를 중단하고 _멈춰_세우기 때문이다. 따라서 예를 들어, 락을 기다리는 것조차 할 수 없다. 락을 쥐고 있는 코드는 시그널 핸들러가 끝날 때까지 멈춰 있기 때문이다. 이는 stdio 함수들, malloc 등 많은 편리한 함수들이 시그널 핸들러에서 사용 불가능하다는 뜻인데, 그것들은 내부적으로 락을 잡기 때문이다.” - signalfd is useless 사실 이것이 fork의 “async-signal-safe” 함수 목록이 나온 곳이다. fork 이후에 할 수 있는 일에 대한 규칙은 대체로 시그널 핸들러에서 할 수 있는 일과 같다. 다시 말해 foo가 현실적으로 자기 자신을 이로부터 보호할 수는 없으므로, 일반 규칙은 “스레드를 절대 멈추지 마라”이다.
취소와 달리, future를 스누즈하는 것은 스레드를 멈추는 것보다 나을 것이 없다. Futurelock은 SuspendThread와 유닉스 시그널 핸들러가 언제나 갖고 있던 오래된 문제를 새로운 방식으로 뒤틀어 놓은 것이다. Oxide and Friends 팟캐스트의 Futurelock 에피소드에서도 시그널 처리 버그와의 유사성을 언급한다. 정상적인 애플리케이션 코드는 전역 락을 끊임없이 건드린다. 출력할 때, 메모리를 할당할 때, 동적 라이브러리를 로드할 때, DNS와 통신할 때, 등등 말이다. 어떤 “정상 코드”를 얼려 두었을 때 그것과의 데드락 위험을 피하고 싶다면, 그 코드를 다시 풀어 주기 전까지는 우리도 어떤 락도 건드리지 말아야 한다. 이는 매우 저수준의 매우 unsafe한 맥락에서는 가능하지만, “정상 코드”에서는 거의 희망이 없다. “Win32에서는 프로세스 힙이 스레드 안전 객체이고, Win32에서 힙에 접근하지 않고는 거의 아무것도 할 수 없기 때문에, Win32에서 스레드를 suspend하는 것은 프로세스를 데드락시킬 가능성이 매우 높다.”
그런데도, 오늘날 우리가 참조로 select!를 하거나 buffered 스트림을 사용할 때 우리는 암묵적으로 바로 그 상황에 직면한다. 분명히 말하자면, 스누징이 예를 들어 전역 malloc 락을 데드락시키지는 못한다. 우리는 그 특정 락을 await 지점 너머로 들고 있지 않기 때문이다. 하지만 async 프로그래밍이 더 대중화되고 async 애플리케이션이 더 복잡해질수록, 공유 자원을 async 락으로 관리하는 일은 점점 흔해진다. 원래 “Futurelock” 버그가 tokio::sync::mpsc의 세마포어에서 데드락이었음을 기억하라. 우리는 무엇을 할 수 있을까?
select!
select!에서의 세밀한 취소(fine-grained cancellation)는 async Rust가 제로 코스트 추상화가 되고, 여기저기에 락이나 액터를 만들 필요를 피하게 해 주는 요소다.
소유한 future로 select!를 사용하는 것은 보통 괜찮다. 위에서 예외를 하나 봤다. stream.next()는 future를 반환하지만, 그것을 select하는 것만으로도 데드락이 발생했다. 하지만 그것은 select!에 특유한 것이 아니다. 어떤 형태의 취소로도 재현할 수 있다. 다음은 timeout을 사용한 버전. 이는 사실 next 자체의 문제다. 이에 대해서는 아래에서 더 이야기하겠다. 취소에 우리가 동의하는 한, select!은 모든 “스크루티니(scrutinee)” future를 즉시 drop하기 때문에 대체로 괜찮다. 우리가 진짜로 피해야 하는 것은 참조로 select!를 쓰는 것이다. 불행히도, 말은 쉽지만 현실은 그렇지 않다.
각 future를 tokio::spawn으로 각자의 태스크에서 돌리는 것은 스누징을 막는 한 방법이다 — 스레드처럼 태스크도 “자기 생명”이 있다 — 하지만 이는 borrow와 충돌하는 'static 바운드를 동반한다. 이런 오류를 고치는 가장 흔한 방법은 Arc<Mutex<_>>를 마구 적용하는 것인데, 기껏해야 성가시고, borrow가 호출자에서 온 것이라면 대규모 리팩터링이 필요할 수 있다. 또한 새로운 데드락을 도입할 수도 있다. moro 크레이트는 std::thread::scope와 비슷한 non-'static 태스크 스폰 API를 제공하며, 이런 문제의 상당수를 해결할 수 있다. moro는 모든 태스크를 같은 스레드(즉 현재 태스크 내부)에서 실행하는데, 이는 “Scoped Task Trilemma”를 피한다. scoped 태스크를 다른 스레드에서 안전하게 실행하는 것은 async Rust의 주요한 미해결 문제다. 나는 moro를 열렬히 추천하며, 더 널리 쓰이지 않는 것이 놀랍다. 하지만 moro가 select!를 완전히 대체할 수는 없다. Niko Matsakis의 “mini-redis에서 pub-sub 사례 연구”는 select!만이 처리할 수 있는 경우를 논의한다. select!는 매크로 확장으로 match가 되며, 서로 다른 match arm이 같은 변수를 변이할 수 있는데, 동시 태스크는 그렇지 못하다. 사실 스트림에 대한 참조를 select하는 경우라면, arm 본문은 스트림 자체를 변이할 수도 있다. 참조는 match 전에 drop되기 때문이다. 다시 말해, 스크루티니가 스누즈된다는 사실은 borrow checker에 보이는 방식으로 드러나며, 야생의 실제 코드가 그것에 의존하고 있다! (이 select! 스크루티니와 다른 arm에서의 이 변이를 비교해 보라.) 스누징 위험 없이 이런 패턴을 지원하는 것은 복잡하다.
나는 이 간극을 메우려는 실험적 크레이트를 가지고 있다: join_me_maybe이다. 이것은 select!과 비슷한 기능을 일부 가진 join! 매크로를 제공한다. 다음은 위의 select! 루프를 대체할 수 있는 한 가지 방식이다. join_me_maybe에는 이를 표현하는 여러 방법이 있다. 여기에서 보이는 maybe 키워드 외에도, 레이블이 붙은 arm을 .cancel()할 수도 있고, 호출 함수에서 return할 수도 있다. 또한 여기에서 “maybe async”처럼 읽히는 것은 사실 <future>가 async 블록인 “maybe``<future>”라는 점에 유의하라. 문법 개선의 여지가 있을까?
join_me_maybe::join!( foo(), // 첫 번째 foo가 실행되는 동안 주기적인 백그라운드 작업을 한다. // join!은 두 arm을 동시에 실행하지만, maybe 키워드는 // 이 arm이 끝날 때까지 기다리지 않는다는 뜻이다. maybe async { loop { tokio::time::sleep(Duration::from_millis(5)).await; foo().await; } });
다른 “join” 패턴들처럼, 이 join! 매크로는 자신이 폴링하는 future들을 소유하므로, 어떤 것도 스누즈될 위험이 없다. 혹은 더 정확히 말하면, 그것은 소유할 수 있으며, 우리가 일부러 foo future를 pin!하고 참조로 넘길 특별한 이유는 없다. 다만 여전히 그렇게 하는 것이 가능하며, 그렇게 하면 여전히 스누징을 일으킬 수 있다. join_me_maybe::join! 같은 매크로는 소유한 future로 더 많은 것을 표현하게 해 주지만, 참조로 await하는 것을 아예 금지할지 여부는 별개의 문제다. 이에 대해서도 아래에서 더 이야기하겠다. 이 크레이트는 일반적인 사용을 추천하기 전에 실제 피드백이 더 필요하지만, 현재도 원래 “Futurelock” select!과 mini-redis에서 moro를 좌절시켰던 select! 모두를 다룰 수 있다. 이런 식의 더 많은 동시성 패턴을 위한 설계 공간은 넓게 열려 있고, borrow checker 유연성을 더 주기 위한 새 언어 기능의 여지도 있다.
이 메서드는 cancel safe이다.
“Cancel safety”는 아직 공식적으로 정의되어 있지 않지만, 대략적으로 말해 우리는 취소된 호출이 어떤 부작용도 일으키지 않는 것이 보장될 때 그 async 함수가 cancel-safe하다고 말한다. 또한 우리는, 예컨대 타임아웃 루프에서 함수 호출을 반복하다가 결국 타임아웃 안에 성공하는 프로그램과, 동일한 프로그램을 함수 한 번 호출해 결과를 await하는 프로그램 사이에 차이가 있는지 묻고 싶을 수도 있다. 이런 프레이밍은 tokio::sync::Mutex::lock 같은 함수의 “공정성” 속성을 포착하게 해 주는데, 이 함수들이 반환하는 future를 취소하면 “줄에서 자기 자리를 포기하는” 부작용이 생긴다. 데드락은 분명히 부작용이며, 나는 cancel safety의 정의가 확장되어 다른 future를 스누즈하지 않는 것까지 포함해야 한다고 생각한다. 오늘날 futures의 StreamExt::next와 tokio의 StreamExt::next는 이 확장된 의미에서 일반적으로 cancel-safe하지 않다. 우리가 select!와 next로 위에서 데드락을 만들 수 있었던 이유가 그것이다.
위의 다른 두 스트림 데드락, 즉 buffered와 FuturesUnordered를 사용한 데드락은 별개의 문제다. 이 예시들은 next 호출을 취소하지 않는다. 이 부분은 미묘하다. FuturesUnordered 예시는 분명히 next 호출을 취소하지 않는다. 눈으로 확인할 수 있다. 하지만 buffered 예시는 더 저수준에서 동작하며, 내부에서 iter 스트림에 대해 poll_next를 호출한다. 이 특정 경우에 그 호출들은 둘 다 Ready(Some(_))을 반환하므로, 즉시 완료되는 next 호출과 사실상 동일하다. 하지만 poll_next가 Pending을 반환했고 호출자가 그 이후 폴링을 계속하지 않았다면, 그것은 사실상 next 호출을 취소한 것과 같다. 그것이 여기서의 스누징 원인은 아니지만, 그런 것이 원인이 되는 다른 예시도 만들 수 있을 것이다. 대신 이 스트림들은 내부에 pending future를 보관하고, next 호출들 사이에서 다른 무언가가 .await되면 그 future들을 스누즈한다. 명확한 증거는 없지만, 나는 이것이 오늘날 실제 환경에서 데드락을 일으킨다고 내기 걸겠다.
나는 이 문제에 대해 두 가지 가능한 해결책을 보고 있고, 결국 Stream 트레이트 자체가 그중 하나를 선택해야 할 것이다. 혹은 두 가지 모두를 택할 수도 있는데, 두 개의 서로 다른 Stream 유사 트레이트를 정의하는 방식으로 말이다. 하지만 gen/yield 문법을 안정화할 때에는 결국 하나를 선택해야 한다. 첫 번째 가능성은 next를 유지하고, 호출 사이의 간격이 예상되며 허용된다고 선언하는 것이다. cancel safety 문제를 해결하기 위해, next가 스트림 self를 값으로 받아 완료 시 다음 값(옵션)과 함께 튜플로 스트림을 반환하도록 할 수도 있다. 그러면 next future를 취소하면 스트림을 스누즈하는 대신 스트림 전체가 drop될 것이다. 동작은 하지만 어색해 보이고, 좋아할 사람이 있을지 모르겠다. (또한 일반적으로 Pin<Box<_>> 같은 것이 필요할 것이다.) 또는 Rust가 취소될 수 없는 future를 정의할 수 있게 하고, next가 그런 것 중 하나가 될 수도 있다. 어쨌든 buffered와 FuturesUnordered에서의 스누징 문제는 이 cancel safety 질문과는 독립적이다. 그 경우 buffered와 FuturesUnordered는 고칠 수 없게 되고, 폐기(deprecate)해야 한다. 다른 선택지는 poll_progress 메서드를 Stream 트레이트에 추가하고, poll_next를 호출하는 모든 것이 poll_progress가 Ready를 반환할 때까지 그것도 호출해야 한다고 선언하는 것이다. 대부분의 스트림 콤비네이터는 그 새 규칙을 따르도록 적응할 수 있겠지만, next는 고칠 수 없게 되어 폐기해야 한다.
Rust의 약속은 이런 종류의 비지역적(non-local) 추론을 할 필요가 없다는 것이다 — 어떤 동작 근처의 코드를 직접 보며 중요한 행동을 이해하고, 그다음 타입 시스템을 통해 그것을 전역적인 정합성으로 확장할 수 있다는 것.
위의 제안들이 마음에 들더라도, 여기서의 일반 규칙은 무엇일까? 고수준 애플리케이션 코드에서는 Clippy 같은 도구가 자동으로 검사할 수 있는 것이 필요하다. 나는 다음을 제안한다.
async 함수에서 어떤 것도 pin하지 마라. pinning은 안전한 연산이며 non-async 헬퍼 안에 숨어 있을 수 있으므로, 실제로는 “async 함수에서 Pin<_> 값을 다루지 마라”로 확장하는 것이 좋을 것이다.
pinning 자체가 잘못된 것은 아니다. 그것은 async Rust의 근본적인 빌딩 블록이며, Future나 Stream을 “손으로” 구현할 때 필요하다. 반면 pinning은 async Rust에서 가장 헷갈리는 부분 중 하나이고, 오늘날 우리는 여전히 초보자에게 이것을 가르쳐야 한다. Future 트레이트를 배우기 전까지는 pinning을 보지 않아도 되게 만들 수 있다면 훌륭할 것이다. 그런데 async``fn에서 pinning을 해야 하는 경우는 보통, 어떤 것이 자신이 소유하지 않는 future를 폴링하고 있기 때문이다.
이 패턴의 흥미로운 예외 하나가 있는데, 그럼에도 규칙을 잘 적용한 사례다. 그것은 futures::future::select 함수(매크로가 아님)다. 이 함수는 자신이 폴링하는 future들을 소유하지만, “패자”를 drop하지 않고 호출자에게 되돌려주기 때문에 Unpin을 요구한다. 이는 참조로 폴링하는 것과 동일하게 같은 스누징 데드락을 일으킬 수 있다.
참조로 pinning 없이 폴링할 수 있는 Unpin future도 많이 존재하며, 원칙적으로는 그런 것 하나를 스누즈해도 await 지점 너머로 락을 들고 있을 수 있다. 실제로는 가능성이 낮은데, 대부분의 “흥미로운 일”이 async 함수에서 일어나고, 그런 future는 항상 !Unpin이기 때문이다. 나는 현실 세계 사례를 알지 못하지만, 그 허점을 사전에 닫고 싶다면 다음의 추가 규칙을 고려할 수 있다.
future에 대한 참조를 그 자체로 future로 사용하지 마라. 구체적으로는 impl``Future``for``&mut``F 또는 impl``Future``for``Pin<P> 사용에 경고를 내라.
이런 규칙들이 고수준 코드에서 스누징 실수를 잡아내기에 충분할지도 모르지만, 우리는 여전히 헬퍼와 콤비네이터가 내부에서 future를 스누즈하지 않는다고 가정해야 한다. 오늘날 buffered 스트림은 그 가정을 깨고 있고, 이를 고치려면 호환성 깨짐(incompatible changes)이 필요할 거라고 생각한다.
일반적으로 Future나 Stream 구현이 스누즈 프리(snooze-free)임을 증명하는 간단하고 기계적인 규칙은 아마 존재하지 않을 것이다. 우리는 그것들을 작성할 때 조심해야 한다. 하지만 나는 그 정도는 감수할 수 있다고 본다. poll과 poll_next 함수를 작성하는 것은 async Rust의 “고급 모드”다. 애플리케이션 로직에서는 자주 필요하지 않고, 초보자에게 가르칠 필요도 없다. 코드 리뷰에서 이런 저수준 부분을 볼 때, 우리는 그저 최선을 다해 다음을 기억하면 된다.
미래를 절대 스누즈하지 마라.