Rust의 mem::uninitialized가 사용 중단되고 MaybeUninit이 안정화된 배경을 설명하며, 초기화되지 않은 메모리의 의미와 이론적 모델, Rust에서의 비안전한 활용법, mem::uninitialized가 근본적으로 문제가 있는 이유, 그리고 MaybeUninit을 이용해 올바르게 초기화하는 방법을 다룹니다.
Aria Desires
2019년 5월 21일 -- Rust Nightly 1.36.0
Rust에서 악명 높은 mem::uninitialized 메서드가 오늘자 나이트리 빌드에서 사용 중단(deprecated)되었습니다. 그 대체재인 MaybeUninit은 안정화되었습니다. 만약 기존의 방법을 쓰고 있다면, 가능한 한 빨리(아마 6주 뒤 stable에 도달하면) 새 방법으로 마이그레이션해야 합니다. 이는 mem::uninitialized가 근본적으로 망가져 있으며, 제대로 작동하도록 만들 수 없다고 판단되었기 때문입니다.
이 글의 대부분은 초기화되지 않은 메모리의 본질과 그것을 Rust에서 어떻게 다룰 수 있는지에 대해 설명합니다. mem::uninitialized가 왜 문제인지의 상세한 이유로 바로 건너뛰어도 됩니다.
Rust에서 메모리를 할당하면 그 메모리는 기본적으로 초기화되지 않은 상태로 도착합니다. 이게 정확히 무엇을 의미하는지는 놀랄 만큼 미묘한 문제입니다.
저수준 구현 관점에서 이는 대체로 어떤 메모리 영역이 “당신의 것”으로 선언되었다는 뜻일 뿐이며, 그 메모리가 이전에 다른 용도로 사용되었을 수 있기 때문에 그 안의 비트들이 무엇인지 보장되지 않는다는 의미입니다. 왜 다른 곳의 옛 값들이 여전히 남아있을까요? 메모리를 굳이 지우지 않는 것이 더 빠르고 쉽기 때문입니다.
초기화되지 않은 메모리를 읽으면 본질적으로 무작위 비트를 읽게 되고, 그래서 프로그램이 무작위로 동작할 수 있습니다. 이런 관점에서 초기화되지 않은 메모리를 읽는 것은 거의 항상 심각한 버그입니다. 물론 그렇지 않은 경우를 구성할 수도 있겠지만요.
이 모델은 운영체제로부터 확보한 메모리를 반환하지 않고 프로세스가 내부적으로 재사용하는 단일 프로세스 모델을 전제합니다. 보안상의 이유로, 운영체제로부터 “새로” 확보한 메모리는 모두 0으로 초기화되어 제공되는 것이 보장됩니다. 물론 프로세스 내부에서도 이렇게 하길 바라는 보안 지향적인 사람들도 분명 있습니다.
이론적 관점은 좀 더 엄격하며, 또한 명세가 부실합니다. 요점을 말하자면, 컴파일러는 초기화되지 않은 메모리를 훨씬 더 이국적인 것으로 간주합니다. 메모리는 0, 1, 혹은 미초기화 상태일 수 있습니다. 그러니까 흔히 말하는 세 가지 상태의 불리언 같은 겁니다. 미초기화 상태에서는, 컴파일러는 그 메모리가 원하면 어떤 값이든 가질 수 있다고 가정할 수 있습니다.
예를 들어 단순화 규칙 y & x => y를 적용하고 싶다고 해봅시다. 이를 위해서는 x가 전부 1이라는 것을 증명해야 합니다. 어, 미초기화 메모리라고요? 그럼 그냥 전부 1이라고 가정해버리죠. y | x의 경우에는 전부 0이라고 가정할 수 있습니다. 그 순간 가장 편리한 대로요! 더 논쟁적인 예로, 각 읽기마다 값이 바뀐다고 가정하여 x == x => false와 같은, 평소에는 불가능한 결론도 낼 수 있습니다.
불행히도, 컴파일러가 세상에서 가장 사랑하는 것은 어떤 것이 Undefined Behaviour(정의되지 않은 동작, UB)임을 증명하는 것입니다. Undefined Behaviour이면 공격적인 최적화를 적용하여 모든 것을 빠르게 만들 수 있거든요! 보통은 당신의 코드를 전부 삭제하는 방식으로 말입니다.
그래서 보수적인 모델로는, 초기화되지 않은 메모리에 대해 그것을 그냥 복사하는 것 이외에 무엇이든 하면 정의되지 않은 동작이라고 선언하는 것이 합리적입니다. 그게 전부입니다.
기본적으로 Rust는 초기화되지 않은 메모리를 관측하는 것을 완전히 막습니다. 심지어 실제로는 그것을 다루는 여러 방법을 제공하면서도 말이죠. 하지만 알다시피 Rust에는 unsafe 세계가 있습니다.
제가 아는 한, Rust에는 읽는 것을 막을 수 없는 초기화되지 않은 메모리를 획득하는 unsafe 방법이 3가지 있습니다:
std::alloc::alloc을 호출하면 완전히 새로운 할당에 대한 포인터를 얻게 됩니다. 즉, 초기화되지 않은 메모리에 대한 포인터입니다. 이 메모리를 올바르게 다루려면 write나 copy_from 같은 원시 포인터 메서드로 꼼꼼하게 초기화해야 합니다. 이들 메서드는 대상이 초기화되지 않았다고 가정하고, 그것을 초기화할 수 있게 해줍니다. 메모리가 초기화되지 않았는지 여부를 추적하는 것은 전적으로 여러분의 책임입니다. 이상적으로는, 작업이 끝났을 때 Drop을 구현한 타입의 초기화된 메모리에 대해 read나 drop_in_place를 호출해야 하지만, 기술적으로 호출하지 않아도 허용됩니다.
여기까지는 그다지 복잡하지 않습니다.
반면 태그 없는 유니언은 조금 더 미묘합니다. 대체로 Rust는 유니언을 다른 타입과 동일하게 취급합니다. 그냥 초기화를 건너뛸 수는 없습니다. 다음 코드는 여전히 컴파일되지 않습니다:
union MyUnion {
case1: u32,
case2: u64,
}
unsafe {
let x: MyUnion;
println!("{}", x.case2);
}
error[E0381]: borrow of possibly uninitialized variable: `x`
--> src/main.rs:9:20
|
9 | println!("{}", x.case1);
| ^^^^^^^ use of possibly uninitialized `x.case1`
하지만 유니언의 경우들(case)은 크기가 비대칭적입니다. 작은 경우를 초기화했는데, 큰 경우를 읽으면 어떻게 될까요?
union MyUnion {
case1: u32,
case2: u64,
}
unsafe {
let x = MyUnion { case1: 0 };
println!("{}", x.case2);
}
> 140720308486144
어이쿠! Rust는 우리를 막지 못하며, 따라서 힙 할당 없이도 초기화되지 않은 메모리를 읽을 수 있는 방법이 생깁니다. 흥미롭게도, 이걸 극한까지 밀어붙여 UntaggedOption을 만들 수 있는데, 이를 통해 어떤 값이든 동적으로 초기화할 수 있습니다:
union UntaggedOption<T: Copy> {
none: (),
some: T,
}
unsafe {
let mut x = UntaggedOption { none: () };
if some_condition() {
x.some = 7;
}
// 제발 some_condition 분기를 탔기를!
println!("{}", x.some);
}
C++와 달리 Rust에는 “활성” 유니언 멤버라는 개념이 없습니다. 또한 Rust에는 C++의 엄격한 타입 기반 별칭(aliasing) 규칙도 없습니다. 따라서 Rust에서는 타입 퍼닝(type punning)을 위해 유니언을 자유롭게 사용할 수 있습니다. 단, 초기화되지 않은 메모리를 읽지 않도록 주의하세요! (패딩 바이트 포함)
마침내 이 글의 핵심에 도달했습니다.
mem::uninitialized의 의도된 의미론은, 어떤 메모리를 초기화하는 값을 만들어낸 척하지만 실제로는 아무것도 하지 않는 것입니다. 이렇게 하면 정적 초기화 검사기가 그 메모리가 초기화되었다고 믿게 되지만, 실제 작업은 수행되지 않습니다. 이 함수의 동기는, 컴파일러가 이해하지 못하는 방식으로 동적으로 값을 초기화하되 아무 오버헤드도 없는 경우에 있습니다.
컴파일러 쪽 독자들을 위해 말하면, mem::uninitialized는 단순히 LLVM의 undef로 로워링됩니다.
물론 이를 사용할 때는 특히 “초기화된 척” 하는 타입에 소멸자(Destructor)가 있다면 매우 조심해야 합니다. 그래도 ptr::write와 ptr::read를 조합해 올바르게 사용할 수 있을 것이라고 상상할 수 있습니다. Copy 타입에 대해서는 겉보기에는 그렇게 어렵지 않아 보입니다. 이 기능을 동기 부여한 프로그램은 대략 이런 모습입니다:
unsafe {
// 컴파일러가 우리가 이걸 초기화했다고 믿게 하자!
let mut results: [u32; 16] = std::mem::uninitialized();
for i in 0..16 {
// 복잡한 로직...
results[i] = some_val();
}
// 모든 값은 프로그래머가 신중히 초기화되었음을 증명
println!("{:?}", &results[..]);
}
좋습니다. 실제로 초기화하지 않고도 컴파일러의 안전성 검사를 속여 어떤 값이 초기화되었다고 믿게 해야 하는 특수한 경우가 있다고 합시다. 예를 들어 이렇게 썼다고 해보죠:
unsafe {
// 컴파일러가 우리가 이걸 초기화했다고 믿게 하자!
let mut results: [bool; 16] = std::mem::uninitialized();
for i in 0..16 {
results[i] = some_condition(i);
}
// 모든 값은 프로그래머가 신중히 초기화되었음을 증명
println!("{:?}", &results[..]);
}
제가 이 글을 쓰는 시점에는, 이 프로그램은 컴파일되고 정상적으로 실행됩니다. 안타깝게도, 이 프로그램에는 정의되지 않은 동작이 있습니다. 왜 그럴까요? bool은 _유효하지 않은 값_을 가질 수 있는 원시 타입이기 때문입니다. Rustonomicon의 표현을 빌리면, 다음과 같은 유효하지 않은 원시 값을 만들어내면 정의되지 않은 동작입니다:
fn 포인터boolenum 디스크리미넌트charstr초기화되지 않은 메모리를 컴파일러가 마음대로 어떤 값으로든 만들 수 있다고 말했던 거 기억나시나요? 그리고 컴파일러가 모든 것을 정의되지 않은 동작으로 만들고 싶어 한다는 것도요? 우리가 컴파일러에게 bool이 0이나 1이라고 알려주었기 때문에, 만약 컴파일러가 bool 타입의 값이 초기화되지 않은 메모리라는 것을 증명할 수 있다면, 프로그램이 정의되지 않은 동작임을 성공적으로 증명한 셈이 됩니다. 우리가 그 미초기화 메모리를 읽지 않았다는 사실은 중요하지 않습니다.
그래서 mem::uninitialized는 어쩌면 올바르게 사용할 수 있을지 몰라도, 어떤 타입들에 대해서는 올바르게 사용하는 것이 _불가능_합니다. 따라서 우리는 이것을 휴지통에 버리려 합니다. 잘못된 설계입니다. 대체재인 MaybeUninit을 사용해야 합니다.
그리고 아주 분명히 하자면, Unsafe Code Guidelines 팀도 u32 같은 항상-유효(always-valid) 타입에 대해서조차 mem::uninitialized가 사용 가능하다고 확신하지 못합니다. 단지 bool 같은 타입에서는 더 명백하게 사용할 수 없을 뿐입니다. 그러니 그런 타입을 절대 다루지 않을 거라고 자신하더라도 안전을 위해 MaybeUninit을 사용하세요.
태그 없는 유니언 섹션에서, 극단적으로는 UntaggedOption 타입을 만들 수 있다고 했습니다:
union UntaggedOption<T: Copy> {
none: (),
some: T,
}
알고 보니 MaybeUninit도 본질적으로 이것과 같습니다. 정확히는 다음과 같이 정의되어 있습니다:
pub union MaybeUninit<T> {
uninit: (),
init: ManuallyDrop<T>,
}
하지만 그게 다입니다. 컴파일러는 이를 특별한 타입으로조차 인지하지 않습니다. 그냥 더미 “uninit” 케이스를 가진 유니언일 뿐입니다. 이것으로, 우리는 프로그램을 올바르게 만들 수 있습니다:
use std::mem::MaybeUninit;
unsafe {
// 유니언을 비어 있는(uninit) 케이스로 초기화한다고 컴파일러에 알리기
let mut results = MaybeUninit::<[bool; 16]>::uninit();
// 매우 신중하게: 모든 메모리를 초기화할 것
// 하지 말 것: &mut [bool; 16]를 만들기
// 하지 말 것: &mut bool을 만들기
let arr_ptr = results.as_mut_ptr() as *mut bool;
for i in 0..16 {
arr_ptr.add(i).write(some_condition(i));
}
// 모든 값은 프로그래머가 신중히 초기화되었음을 증명
let results_ref = &*results.as_ptr();
println!("{:?}", results_ref);
}
이게 mem::uninitialized를 썼을 때와 무엇이 다른가요? 컴파일러가 우리의 메모리가 “bool 배열이거나, 아니면 아무것도 아니다”라는 타입이라는 것을 명확히 볼 수 있기 때문입니다. 그래서 그 메모리가 특정한 값을 반드시 가져야 한다고 단정하지 않습니다.
비슷한 이유로 이는 enum 레이아웃 최적화 같은 것도 억제합니다. 따라서 size_of::<Option<bool>>() != size_of::<Option<MaybeUninit<bool>>>() (1 != 2)입니다.
여전히 주의해야 합니다. 아직 최종 확정은 아니지만, 참조의 선호되는 의미론은 컴파일러가 참조가 댕글링이 아니고 유효한 메모리를 가리킨다고 가정할 수 있게 합니다. 이런 의미론하에서는 &mut [bool; u16]이나 &mut bool을 만들면 정의되지 않은 동작이 될 수 있습니다.
이 문제를 피하기 위해, 힙 할당을 초기화할 때와 같은 방식으로 원시 포인터만 사용해 메모리를 조작합니다. arr[i] = x가 참조를 생성하지 않는다고 자신 있게 말할 수 있을지 100% 확신이 서지 않아서, 안전을 위해 포인터 연산을 사용했습니다.
끔찍하게 unsafe이지만 확실하고, 절대적이며, 엄밀하게 올바름이 증명된 프로그램을 즐겁게 작성하시길!