Rust프로그래밍Rust 개발자

수동으로 구현된 **Future** 내에서 **Pin**<&mut Self>에 대한 필드 프로젝션을 어떻게 수행해야 핀 보장을 유지할 수 있으며, 왜 단순한 가변 참조가 **Pin** 계약을 위반하는가?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

질문의 역사

Rust 1.39에서 async/await의 안정화와 함께 버전 1.33에서 도입된 Pin 타입은 비동기 상태 머신에 필수적인 안전한 자기 참조 구조체를 가능하게 했습니다. 이러한 구조체는 종종 버퍼 및 해당 버퍼에 대한 활성 뷰와 같은 구조체 자신이 소유한 데이터를 참조하는 내부 포인터를 포함합니다. 수동 미래 또는 복잡한 침투식 데이터 구조를 구현할 때, 개발자는 Pin<&mut Self>를 통해 개별 필드에 접근해야 하며, 이를 통해 메모리 위치 보장을 유지하는 안전한 프로젝션 메커니즘이 필요해집니다.

문제

구조체가 Pin을 통해 핀될 때, 컴파일러는 해당 구조체의 메모리 주소가 핀의 생애 동안 일정하게 유지되도록 보장합니다. 단, 이 타입이 Unpin을 구현하지 않는 경우에 한합니다. 구조체가 자기 참조 포인터를 보유하고 있을 경우, 내부 벡터의 원시 포인터와 같은 구조체를 이동하면 이러한 포인터가 무효화되어 덩어리 참조가 만들어지게 됩니다. 단순히 Pin<&mut Self>&mut Self로 역참조하는 단순한 프로젝션 접근 방식은 필드를 안전한 Rust 코드에 노출시켜, 이 필드에 대해 합법적으로 mem::swap 또는 mem::replace를 호출할 수 있게 되어, 이로 인해 핀된 메모리 위치에서 이들을 이동시키고 기본적인 핀 계약을 위반하게 됩니다.

해결책

안전한 프로젝션은 핀 보존 불변성을 유지하는 안전하지 않은 변환이 필요합니다. 즉, 부모 구조체가 !Unpin인 경우, 필드 프로젝션은 &mut Field가 아니라 Pin<&mut Field>를 반환해야 하며, 이동을 방지해야 합니다. 구현은 필드가 구조적으로 핀되어야 하므로, 필드의 핀 상태가 부모 구조체의 핀 상태와 연결되어 있음을 보장해야 하며, 일반적으로 포인터 산술 또는 Pin::map_unchecked_mut를 통해 달성됩니다. Unpin을 구현하는 필드의 경우, 프로젝션은 안전하게 &mut Field를 반환할 수 있습니다. 왜냐하면 이러한 유형은 핀된 데이터 내에서 중첩되어 있을 때 이동이 허용되기 때문입니다. 그러나 이러한 이동이 다른 자기 참조 필드를 무효화하지 않도록 주의해야 합니다.

