러스트에서 범용적인 애플리케이션 코드를 작성할 때는 잘 쓰이지 않는 메모리 고정 Pin에 대해서 알아보자.

일반적인 상황에서는 별로 접할 기회가 없지만, 러스트의 핵심 동작을 이해하려면 반드시 짚고 넘어가야 하는 개념이다. 보통 이 Pin을 마주치게 되는 케이스는 크게 두 가지다.

  1. 비동기 (async/await) 생태계
  2. 극단적인 성능 최적화 (e.g Zero Allocation Something)

우선 비동기부터 빠르게 알아보자.

비동기에서의 Pin

좀 복잡하다. 러스트의 비동기 동작 원리를 이해해야 Pin이 왜 필요한지 알 수 있다. 예를 들어 다음과 같은 비동기 함수가 있다고 가정하자:

async fn foo() {
    let mut buffer = [0u8; 2048];
    let ptr = &mut buffer;
    
    some_io_action(ptr).await;
    
    println!("bar");
}

비동기 함수 내부를 보면, ptr이 동일한 스택 내의 buffer를 가리키는 자기 참조(Self-referential) 구조를 띠고 있다.

컴파일러는 이 async 블록을 다음과 같은 상태 머신(State Machine)으로 변환한다 (이해를 돕기 위한 의사 코드, 실제로는 Gen된 코드가 더 복잡하다):

enum FooStateMachine {
    Start,
    // await 지점의 상태를 저장
    YieldedAtSomeIo {
        buffer: [0u8; 2048],
        ptr: *mut [0u8; 2048], // 구조체 내부의 buffer를 가리키는 자기 참조
    },
    Done,
}

만약 이 foo 상태 머신 객체 자체가 메모리상에서 다른 곳으로 이동(Move)되면 어떻게 될까? buffer의 메모리 주소는 새로운 곳으로 바뀌지만, ptr은 여전히 옛날 주소를 가리키고 있을 것이다. 이 상태에서 ptr을 참조하면 UB가 발생한다.

예를 들어 Vec 내에 Future를 넣는다고 가정해 보자.

let mut foo = Vec::new();

// 작업 큐에 비동기 작업을 넣음
foo.push(bar());
foo[0].poll(); // 첫 번째 작업 실행 내부적으로 참조 포인터 생성됨

// 새로운 작업이 들어와서 벡터의 용량(Capacity)이 초과됨
foo.push(bar()); 

// 메모리 재배치가 일어난 후 다시 poll을 시도하면 UB
foo[0].poll();

벡터는 용량이 꽉 차면 더 큰 메모리를 할당받아 기존 요소들을 통째로 새로운 주소로 재배치(Reallocation)한다. 이때 자기 참조 포인터들이 모두 망가진다.

그럼 단순히 Box로 감싸서 힙에 올리면 원본 데이터는 이동하지 않으니까 문제가 없지 않나? 라고 생각할 수 있다. 절반은 맞다. 하지만 Box<T>는 내부 데이터에 대한 순수한 가변 참조자(&mut T)를 제공한다. 악의적이거나 실수로 누군가 std::mem::swap(&mut box1, &mut box2)를 때려버리면 힙 위에서도 내부 데이터가 뒤바뀌며 프로그램이 터질 것이다.

그래서 러스트는 Pin<P>이라는 구조체를 제공한다. 이건 마법이 아니라, **개발자가 순수한 &mut T를 마음대로 빼내지 못하게 막아버리는 포인터 래퍼(Wrapper)**다. 이게 Future 트레잇의 poll 메서드가 &mut Self 대신 Pin<&mut Self>를 인자로 받는 이유다.


Pin의 핵심 동작 원리

최적화 기법을 보기 전에, Pin 자체가 어떻게 동작하는지 톺아보자.

Pin 자체는 이동할 수 있다

헷갈리는 부분인데, Pin 구조체 자체는 자유롭게 이동 가능한 Unpin 타입이다. Pin<&mut T>를 함수 인자로 넘기거나 변수에 재할당하는 행위는 메모리 주소를 담고 있는 8바이트짜리 wrapper를 넘겨주는 것일 뿐이다. 이리저리 move 돼도 결국 가리키는 T의 메모리 주소는 절대 변하지 않는다.

Pin::newPin::new_unchecked

어떤 데이터를 Pin으로 묶을 때는 두 가지 방법이 있다.

  1. Pin::new (safe): 대상이 Unpin 타입(예: i32, String, 평범한 구조체)일 때만 쓸 수 있다. 애초에 메모리에서 이동해도 아무 문제 없는 타입들이라 컴파일러가 Auto Impl 해준다.
  2. Pin::new_unchecked (unsafe): 대상이 이동하면 안 되는 !Unpin 타입(예: Future나 아래에서 다룰 침투형 노드)일 때 사용한다. 컴파일러가 안전을 보장할 수 없다. unsafe 블록을 열고 개발자가 보장의 책임을 져야 한다.

