C 예제와의 비교를 통해 Unsafe Rust에서 초기화되지 않은 메모리와 정렬 제약을 다루는 법을 설명한다. MaybeUninit, addr_of_mut!의 사용 시점과 함정, 그리고 오늘날 unsafe Rust가 왜 더 어렵게 느껴지는지에 대한 저자의 생각을 담았다.
2022년 1월 30일 작성
Rust는 여러 면에서 현대적인 시스템 프로그래밍 언어일 뿐 아니라 꽤 실용적이기도 하다. Rust는 안전성을 약속하고, 런타임 오버헤드를 거의 또는 전혀 늘리지 않으면서도 안전한 추상화를 만들 수 있는 전체적인 틀을 제공한다. 언어에서 잘 알려진 실용적 해법 중 하나는 unsafe를 사용해 안전성에서 명시적으로 빠져나오는 방법이다. unsafe 블록 안에서는 무엇이든 가능하다.
이 글을 예전에 읽어본 적이 있다면 지금과 많이 달라 보여 놀랄 수 있다. 이 글 자체가 필자가 unsafe를 둘러싼 규칙에 대해 혼란을 겪은 피해자였기 때문이다. 이후 함정을 더 잘 설명하는 대체 예제로 바뀌었다. 레딧에서 내 실수를 지적해 준 eddyb에게 감사의 말을 전한다: reddit에서 내 실수를 지적했다.
며칠 전 트위터에서 unsafe Rust를 작성하는 것이 C나 C++보다 더 어렵다고 주장했는데, 그 말이 무슨 뜻인지 설명해 보려 한다.
간단한 것부터 시작하자. 어떤 struct를 몇 가지 값으로 초기화하려 한다. 여기서 흥미로운 값은 name이다. 이는 할당된 문자열을 가리키는 포인터다. 그 외에는 어디에 할당되는지는 중요하지 않으므로 struct 자체는 스택에 둔다. 아이디어는 초기화가 끝난 뒤 그 객체를 안전하게 전달하고 출력할 수 있게 하는 것이다.
c#include <stdio.h> #include <stdlib.h> #include <stdbool.h> struct role { char *name; bool disabled; int flag; }; int main() { struct role r; r.name = strdup("basic"); r.flag = 1; r.disabled = false; printf("%s (%d, %s)\n", r.name, r.flag, r.disabled ? "true" : "false"); free(r.name); }
이제 이것을 Rust로 써 보자. 문서를 너무 꼼꼼히 읽지 말고, unsafe를 이용해 대략 1:1로 번역해 보자. 코드를 보기 전에 한 가지 짚고 넘어가자. 우리는 의도적으로 Rust 프로그래머에게 익숙해 보이고 공개 API로도 볼 수 있는 객체를 만들려고 한다. 그래서 C 문자열 대신 String을 사용하므로 C 코드와는 일부 차이가 있다.
rustuse std::mem; struct Role { name: String, disabled: bool, flag: u32, } fn main() { let role = unsafe { let mut role: Role = mem::zeroed(); role.name = "basic".to_string(); role.flag = 1; role.disabled = false; role }; println!("{} ({}, {})", role.name, role.flag, role.disabled); }
당장 왜 여기서 unsafe가 필요한지 묻고 싶을 것이다. 답은, 물론 여기서는 필요 없다. 다만 이 코드는 비최적의 함수 std::mem::zeroed를 사용한다. 최신 Rust 컴파일러에서 이 코드를 실행하면 다음과 같은 결과를 얻게 된다:
thread 'main' panicked at 'attempted to zero-initialize type `Role`,
which is invalid', src/main.rs:11:30
오래된 Rust 컴파일러에서는 이 코드가 실행되지만 애초에 올바르지 않았다. 그렇다면 어떻게 해결할까? 컴파일러가 이미 다른 것을 사용하라고 알려준다:
warning: the type `Role` does not permit zero-initialization
--> src/main.rs:11:30
|
11 | let mut role: Role = mem::zeroed();
| ^^^^^^^^^^^^^
| |
| this code causes undefined behavior when executed
| help: use `MaybeUninit<T>` instead, and only call
| `assume_init` after initialization is done
|
왜 이 타입은 제로 초기화를 지원하지 않는가? 무엇을 바꿔야 하나? zeroed는 아예 쓰면 안 되는가? 어떤 분은 struct에 #[repr(C)]를 붙여 C 레이아웃을 강제하면 된다고 생각할지 모르지만, 그건 문제를 해결해 주지 않는다. 실제로는 컴파일러가 말해 주듯 MaybeUninit을 사용해야 한다. 우선 그걸로 시도해 보고, 왜 필요한지 나중에 살펴보자:
rustuse std::mem::MaybeUninit; struct Role { name: String, disabled: bool, flag: u32, } fn main() { let role = unsafe { let mut uninit = MaybeUninit::<Role>::zeroed(); let role = uninit.as_mut_ptr(); (*role).name = "basic".to_string(); (*role).flag = 1; (*role).disabled = false; uninit.assume_init() }; println!("{} ({}, {})", role.name, role.flag, role.disabled); }
zeroed를 MaybeUninit::zeroed로 바꾸자 모든 것이 달라졌다. 더 이상 struct를 직접 다룰 수 없고, 이제는 원시 포인터를 다뤄야 한다. 그 원시 포인터는 Deref를 구현하지 않고, Rust에는 -> 연산자가 없기 때문에, 필드에 값을 대입하려면 저 어색한 문법으로 포인터를 매번 역참조해야 한다.
가장 먼저 묻자. 이제 이 코드는 동작하는가? 그렇다. 하지만 올바른가? 아니다. 무엇이 바뀌었는가? 핵심은, 가변 참조(&mut)나 스택에 있는 값 자체(설령 unsafe 안이라고 해도)처럼 unsafe 바깥에서도 유효해야 할 구성 요소는 언제나 유효한 상태여야 한다는 점이다. zeroed는 0으로 채워진 struct를 반환하지만, 그것이 struct 자체나 그 안의 필드들에 대해 유효한 표현이라는 보장은 없다. 우리의 경우 우연히도 String이 전부 0인 상태에서도 동작했을 뿐인데, 이는 보장되지 않으며 정의되지 않은 동작이다.
중요한 점 하나: 가변 참조는 결코 유효하지 않은 객체를 가리켜서는 안 된다. 따라서 객체가 완전히 초기화되지 않은 상태에서 let role = &mut *uninit.as_mut_ptr() 같은 코드를 쓰는 것도 잘못이다.
그렇다면 zeroed 대신 uninit로 바꿔 보자. 다시 실행하면 크래시가 난다. 왜 그럴까? name에 문자열을 대입하는 순간, 그 전에 있던 오래된 문자열을 drop하기 때문이다. 이전에는 Drop이 우연히 0으로 채워진 문자열을 처리할 수 있었기 때문에 문제가 드러나지 않았을 뿐이고, 사실 우리는 깊은 정의되지 않은 동작 속에 있었다. 그럼 어떻게 해결할까? 해당 포인터에 직접 써야 한다.
MaybeUninit이 필요하다는 사실을 받아들이고, 여기서는 원시 참조를 다뤄야 한다고 하자. 다소 번거롭지만 그렇게 나쁘지는 않아 보인다. 이제 새로운 문제가 둘 생긴다. &mut X는 허용되지 않지만 *mut X는 가능하다는 것을 안다. 그런데 처음부터 &mut X 없이 어떻게 *mut X를 얻을 수 있을까? 아이러니하게도 Rust 1.51까지는 규칙을 어기지 않고는 그런 것을 만들 수 없었다. 오늘날은 addr_of_mut! 매크로를 사용할 수 있다. 그러면 이렇게 할 수 있다:
rustlet name_ptr = std::ptr::addr_of_mut!((*role).name);
좋다. 이제 포인터를 얻었다. 여기에 어떻게 써 넣을까? 대신 write 메서드를 사용할 수 있다:
rustaddr_of_mut!((*role).name).write("basic".to_string());
이제 괜찮을까? 우리가 일반 struct를 사용하고 있다는 점을 기억하자. 문서를 읽어 보면, 그런 struct에 대해 아무런 보장이 없다는 것을 알게 된다. 비록 현재 문서가 뭐라고 말하든 필드가 정렬(aligned)된다는 점은 믿을 수 있는 것으로 보인다. 하지만 만약 #[repr(packed)]를 다루고 있었다면, Rust가 struct의 구성원 중 하나를 비정렬로 배치할 수 있으므로 write_unaligned를 사용해야 하며, 그것은 합법적이다. 따라서 최종 버전은 이렇게 될 수 있다:
rustuse std::mem::MaybeUninit; use std::ptr::addr_of_mut; struct Role { name: String, disabled: bool, flag: u32, } fn main() { let role = unsafe { let mut uninit = MaybeUninit::<Role>::uninit(); let role = uninit.as_mut_ptr(); addr_of_mut!((*role).name).write("basic".to_string()); (*role).flag = 1; (*role).disabled = false; uninit.assume_init() }; println!("{} ({}, {})", role.name, role.flag, role.disabled); }
addr_of_mut!두 가지 경우를 고려해야 한다. 초기화되지 않은 메모리와 비정렬 참조다. 어떤 경우에도(일시적으로라도) 비정렬 참조를 만들 수 없고, 초기화되지 않은 메모리에 대한 참조를 만들 수도 없다. 그렇다면 이런 참조는 언제 만들어지는가?
(*role).flag = 1;처럼 쓰는 것은, 그 타입이 Drop을 구현하지 않는다면 Rust 규칙상 괜찮다. 만약 Drop을 구현한다면 더 큰 문제가 생긴다. Drop::drop이 호출되는데, 초기화되지 않은 메모리에 대해 호출되기 때문이다. 이 경우에는 addr_of_mut!를 통해 가야 한다. 그래서 flag에는 직접 대입할 수 있지만, String인 name의 경우에는 addr_of_mut!를 거쳐야 한다.
MaybeUninit더 큰 메타 이슈로, 안전성에 대한 이해가 시간에 따라 변해 왔다. 한때 mem::uninitialized는 sound(건전한)한 API로 여겨졌다. 이후 발견된 한계를 해결하기 위해 MaybeUninit이 도입되었다. 하지만 MaybeUninit은 부분적으로 초기화된 타입 문제 때문에 실용적으로는 이상적이지 않다. MaybeUninit<T>와 T는 #[repr(transparent)] 덕분에 메모리 상 호환되지만, 중첩 사용과는 잘 어울리지 않는다.
struct의 필드에 MaybeUninit이 필요하지만, 나중에는 그 추상화를 걷어내고 싶어지는 일이 드물지 않다. 실제로 MaybeUninit을 다루는 일은 꽤 도전적인 경험일 수 있으며, 이 블로그 글만으로 그 어려움을 충분히 담아내지 못한다.
지금은 2022년이고, 나는 더 이상 unsafe Rust 코드를 자신 있게 쓸 수 있다고 느끼지 않는다. 규칙은 아마 예전부터 복잡했을 것이다. 하지만 수년 간 많은 unsafe Rust 코드를 읽어 보면서, 대부분의 unsafe 코드는 그런 규칙을 신경 쓰지 않거나 무시해 왔다는 것을 알게 되었다. addr_of_mut!가 1.53까지 언어에 추가되지 않았던 데에는 이유가 있다. 심지어 오늘날에도 문서는 네이티브 rust struct 표현의 정렬에 대해 아무런 보장이 없다고 말한다.
지난 몇 년 동안, Rust 개발자들이 실제로는 unsafe Rust를 더 쓰기 어렵게 만들어 왔고, 규칙이 너무 복잡해져서 일반 프로그래머가 이해하기 어려워졌으며, 그에 관한 문서도 쉽게 오해될 수 있게 되었다. 예컨대 이 글의 이전 버전은 사실 필요하지 않은 addr_of_mut!의 사용이 필요하다고 가정했다. 그리고 누군가 그 실수를 지적하기 전까지 꽤 많이 공유되기도 했다!
이러한 규칙은 Rust의 최고 장점 중 하나를 점점 덜 접근 가능하게 만들고, 이해하기 어렵게 만들었다. 예전의 mem::uninitialized API 대신 굳이 MaybeUninit이 존재해야 한다는 사실은 받아들일 수 있지만, 그것은 언어 규칙이 얼마나 복잡한지를 보여 준다.
이건 좋은 일이 아니라고 생각한다. 사실 점점 더 적은 사람들이 unsafe Rust를 이해하게 되는 이 추세는 전혀 바람직하지 않다고 믿는다. C 상호 운용성은 Rust를 훌륭하게 만든 큰 요소 중 하나였고, 이렇게 거대한 장벽을 만드는 것은 바람직하지 않다. 더 중요한 점은, 내가 잘못하고 있을 때 컴파일러가 별로 도움이 되지 않는다는 것이다.
unsafe를 더 인체공학적(ergonomic)으로 만드는 일은 분명 어려운 문제지만, 해결할 가치가 있을지 모른다. 분명한 사실 하나는 사람들은 당분간 unsafe 코드를 쓰는 일을 멈추지 않을 것이라는 점이다.