use std::pin::Pin; use std::marker::PhantomPinned; struct Buffer { data: [u8; 1024], cursor: *const u8, _pin: PhantomPinned, } impl Buffer { // 데이터 필드로의 안전한 프로젝션 (Unpin) fn data_mut(self: Pin<&mut Self>) -> &mut [u8; 1024] { unsafe { &mut self.get_unchecked_mut().data } } // 커서 필드로의 프로젝션 fn cursor(self: Pin<&mut Self>) -> *const u8 { unsafe { self.get_unchecked_mut().cursor } } }

실생활 상황

맥락

우리는 메시지가 재사용되는 내부 버퍼의 하위 범위를 참조할 수 있는 고성능 제로 복사 파서를 개발하고 있었습니다. 파서 상태는 비동기 I/O 작업 간에 유지되어야 하므로, 구조체는 버퍼에 대한 자기 참조 포인터를 허용하기 위해 Pin되어야 했습니다.

문제 설명

Parser 구조체는 Vec<u8> 버퍼와 현재 메시지를 나타내는 해당 버퍼를 가리키는 &[u8] 슬라이스를 보유하고 있었습니다. 이 파서를 위해 Stream을 구현할 때, poll_next 메서드는 Pin<&mut Self>를 받습니다. 우리는 슬라이스 참조의 유효성을 유지하면서 버퍼를 변형(더 많은 데이터를 읽기 위해)해야 했으며, 이를 위해 신중한 필드 프로젝션이 필요했습니다.

고려된 해결책

해결책 A: 인덱스 기반 주소 지정 슬라이스 &[u8] 대신, 우리는 벡터에 대한 (usize, usize) 인덱스를 저장했습니다. 장점: 완전히 안전하며, Pin 복잡성 없음, 구현이 쉬움. 단점: 런타임 경계 검사 오버헤드, 접근할 때마다 수동 슬라이싱이 필요한 덜 편리한 API, 인덱스 비동기화 버그의 잠재성.

해결책 B: 원시 포인터를 이용한 안전하지 않은 핀 프로젝션 우리는 메시지를 원시 포인터 *const u8와 길이로 저장하고, 포인터 필드를 핀 상태로 유지하면서 버퍼에 접근하기 위해 Pin::map_unchecked_mut를 사용하는 수동 프로젝션 메서드를 구현했습니다. 장점: 제로 비용 추상화, 자기 참조성을 유지, 직접 포인터 산술을 허용. 단점: unsafe 코드 블록이 필요하고, Pin 불변성이 위반될 경우 정의되지 않은 동작의 위험이 있음 (예: Unpin을 잘못 구현한 경우).

해결책 C: pin-project 크레이트 사용 안전한 프로젝션 코드를 자동으로 생성하기 위해 프로시저 매크로를 활용했습니다. 장점: 편리함, 잘 테스트된 안전 불변성, 보일러플레이트 감소. 단점: 추가 종속성, 매크로 생성 코드가 디버그하기 어려울 수 있음, 약간의 컴파일 시간 비용.

선택된 해결책 및 결과

우리는 외부 종속성을 피하고 메모리 레이아웃에 대한 명시적 제어를 유지하기 위해 해결책 B를 선택했습니다. 우리는 구조체가 Unpin을 구현하지 않도록 PhantomPinned를 추가하고 핀 불변성을 검증하기 위해 철저한 Miri 테스트를 작성했습니다. 결과적으로, 우리는 메시지당 할당 없이 10Gbps의 처리량을 유지하는 제로 복사 의미론을 가진 파서를 얻었습니다.

후보자들이 종종 놓치는 점

자기 참조 포인터가 포함된 구조체에 대해 Unpin을 구현하는 것이 왜 안전하지 않은가?

Unpin은 특정 타입이 Pin으로 포장되더라도 이동이 안전하다는 신호를 보내며, 안전한 코드가 Pin<&mut T>에서 &mut T를 얻는 것을 허용합니다. 자기 참조 구조체의 경우, 구조체를 이동하면 그 내용의 메모리 주소가 변경되어 해당 내용을 참조하는 내부 포인터가 무효화됩니다. Unpin을 구현하면 안전한 코드가 핀된 상태에서 구조체를 이동할 수 있게 되므로, 비동기 런타임에 대해 Pin이 제공하는 안전 보장을 위반하게 되어 use-after-free 취약점으로 이어집니다. 따라서, 이러한 구조체는 PhantomPinned를 사용하여 Unpin의 자동 구현을 방지해야 합니다.

열거형 변형에 대한 프로젝션은 구조체 필드와 어떻게 다른가?

많은 후보자는 열거형과 구조체에 대한 프로젝션 메커니즘이 동일하다고 가정하지만, 열거형은 현재 활성 변형을 결정짓는 변별자가 있으므로 고유한 문제를 나타냅니다. Pin<&mut Enum>을 특정 변형으로 프로젝션하기 위해서는 변별자가 변경되지 않도록 하면서 변형이 핀 상태로 유지되도록 보장해야 하며, 변형 전환은 기본 데이터를 이동시킵니다. Rust는 변별자와 변형 데이터가 메모리 레이아웃을 공유하기 때문에 변형 프로젝션에 대한 안정적인 내장 지원이 부족합니다. 안전한 프로젝션은 활성 변형을 주장하고 열거형이 핀 상태로 유지되는 동안 변형 교체가 발생하지 않도록 보장하는 안전하지 않은 코드를 필요로 합니다.

PhantomPinned가 자동 특성 구현을 방지하는 역할은 무엇인가?

초보자는 Rust가 대부분의 타입에 대해 기본적으로 Unpin을 자동으로 구현하는 데 주의하지 못하는 경향이 있습니다. 이는 해당 타입이 명시적으로 !Unpin 필드를 포함하지 않는 한 발생합니다. PhantomPinned는 명시적으로 !Unpin으로 정의된 무게가 없는 마커 타입으로, 구조체에 포함될 경우 부정적 구현 경계를 나타냅니다. 이 마커가 없으면, 개발자가 구조체가 이동할 수 없다고 가정하는 안전하지 않은 프로젝션 코드를 작성하더라도, 컴파일러는 Unpin을 자동으로 구현할 수 있습니다. 그로 인해 안전한 코드가 Pin::into_inner_unchecked를 통해 구조체를 추출하고 이동할 수 있게 되어 안전하지 않은 불변성이 깨지고 정의되지 않은 동작을 초래할 수 있습니다.