Rust의 Pin과 Unpin이 왜 중요한지, 특히 C++의 move 의미론과 비교하고 async.await와의 관계를 통해 설명합니다.
Pin은 Rust에서 최근 도입된 async.await 기능에 꽤 중요합니다. 저는 문서를 읽었습니다. 이해가 안 됐습니다1. 이 글은 제가 Pin이 왜 중요한지 이해하기 위해 거친 과정입니다.
문서를 펼쳐 보면, 페이지는 Unpin에 대한 설명으로 시작합니다. Unpin은 이상합니다. 기본적으로 Unpin은 "그래, 이게 pin 되었다는 건 알지만 그 사실은 무시해도 돼"라고 말합니다. Unpin에 대한 저의 직감적인 반응은 "도대체 왜 이게 필요한 거지?"였습니다. 이건 Pin의 목적을 무너뜨리는 것 아닌가요? 왜 모든 것이 기본적으로 Unpin인 걸까요??
계속 읽어가면, Pin의 unsafe 생성자에서 반드시 지켜야 하는 규칙 목록이 나옵니다. 그중에서도 !Unpin인 타입에 대한 다음 제약은 특히 이해하기 어려웠습니다:
&mut P::Target을 얻은 뒤 그 참조로부터 값을 move out 하는 것(예를 들어mem::swap사용)이 가능해서는 안 된다.
Pin을 설명하는 다른 글들도 mem::replace 호출 역시 허용되어서는 안 된다고 지적했는데, 이것 역시 가변 참조를 받습니다.
다시 이것을 봅시다:
&mut P::Target을 얻은 뒤 그 참조로부터 값을 move out 하는 것(예를 들어mem::swap사용)이 가능해서는 안 된다.
여기서 분명히 move가 중요합니다. 정확히 무슨 뜻일까요? 그리고 왜 이것이 그렇게 큰 문제일까요?
저는 C++에 더 익숙하고, 아마 그 익숙함 때문에 오해가 생긴 것 같습니다. 먼저 C++에서 무언가를 move한다는 것이 무슨 뜻인지부터 이해해 봅시다.
다음 struct를 보겠습니다:
struct Thing {
Thing(uint64_t id)
: id(id)
{ }
// The move constructor is only required to leave the object in a
// well defined state
Thing(Thing&& other)
: id(other.id)
{
other.id = 0;
}
Thing& operator=(Thing&& other)
{
id = other.id;
other.id = 0;
return *this;
}
// non-copyable for clarity
Thing(Thing const&) = delete;
Thing& operator=(Thing const&) = delete;
uint64_t id;
};
C++는 move 생성자가 move된 객체를 정의되지 않은 상태이지만 유효한 상태로 남겨 두어야 한다고 말합니다.
int main() {
Thing a(10);
Thing const& ref = a;
Thing c = std::move(a); // moves a, but leave in defined state
printf("ref %zu\n", ref.id); // prints 0
}
다음으로, 이것2과 같은 swap 구현과 그 사용을 생각해 봅시다:
template <typename T>
void swap(T& a, T& b)
{
T tmp = std::move(a); // lots of moves
a = std::move(b); // move again
b = std::move(tmp); // oh look, move again!
}
int main() {
Thing a(1);
Thing b(2);
Thing& ref = a;
swap(a, b);
printf("ref %zu\n", ref.id); // prints 2
}
제가 알기로 이것은 완전히 유효한 C++입니다. 참조는 단지 어떤 메모리 덩어리를 가리키는 포인터일 뿐이고, 우리가 수행한 모든 move는 move된 객체를 "유효한" 상태로 남기도록 정의되어 있습니다(다만 그것들을 다룰 때는 주의가 필요할 수 있습니다).
마지막으로 struct를 하나 더 살펴봅시다.
template <typename T, size_t N>
struct ring_buffer {
std::array<T, N+1> entries; // use one extra element for easy book-keeping
// Store pointers. This is bad, there are better ways to make a ring
// buffer, but the demonstration is useful.
T* head = entries;
T* tail = head+1;
// ...
};
head와 tail은 둘 다 entries의 원소를 가리킵니다. C++는 기본 move 생성자를 자동으로 만들어 주지만, 그 기본 동작은 단지 memcpy입니다. 이 생성자가 실행되면 포인터들은 잘못된 배열을 가리키게 됩니다. 따라서 사용자 정의 move 생성자를 작성해야 합니다.
ring_buffer(ring_buffer&& other)
: entries( std::move(other.entries) )
, head( entries.data() + (other.head - other.entries.data())) // adjust pointer
, tail( entries.data() + (other.tail - other.entries.data())) // adjust pointer
{
other.head = other.entries.data();
other.tail = other.head + 1;
}
즉, C++에서 move는 특정한 특별한 위치에서 활용할 수 있는 또 하나의 사용자 정의 연산일 뿐입니다.
이제 Rust에서 같은 연습을 다시 해봅시다. 먼저 Thing 구조체부터 시작합니다.
struct Thing {
pub id: u64
}
impl Thing {
pub fn new(id: u64) -> Self {
Self { id }
}
}
첫 번째 예제를 그대로 Rust로 옮기려 하면 동작하지 않습니다.
fn main() {
let a = Thing::new(10);
let r = &a;
let c = a; // this is a move, but won't compile
println!("ref {}", r.id);
}
컴파일러는 이것을 싫어합니다. 다음과 같이 말합니다:
error[E0505]: cannot move out of `a` because it is borrowed
--> ex1.rs:16:13
|
15 | let r = &a;
| -- borrow of `a` occurs here
16 | let c = a; // this is a move, but won't compile
| ^ move out of `a` occurs here
17 |
18 | println!("ref {}", r.id);
| ---- borrow later used here
Rust는 우리가 값을 move했다는 것을 알고 있으며, move했기 때문에 더 이상 그것을 사용할 수 없다고 말하고 있습니다. 그런데 이것은 정확히 무슨 뜻일까요? 실제로는 무슨 일이 일어나고 있는 걸까요?
unsafe와 undefined behavior를 유발하는 Rust로 그것을 확인해 봅시다. 제가 처음 이런 것을 시도했을 때는 무엇을 기대해야 할지 잘 몰랐지만, 이 예제는 분명할 것입니다.
fn main() {
let a = Thing::new(1);
let r: *const Thing = &a;
let c = a;
println!("ref {}", unsafe { (*r).id });
}
이 코드는 UB이므로 출력은 안정적이지 않을 수 있습니다. 하지만 이 글이 쓰인 시점에는 이것이 "1"을 출력했습니다. 이유는 컴파일러가 a라는 이름의 객체가 사용하던 스택 공간을 c라는 이름의 객체를 저장하는 데 재사용했기 때문입니다. C++에서는 a의 "빈 껍데기"를 남겨 두어야 하지만, Rust에서는 a가 move된 뒤에는 아무도 더 이상 그것에 접근할 수 없다는 사실을 컴파일러가 "알고" 있으므로, 그 저장 공간을 재사용할 수 있습니다.
이 동작은 C++의 move와 매우 다릅니다. Rust 컴파일러는 move를 인지하고 있으며, 그 사실을 이용해 스택 공간을 절약할 수 있습니다. unsafe 코드를 쓰지 않는 이상, a에서 필드에 다시 접근할 방법은 전혀 없으므로, move 이후 a가 차지하던 공간을 어떻게 사용할지는 전적으로 컴파일러의 결정입니다.
Rust move의 규칙 1: 컴파일러는 당신이 move했다는 것을 안다. 그리고 이를 최적화에 활용할 수 있다.
다음 C++ 예제는 swap이었습니다. C++에서 swap은 데이터를 이리저리 옮기기 위해 몇몇 move 생성자를 호출합니다. C++의 swap 예제에서는 이러한 (암묵적인) move 생성자들이 그저 memcpy였습니다.
Rust에서의 swap은 C++ 버전만큼 단순하지 않습니다. C++ 버전에서는 어려운 작업을 모두 사용자 정의 move 생성자가 처리하게 하면 됩니다. Rust에는 호출할 수 있는 이런 사용자 정의 함수가 없으므로, swap이 실제로 무엇을 하는지 명시적으로 적어야 합니다. 이 버전의 swap은 Rust 표준 라이브러리에서 가져와 수정한 것입니다:
fn swap<T>(a: &mut T, b: &mut T) {
// a and b are both valid pointers
unsafe {
let tmp: T = std::ptr::read(a); // memcpy
std::ptr::copy(b, a, 1); // memcpy
std::ptr::write(b, tmp); // memcpy
}
}
다시 undefined behavior 영역으로 들어가 보면:
fn main() {
let mut a = Thing::new(1);
let mut b = Thing::new(2);
let r: *const Thing = &a;
swap(&mut a, &mut b);
println!("{}", unsafe { (*r).id }); // prints 2
}
이 예제는 기대한 대로 동작하기 때문에 좋지만, Rust의 move 의미론에서 아주 중요한 점을 드러냅니다. Rust에서 move는 언제나 memcpy입니다. Rust의 move는 memcpy 외에 다른 것일 수 없습니다. Rust는 구조체와 함께 사용자가 다른 연산을 지정할 수 있도록 하는 어떤 것도 정의하지 않기 때문입니다.
규칙 2: Rust의 move는 언제나 단순한 memcpy다.
이제 링 버퍼를 생각해 봅시다. Rust에서 C++ 버전의 링 버퍼와 비슷한 것을 작성하는 것은 조금도 관용적이지 않지만3, 그래도 해봅시다. 또한 설명을 명확하게 하기 위해 const generics가 이미 완성되었다고 가정하겠습니다.
struct RingBuffer<T, const N: usize> {
entries: [T; N+1],
head: *const T, // next pop location, T is moved (memcpy) out
tail: *mut T, // next push location, T is moved (memcpy) in
}
이제 문제는 사용자 정의 move 생성자를 정의할 수 없다는 것입니다. 이 구조체가 한 번이라도 move되면(swap/replace에서의 memcpy에 의한 move를 포함하여), 저장된 포인터들은 잘못된 메모리 조각을 가리키게 됩니다.
Rust에서 이 문제를 해결하는 방법은 타입을 !Unpin으로 표시하는 것입니다.
무언가가 !Unpin으로 표시되면, 그것에 대한 가변 참조를 얻는 일은 unsafe가 됩니다. 만약 !Unpin인 pinned 타입에 대한 가변 참조를 얻었다면, 그 타입으로부터 값을 move out 하는 어떤 것도 절대 호출하지 않겠다고 약속해야 합니다. 이 규칙들을 실제로 지키는 것이 얼마나 현실적인지에 대해서는 저도 생각이 있지만, 그건 다음 기회에 이야기할 주제입니다.
이제 이것이 왜 Rust의 async.await 지원을 위한 전제 조건인지 이해할 수 있기를 바랍니다.
다음 async 함수를 생각해 봅시다:
async fn foo() -> u32 {
// First call to poll runs until the line with the await
let x = [1, 2, 3, 4];
let y = &x[1];
let nxt_idx= make_network_request().await;
// next call to poll runs the last line
return y + x[nxt_idx];
}
컴파일러는 이 함수를 대략 2개의 상태를 가진 상태 기계로 변환합니다. 이 상태 기계는 어떤 구조체로 표현되며, 상태는 poll 함수를 호출하여 갱신됩니다. 이 상태 기계의 데이터를 저장하는 데 사용되는 구조체는 대략 다음과 같이 생겼을 것입니다:
struct StateMachineData_State1 {
x: [u32, 4],
y: &u32, // ignore lifetime. This will point into `x`
}
y는 참조(포인터)이므로, 중간 상태를 move(memcpy)하면 포인터가 망가집니다. 이것이 async에서 Pin이 중요한 이유입니다.
문서는 훌륭하지만, 저에게는 뭔가 딱 맞아떨어지지 않았습니다.
아마도 틀렸을 겁니다. 올바른 C++ 코드라는 것은 존재하지 않으니까요.
Rust다운 관용적 버전이 더 낫습니다(포인터 대신 인덱스를 사용하세요). 이것은 Rust에 대한 비난이 아닙니다. 관용적인 Rust 버전은 C++에서도 더 나았을 것입니다.