NeuroWhAI의 잡블로그

[Rust] Pin과 Unpin 설명. 본문

개발 및 공부/언어

[Rust] Pin과 Unpin 설명.

NeuroWhAI 2019. 9. 26. 23:52


Rust 컴파일러는 기본적으로 모든 타입을 이동 가능한 것으로 취급합니다.
실제로 대부분의 타입은 이동되어도 문제가 없죠.
그러나 세상 사는 게 그렇게 순탄하지는 않습니다.

만약 i32 변수 a와 이를 가리키는 포인터 b가 있고 둘 다 객체 obj의 멤버라고 합시다.
obj { a <- b }
a엔 어떤 정수 값이 있을 것이고 이 값의 주소를 b가 가지고 있는 상황인 것이죠. (unsafe에서만 가능)
만약 여기서 obj가 이동하게 되면 어떻게 될까요?
이동한다는 것은 다른 메모리 주소로 값을 복사한다는 이야기인데 a야 그냥 복사하면 되지만 b가 담고 있는 이전 a의 주소값도 복사해서는 댕글링 포인터가 되어버릴 것입니다.
기존 메모리 주소 : obj { a <- b }
새 메모리 주소 : obj { a, ^b }
C++이었다면 이동 생성자나 이동 할당 연산자에서 조정 작업을 수행하면 되겠지만 Rust엔 그런 문법이 없습니다.
그저 깊은 복사를 수행하는 다른 메소드를 추가할 뿐이겠죠. Copy나 Clone trait을 사용하는 등...

아무튼 이 객체 obj의 구조를 바꿀 순 없는데 그렇다고 그냥 두자니 까먹고 이동 시켜버리면 버그가 폭발할테고... 고민이 쌓입니다.
바로 이럴 때 Pin<P>이 등장합니다! (P는 Deref를 구현하는 타입. 예를 들어 &mut T, Box<T> 등입니다.)
Pin은 내부 포인터가 가리키는 값을 이동할 수 없도록 인터페이스를 강제합니다.
무슨 말이냐면 일반적인 스마트 포인터는 Deref와 함께 DerefMut을 지원해서 mutable reference를 얻을 수 있고 이것을 mem::swap 같은 함수에 넘겨 이동이 발생하도록 할 수 있습니다.
또는 Box처럼 특별한 경우 그냥 역참조해서 다른 변수에 대입하는 식으로 이동시킬 수도 있지요.

그러나 Pin<P<T>>은 Deref만 기본적으로 제공하며 DerefMut은 T가 이동에 안전한 타입일 경우에만 구현하도록 되어있습니다.
(이동에 안전한 타입이라는 것을 컴파일러가 어떻게 판단하는지는 다음 Unpin 내용에서 설명합니다.)
역참조 후 대입으로 이동하는 것도 물론 불가능합니다!
이런 구현 덕분에 Pin은 내부 포인터가 가리키는 값의 이동을 문법적으로 막을 수 있게 됩니다.

그럼 Unpin은 뭘까요?
뭔가 이름만 봐서는 Pin의 반대니까 이동을 막지 않는다? 같은 직관을 가질 수 있을 것 같습니다.
실제로도 맞습니다.
Unpin을 구현하는 타입은 '나는 이동되어도 상관없어요~'라고 말하는 것과 동일합니다.
또한, Unpin은 SendSync와 같이 자동 구현되는 빈 trait입니다.
글 제일 처음에 대부분의 타입은 이동되어도 문제가 없다고 했죠?
그래서 원시 타입, 대부분의 표준 라이브러리 타입들은 다 Unpin을 구현하고 있습니다.
또한, Unpin을 구현하는 타입으로만 이뤄진 타입도 자동으로 Unpin이 구현됩니다.
그리고 Pin이 DerefMut을 T가 이동에 안전할 경우에만 구현한다고 했는데 그것을 이 Unpin으로 판단합니다.

보시다시피 T(Target)가 Unpin을 구현하고 있을 경우에만 DerefMut 구현되도록 되어있습니다.
그래서 사실 대부분의 타입은 Pin<P<T>>일때나 P<T>일때나 다른 것이 없습니다.

이제 다시 우리의 obj 예시로 돌아가서 봅시다.
먼저 우리의 obj는 이동에 안전하지 않으니 Unpin을 구현하면 안됩니다.
Unpin이 대부분 자동으로 구현되니까 구현되지 않도록 조치를 취해야 하는데 Send처럼 !Send를 impl하는 것은 아직 지원하지 않습니다.
그래서 표준이 제공하는, Unpin을 구현하지 않은 marker 타입 PhantomPinned를 사용해야 합니다.
그러면 우리의 obj는 이렇게 됩니다.
obj { a <- b, PhantomPinned }
이렇게 해서 일단 Pin에게 우리의 obj는 이동에 안전하지 않다고 알려줄 수 있습니다.
물론 이게 다는 아닙니다.
Unpin 구현을 해제하였다고 해서 끝이 아니고 앞서 설명했듯이 Pin이 감싸야 합니다.
객체를 만들때마다 Pin으로 감싸서 사용하는 것은 번거로우니 처음부터 생성자가 Pin으로 감싸서 반환하도록 하면 좋을 것 같네요.
흔히 생성자로 쓰는 함수인 new() -> Self가 있었다면 이것을 new() -> Pin<Box<Self>>로 바꿔버리면 됩니다.
Pin<Box<T>>를 만드는 것은 Box::pin(obj)로 쉽게 할 수 있습니다.

 

▼ 예제 코드 : Pin으로 감싸져 있지만 T=String이 Unpin이라 이동이 가능한 경우.

use std::mem;
use std::pin::Pin;

fn main() {
    let mut string = "this".to_string();
    let mut pinned_string = Pin::new(&mut string);
    let mut string2 = "none".to_string();
    let mut pinned_string2 = Pin::new(&mut string2);
    
    println!("{} {}", *pinned_string, *pinned_string2);
    mem::swap(&mut *pinned_string, &mut *pinned_string2); // 이동 발생.
    println!("{} {}", *pinned_string, *pinned_string2);
}

 

▼ 예제 코드 : Unpin 구현을 해제하여 이동이 불가능한 타입 시연.

use std::mem;
use std::pin::Pin;
use std::marker::PhantomPinned;

struct NotUnpinData(i32, PhantomPinned);

fn main() {
    let d = NotUnpinData(1, PhantomPinned);
    let mut pinned_d = Box::pin(d);
    let d2 = NotUnpinData(2, PhantomPinned);
    let mut pinned_d2 = Box::pin(d2);
    
    println!("{} {}", pinned_d.0, pinned_d2.0);
    mem::swap(&mut *pinned_d, &mut *pinned_d2);
    // ^ error: cannot borrow data in a `&` reference as mutable
    println!("{} {}", pinned_d.0, pinned_d2.0);
}

 

보통 Pin을 사용하는 것은 unsafe를 사용하겠다는 말이라서 대충 봐도 된다...고 생각을 했었으나 async 문법이 도입되면서 Pin을 사용하는 예제가 많아졌습니다.
그래서 제대로 공부하고 정리 차원에서 글을 썼습니다.
지적 환영...



Comments