Pin API는 원래 저수준 구현자만 다루도록 설계되었지만, 실제로는 고수준 코드에도 종종 스며듭니다. 이 글은 그 현상이 나타나는 세 가지 경우(루프에서의 선택, Stream::next 호출, 포인터 뒤의 Future 대기)를 짚고, AsyncIterator 기반의 merge! 매크로, for await 구문, first 같은 조합기 등으로 대부분을 완화하는 방안을 제안합니다. 아울러 Box가 항상 Unpin인 설계 선택이 불러온 근본적 제약도 지적합니다.
Pin API를 설계할 때 우리의 비전은 “보통 사용자” — 즉 러스트의 “고수준” 레지스터를 사용하는 사용자 — 는 이 API를 마주칠 일이 없도록 하자는 것이었습니다. 오직 저수준 레지스터에서 핸드메이드 Future를 구현하는 사용자만이 그 추가적인 복잡성을 다루게 하려 했죠. 그렇게 해서 모든 사용자에게 돌아갈 이점은, 폴링 중에는 이동 불가능한 futures가 자신의 상태에 자기참조를 저장할 수 있게 된다는 것이었습니다.
일은 계획대로만 흘러가진 않았습니다. Pin의 이점이 누적된 것은 사실입니다 — 우리는 항상 자기참조적인 async 함수를 쓰고 있고, 주요 런타임의 저수준 동시성 프리미티브는 내부적으로 침투형(침습적) 연결 리스트를 구현하기 위해 Pin을 활용합니다. 하지만 Pin은 때때로 “고수준” 코드에도 불쑥 고개를 들고, 그때 사용자가 답답함과 혼란을 느끼는 것은 놀랍지 않습니다.
제 경험상, 이런 일이 일어나는 경로는 크게 세 가지입니다. 그중 두 가지는 AsyncIterator에 대한 더 나은 편의 제공으로 해결할 수 있습니다(제가 이걸 그토록 강하게 안정화하자고 밀고 있는 이유 중 하나!). 세 번째는 결국 우리가 Pin을 설계할 때 저지른 실수 때문이며, 하위 호환을 깨는 변경 없이는 어쩔 수 없습니다. 그것들은 다음과 같습니다.
Future를 선택하기.Stream::next 호출하기.Future를 await 하기.제가 poll_next에 관한 글에서 썼듯이, 오늘날 문제가 되는 패턴 중 하나가 루프 안에서 선택(select)하는 것입니다:
loop {
select! {
result = async_function1() => // ...
result = async_function2() => // ...
}
}
문제는 이 코드가 각 분기마다 future를 새로 구성하고, 그중 하나가 끝날 때까지 기다린 다음, 반복이 끝나면 나머지 분기는 취소해 버린다는 데 있습니다. 다음 반복에서 모든 future가 다시 생성되고, 또다시 취소됩니다. 만약 future를 취소하는 것이 의미 있는 동작이라면, 이것은 사용자가 원한 바와 다를 가능성이 큽니다.
현재 올바른 구현 방식은 async 함수를 루프 바깥으로 끌어내고, pin 고정하고, fuse하는 것입니다. 예를 들면:
let future1 = pin!(async_function1().fuse());
let future2 = pin!(async_function2().fuse());
loop {
select! {
result = &mut future1 => // ...
result = &mut future2 => // ...
}
}
pin이 필요한 이유는 루프 안에서 참조로 폴링하기 때문입니다. pin하지 않으면, 루프에서 폴링한 뒤 그 값을 이동시키고 다른 곳에서 다시 폴링할 수 있습니다. 이는 그 상태에 저장된 어떤 자기참조도 무효화할 수 있습니다.
이 문제의 해법은 AsyncIterator를 기반으로, 루프 안의 select를 대체할 수 있는 새로운 API를 도입하는 것입니다. 즉 여러 AsyncIterator를 받아 모두를 폴링하고, 각자가 아이템을 반환할 때마다 산출하는 merge! 매크로입니다. 이렇게 하면 merge!가 반복되는 동안 future가 취소되지 않기 때문에, future를 루프 밖으로 끌어올릴 필요가 없습니다. 예를 들면:
merge! {
result = once(async_function1()) => // ...
result = once(async_function2()) => // ...
}
Stream::next사용자가 값을 제자리에서 pin 고정해야 하는 또 다른 경우는, Unpin을 구현하지 않은 Stream에서 next 메서드를 호출할 때입니다.
next 메서드는 Self: Unpin 제약을 가집니다. 이유는 next는 self를 가변 참조로 받지만, 그 내부의 poll_next 메서드는 self를 pin 고정된 참조로 받기 때문입니다. pin 고정된 참조를 안전하게 가변 참조로 바꿀 수 있는 것은, 해당 타입이 Unpin을 구현한 경우뿐입니다.
만약 그 Stream이 Unpin을 구현하지 않는다면, 해결책은 그것을 pin 고정하는 것입니다. 스트림에 대한 pin 고정된 참조는 Unpin과 Stream을 모두 만족하므로, 그 위에서 next를 호출할 수 있습니다. 이는 동작하지만, 고수준 레지스터에서 pin을 마주치게 만든다는 점이 아쉽습니다.
러스트 프로젝트는 Stream을 대체할 AsyncIterator를 제공하려 합니다. 이 문제를 피하는 것이 바람직합니다. 한 가지 방법은 AsyncIterator가 pin 고정된 참조를 받지 않게 하는 것입니다(이는 poll_next의 시그니처를 바꾸거나 async 메서드를 사용하는 방식으로 가능). 그러나 이렇게 하면 AsyncIterator를 pin 고정했을 때 얻는 유익한 편의성을 잃게 됩니다. 제 이전 글들에서 이것이 잘못된 선택인 이유를 자세히 이야기했습니다. 또 다른 이유는 async 제너레이터가 자기참조가 될 수 없게 되기 때문입니다.
대신 최선의 해법은 사용자가 next 메서드를 그렇게 자주 호출할 필요가 없도록 만드는 것입니다. 지금 사용자가 스트림을 순회하는 방식은 대개 다음과 같습니다:
let stream = pin!(stream);
while let Some(element) = stream.next().await {
// ...
}
대신 AsyncIterator를 순회하기 위한 해법은, 디설거링 과정의 일부로 스트림을 제자리에 pin 고정까지 처리해 주는 일급 구문이어야 합니다. 보통 for await 루프로 불리는 문법은 다음과 같을 것입니다:
for await element in async_iter {
// ...
}
이 문법이 있다면 사용자가 여전히 next를 호출해야 하는 경우는 두 가지뿐이라고 봅니다:
AsyncIterator를 값으로 받아 첫 번째 요소(또는 처음 N개)를 돌려주는, 이를테면 first 같은 특수 조합기를 사용할 수 있습니다. 값으로 받기 때문에, 조합기가 AsyncIterator를 대신 pin 고정해 줄 수 있습니다.Unpin을 구현하게만 하면 됩니다. 바로 이런 경우가 메모리 안전을 위해 pin이 필요한 상황이니까요!저는 이 두 경우 모두 극히 드물다고 생각하며, 특히 두 번째는 더더욱 그렇습니다. for await와 어쩌면 first 조합기를 구현하면, 사용자가 실제로 pin 없이 안전하지 않은 일을 하려는 그 드문 경우를 제외하고는 AsyncIterator를 순회하기 위해 pin이 필요한 문제를 대부분 해소할 수 있습니다.
pin이 모습을 드러내는 마지막 큰 경우는, 어떤 포인터 뒤에 간접적으로 접근 가능한 future를 await 할 때입니다. 주로 두 가지 모습으로 나타납니다:
Sized가 아니므로 포인터 뒤에 있어야 합니다.정말 답답한 점은, 적어도 Box의 경우에는 이게 설계 실수라는 것입니다. 2018년에 Taylor Cramer, Ralf Jung, 그리고 저 셋이 Pin 인터페이스에 관해 중요한 결정을 내렸던 대화를 기억합니다. 그 결정이 이런 함의를 가질 줄은 우리 누구도 이해하지 못했던 것 같고, 만약 알았다면 반대로 결정했을 겁니다. 자세히 설명해 보죠.
우리는 Box<T>가, 그 박스가 소유한 타입이 Unpin을 구현하지 않더라도, 항상 Unpin을 구현하도록 했습니다. 사실 이는 자의적인 결정이었습니다. Box<T>가 Unpin이라는 것이 우리에겐 그럴듯하게 느껴졌지만, 그것이 반드시 “필요한” 것은 아니었습니다. 가변 참조에 대해서는 필요하지만, Box에 대해서는 그저 Box 자체가 자기참조를 포함하지 않으니 Unpin이라 해도 말이 된다고 느꼈고, 비소유 참조 타입들에 대한 Unpin 구현과도 일관되기도 했습니다.
이 결정의 결과는 Box<T: Future>가 Future를 구현하지 않는다는 점입니다. 만약 구현했다면, Box를 통해 그 future를 폴링해 놓고도, Box가 Unpin이므로 그 안의 future를 꺼내 이동시켜 버릴 수 있습니다. 이는 사운드니스 버그가 됩니다. 대신 Pin<Box<T: Future>>만이 Future를 구현합니다.
만약 우리가 Box에 대한 그 Unpin 구현을 생략했다면, 대신 Box<T: Future>가 Future를 구현하도록 할 수 있었을 겁니다. 그러면 pin 없이 박스된 future를 await 할 수 있었겠죠. 빌린 future(즉 가변 참조 뒤의 future)는 여전히 await 하려면 pin이 필요하지만, 그건 더 흔치도 않고 사운드니스를 위해 본질적으로 필요한 일입니다.
솔직히 말해, 이는 우리가 async/await를 설계하면서 저지른 가장 큰 실수였다고 생각합니다. 아마 이걸 알고 있거나, 늘 생각하는 사람은 저밖에 없을 겁니다. 지금 시점에서는, 설령 에디션이 바뀐다 해도 그 impl을 제거하는 것은 불가능하다고 봅니다. 우리는 아마도 영원히 그 실수에 묶여 있을지도 모릅니다.
처음으로 돌아가, 박스된 future의 경우를 제외하면 AsyncIterator에 대한 더 나은 지원이 오늘날 제가 경험하는 고수준 코드 속 pinning 사례의 거의 전부를 해결해 줄 것임을 강조하고 싶습니다:
merge! 매크로는 future를 루프 밖으로 끌어올리는 문제 전체를 피하게 해 주어, 그 지점에서 pin을 마주치지 않게 합니다.for await 루프는 AsyncIterator를 순회하기 위해 next 어댑터를 호출해야 하는 필요를 없애, next를 호출하기 전에 AsyncIterator를 pin 고정해야 하는 요구를 피합니다.first 조합기는 남아 있는 대부분의 next 사용 필요를 없애 주어, 실제로 안전을 위해 pin이 필요한 상황만 남기게 합니다.이것이 제가 AsyncIterator를 그렇게 강하게 추진하는 동기의 일부입니다. 특히 언어 차원의 구성인 for await는 생태계 차원에서만 존재할 수 없습니다. 이 비동기 순회를 위한 일급 지원은 비동기 생태계의 주요 고통 지점을 해소할 것입니다.