Rust의 Pin을 약한 참조 및 정적 빌림 관점에서 이해하며 intrusive 리스트와 !Unpin 예제로 안전한 Pin 기반 API의 직관을 제공하는 글
디스클레이머: 나는
Pin전문가가 전혀 아니기 때문에, 글의 내용이 미묘한 부분에서 틀려 있을 수도 있다. 그런 부분이 있다면 꼭 알려주길 바란다!
Pin은 악명 높게도 미묘하다. 한 번 pinning 되면, 그 자리는 Pin<&mut T> 참조가 스코프를 벗어난 뒤에도 영원히 접근이 제한된다. 이유는 단순하다. 우리는 그 자리에 대한 포인터가 우리가 전혀 알지 못하는 어딘가에 저장될 수 있도록 허용하고 싶고, 그렇다면 그 자리에 있는 데이터는 계속 일관성을 유지해야 하기 때문이다.
다시 말해, 어떤 자리가 접근이 제한되는 이유는 그 자리에 대한 포인터가 존재할 수도 있기 때문이다. 그런데 이건 보통 빌림 검사기(borrow checker)가 추적하는 종류의 일이다!
여기서는 오로지 Pin을 더 쉽게 이해하기 위한 목적으로, 약한 참조(weak reference) 라는 아이디어를 제안해 보려 한다. &weak T라는 새로운 참조 종류가 있다고 하자. 이 참조는 다음과 같은 성질을 가진다:
!Unpin 타입도 그 자리에서 이동(move) 될 수 없다. 그 외의 변경(mutation)은 허용된다;즉, 기본적으로는 생 포인터(raw pointer)에 가깝지만, 내가 어떤 자리에 대한 &weak T를 쥐고 있는 동안에는 그 자리가 어느 정도 일관성을 유지한다는 확신을 줄 수 있다. 특히 Drop이 먼저 호출되지 않고는 그 자리가 해제되지 않으므로, Drop 구현은 내가 가진 &weak 참조가 더는 사용될 수 없다는 사실을 알려줄 기회를 갖게 된다(아래 예제에서 이를 보여 준다).
여기에 Pin을 끼워 맞춰 보면, 다음과 같이 정의할 수 있다:
ruststruct Pin<P: Deref>(P, &'static weak P::Target);
여기서 Pin의 이상한 점이 분명해진다. Pin은 가리키는 자리를 영원히 약하게 빌린(weakly-borrowed) 상태로 만든다. Pin의 API 표면과 안전성 요구사항은, 우리가 그 P 포인터를 &'static weak 참조가 부과하는 불변식을 깨뜨리지 않는 방식으로만 사용하도록 보장하기 위해 존재한다.
눈여겨볼 점은, 여기서 로컬 변수에 대한 'static 참조를 취해도 괜찮다는 것이다. &weak은 가리키는 자리가 해제되도록 허용하기 때문이다. 물론 그 자리를 아직 할당된 상태인지 알아낼 수 있는 어떤 메커니즘이 있을 때에만 실제로 사용할 수 있다.1
예제로 어떻게 동작하는지 살펴보자. intrusive 연결 리스트를 생각해 보자(Ralf의 글을 바탕으로 한다).
ruststruct Collection<T> { // `Entry`의 Drop 구현은, 리스트에 들어 있는 항목들에 접근할 수 있음을 보장한다. objects: RefCell<Vec<&'static weak Entry<T>>>, } impl<T> !Unpin for Collection<T> {} struct Entry<T> { x: T, // 어떤 컬렉션의 일부라면 `Some`으로 설정된다. // `Collection`의 Drop 구현은 그 컬렉션에 접근할 수 있음을 보장한다. collection: Cell<Option<&'static weak Collection<T>>>, } impl<T> !Unpin for Entry<T> {} impl<T> Collection<T> { fn new() -> Self { Collection { objects: RefCell::new(Vec::new()) } } // 항목을 컬렉션에 추가한다. fn insert(mut self: Pin<&mut Self>, entry: Pin<&mut Entry<T>>) { if entry.collection.get().is_some() { panic!("Can't insert the same object into multiple collections"); } // 컬렉션에서 항목으로 가는 포인터. 이 `&mut`는 unsafe하다: 이 포인터를 통한 // 모든 변경이 허용되는 것은 아니다. let mut_this : &mut Self = unsafe { Pin::get_mut(&mut self) }; mut_this.objects.borrow_mut().push(Pin::get_weak(&entry)); // 항목에서 컬렉션으로 가는 포인터. let weak_this: &weak Self = Pin::get_weak(&self); entry.collection.set(Some(weak_this)); } // 컬렉션의 모든 항목을 보여 준다. fn print_all(self: Pin<&mut Self>) where T: Debug { print!("["); for entry in self.objects.borrow().iter() { // 안전성: `&weak` 참조는 다음을 보장한다: // 1. `entry.collection`이 변경될 수 없으며 계속 이 컬렉션을 가리킨다; // 2. entry가 Drop을 실행하지 않고는 해제되지 않는다. // `Entry`의 Drop 구현은 자기 자신을 `entry.collection`에서 제거한다. // 따라서 위 보장과 합쳐 보면, 여기서 우리가 들고 있는 weak 참조는 // 사용 가능함을 알 수 있다. let entry : &Entry<T> = unsafe { &**entry }; print!(" {:?},", entry.x); } println!(" ]"); } } impl<T> Drop for Collection<T> { fn drop(&mut self) { // 항목들을 순회하며 컬렉션에 대한 포인터를 제거한다. for entry in self.objects.borrow().iter() { // 안전성: `&weak` 참조는 다음을 보장한다: // 1. `entry.collection`이 변경될 수 없으며 계속 이 컬렉션을 가리킨다; // 2. entry가 Drop을 실행하지 않고는 해제되지 않는다. // `Entry`의 Drop 구현은 자기 자신을 `entry.collection`에서 제거한다. // 따라서 위 보장과 합쳐 보면, 여기서 우리가 들고 있는 weak 참조는 // 사용 가능함을 알 수 있다. let entry : &Entry<T> = unsafe { &**entry }; entry.collection.set(None); } } } impl<T> Entry<T> { fn new(x: T) -> Self { Entry { x, collection: Cell::new(None) } } } impl<T> Drop for Entry<T> { fn drop(&mut self) { // 컬렉션을 순회하며 이 entry를 제거한다. if let Some(collection) = self.collection.get() { // 안전성: `&weak` 참조는 다음을 보장한다: // 1. `collection.objects`는 `Collection` API의 협조 없이 변경될 수 없다; // 2. 컬렉션은 Drop을 실행하지 않고는 해제되지 않는다. // `Collection`의 Drop 구현은 자신이 담고 있는 모든 entry에서 // 자기 자신을 제거하므로, 위 보장들과 합쳐 보면 여기서 우리가 // 들고 있는 weak 참조는 사용 가능함을 알 수 있다. let collection : &Collection<T> = unsafe { &*collection }; collection.objects.borrow_mut().retain(|ptr| ptr.addr() != self.addr()); } } }
여기서는 컬렉션이 각 entry에 대한 &weak 참조를 보관하고 있으며, 우리의 API 안전성은 &weak이 주는 Drop 관련 보장에 의존하고 있다. 'static 대신 실제 라이프타임을 사용하는 것도 상상해 볼 수 있다. entry는 컬렉션 안에 존재하는 동안에만 pinning 되어 있으면 된다. 한 entry를 제거하면 그에 대응하는 &weak 참조의 스코프가 끝나므로, 이론적으로는 그 entry에 다시 마음대로 무엇이든 할 수 있게 될 것이다.2
흥미로운 점(그리고 개인적으로는 직관적이지 않았던 부분)은, 컬렉션 역시 pinning 되어야 한다는 사실이다. 그 이유는 entry들이 컬렉션에 대한 포인터를 유지해야 하기 때문이다. 이는 안전한 Pin 기반 API를 만들기 위한 두 가지 핵심 재료를 잘 보여 준다:
Drop 구현이, 우리가 그 포인터를 더 이상 사용하지 않게 만드는 어떤 메커니즘.전형적으로는 이것이 참조 순환(reference cycle) 을 의미하지만, 꼭 그래야만 하는 것은 아니다. 다음 예시는 참조 순환을 포함하지 않지만, 여전히 pinning이 필요하다:
ruststruct A<'a> { // 이 값이 `true`인 동안, `ptr`은 접근에 대해 유효하다. is_ptr_valid: &'a AtomicBool, ptr: &'static weak B<'a>, } struct B<'a> { flag: &'a AtomicBool, some_data: Data, } impl !Unpin for B<'_> {} impl Drop for B<'_> { fn drop(&mut self) { self.flag.store(false, Ordering::Relaxed); } }
또한 !Unpin이 왜 필요한지도 이해할 수 있다. 만약 !Unpin이 아니라면, 두 Entry를 서로 교환(swap)할 수 있는데, 그러면 각 Entry의 collection 필드가 더 이상 올바른 대상을 가리키지 않게 된다. 어떤 의미에서
rustEntry: !Unpin
의 “원인”은 내부에 들어 있는 &weak Collection이다. 따라서 entry.collection.is_none()일 때에는, 이론적으로는 그 entry를 안전하게 옮길 수 있을 것이다.3 비슷하게, B가 !Unpin인 이유는 B가 약하게 빌려져 있는 동안에는 내부의 &AtomicBool이 바뀌어(예를 들어 다른 것과 교체되어) 버리지 않도록 보장하기 위해서이다.
결국, 내게는 이것이 꽤 통찰을 주었다. Pin<P>은 기본적으로 추적되지 않는(untracked) 어떤 정적 빌림(static borrow) 을 안전한 API로 관리하는 방식이다. 내가 Pin<P>을 가지고 있으면, 여기에 붙은 weak 참조를 어디에나 마음대로 저장할 수 있고, 그런 weak 참조를 쥐고 있다는 사실만으로도 그 자리에 일어날 수 있는 일을 딱 필요한 만큼만 제한하여, 전체를 일관되고 안전하게 유지할 수 있다.4
이 정신 모델이 도움이 되었다면 알려 주길 바란다!
<a name="fn-1"></a>이걸 언어 차원에서 일관되게 구현할 수 있을지 궁금하다. 라이프타임은 보통 어떤 자리에 대한 대여(loan)에 연결되어 있는데, 여기서는 그 대여가 그 자리를 넘어서까지 살아남을 수도 있기 때문이다. 이것이 다른 무언가를 깨뜨리지 않기를 바랄 따름이다. 확실히 매우 특이한 종류의 참조다. ↩
<a name="fn-2"></a>다만 오늘날의 빌림 검사기로는 이런 걸 추적할 수 없다. Vec<Foo<'a>>에서 어떤 아이템을 제거해도 컬렉션의 타입은 바뀌지 않으므로, 빌림 검사기는 이제 라이프타임을 끝낼 수 있다는 사실을 알 수 없다. ↩
<a name="fn-3"></a>이런 걸 트레이트 시스템 안에서 표현할 수 있다면 꽤 귀엽지 않을까? 아니면 공포스러울지도. 어느 쪽일지 잘 모르겠다. ↩
<a name="fn-4"></a>이게 이렇게 깔끔하게 돌아가는 걸 보면 감탄이 절로 나온다. 거의 묘기 수준 같다. 당시 장황한 논의들이 어렴풋이 기억나는데, 이런 게 가능할지 전혀 확실하지 않았던 걸로 안다. 여기에 관여한 모든 분들에게 경의를 표한다. ↩