Rust의 `std::pin::Pin`이 왜 필요한지, 어떻게 동작하는지, `Unpin`과의 관계, 그리고 `async`/`await`와 `Future`에서 어떤 역할을 하는지 설명합니다.
std::pin::Pin은 해당 포인터를 통해 가리키는 값이 이동되지 않을 것이라는 보장을 나타내는 포인터 래퍼입니다.
핀 고정의 필요성은 자기 참조 타입에서 생깁니다. 자기 참조 구조체는 주소에 민감한 타입의 가장 흔한 예이며, Pin의 주된 동기이기도 합니다. 예를 들어 다음 구조체를 보겠습니다:
1struct SelfRef {
2 data: i32,
3 ptr: *const i32, // self.data를 가리킴
4}
이 구조체의 인스턴스를 이동하면(예를 들어 소유권을 다른 변수로 옮기거나 반환하면) 메모리 주소가 바뀝니다. 하지만 원시 포인터 ptr은 여전히 이전 메모리 위치를 가리키므로, 댕글링 포인터가 됩니다. 따라서 이런 자기 참조가 설정된 뒤에는 SelfRef가 이동되지 않도록 막을 방법이 필요합니다.
이 문제는 가장 흔하게 async/await와 Futures에서 나타납니다.
.await 지점을 지나서도 살아 있는 지역 변수들은 컴파일러가 생성한 상태 머신의 필드가 됩니다. 만약 어떤 지역 변수에 대한 참조 또한 같은 .await 지점을 지나서 살아 있다면, 생성된 future는 자기 참조 구조가 됩니다.
일단 polling이 시작되면, future는 자기 내부의 다른 필드를 가리키는 내부 참조에 의존할 수 있습니다. 그 이후에 future를 이동하면 그 참조들은 무효화됩니다. 이를 막기 위해 Future::poll 메서드는 future가 핀 고정되어 있을 것을 요구합니다:
1pub trait Future {
2 type Output;
3 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
4}
&mut self 대신 Pin<&mut Self>를 받기 때문에, poll의 호출자는 polling이 시작된 뒤 future가 이동되지 않을 것임을 보장해야 합니다.
Pin<P>는 해당 포인터를 통해 가리키는 값을 안전한 코드에서 이동하지 못하게 하면서도, 핀 고정된 값에 대한 일반적인 변경은 여전히 허용합니다.
&mut T의 문제가변 참조 &mut T가 있으면, mem::replace, mem::swap, 대입 같은 함수로 그 메모리 위치에 저장된 값을 다른 곳으로 옮길 수 있습니다.
Pin은 일반적인 가변 참조를 다시 얻는 것을 제한합니다. T: Unpin이 아닌 한, 안전한 코드는 Pin<&mut T>로부터 일반적인 &mut T를 얻을 수 없습니다.
1impl<'a, T: ?Sized> Pin<&'a T> {
2 pub const fn get_ref(self) -> &'a T { ... }
3}
4
5impl<'a, T: ?Sized> Pin<&'a mut T> {
6 pub const fn get_mut(self) -> &'a mut T
7 where
8 T: Unpin
9 { ... }
10}
중요
Pin은 오직 핀 고정된 포인터를 통한 이동만 막습니다. 핀 고정된 값의 변경 자체를 막지는 않습니다. 핀 고정된 타입의 메서드는 필드를 이동시키지만 않는다면 자유롭게 변경할 수 있습니다.
타입이 Unpin을 구현하지 않는다면(즉, !Unpin이라면), 안전한 코드로는 &mut T를 얻을 수 없습니다. 대신 Pin::get_unchecked_mut 같은 unsafe 메서드를 사용해야 하며, 이는 그 참조를 통해 값을 절대 이동시키지 않겠다고 컴파일러에 약속하는 것입니다.
Unpin이란 무엇일까요?Unpin을 구현하는 타입은 안전성을 위해 핀 고정에 의존하지 않습니다.
1// std::marker
2pub auto trait Unpin {}
Rust의 대부분의 타입(i32, String, Vec 등)은 이동되는 것에 신경 쓰지 않으며 기본적으로 Unpin입니다. Unpin은 명시적으로 !Unpin이 구현되지 않는 한 모든 타입에 자동으로 구현됩니다.
팁
마커 구조체 std::marker::PhantomPinned는 명시적으로 !Unpin입니다. 자동 트레이트는 자동으로 전파되므로, PhantomPinned 필드를 포함하는 모든 구조체도 자동으로 !Unpin이 됩니다.
1use std::marker::PhantomPinned;
2
3struct SelfRef {
4 data: i32,
5 ptr: *const i32,
6 _phantom: PhantomPinned, // 전체 구조체를 !Unpin으로 만듦
7}
이것은 사용자 정의 구조체가 핀 고정된 뒤에는 이동하면 안전하지 않다는 것을 선언하는 표준적인 방법입니다.
컴파일러는 자기 참조를 자동으로 감지할 수 없기 때문에(보통 unsafe 원시 포인터로 생성되므로), 이런 구조체를 자동으로 !Unpin으로 표시할 수도 없습니다.
따라서 이것은 하나의 계약에 의존합니다. 개발자는 자기 참조 구조체에 대해 Unpin을 명시적으로 포기해야 하며(보통 PhantomPinned 필드를 포함시켜서), 그렇게 해야 합니다. 자기 참조 타입이 실수로 Unpin 상태로 남아 있으면, 안전한 코드가 Pin으로부터 가변 참조를 되찾아 값을 이동시킬 수 있고, 이는 자기 참조를 만든 unsafe 코드가 세운 가정을 깨뜨리게 됩니다.
Pin이 값을 물리적으로 이동하지 못하게 만드는 것은 아닙니다. 대신, 그 값이 해당 포인터를 통해서는 이동되지 않을 것이라는 타입 수준의 보장입니다. 따라서 Pin을 안전하게 생성하려면, 핀의 수명 동안 가리키는 값이 안정된 메모리 위치에 머무를 것임을 보장해야 합니다.
Pin 구성하기Pin 자체가 값을 핀 고정하는 것은 아닙니다. 대신 Pin을 구성한다는 것은, 핀의 수명 동안 가리키는 값이 안정된 메모리 위치에 남아 있을 것임을 증명하는 것입니다.
Pin::newPin을 만드는 가장 간단한 방법은 Pin::new를 사용하는 것입니다:
1let mut value = 42;
2let pinned = Pin::new(&mut value);
하지만 이 생성자는 T: Unpin일 때만 사용할 수 있습니다.
Unpin 타입은 안전성을 위해 핀 고정에 의존하지 않으므로, 이를 Pin으로 감싸는 것은 언제나 안전합니다. 이 경우 핀 고정 보장은 사실상 아무 효과가 없습니다.
pin!힙에 할당하지 않고 값을 지역적으로 핀 고정해야 할 때는 pin! 매크로를 사용할 수 있습니다:
1use std::pin::pin;
2
3let future = pin!(async {
4 println!("Hello");
5});
이 매크로는 지역 변수를 만들고 그것을 가리키는 Pin<&mut T>를 반환합니다. 컴파일러는 해당 지역 변수가 남은 수명 동안 이동되지 않도록 보장하므로, 이것은 스택 위에서 !Unpin 값을 안전하게 핀 고정하는 방법입니다.
경고
이름과 달리 pin!은 스택 메모리 자체를 핀 고정하지는 않습니다. 이것은 수명이 지역 변수에 묶인 핀 고정 참조를 만들 뿐입니다. 변수가 스코프를 벗어나면 핀 고정 보장도 끝납니다.
Box::pin!Unpin 타입의 경우 가장 흔한 생성자는 Box::pin입니다:
1let pinned = Box::pin(SelfRef { ... });
지역 변수에 묶인 Pin<&mut T>를 만드는 pin!과 달리, Box::pin은 수명이 Box에 의해 소유되는 Pin<Box<T>>를 반환합니다. 힙 할당 자체는 이동하지 않으므로, Box의 수명 동안 가리키는 값은 안정된 메모리 위치를 가지며, 따라서 안전하게 핀 고정될 수 있습니다.
참고
Box 자체를 이동해도 그것이 소유한 값이 이동하는 것은 아닙니다. 이동하는 것은 Box 안에 저장된 포인터뿐이고, 힙 할당은 같은 주소에 남아 있습니다.
Pin::new_unchecked때로는 안전한 생성자가 값이 제자리에 남을 것임을 증명할 수 없습니다. 이런 경우 unsafe 코드가 수동으로 Pin을 구성할 수 있습니다:
1let pinned = unsafe { Pin::new_unchecked(ptr) };
Pin::new_unchecked를 호출함으로써, 호출자는 결과로 얻은 Pin의 수명 동안 어떤 포인터를 통해서도 다시는 가리키는 값을 이동시키지 않겠다고 약속합니다. 이 약속이 깨지면, 핀 고정 보장에 의존하는 모든 코드는 정의되지 않은 동작을 보일 수 있습니다. 그래서 Pin::new_unchecked는 보통 이런 불변식을 지킬 수 있는 저수준 추상화를 구현할 때만 사용됩니다.
대부분의 Rust 개발자에게 Pin과 Unpin은 배경에서 조용히 작동합니다. 일반적으로 다음 두 가지 경우에만 이것들을 의식하면 됩니다:
Box::pin(future)(힙에 핀 고정) 또는 std::pin::pin!(future)(스택에 지역적으로 핀 고정)을 사용하면 됩니다.Future 구현: 사용자 정의 상태 머신이나 다른 저수준 async 기본 요소를 작성한다면, Pin<&mut Self>를 다뤄야 하며 핀 고정 불변식을 지키기 위해 PhantomPinned와 unsafe 코드를 사용해야 할 수도 있습니다.결국 Pin은 주소에 민감한 타입 문제에 대한 Rust의 제로 비용 해법입니다. 이것은 가비지 컬렉터를 요구하지 않으면서도 Rust의 메모리 안전성 보장을 유지한 채, 사용하기 편한 async/await와 다른 자기 참조 추상화를 가능하게 합니다.