async Rust에서 다중 태스크 동시성과 태스크 내 동시성의 차이를 배경으로, FuturesUnordered를 이용한 두 가지 패턴(버퍼링된 스트림, 스코프드 태스크)의 동작과 백프레셔, 순서화로 인한 교착상태의 위험을 분석하고, 소유권·빌림을 활용한 안전한 동시성 설계에 대해 논한다.
FuturesUnordered와 future의 순서
이전 글에서(https://without.boats/blog/let-futures-be-futures) async Rust의 “다중 태스크”와 “태스크 내” 동시성의 구분에 대해 썼다. 이 글은 사용자가 흔히 마주치는 한 패턴을 출발점으로 삼아, 각 기술을 사용해 그것을 어떻게 구현할 수 있는지 살펴본다.
이걸 “서브태스킹(sub-tasking)”이라 부르자. 수행해야 할 작업 단위가 있고, 그 단위를 더 작은 여러 작업 단위로 나누어 각각을 동시에 실행하고 싶다. 의도적으로 매우 추상화된 설명이다. 의미 있는 거의 모든 프로그램은 최소 한 번(종종 여러 번) 이 패턴의 인스턴스를 포함하며, 최선의 해법은 수행되는 작업의 종류, 작업량, 동시성의 차수(arity) 등에 따라 달라진다.
다중 태스크 동시성을 사용하면, 더 작은 각 작업이 그 자체로 태스크가 된다. 사용자는 이 태스크들을 실행기(executor)에 스폰(spawn)한다. 태스크의 결과는 채널 같은 동기화 프리미티브로 모으거나, JoinSet으로 함께 await 한다.
태스크 내 동시성을 사용하면, 작은 각 단위를 하나의 태스크 안에서 동시에 실행되는 future로 만든다. 사용자는 모든 future를 구성한 후, 접근 패턴에 따라 join!이나 select! 같은 동시성 프리미티브로 이들을 하나의 future로 결합한다.
각 접근법마다 장단이 있다. 여러 태스크를 스폰하면 각 태스크가 'static이어야 하므로, 부모 태스크로부터 데이터를 빌릴 수 없다. 이는 종종 매우 성가신 제약이다. 공유 소유권(즉 Arc 그리고 경우에 따라 Mutex)을 쓰는 비용 문제가 있을 수 있을 뿐만 아니라, 설령 그 맥락에서 공유 소유권의 사용이 문제없다 해도, Rust의 설계상 빌림에 비해 훨씬 더 성가시게 느껴지기 때문이다. (이 부분이 바뀌면 정말 좋겠다! Arc와 Rc 같은 값싼 공유 소유권 구조는 비-어파인(non-affine) 의미론을 가져서 매번 clone을 호출하지 않아도 되도록 했으면 한다.)
여러 future를 join하면, 이들은 동일한 태스크 내의 외부 상태를 빌릴 수 있다. 하지만 이전 글에서 썼듯, join할 수 있는 future의 수는 정적으로만 정할 수 있다. 공유 소유권을 피하고 싶으면서도 실행해야 하는 하위 태스크의 수가 동적인 사용자는 다른 해법을 찾게 된다. 여기서 FuturesUnordered가 등장한다.
FuturesUnordered는 futures 라이브러리의 독특한 추상화로, future들의 집합을 Stream(표준 용어로는 AsyncIterator)으로 표현한다. 표면적으로는 tokio의 JoinSet과 꽤 비슷하지만, JoinSet과 달리 여기에 넣은 future들은 실행기에 따로 스폰되는 것이 아니라, FuturesUnordered가 폴링될 때 함께 폴링된다. 태스크를 스폰하는 것과 마찬가지로, FuturesUnordered에 넣는 각 future는 개별적으로 할당되므로, 표현 관점에서는 다중 태스크 동시성과 매우 비슷하다. 하지만 각 future를 폴링하는 주체가 FuturesUnordered이므로 이들은 독립적으로 실행되지 않고, 'static일 필요도 없다. FuturesUnordered가 그 주변 상태보다 오래 살지 않는 한, 주변 상태를 빌릴 수 있다.
어떤 의미에서 FuturesUnordered는 태스크 내 동시성과 다중 태스크 동시성의 하이브리드다. 태스크 내처럼 같은 태스크의 상태를 빌릴 수 있지만, 다중 태스크처럼 임의 개수의 future를 동시에 실행할 수 있다. 따라서 위에서 말한, 바로 그 조합의 특성이 필요한 경우에 잘 들어맞는 자연스러운 선택처럼 보인다. 하지만 FuturesUnordered는 async Rust를 작성하는 과정에서 사용자들이 겪어 온 더 성가신 버그들의 주범이기도 했다. 이 글의 나머지에서는 그 이유를 살펴보려 한다.
FuturesUnorderedFuturesUnordered로 인해 생길 수 있는 버그는 그것을 어떻게 사용하느냐에 달려 있다.
FuturesUnordered를 사용하는 가장 단순한 방법은 컬렉션을 future들로 채운 다음, 모든 결과를 모아 한꺼번에 처리하는 것이다. 보통 이 사용법은 문제가 없다. 그러나 많은 경우, 모든 future를 기다리는 동안 두 가지 중 하나를 하고 싶을 때가 있다. 하나는 FuturesUnordered에 더 많은 작업을 밀어 넣고 싶은 경우이고, 다른 하나는 모든 작업이 끝날 때까지 기다리는 대신 결과가 도착하는 대로 처리하고 싶은 경우다.
이 추가적인 편의를 FuturesUnordered 위에 얹어 주는 두 가지 주요 패턴이 있다.
“버퍼링된 스트림(buffered stream)” 패턴: 해야 할 작업을 future의 스트림으로 표현하고, StreamExt의 buffered 어댑터 같은 것으로 버퍼링한다. 더 많은 작업이 필요할 때, 그리고 공간이 생길 때 더 많은 작업이 FuturesUnordered에 들어가며, 각 결과는 준비되는 대로 처리할 수 있다.
“스코프드 태스크(scoped task)” 패턴: 해야 할 작업을 어떤 핸들을 사용해 FuturesUnordered에 “스폰”된 태스크로 표현한다. 이 종류의 API 예로 Niko Matsakis의 moro 라이브러리가 있다. 스폰된 어떤 태스크에서도 자유롭게 더 많은 태스크를 스폰할 수 있다. 이러한 태스크의 결과를 처리하려면, 그 join 핸들을 await 하거나 동기화 프리미티브로 다른 태스크에 데이터를 전달한다.
두 접근법에는 여러 차이가 있다. 그중 하나는 각 패턴이 백프레셔를 다루는 방식이다. 시스템 구성 요소 설계에서, 다른 구성 요소가 이 구성 요소가 처리할 수 있는 것보다 더 많은 작업을 만들어 낼 때 이를 제한하는 백프레셔가 중요하다. 그렇지 않으면 작업물이 계속 쌓여 이 구성 요소가 도저히 따라잡지 못하게 되거나, 과부하로 다운된다.
버퍼링된 스트림 API는 한 번에 버퍼링할 future의 수를 제한하도록 유도함으로써 백프레셔를 가능하게 한다. 동시 실행 중인 future 수가 최대치에 도달하면, future를 생성하는 하위 스트림은 더 이상 폴링되지 않으므로(압력이 거꾸로 가해지므로) 생산 측에 제약이 걸린다. 반면, moro가 구현한 스코프드 태스크 패턴에는 백프레셔를 적용하기 위한 장치가 없다. 사용자는 원하는 만큼 태스크를 스폰할 수 있다. 스폰 자체가 비동기이고, 실제로 태스크를 스폰할 수 있을 때에만 ready를 반환하는 식으로, 이 방식에 백프레셔를 적용하는 변형을 상상할 수는 있다. 하지만 그런 라이브러리는 알지 못한다. 스폰과 유사한 API를 사용할 때 사용자는 채널 길이를 제한하는 등 다른 메커니즘으로 백프레셔를 도입할 것으로 기대된다.
또 하나의 차이는 동시 하위 태스크들의 결과를 어떻게 처리하는지다. 버퍼링된 스트림은 future의 스트림을 그 future들의 출력 스트림으로 변환한다. 즉, 루프를 돌며 처리한다. 반면 스코프드 태스크는 하위 태스크들의 결과를 처리하는 방식에 특정한 구조를 부여하지 않는다. 사용자가 각 태스크의 JoinHandle을 await 하는 방식으로 하위 태스크들을 “연결”해야 한다.
그 결과, 버퍼링된 스트림 접근법은 특정한 종류의 순차적 사건 흐름을 강제한다. 버퍼링된 Buffered 스트림에 더 많은 작업을 위한 공간이 있을 때만, 그리고 그 버퍼링된 스트림을 처리하는 루프가 다음 결과를 처리할 준비가 되었을 때만, 기저 스트림은 폴링된다. poll_progress API 변경의 요점은 Buffered 스트림을 그 루프와 동시에 실행할 수 있게 하는 것이다. 이것은 두 번째 순서화 지점을 제거할 것이다. 하지만 첫 번째 순서화 지점은 없앨 수 없다. 그 지점의 목적 자체가 백프레셔를 적용하는 것이기 때문이다. poll_progress 변경이 없을 때, 버퍼링된 스트림의 순서화는 다음과 같이 보인다:
┌ WAITS FOR SPACE IN ───┐ ┌ WAITS TO BE PROCESSED BY ─┐
╔═══════════════╗ ╔════▼═════════╗ ╔══════════▼════╗
║ ║▐▌ ║ ║▐▌ ║ ║▐▌
║ STREAM OF ║▐▌ ║ BUFFERED ║▐▌ ║ FOR AWAIT ║▐▌
║ FUTURES ║▐▌ ║ STREAM ║▐▌ ║ LOOP ║▐▌
║ ║▐▌ ║ ║▐▌ ║ ║▐▌
╚════════▲══════╝▐▌ ╚═════════▲════╝▐▌ ╚═══════════════╝▐▌
▀▀▀▀▀▀▀▀│▀▀▀▀▀▀▀▀▘ ▀▀▀▀│▀▀▀▀│▀▀▀▀▀▀▘ ▀▀▀▀▀▀▀▀▀▀│▀▀▀▀▀▀▘
└─ BUFFERS FUTURES FROM ┘ └─── PROCESSES RESULTS FROM ┘
과정의 각 단계는 서로 교차하지만, 전적으로 동시 실행되지는 않는다. 한 단계가 실행되는 동안, 설령 더 이상 할 일이 없더라도 다른 단계는 진행할 수 없다.
반면 스코프드 태스크 접근법은 그 자체로는 어떤 순서화 지점도 도입하지 않는다. “스코프드 태스크”로 스폰된 모든 future는 동시에 실행된다. 사용자는 다른 태스크들의 결과를 await 하는 방식으로 스스로 순서를 부여해야 한다. 예를 들어, 스코프드 태스크 패턴을 사용하는 사용자가 두 개의 하위 태스크를 스폰해 조인하고, 그중 하나가 또 다른 하위 태스크를 스폰해 그것을 await 한다고 하자. 그러면 다음과 같이 보일 것이다:
╔═══════════════════════════════════╗
║ SCOPED TASK SET ║▐▌
║ ║▐▌
║ ╔════════╗ ║▐▌
║ ║ TASK ║▐▌ ║▐▌
║ ╚════════╝▐▌ ║▐▌
║ ▀▀▀│▀▀▀▀▀▀▘ ║▐▌
║ ┌─── JOINING ───┐ ║▐▌
║ │ │ ║▐▌
║ ╔═══▼════╗ ╔══▼═════╗ ║▐▌
║ ║ TASK ║▐▌ ║ TASK ║▐▌ ║▐▌
║ ╚════════╝▐▌ ╚════════╝▐▌ ║▐▌
║ ▀▀▀▀│▀▀▀▀▀▘ ▀▀▀▀▀▀▀▀▀▀▘ ║▐▌
║ AWAITING ║▐▌
║ │ ║▐▌
║ ╔═══▼════╗ ║▐▌
║ ║ TASK ║▐▌ ║▐▌
║ ╚════════╝▐▌ ║▐▌
║ ▀▀▀▀▀▀▀▀▀▀▘ ║▐▌
║ ║▐▌
╚═══════════════════════════════════╝▐▌
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▘
스코프드 태스크 집합 내부의 태스크들은 기본적으로 독립적으로 진행할 수 있다. 순서는 사용자가 async Rust의 다른 부분에 사용하는 것과 동일한 동시성 프리미티브로 서로의 관계를 어떻게 맺느냐에 의해 부여된다. 다른 사용 패턴이라면 태스크들 사이에 전혀 다른 관계가 생길 것이다.
일부 사용자는 버퍼링된 스트림을 사용할 때 기대보다 동시성이 낮다고 보고한다. 이들은 각 단계를 동시에 일어나는 것으로 개념화하는데, 실제로는 그렇지 않다는 순서화를 깨닫지 못하기 때문이다. 이 순서화로 인해 발생할 수 있는 최악의 문제는 데드락(교착상태)이다. 태스크 내의 어떤 future도 서로를 기다리느라 진전하지 못하는 상황이다.
교착상태는 네 가지 조건이 모두 만족될 때만 발생한다. 이는 1971년에 교착상태에 관한 결정적 논문을 쓴 Edward G. Coffman의 이름을 따 “Coffman 조건”으로 알려져 있다. 원 논문은 내가 표현하는 것보다 훨씬 간결하게 요구사항을 서술한다(강조는 인용자):
- Tasks claim exclusive control of the resources they require (“mutual exclusion” condition).
- Tasks hold resources already allocated to them while waiting for additional resources (“wait for” condition).
- Resources cannot be forcibly removed from the tasks holding them until the resources are used to completion (“no preemption” condition).
- A circular chain of tasks exists, such that each task holds one or more resources that are being requested by the next task in the chain (“circular wait” condition).
이 주제를 더 탐구하기 위해, 버퍼링된 스트림이나 FuturesUnordered를 전혀 쓰지 않는 훨씬 단순한 비동기 이터레이터 예제로 교착상태를 보여 주고자 한다. 이터레이터 정의에는 편의를 위해 async 제너레이터 문법을 쓰겠지만, 교착상태 자체와는 무관하다.
하나의 작업 단위(비동기 제너레이터)가 공유 자원에 대한 락을 잡은 상태에서 x와 y 두 값을 yield 한다고 하자. 다른 작업 단위(for await 루프)는 그 값들을 소비해 출력하는데, 각 값을 처리할 때마다 역시 그 락을 잡는다. 이 의사 코드에서 락을 잡는 행위 자체는 필요 없지만, 생산과 소비 알고리즘이 모두 Mutex로 보호되는 자원에 대한 배타적 접근을 필요로 하는 더 복잡한 예를 떠올려 보자.
// yield two values with an async generator:
let iter = async gen {
let mut guard = mutex.lock().await;
yield x;
yield y;
drop(guard);
};
// consume two values with a for await loop:
for await elem in iter {
let mut guard = mutex.lock().await;
println!("{}", elem);
drop(guard);
}
이 코드는 교착상태를 일으킨다. 먼저 제너레이터가 mutex의 락을 잡고, 그 락을 쥔 채 첫 번째 값을 yield 한다. 그러면 for 루프는 그 값을 처리하는 동안 락을 잡으려 하지만, 제너레이터가 그것을 놓기 전에는 잡을 수 없다. 그러나 제너레이터는 두 번째 값을 yield 할 때까지 락을 놓지 않는다. 그런데 for 루프는 첫 번째 값을 처리하기 전에는 두 번째 값을 처리할 준비가 되지 않는다. 그리고 poll_progress도 소용없다. 제너레이터가 진전할 수 있는 방법은 두 번째 값을 yield 하는 것뿐인데, 이는 for 루프가 첫 번째 값을 다 소비할 때까지 불가능하기 때문이다.
이는 이터레이터가 for 루프를 기다리고, for 루프는 이터레이터를 기다리는 고전적인 “순환 대기(circular wait)” 시나리오다. 이 시나리오에서 락은 하나뿐이라는 점이 흥미로울 수 있다. 그렇다면 이 교착상태 시나리오에서 두 번째 공유 자원은 무엇인가? 이터레이터는 Mutex를 쥐고 있는데, for 루프는 무엇을 쥐고 있는가?
두 번째 자원은 코드 실행 자체에 대한 제어권이다. 단일 태스크에서는 어느 한 시점에 태스크의 한 부분만 실행될 수 있다. 그 제어권 자체가, 태스크의 서로 다른 하위 단위가 진전하려면 배타적으로 접근해야 하는 묵시적 공유 자원이다. 우리 예제에서, 이터레이터는 Mutex의 락을 쥐고 있고, for 루프는 태스크의 제어권을 쥐고 있다.
┌ WAITING TO BE POLLED ───┐
╔════════════════╗ ╔══════════▼═════╗
║ ║▐▌ ║ ║▐▌
║ ASYNC ITERATOR ║▐▌ ║ FOR AWAIT LOOP ║▐▌
║ (holds lock) ║▐▌ ║ (holds task) ║▐▌
║ ║▐▌ ║ ║▐▌
╚══════▲═════════╝▐▌ ╚════════════════╝▐▌
▀▀▀▀▀▀│▀▀▀▀▀▀▀▀▀▀▀▘ ▀▀▀▀▀│▀▀▀▀▀▀▀▀▀▀▀▀▘
└── WAITING TO TAKE LOCK ┘
계속하기에 앞서 한 가지 주의할 점: 이 문제는 async Rust에만 특유한 게 아니라, 모든 명령형 절차적 언어에 내재한다. 같은 코드를 블로킹 Mutex로 바꾸고 async/await 키워드를 모두 제거해도 같은 동작을 보게 된다. 이터레이터가 락을 잡고 첫 아이템을 내놓은 다음, for 루프는 그 아이템을 처리하려고 락을 잡는 데서 블록되고, 시스템은 교착상태에 빠진다. 시간을 나타내는 단위를 future로 표현하든 스레드로 표현하든, 코드 실행이 “한 번에 한 가지씩” 순차적으로 진행되는 한, 이 알고리즘은 교착상태에 빠진다.
이 교착상태를 막는 유일한 방법은 관련된 두 자원 중 하나에 대한 배타적 접근을 선점(preempt)하는 것이다. 즉 Mutex를 선점하거나, 계산 제어권을 쥔 코드를 선점해야 한다. 두 번째는 두 구성 요소 사이에 순차적 순서를 명시한 명령형 언어에서는 불가능하다. 이 문제를 피하고자 순서 제약이 덜 엄격한(예: Haskell 같은) 언어에 대한 연구가 진행되기도 했다. 순서가 엄격한 언어에서는, 유일한 해법이 두 연산 사이의 순서가 중요하지 않음을 명시하는 것, 다시 말해 둘이 동시적임을 선언하는 것이다. Async Rust는 이를 위한 여러 수단을 제공하지만, 위 코드는 그것을 활용하지 않는다.
한편, “재진입 가능한(re-entrant)” 락을 사용해 Mutex를 선점하는 것도 가능하다. 재진입 가능한 락에서는(동일 스레드 또는 동일 태스크이기 때문에) 이터레이터가 락을 쥔 상태에서도 for 루프가 락에 접근할 수 있다. 루프가 계산 순서를 이미 제어하고 있기 때문에 그 자체가 순서를 보장하고, 이 덕분에 데이터 레이스가 발생하지 않는다. 다시 말해, 재진입 가능한 Mutex는 기반의 순차적 프로그래밍 모델이 부여한 순서화를 등에 업어 교착상태를 깨뜨린다. 그러나 이 맥락에서 재진입 가능한 Mutex를 사용하는 것은 위험하다. 프로그램 로직이 이터레이터의 연속된 yield 사이에 공유 자원의 상태가 변하지 않는다는 보장에 의존한다면, 재진입 가능한 Mutex 사용은 교착상태를 미묘한 논리 버그(잠재적으로 치명적 결과를 낳을 수 있는)로 “승격”시킬 수 있다. 즉, 대개는 해법이 되지 않는다.
위 코드 샘플은 FuturesUnordered를 포함하지 않았지만, 버퍼링된 스트림 패턴을 사용할 때 발생하는 교착상태 및 성능 문제는 같은 역학에서 비롯된다. poll_progress 없이, 버퍼링되는 future가 락을 잡고 그다음 for 루프가 같은 락을 잡으려 하면 전체가 교착상태에 빠진다. poll_progress는 그 순서화 지점을 없애 주지만, 백프레셔를 적용하기 위해 쓰이는 다른 순서화 지점은 남아 있다. 즉, 기저 future 스트림이 락을 잡은 뒤, 버퍼링된 future들도(또는 for 루프가) 그 락을 잡으려 하면 유사한 교착상태가 발생한다.
한 사용자는 2022년에 자신의 프로그램에서 바로 그런 버그를 보고했다. 이 사용자는 FuturesUnordered의 시그니처를 바꿔, 독립적으로 실행되는 태스크의 조인 핸들만 await할 수 있게 하자고 제안했다. 나는 그 접근이 권할 만하다고 보지 않는다. 그렇게 하면 JoinSet과 의미론적으로 동등하면서 구현은 덜 최적화된 도구가 되기 때문이다. FuturesUnordered를 쓰는 이유는, 주변 스코프에서 빌리는 동적인 수의 future를 동시에 실행하기 위해서다. 기능적으로 동등한 권고는 FuturesUnordered를 아예 피하라는 것인데, 그것이 교착상태를 예방하기 위해 필수적이거나 충분하다고 생각하지 않는다.
이번에는 버퍼링된 스트림 패턴 대신 스코프드 태스크 패턴을 사용해 다른 교착상태를 생각해 보자. 이 교착상태는 실행기 위의 일반 태스크로도 동일하게 만들 수 있다. FuturesUnordered의 의미론에 특이한 점에 의존하지 않는다. 우리는 본질적으로 같은 알고리즘을 구현하고, 사실상 같은 이유로 교착상태에 빠질 것이다.
이 코드는 채널을 통해 통신하는 두 개의 하위 태스크를 스폰한다. 첫 번째 하위 태스크는 락을 쥔 채 x와 y를 채널로 보낸다. 두 번째는 채널에서 모든 값을 받으며, 매번 락을 잡아 그것을 출력한다. 요점을 분명히 하기 위해, 채널은 길이가 0인 유한 채널로 하자. 이는 std의 sync_channel API처럼, 반대편에서 실제로 수신될 때까지 전송이 완료되지 않음을 의미한다. (tokio 채널은 취소와 관련된 위험 때문에 이 동작을 지원하지 않는다.)
let (tx, rx) = channel(0);
// send two values through a channel:
scope.spawn(async {
let mut guard = mutex.lock().await;
tx.send(x).await;
tx.send(y).await;
drop(guard);
});
// process all values sent through the channel:
scope.spawn(async {
for await elem in rx {
let mut guard = mutex.lock().await;
println!("{}", elem);
drop(guard);
}
});
여기서도 정확히 같은 교착상태가 다시 나타난다. 채널에, 다른 태스크가 그것을 처리하기를 기다리는 동안 보낼 데이터를 담아 둘 공간이 충분하지 않기 때문이다. 같은 순환 대기 패턴인데, 태스크의 제어권이 채널의 공간으로 바뀌었을 뿐이다:
┌ WAITING TO SEND DATA ───┐
╔════════════════╗ ╔══════════▼══════╗
║ ║▐▌ ║ ║▐▌
║ FIRST SUB-TASK ║▐▌ ║ SECOND SUB-TASK ║▐▌
║ (holds lock) ║▐▌ ║ (holds rx) ║▐▌
║ ║▐▌ ║ ║▐▌
╚══════▲═════════╝▐▌ ╚═════════════════╝▐▌
▀▀▀▀▀▀│▀▀▀▀▀▀▀▀▀▀▀▘ ▀▀▀▀▀│▀▀▀▀▀▀▀▀▀▀▀▀▀▘
└── WAITING TO TAKE LOCK ┘
여기서 두 가지 결론을 도출할 수 있다.
첫 번째이자 더 매력적인 결론은, 락과 채널을 사용한 순환 의존성은 더 찾기 쉽다는 것이다. 사용자는 동기화 프리미티브를 사용할 때 자원을 공유하고 있음을 “안다.” 따라서(스코프드 태스크든 독립 태스크든) 태스크를 스폰하거나, 미묘한 순서화 지점을 도입하지 않는 태스크 내 동시성 프리미티브를 쓰는 편이 교착상태 가능성이 낮다. 코드를 더 쉽게 분석할 수 있기 때문이다. 이는 동시 단위들 사이에 묵시적 공유 자원이 존재하지 않고, 모든 의존성이 동기화 프리미티브의 사용을 통해 더 명백해지기 때문이다.
다시 말해, 코드가 아니라 데이터를 버퍼링하라(buffer data, not code). 실무적으로는 버퍼링된 스트림 패턴은 위험하고 혼동을 주기 쉬운 관행으로 보고, 스코프드 태스크 패턴을 선호하자는 권고가 된다. 이는 사용자에게 교착상태 위험을 줄이기 위한 간단명료한 규칙을 제공한다는 점에서 매력적이다.
덜 유쾌하지만 진지하게 받아들여야 할 대안적 결론도 있다. 궁극적으로 두 경우 모두 교착상태는 버퍼링할 수 있는 단위 수에 대한 한계에서 비롯된다는 점을 생각해 보자. 사용자는 버퍼링된 스트림에서 동시 future의 수를 제한하거나, 채널의 객체 수를 제한한다. 이는 시스템에서 백프레셔를 달성하기 위해 필요하다. 그러나 순환 의존성이 있고 그 순환의 양방향이 모두 유한하게 제한되어 있다면, 그 사이의 모든 큐가 꽉 차는 순간 교착상태가 발생한다.
큐가 절대 꽉 찰 수 없다면 교착상태를 막을 수 있다. 예를 들어 위 코드에서 채널 길이를 0보다 크게 설정하기만 해도 교착상태를 막을 수 있다. 하지만 보낸 쪽이 큐에 넣을 수 있는 아이템의 양이 큐의 상한보다 크고 순환 의존성이 존재한다면, 큐 크기가 얼마이든 교착상태를 유발할 수 있다. 이것은 빈도의 문제로 바뀐다. 큐가 가득 차는 일이 매우 드물면, 교착상태는 매우 불가능해 보일지라도 불가능하지는 않다.
내 경험상, 사용자는 채널의 상한을 궁극적으로는 임의이지만 비교적 큰 숫자로 설정하는 경우가 많다. 수백에서 수천 초반의 값이 흔한데, 그 이유는 대개 그리 타당하지 않다. 반면, 버퍼링된 스트림의 동시성 제한은 훨씬 낮게(한 자리 수 등) 설정되는 경우가 많다. 즉, 채널 사용 방식이 교착상태를 덜 “그럴듯하게(probable)” 만들 뿐, 덜 “가능하게(possible)” 만드는 것은 아닐 수 있다. 그래서 테스트에서는 만나지 않지만, 실제로는 코드 어딘가에 숨어 있다가 프로덕션에서 나타날 수 있다. 혹시 버퍼링된 스트림과 채널의 차이는, 사용자가 일반적으로 선택하는 큐 크기뿐인 것은 아닐까?
어떤 사용 사례에서는, 발생 확률이 매우 낮은 교착상태는 받아들일 만하다. 아주 드물게 발생하는 교착상태로 인한 서비스 저하와 데이터 손실이 시스템에 감내 가능한 수준일 수 있다. 그렇지 않다면, 큐를 임의로 크게 잡는 방법은 잠재적 교착상태를 숨길 수 있다. 특히 시스템 구성 요소 간의 흐름이, 애플리케이션의 규모와 복잡성 때문에 프로그래머에게 명확하지 않을 때 그렇다. 이를 완화하는 한 가지 방법은, 프로덕션에서는 성능 향상을 위해 버퍼링된 채널을 사용하되, 테스트 시에는 무버퍼(길이 0) 채널을 사용해(성능은 떨어져도) 시스템이 계속 진전함을 보장하는 것이다.
중요한 사실 하나: 위와 같은 교착상태는 사용자가 동기화 프리미티브를 사용할 때만 가능하다. 락이나 채널 같은 것을 동반하지 않은 버퍼링된 스트림만으로는 순환 대기 조건을 만들 수 없다. 소유권과 빌림이 “대기” 조건을 막기 때문이다. 상호 배타성은 정적으로 결정되므로, 코드가 공유 자원에 대한 배타적 접근을 기다릴 일이 없다. 코드가 기다릴 수 있는 유일한 “자원”은 현재 태스크의 제어권뿐이며, 여러 자원이 없으면 “순환 대기”에 빠질 수 없다. 교착상태 대신, 빌림 검사기(borrow checker) 에러를 얻게 될 것이다.
이는 사용자가 동기화 프리미티브를 전적으로 피해야 한다는 뜻은 아니다. 다만 그것들이 추가적인 위험을 동반함을 알아야 한다는 뜻이다. 이러한 프리미티브를 사용할 때, 사용자는 잠재적 교착상태를 인지하기 위해 자신의 동기화 의존성 그래프의 형태를 이해해야 한다. 또한 제어 흐름 자체도 공유 자원이며, 단일 태스크의 비동시 구성 요소에서 동기화 프리미티브를 여러 번 사용하는 상황에도 주의를 기울여야 한다. 버퍼링된 스트림이 그 비동시적 제어 흐름을 너무 불투명하게 만든다면, 그 패턴은 지양되어야 한다.
사용자가 종종 동기화와 공유 소유권을 피하게 해 주는 것, 따라서 잠재적 교착상태를 피하게 해 주는 것이, 사용자가 FuturesUnordered를 찾는 전체 이유다. Rust의 타입 시스템이 이를 가능하게 한다는 사실은 Rust의 가장 흥미롭고 중요한 점 중 하나다. 궁극적으로, 이런 방식으로 소유권과 빌림을 활용하는 패턴은 Rust의 강점을 살리므로 더욱 적극적으로 채택되어야 한다. 이상적으로는, FuturesUnordered 같은 별도 추상화를 요구하지 않고, 사용하는 런타임에 스코프드 태스크 패턴이 통합되어야 한다. 즉, JoinSet이 'static이 아닌, 주변 컨텍스트에서 빌릴 수 있는 태스크를 스폰할 수 있어야 한다. 이를 달성하려면 스코프드 태스크 트릴레마의 해결이 필요하다.