극단적인 최적화: 침투형 연결 리스트

이제 극단적인 최적화 케이스에서 Pin이 어떻게 활용되는지 알아보자. 할당 비용을 빡세게 최적화한 **침투형 연결 리스트(Intrusive Linked List)**가 예시로 꼽을 수 있겠다.

일반적인 Linked List는 보통 다음과 같이 구현된다.

struct Node<T> {
    data: T,
    prev: *mut Node<T>,
    next: *mut Node<T>,
}

노드가 하나 추가될 때마다 힙 할당(Box::new 등)이 발생한다. 비용적으로 별로 좋지 않기 때문에 아래와 같은 침투형 연결 리스트를 사용할 수 있다.

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

struct ListLink {
    prev: *mut ListLink,
    next: *mut ListLink,
}

struct Order {
    price: u64,
    link: ListLink,
    
    // 컴파일러에게 이 타입은 이동 불가(!Unpin)임을 알림
    _marker: PhantomPinned, 
}

여기서 PhantomPinned라는 마커 타입이 나온다.

현재 Stable Rust에서는 impl !Unpin for Order {} 처럼 명시적인 Negative Impl 문법을 지원하지 않는다. 그래서 러스트에서 지원해주는 !UnpinPhantomPinned 더미 필드를 집어넣어서 구조체 전체를 전염시켜 강제로 !Unpin 타입으로 만드는 꼼수를 쓴다.

구조체가 !Unpin이 되면, 안전한 Rust 내에서는 절대 Pin을 벗겨내고 &mut Order를 가져올 수 없다. 즉, 누군가 std::mem::swap으로 주문을 섞어버리는 것을 원천 차단한다. 실제로 마커 타입 넣고 std::mem::swap 돌려보면 Unpin 트레잇을 만족하지 않는다면서 컴파일 삐꾸낸다.

리스트를 순회할 때 Order의 포인터가 아니라 내부 필드인 ListLink의 포인터를 얻게 된다. 원본 Order 객체를 다루려면 포인터 역산을 해야 한다. 다행히 러스트 1.77부터 offset_of! 매크로를 지원하여 일일이 바이트를 계산할 필요가 없어졌다.

use std::mem::offset_of;

impl ListLink {
    unsafe fn get_parent(&self) -> *mut Order {
        // 포인터 주소 구하기
        let link_ptr = self as *const ListLink as *const u8;
        
        // offset_of! 매크로를 사용해서 Order 내의 link 필드 오프셋 구하기
        let offset = offset_of!(Order, link);
        
        // link 주소에서 오프셋만큼 빼서 본체의 시작 주소로 이동
        let parent_ptr = link_ptr.sub(offset);
        
        // 원본 Order 캐스팅
        parent_ptr as *mut Order
    }
}

unsafe하긴 하지만, ListLink가 무조건 Order 내부에 존재한다는 것을 API 설계자가 캡슐화로 보장한다면 문제없이 동작한다.

이거 외에 개발자가 신경써줘야할 게 좀 있다.

1. Pin 투영 (Pin Projection)

Pin<&mut Order> 상태인 구조체에서 내부 필드를 가져오는 헬퍼 함수를 작성할 때 주의해야 한다. **Pin 투영(Projection)**이라고 하는데, 구조체 설계자가 다음 두 가지 중 하나를 선택해야 한다.

  • Structural Pinning: 내부 필드(link)도 절대로 메모리에서 재배치되면 안 된다. 따라서 getter는 반드시 Pin<&mut ListLink>를 반환해야 한다.
  • Non-Structural Pinning: 내부 필드(price)는 값만 빼서 바꿔도 리스트에 타격이 없다. 따라서 내부 타입인 &mut u64를 반환해도 된다.

조심해야할 부분은 동일한 필드에 대해 묶인 참조(Pin<&mut T>)와 풀린 참조(&mut T)를 동시에 노출하는 것이다. 이렇게 되면 풀린 참조를 통해 swap을 해버릴 수 있어 Pin이 보장하던 불변성이 파괴되며 UB가 된다. Pin 투영의 모든 보장의 책임은 전부 개발자에게 있어서 꽤 까다롭다.

2. Drop 보장성

객체가 메모리에서 해제될 때 호출되는 Drop 트레이트도 고려해줘야한다. 구조체가 묶여(Pin) 있더라도 소멸자 함수인 drop(&mut self) 함수의 인자로 순수한 &mut self를 던져준다.

이때 Structural하게 묶어둔 필드(link)를 replace하거나 재배치해버리면 UB가 발생한다. 따라서 Drop 내부에서는 Pin::new_unchecked(self)를 사용해 강제로 다시 족쇄를 채운 뒤, 안전하게 리스트 연결을 해제(Unlink)하는 로직을 작성해야 한다.

추가로, 메모리 압축을 위한 #[repr(packed)] attribute를 걸면 내부 필드 메모리 정렬이 깨져 포인터 연산 시 CPU 예외가 발생할 수 있다.


참조