초기화되지 않은 메모리가 무엇인지, 그리고 Rust, C, C++ 같은 고성능 언어에서 "하드웨어가 하는 일"로 사고하는 것이 왜 잘못인지 설명한다. 추상 기계와 정의되지 않은 동작(UB), 최적화와의 관계, 그리고 Rust에서의 안전한 다루기 방식까지 다룬다.
이 글은 초기화되지 않은 메모리에 관한 것이지만, 고도로 최적화된 “저수준” 언어 전반의 의미론에 관한 이야기이기도 하다. Rust, C, C++ 같은 언어에 관해 이야기할 때 “하드웨어가 하는 일”로 추론하는 방식이 본질적으로 잘못되었음을 설득해 보려 한다. 이런 언어들은 저수준 언어가 아니다. 나는 이전에 포인터 맥락에서 이 점을 이야기한 적이 있는데, 이번에는 초기화되지 않은 메모리에 대해 이야기해 보겠다.
이 글을 쓰게 된 계기는 Rust 1.36에서 mem::uninitialized()이 폐지된 것이지만, 이 글은 Rust뿐만 아니라 C/C++에도 똑같이 중요하다.1
어떤 메모리를 할당(스택이든 힙이든)했는데 초기화하지 않았다면, 그 내용은 무엇일까? 우리는 이를 “초기화되지 않은 메모리”라고 부르지만, 정확히 무엇을 의미하며 그것을 읽으면 무슨 일이 벌어질까? 많은 언어에서는 이 질문이 무의미하다. Java, Haskell, OCaml 등 모든 안전한 언어에서는 초기화되지 않은 메모리를 읽을 수 없으며, 타입 시스템이 이를 막는다. 사실 안전한 Rust도 마찬가지다. 하지만 unsafe Rust나 C, C++처럼 본질적으로 안전하지 않은 언어에서는 메모리를 굳이 초기화하지 않는 것이 중요한 최적화가 될 수 있으므로, 매우 중요한 질문이다.
C와 C++ 명세는(세부 사항을 모두 다루지는 않겠지만) 초기화되지 않은 메모리를 “불확정(indeterminate)”이라고 말하지만, 그것이 정확히 무엇을 뜻하는지는 불명확하다. 많은 사람은 “초기화되지 않은 메모리에는 무작위 비트 패턴이 들어 있다”고 말하곤 한다. 이는 틀렸다. 어떤 이는 시스템 할당자나 OS 커널이 프로그램이 사용할 페이지를 어떻게 할당하는지 이야기할지도 모른다. 그것은 전혀 관련 없는 정보다.2
다음 예제는 왜 “무작위 비트 패턴”이 초기화되지 않은 메모리를 설명할 수 없는지를 보여준다:
use std::mem;
fn always_returns_true(x: u8) -> bool {
x < 120 || x == 120 || x > 120
}
fn main() {
let x: u8 = unsafe { mem::MaybeUninit::uninit().assume_init() };
assert!(always_returns_true(x));
}
업데이트(2022-11-17): 최신 버전의 Rust에서 예제가 동작하도록 MaybeUninit로 변경.
업데이트(2024-10-18): Rust 1.82에서 동작하는 버전은 여기 참고.
always_returns_true는 분명히 가능한 모든 8비트 부호 없는 정수에 대해 true를 반환하는 함수다. 결국 x의 모든 가능한 값은 120보다 작거나, 120과 같거나, 120보다 크다. 간단한 루프를 돌려 보면 이 사실이 확인된다. 하지만 예제를 실행해 보면 단언이 실패하는 것을 볼 수 있다.3
어떻게 이럴 수 있을까? 답은, 우리 프로그램의 동작을 규정하기 위해 사용하는 “추상 기계(abstract machine)”에서는 메모리의 모든 바이트가 0..256(Rust 문법으로 좌측 포함 우측 제외 구간)을 값으로 가질 뿐 아니라 “초기화되지 않음” 상태일 수도 있다는 것이다. 메모리는 당신이 그것을 초기화했는지를 “기억”한다. always_return_true에 전달된 x는 어떤 숫자의 8비트 표현이 아니라, 초기화되지 않은 바이트다. 초기화되지 않은 바이트에 대해 비교 같은 연산을 수행하는 것은 정의되지 않은 동작(UB)이다. 그 결과, 우리의 프로그램은 UB를 가지므로 이상하게 동작해도 놀랄 일이 아니다.
물론 이런 UB에는 이유가 있다. 추상 기계가 이렇게 정의된 데에는 이유가 있다. 컴파일러가 단지 개발자를 괴롭히려는 것이 아니다. 초기화되지 않은 데이터에 대한 비교 같은 연산을 금지하는 것은 유용하다. 그 덕분에 컴파일러는 초기화되지 않은 변수의 정확한 비트 패턴을 “기억”할 필요가 없기 때문이다! 올바르게 작성된(UB가 없는) 프로그램은 어차피 그 비트 패턴을 관찰할 수 없다. 그래서 초기화되지 않은 변수가 한 번 사용될 때마다 그냥 아무 기계 레지스터나 사용해도 된다 — 그리고 서로 다른 사용에서는 서로 다른 레지스터를 쓸 수도 있다! 우리의 예제에서는 프로그램이 이렇게 “관찰 불가능한” 비트 패턴을 상수와 비교하므로, 컴파일러는 그 결과를 마음대로 상수 접기(constant folding)할 수 있다. 값이 “불안정(unstable)”하도록 허용되었기 때문에, 컴파일러는 두 번의 비교에서 “일관된 선택”을 할 필요가 없다. 만약 일관성을 강제하면 이런 최적화를 적용하기 훨씬 어려울 것이다. 그래서 어떤 순간에 x를 “보면” 컴파일러는 그것이 최소 150이라고 가장할 수 있고, 다시 보면 최대 120이라고 할 수 있다. 비록 x 자체는 바뀌지 않았음에도 말이다. 이것이 컴파일된 예제 프로그램이 그런 식으로 동작하는 이유다. 이 LLVM 문서는 “불안정한” 초기화되지 않은 메모리를 더 동기부여해 준다.
Rust(또는 C, C++)에 대해 생각할 때는 실제 하드웨어가 아니라 “추상 기계”의 관점에서 생각해야 한다. 메모리의 모든 바이트는 0..256의 어떤 값으로 초기화되어 있거나, 아니면 초기화되지 않았다고 상상하라. 메모리가 각 위치에 Option<u8>을 저장한다고 생각해도 좋다.4 지역 변수(스택)나 힙에 새 메모리가 할당될 때, 사실 무작위로 일어나는 일은 아무것도 없고 모든 것이 완전히 결정적이다. 이 메모리의 모든 단일 바이트는 _초기화되지 않음_으로 표시된다. 모든 위치가 None을 저장하는 셈이다. (LLVM에서 이 None은 poison에 해당하며, 이는 장차 undef를 완전히 대체할 잠재력이 있다.)
안전한 Rust를 쓸 때는 이런 것에 신경 쓸 필요가 없지만, unsafe 코드에서 초기화되지 않은 메모리를 다룰 때 마음속에 가져야 할 좋은 모델이 바로 이것이다. Alexis가 Rust에서 이를 다룰 때 어떤 API를 써야 하는지에 관해 훌륭한 글을 썼다. 여기서 모두 반복할 필요는 없다. (그 글에서 Alexis는 각 _비트_가 0, 1, 초기화되지 않음 중 하나일 수 있다고 말하는데, 각 _바이트_가 초기화되었는지 여부만 구분하는 모델과 다르게 보인다. 하지만 메모리 접근은 바이트 단위로 일어나므로, 적어도 C 스타일 비트필드가 없는 Rust에서는 두 모델이 사실상 동일하다.)
이제 초기화되지 않은 메모리에 관해 구체적으로 이야기할 수 있게 되었으니, 초기화되지 않은 바이트를 포함하는 값에 대해 어떤 연산이 허용되는지 이야기해 보자. 내가 C/C++ 규칙을 해석한 바, 그리고 Rust에 대한 내 제안은, 정수의 “값”에 작동하는 모든 연산(산술 및 논리 연산, 비교, 조건 분기)은 입력 중 하나라도 초기화되지 않았다면 UB라는 것이다. 특히 x가 초기화되지 않았다면 x + 0도 UB다. 하지만 이로써도 여러 질문이 남는다. 예컨대 Rust에서 초기화되지 않은 u8을 만들기만 해도 UB인지(이는 활발히 논의 중이다), 혹은 입력의 일부 바이트만 초기화되지 않았을 때는 어떻게 되는지 등이다. 시간이 지나면 여기서 어떤 식으로든 타협점에 도달할 것이다. 그러나 중요한 점은(Rust와 C/C++ 모두에게) 우리가 _초기화되지 않은 메모리가 무엇인지_에 대한 명확한 정신 모델을 갖고 이 논의를 진행해야 한다는 것이다. Rust는 이 점에서 좋은 길을 가고 있다고 본다. C/C++ 위원회도 언젠가 따라오길 바란다.
초기화되지 않은 값에 대한 모든 연산을 금지하면 이 귀여운 자료구조를 구현하는 것도 불가능해진다. 이 글의 is-member 함수는 초기화되지 않은 값(sparse[i])을 두 번 “관찰”하면 같은 결과를 얻을 수 있다는 가정을 전제하는데, 위에서 본 것처럼 이는 사실이 아니다. 이는 어떤 데이터든 주어지면 초기화되지 않은 바이트를 어떤 비결정적으로 선택된 초기화된 바이트로 바꾸는 “freeze” 연산을 제공함으로써 고칠 수 있다. 이를 “freeze(동결)”라고 부르는 이유는 그 효과가 “값을 볼 때마다 바뀌던 것이 멈추게” 하기 때문이다. is-member는 sparse[i]를 한 번 동결한 다음, 그 값을 두 번 “보면” 반드시 일관된 결과를 얻는다고 확신할 수 있다. 불행하게도 C/C++은 자신들의 메모리 모델이 실제로 어떤 모습인지 인정하지 않기 때문에, “freeze” 같은 결정적인 연산이 컴파일러에서 공식적으로 지원되지 않는다. 최소한 LLVM에서는 변화가 있을 수도 있다.
이 글에서 가장 중요한 교훈은, Rust/C/C++ 프로그램이 무엇을 하는지를 논의할 때, 이미 UB가 없다는 것을 입증하지 않았다면 “하드웨어가 하는 일”은 대부분의 경우 _무관_하다는 점이다. 물론 하드웨어(정확히는 대부분의 하드웨어)에는 “초기화되지 않은 메모리”라는 개념이 없다. 하지만 당신이 작성한 Rust 프로그램은 하드웨어에서 실행되지 않는다. 그것은 Rust의 추상 기계에서 실행되며, 이 기계(우리 마음속에만 존재한다)는 “초기화되지 않은 메모리”라는 개념을 가지고 있다. 최종적으로 컴파일된 프로그램이 실제 물리 하드웨어에서 실행되긴 하지만, 그것은 이 추상 기계를 매우 효율적이지만 부정확하게 구현한 것에 불과하다. 그리고 Rust의 UB 규칙들은 이 부정확함이 올바른(UB 없는) 프로그램에는 보이지 않도록 함께 작동한다. 하지만 UB가 있는 프로그램에서는 이 “환상”이 깨지고, 무슨 일이든 벌어질 수 있다.
UB가 없는 프로그램만이 어셈블리를 보고 의미를 파악할 수 있으며, 프로그램에 UB가 _있는지 여부_는 그 수준에서 판단할 수 없다. 이를 위해서는 추상 기계의 관점에서 생각해야 한다.5
이것은 초기화되지 않은 메모리뿐 아니라 다른 것에도 적용된다. 예컨대 x86 어셈블리에서는 “relaxed”와 “release/”acquire“-스타일 원자적 메모리 접근 사이에 차이가 없다. 하지만 Rust 프로그램을 작성할 때, 심지어 x86으로만 컴파일할 의도로 Rust 프로그램을 작성하더라도, 프로그램에 UB가 있다면 “하드웨어가 하는 일”은 아무 의미가 없다. Rust의 추상 기계는 “relaxed”와 “release/”acquire“를 _구분_하며, 그 사실을 무시하면 프로그램은 잘못될 것이다. 결국 x86에는 “초기화되지 않은 바이트”도 없지만, 우리의 예제 프로그램은 여전히 잘못되었다.
물론 추상 기계가 왜 그렇게 정의되어 있는지 설명하려면 최적화나 하드웨어 수준의 고려를 살펴봐야 한다. 하지만 추상 기계 없이 컴파일러가 수행하는 모든 최적화가 서로 일관성을 유지하도록 보장하는 것은 매우 어렵다 — 실제로 LLVM과 GCC 모두에서, 각각은 개별적으로는 문제가 없어 보이지만 함께 결합되면 잘못된 코드 생성을 유발하는 최적화들의 조합으로 인한 오컴파일 문제가 발생한다. 추상 기계는 어떤 최적화들을 서로 안전하게 결합할 수 있는지 결정하는 최종 심판으로 필요하다. 또한 unsafe 코드를 작성할 때, 언제든 바뀔 수 있고 어떤 순서로 적용될지 모르는 최적화 집합보다 고정된 추상 기계를 머릿속에 유지하는 편이 훨씬 쉽다고도 생각한다.
안타깝게도, 내 생각에 Rust/C/C++에서 UB를 둘러싼 논의는 이 언어들의 “추상 기계”가 구체적으로 어떤 모습인지에 충분히 집중하지 못하고 있다. 대신 사람들은 종종 하드웨어 동작과 허용되는 최적화 집합에 의해 그것이 어떻게 달라질 수 있는지 이야기한다. 그러나 컴파일러가 수행하는 최적화는 새로운 기법이 발견됨에 따라 바뀌며, 이러한 기법들이 허용되는지는 추상 기계가 정한다. C/C++에는 UB의 많은 경우를 매우 자세히 설명하는 방대한 표준이 있지만, C/C++ 추상 기계의 메모리가 순진하게 예상할 수 있는 u8이 아니라 Option<u8>을 저장한다는 말은 어디에도 없다. Rust에서는 Miri가 있어 매우 기쁘다. Miri는 Rust 추상 기계를 직접 구현(또는 거의 그에 가깝게 구현)하는 것을 목표로 하며, 나는 Rust 명세(작업이 시작되면)를 이 기계를 매우 명시적으로 드러내는 방식으로 작성하자고 강력히 주장하고 있다. C/C++도 결국 그렇게 하길 바란다. 그 방향의 훌륭한 작업이 있긴 하지만, 그것이 표준 자체에 어느 정도까지 영향을 미칠지는 두고 봐야 한다.
부탁이 있다면, 이 내용을 널리 퍼뜨려 달라! 나는 Rust 논의에서 “하드웨어가 하는 일”이라는 신화를 볼 때마다 열심히 반박하려 하지만, 모든 논의를 다 볼 수는 없다 — 그러니 다음에 그런 주장을 보게 되면, 초기화되지 않은 메모리든 동시성이든 경계 밖 메모리 접근이든 무엇이든, UB가 얽혀 있다면 논의를 “Rust 추상 기계가 무엇을 하는가”로 이끌어 주고, 프로그래머와 최적화 컴파일러 모두에게 가장 유용하도록 Rust 추상 기계를 어떻게 설계하고 조정할 수 있을지에 관해 도와주길 바란다.
언제나처럼 의견, 제안, 질문이 있다면 포럼에서 알려 달라.
이 폐지는 2년이 넘게 준비해 왔고, 내가 이를 밀어붙이기 시작한 지 거의 1년이 지났다. 드디어 여기까지 와서 매우 기쁘다
분명히 하자면, 내가 누군가를 오해했다고 비난하려는 것이 아니다. 잘못된 정보가 너무 많고, 표준은 지독하게 모호하다. 그래서 이 글을 쓰는 것이다.↩
미래의 Rust 버전에서 상황이 바뀔 경우를 대비해, 여기에 동일한 예제를 godbolt로 올려 두었다. xor eax, eax는 함수가 0, 즉 false를 반환함을 나타낸다. 그리고 C++ 버전은 여기.↩
사실 바이트는 그것보다도 더 복잡하지만, 그것은 다른 주제다.↩
이는 최종 어셈블리에서 동작하는 valgrind 같은 도구가 결코 모든 UB를 신뢰성 있게 탐지할 수는 없음을 시사한다.↩