Rust의 std::pin::Pin은 무엇인가?
(vrong.me)std::pin::Pin은 포인터가 가리키는 값이 그 포인터를 통해 이동되지 않는다는 타입 수준 보장을 표현하며, 자기 자신 내부를 참조하는 타입처럼 주소가 안정적이어야 하는 값 때문에 필요함async/await에서는.await를 넘어 살아남는 지역 변수와 참조가 컴파일러 생성 상태 머신의 필드가 될 수 있어, 폴링 이후 future 이동을 막기 위해Future::poll이Pin<&mut Self>를 요구함Pin<P>는 고정된 값을 안전한 코드로 이동하는 일을 막지만 일반적인 변경까지 금지하지는 않으며,T: Unpin이 아니면 안전하게Pin<&mut T>에서&mut T를 꺼낼 수 없음- Rust 타입 대부분은 기본적으로 Unpin이므로, 이동되면 안 되는 자기 참조 구조체는 보통
PhantomPinned필드를 넣어!Unpin으로 만들어야 함 - 실제로는 future를 직접
poll하거나 pinned future를 요구하는 API에 넘길 때Box::pin또는std::pin::pin!을 쓰며, 직접Future나 저수준 async 원시 타입을 구현할 때는unsafe불변식까지 다뤄야 함
Pin이 필요한 이유
std::pin::Pin은 포인터 래퍼로, 포인터가 가리키는 값이 그 포인터를 통해 이동되지 않는다는 보장을 나타냄- 핵심 문제는 자기 참조 타입에서 생김
- 예시 구조체
SelfRef는data: i32와ptr: *const i32를 가지며,ptr은self.data를 가리킴 - 구조체 인스턴스를 다른 변수로 이동하거나 함수에서 반환하면 메모리 주소가 바뀔 수 있음
- 원시 포인터
ptr은 이전 메모리 위치를 계속 가리켜 댕글링 포인터가 됨
- 예시 구조체
- 자기 참조가 설정된 뒤에는 해당 값이 다시 이동되지 않도록 막는 장치가 필요함
async/await와 Future에서 생기는 문제
async/await와 Future는Pin이 자주 등장하는 대표적인 영역임.await지점을 넘어 살아남는 지역 변수는 컴파일러가 생성하는 상태 머신의 필드가 됨- 어떤 지역 변수에 대한 참조도 같은
.await를 넘어 살아남으면, 생성된 future가 자기 참조적일 수 있음 - 폴링이 시작된 뒤 future는 자기 내부의 다른 필드를 가리키는 참조에 의존할 수 있음
- 이 상태에서 future가 이동되면 해당 참조가 무효화됨
- 이를 막기 위해
Future::poll은&mut self대신Pin<&mut Self>를 받음
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
- 호출자는
poll을 부른 뒤 future가 이동되지 않는다는 보장을 제공해야 함
Pin이 막는 것과 허용하는 것
-
Pin<P>는 고정된 포인터를 통해 가리키는 값을 안전한 코드로 이동하지 못하게 막음 -
값의 일반적인 변경은 허용됨
- 고정된 타입의 메서드는 필드를 변경할 수 있음
- 다만 pinning에 의존하는 필드를 값 밖으로 이동시키면 안 됨
-
&mut T의 한계&mut T가 있으면mem::replace,mem::swap, 대입 같은 연산으로 해당 메모리 위치의 값을 재배치할 수 있음Pin은 일반적인 가변 참조를 되찾는 일을 제한함T: Unpin이 아니면 안전한 코드로Pin<&mut T>에서&mut T를 꺼낼 수 없음
impl<'a, T: ?Sized> Pin<&'a T> { pub const fn get_ref(self) -> &'a T { ... } } impl<'a, T: ?Sized> Pin<&'a mut T> { pub const fn get_mut(self) -> &'a mut T where T: Unpin { ... } }- 타입이
Unpin을 구현하지 않는!Unpin이면, 안전한 코드만으로는&mut T를 얻을 수 없음 - 이 경우
Pin::get_unchecked_mut같은 unsafe 메서드를 써야 하며, 값이 그 참조 밖으로 이동되지 않는다는 약속을 코드가 지켜야 함
Unpin과 PhantomPinned
Unpin을 구현하는 타입은 메모리 안전성을 위해 pinning에 의존하지 않음
// std::marker
pub auto trait Unpin {}
- Rust의 대부분 타입은 이동되어도 문제가 없어 기본적으로
Unpin임- 예:
i32,String,Vec
- 예:
Unpin은 명시적으로!Unpin을 만들지 않는 한 모든 타입에 자동 구현됨std::marker::PhantomPinned는 명시적으로!Unpin인 마커 구조체임- auto trait은 자동 전파되므로,
PhantomPinned필드를 포함한 구조체도 자동으로!Unpin이 됨
- auto trait은 자동 전파되므로,
use std::marker::PhantomPinned;
struct SelfRef {
data: i32,
ptr: *const i32,
_phantom: PhantomPinned, // makes the entire struct !Unpin
}
- 사용자 정의 구조체가 고정된 뒤 이동되면 안전하지 않다는 점을 선언하는 표준 방식임
- 컴파일러는 보통 unsafe 원시 포인터로 만들어지는 자기 참조를 자동 감지할 수 없음
- 따라서 개발자가 자기 참조 구조체에 대해 명시적으로
Unpin을 포기해야 함- 보통
PhantomPinned필드를 포함하는 방식으로 처리함
- 보통
- 자기 참조 타입이 실수로
Unpin상태로 남아 있으면, 안전한 코드가Pin에서 가변 참조를 꺼내 값을 이동할 수 있음- 그러면 자기 참조를 만든 unsafe 코드의 가정이 깨짐
Pin을 만드는 방법
-
Pin자체가 값을 고정하는 것은 아님 -
Pin을 만든다는 것은 해당 pointee가 pin의 수명 동안 안정적인 메모리 위치에 남는다는 점을 증명하는 일임 -
Pin::new- 가장 단순한 생성 방식은
Pin::new임
let mut value = 42; let pinned = Pin::new(&mut value);- 이 생성자는
T: Unpin일 때만 사용할 수 있음 Unpin타입은 pinning에 의존하지 않으므로,Pin으로 감싸도 항상 안전함- 이 경우 pinning 보장은 사실상 no-op임
- 가장 단순한 생성 방식은
-
std::pin::pin!- 힙 할당 없이 지역적으로 값을 pin해야 할 때
pin!매크로를 사용할 수 있음
use std::pin::pin; let future = pin!(async { println!("Hello"); });- 이 매크로는 지역 변수를 만들고, 그 변수를 가리키는
Pin<&mut T>를 반환함 - 컴파일러가 해당 지역 변수를 남은 수명 동안 이동되지 않게 보장하므로, 스택에서
!Unpin값을 안전하게 pin할 수 있음 - 이름과 달리
pin!은 스택 메모리 자체를 pin하지 않음 - 지역 변수에 묶인 고정 참조를 만들 뿐이며, 변수가 스코프를 벗어나면 pinning 보장도 끝남
- 힙 할당 없이 지역적으로 값을 pin해야 할 때
-
Box::pin!Unpin타입에서 가장 흔한 생성자는Box::pin임
let pinned = Box::pin(SelfRef { ... });pin!은 지역 변수에 묶인Pin<&mut T>를 만들지만,Box::pin은Box가 소유하는Pin<Box<T>>를 반환함- 힙 할당 자체는 이동하지 않으므로 pointee는
Box의 수명 동안 안정적인 메모리 위치를 가짐 Box자체를 이동해도 소유한 값은 이동하지 않고,Box안의 포인터만 이동됨- 힙 할당은 같은 주소에 남음
-
Pin::new_unchecked- 안전한 생성자가 값이 제자리에 남는다는 점을 증명할 수 없을 때는 unsafe 코드로
Pin을 직접 만들 수 있음
let pinned = unsafe { Pin::new_unchecked(ptr) };Pin::new_unchecked호출자는 반환된Pin의 수명 동안 pointee가 어떤 포인터를 통해서도 다시 이동되지 않는다고 약속함- 이 약속이 깨지면 pinning 보장에 의존하는 코드에서 정의되지 않은 동작이 발생할 수 있음
- 따라서 보통 이 불변식을 지킬 수 있는 저수준 추상화를 구현할 때만 사용됨
- 안전한 생성자가 값이 제자리에 남는다는 점을 증명할 수 없을 때는 unsafe 코드로
실제로 신경 써야 하는 경우
- 대부분의 Rust 개발자에게
Pin과Unpin은 배경에서 조용히 동작함 - 직접 신경 써야 하는 경우는 주로 두 가지임
- async 코드 소비: future를 직접
poll하거나 pinned future를 요구하는 API에 전달해야 하면Box::pin(future)로 힙에 pin하거나std::pin::pin!(future)로 로컬 스택에 pin함 - Future 직접 구현: 사용자 정의 상태 머신이나 저수준 async 원시 타입을 작성할 때
Pin<&mut Self>를 다뤄야 하며, pinning 불변식을 지키기 위해PhantomPinned와 unsafe 코드가 필요할 수 있음
- async 코드 소비: future를 직접
Pin은 주소 민감 타입 문제를 다루는 Rust의 zero-cost 해법임- 이를 통해 Rust는 garbage collector 없이 메모리 안전성 보장을 유지하면서
async/await와 다른 자기 참조 추상화를 사용할 수 있음
댓글과 토론
Lobste.rs 의견들
-
std::pin::Pin은 Rust 세계의 Monad 같음. 일단 이해하고 나면 블로그 글을 쓰지 않을 수 없게 됨- 그런 글들은 대개 monad tutorial fallacy에 걸리기 쉽다
- Monad 때와 마찬가지로, 그런 블로그 글들이 실제로는 아무것도 제대로 설명하지 못한다는 뜻인가?
-
Pin을 이해하려고 할 때 나와 다른 사람들이 걸렸던 몇 가지를 다루면 좋을 듯함
Unpin이라는 이름은 별로 좋지 않음. 더 정확하지만 역시 별로인 이름으로는MovableWhenPinned나PinIsNoOp가 있었을 것임
nightly의!Unpin이중 부정은 이상해 보이지만, 기존 타입을 99%의 기본 경우로 두려면 타입이 빠져나갈 수 있는 자동 트레이트Unpin을 추가해야 해서 그렇게 됨.!MovableWhenPinned이라고 생각하면 더 말이 됨
안정 버전의 대안인PhantomPinned도 이름이 좋지는 않은데, pinned 상태는 pinned 참조가 있어서 생기는 일시적 상태이지 타입의 특성이 아니기 때문임. 대안 이름은PhantomNotMovableWhenPinned정도가 됐을 것임
이런 식으로 머릿속에서 번역하기 시작하니 훨씬 이해가 잘 됐음. 물론 여전히 헷갈리는데 운이 좋았던 것일 수도 있음- 완전히 동의함. 예전에는
!Unpin이 머리를 아프게 했는데,Unpin을SafeToUnpin으로 읽기 시작하니 조금 편해졌음
- 완전히 동의함. 예전에는
-
예전에 이 질문을 했고 누군가 사려 깊게 답해줬던 것 같은데 기억이 안 남. 내가 이해한
Pin은 async에서 나왔고, 지역 변수 참조가 특정 함수의 상태 기계를 나타내는 데이터 덩어리 안에서 자기 참조가 되는 문제였음
async 상태가 이동하면 그 지역 변수 참조들은 예전의 잘못된 위치를 가리키게 됨
그런데 그건 참조가 완전한 절대 주소를 가진 실제 포인터이기 때문에만 그런 것 아닌가? 왜 해법이 참조를 상대 주소로 만드는 것이 아니라, 이동 능력을 제거하는 것이었는지 궁금함
답이 대체로 “컴파일러, CPU, OS가 포인터를 아주 잘 다루도록 수백만 엔지니어-년이 들어갔기 때문에 포인터가 여러 면에서 더 낫고, 그래서 여기저기Pin을 쓰는 편이 낫다”인 건지, 아니면 상대 참조가 대안으로 실제로 성립하지 않는 딱딱한 이유가 있는 건지 궁금함- async 상태 안의 지역 변수가 같은 상태 안의 다른 지역 변수를 직접 참조하는 것만의 문제는 아님. 그 경우라면 컴파일러가 모든 지역 변수를 알고 있으니 접근을 상대적으로 만들 수 있음. 하지만 한 타입 깊숙한 곳의 참조가 다른 타입 깊숙한 곳의 값을 가리키면 훨씬 까다로워짐
참조가 상대적이라면, 그 타입들은 async 상태 안에서 쓰이는지 아닌지에 따라 메모리 표현이 달라져야 하고, 상대 참조에서 실제 포인터를 복원하기 위해 함께 전달해야 하는 기준 포인터 개념도 필요해짐
pinned 참조 안의 중첩 객체들은 루트 객체가 pinned되어 있어도 여전히 자유롭게 이동할 수 있으므로, 가상의 상대 참조들이 모두 같은 기준 포인터에 상대적이라고도 할 수 없음
결국 절대 포인터가 필요하고 상대 참조는 잘 맞지 않음. 그렇다면 Rust 컴파일러가 여기 있는 타입을 다 아니까, 전체 객체 그래프를 추적해서 이동한 객체를 가리키는 참조를 새 위치로 고치는 방식으로 객체를 이동 가능하게 만들면 어떨까? 그러면 사실상 추적 가비지 컬렉터를 만든 셈임
게다가 Rust 컴파일러는 객체 그래프의 모든 타입을 알지 못함. 참조는 FFI를 통해 전달될 수 있고 외부 라이브러리가 그 참조를 보관할 수 있음. FFI 경계를 넘는 이동 참조를 고치는 건 사실상 다루기 어려운 문제임
그래서 정말 까다롭다. 객체 이동 자체도 비교적 새로운 기법이라는 점도 중요함. 대부분의 C/C++ 프로그램에서는 모든 객체가 암묵적으로 pinned됐다고 볼 수 있음. 그쪽에서 pinning이 덜 논의되는 이유는 객체가 그냥 이동하지 않거나, 이동하더라도 매달린 참조가 남지 않게 하는 책임이 프로그래머에게 있기 때문임 Pin은 Rust가 메모리를 불투명한 비트 덩어리처럼 마음대로 옮길 수 없는 다른 언어와의 상호 운용성에도 필요함
내가 이해하기로 C++ 상호 운용성의 문제 중 하나는 객체가 자유롭게 옮길 수 있는 단순한 비트 덩어리가 아니라는 점이고, 결국 꽤 많은 타입에 pinning이 필요해지며 그 사용성이 불편해짐
다만 최소 6개월 전쯤 이 작업을 하던 사람들과 나눈 대화를 바탕으로 한 것이라, 그 뒤로 상황이 얼마나 나아졌는지는 모름
- async 상태 안의 지역 변수가 같은 상태 안의 다른 지역 변수를 직접 참조하는 것만의 문제는 아님. 그 경우라면 컴파일러가 모든 지역 변수를 알고 있으니 접근을 상대적으로 만들 수 있음. 하지만 한 타입 깊숙한 곳의 참조가 다른 타입 깊숙한 곳의 값을 가리키면 훨씬 까다로워짐
-
전반적으로 공식 Rust 문서에 더해 읽기 좋은 설명이라고 봄. 문제로 들어가는 방식이 조금 더 부드럽다
다만 자기 참조 구조체로 시작하는 것은 차라리 빼는 편보다 더 헷갈리게 만든다고 봄. 특히 도입부의 “따라서 그런 자기 참조가 만들어진 뒤에는SelfRef의 이동을 막을 방법이 필요하다”는 문장은, 핵심보다 “이동을 완전히 막는 문제”를 떠올리게 했음
실제 핵심은 훨씬 뒤에 나오는 “Pin은 값을 물리적으로 이동하지 못하게 막지 않는다. 대신 그 포인터를 통해 값이 이동되지 않는다는 타입 수준 보장이다”에 있음
이동 자체를 막을 수는 없으므로, 안전한 API에서 자기 참조 데이터를 독점 참조 뒤에만 노출하기 위해Pin을 쓰는 것임. 내가 이미Pin을 너무 많이 이해한 상태일 수도 있지만, 설명 방식을 조금 다듬으면 독자가 덜 헤맬 듯함- 글을 바꿔서 표현해보겠음
이 글은 pinning에 대한 내 노트에서 가져온 것이고, 처음에는 나도 그렇게 이해했음. “이동을 막는다” 같은 문제를 타입 수준 보장으로 풀 수 있다는 점이 아름답게 느껴졌음
물론 그게Pin이 실제로 하는 일은 아니므로, 그 부분이 드러나도록 글을 고치는 게 맞음
- 글을 바꿔서 표현해보겠음
-
이 글 어딘가에
!UnPin은 nightly Rust에서만 표현 가능하다는 점을 적어둘 만함. 그게PhantomPinned이 존재하는 주된 이유임 -
“포인터 래퍼”라고 하는데, Rust에서도 포인터를 다룰 일이 거의 없음. 왜 써야 하는지 모르겠음
*const는 Google에서 Rust 문서를 찾기 어려운데, 문서화되어 있는지 궁금함
“컴파일러가 생성한 상태 기계의 필드가 된다”는 것도 알아야 하는 건가? 아니면 황당한 컴파일러 오류가 실제로 그런 일이 일어났다고 말하려는 건가?
“생성된 future가 자기 참조가 된다”는 것도 future를 쓰면 암묵적으로 일어나는 일인가?
Future::poll은 직접 써본 적이 없는 것 같음
“안전한 코드는 일반&mut T를 복구할 수 없다”면서 “일반적인 변경은 허용한다”고 하는데, 그러면 어떻게 한다는 건가?
이런 것들 때문에 Rust를 더 파고드는 걸 그만두게 됐음- 원시 포인터는 Rust의 원시 타입 중 하나임. 문서는 여기와 여기에 있음
다만 저수준으로 내려가지 않으면 쓸 일이 거의 없는 것도 맞음. 나도 C 라이브러리를 호출하려고 할 때야 알게 됨
Future::poll은 Rust 비동기 코드의 기본임. 직접 호출하는 것이 아니라 실행기(executor)가 호출함. Rust에는 기본 실행기가 없어서 Tokio, smol, pollster 같은 것을 추가해야 하고, 이들이Future트레이트에 정의된poll같은 메서드를 사용해 일을 처리함 - 원글 작성자는 아니고 이것들이 유일한 이유도 아니지만, Rust에서 포인터를 다뤄야 했던 이유는 FFI와 그래프 같은 자기 참조 자료구조였음
문서는 여기를 포함해 여러 곳에 있음
다른 사람들이 오직 본인이 필요로 했던 것만 설명해야 한다고 기대하는 건 좀 과함
“그래서 어떻게?”에서 무엇을 묻는 건지 잘 모르겠음
- 원시 포인터는 Rust의 원시 타입 중 하나임. 문서는 여기와 여기에 있음