Unsafe Rust
Unsafe Rust
지금까지 다룬 모든 코드는 컴파일 시점에 Rust의 메모리 안전성 보장이 적용됐어요. 그런데 Rust에는 그 안에 두 번째 언어가 숨어 있는데, 바로 이런 메모리 안전성 보장이 적용되지 않는 unsafe Rust예요. 일반 Rust와 똑같이 동작하지만, 안전 코드에서는 할 수 없는 '초능력'을 하나 더 쓸 수 있죠.
unsafe Rust가 존재하는 이유는, 근본적으로 정적 분석이 보수적일 수밖에 없기 때문이에요. 컴파일러가 코드가 보장을 지키는지 판단할 때, 유효하지 않은 프로그램을 받아들이는 것보다 유효한 프로그램 몇 개를 거부하는 편이 낫다고 판단해요. 코드가 어쩌면 괜찮을지라도, Rust 컴파일러가 충분히 확신할 정보가 없으면 그 코드를 거부하죠. 그럴 때 unsafe 코드로 "믿어줘, 나는 내가 뭘 하는지 알아."라고 컴파일러에게 말할 수 있어요. 다만 경고하자면, unsafe Rust는 온전히 여러분의 책임이에요. unsafe 코드를 잘못 쓰면 널 포인터 역참조 같은 메모리 불안정으로 인한 문제가 생길 수 있거든요.
Rust에 unsafe라는 또 다른 자아가 있는 또 다른 이유는, 밑바닥의 컴퓨터 하드웨어 자체가 본질적으로 안전하지 않기 때문이에요. Rust가 unsafe 연산을 허용하지 않는다면, 여러분은 어떤 작업도 수행할 수 없게 돼요. Rust는 운영 체제와 직접 상호작용하거나, 운영 체제를 직접 작성하는 것 같은 저수준 시스템 프로그래밍을 허용할 필요가 있어요. 저수준 시스템 프로그래밍을 다루는 건 이 언어의 목표 중 하나죠. unsafe Rust로 무엇을 할 수 있는지, 그리고 어떻게 하는지 살펴볼게요.
출처: The Rust Book
unsafe 초능력 수련하기 (Performing Unsafe Superpowers)
unsafe Rust로 전환하려면 unsafe 키워드를 쓰고 unsafe 코드를 담을 새 블록을 시작하면 돼요. unsafe Rust에서만 할 수 있고 안전 Rust에서는 할 수 없는 다섯 가지 동작이 있는데, 이를 unsafe 초능력이라 불러요. 그 초능력은 다음과 같아요.
- 원시 포인터(raw pointer) 역참조하기
- unsafe 함수나 메서드 호출하기
- 가변 정적 변수(mutable static variable)에 접근하거나 수정하기
- unsafe 트레이트 구현하기
union의 필드에 접근하기
중요한 건, unsafe가 대여 검사기(borrow checker)를 끄거나 Rust의 다른 안전 검사를 비활성화하지 않는다는 점이에요. unsafe 코드에서 참조를 쓰면 여전히 검사가 이루어져요. unsafe 키워드는 단지 컴파일러가 메모리 안전성 측면에서 검사하지 않는 이 다섯 가지 기능에 접근할 수 있게 해줄 뿐이에요. 그래서 unsafe 블록 안에서도 어느 정도의 안전성은 유지되죠.
게다가 unsafe는 블록 안의 코드가 반드시 위험하거나 확실히 메모리 안전성 문제를 일으킨다는 뜻도 아니에요. 의도는 여러분이 프로그래머로서 unsafe 블록 안의 코드가 메모리에 유효한 방식으로 접근할 것을 보장한다는 거예요.
사람은 실수를 하기 마련이죠. 하지만 이런 unsafe 연산 다섯 가지가 unsafe로 표시된 블록 안에 있어야 한다는 요구 덕분에, 메모리 안전성과 관련된 어떤 오류든 반드시 unsafe 블록 안에 있다는 걸 알 수 있어요. unsafe 블록은 작게 유지하세요. 나중에 메모리 버그를 조사할 때 고마워하게 될 거예요.
unsafe 코드를 최대한 격리하려면, 그런 코드를 안전한 추상화(safe abstraction) 안에 감싸고 안전한 API를 제공하는 게 좋아요. 이건 이번 장에서 unsafe 함수와 메서드를 살펴볼 때 더 다룰게요. 표준 라이브러리의 일부는 감사(audit)를 거친 unsafe 코드 위에 안전한 추상화로 구현되어 있어요. unsafe 코드를 안전한 추상화로 감싸면, unsafe 사용이 여러분이나 여러분의 사용자가 그 기능을 쓰고 싶어 할 모든 곳으로 새어 나가는 걸 막을 수 있어요. 안전한 추상화를 쓰는 건 안전하니까요.
이제 다섯 가지 unsafe 초능력을 하나씩 살펴볼게요. unsafe 코드에 안전한 인터페이스를 제공하는 추상화 몇 개도 함께 볼 거예요.
원시 포인터 역참조하기 (Dereferencing a Raw Pointer)
4장의 "Dangling References" 절에서, 컴파일러가 참조가 항상 유효함을 보장한다고 언급했었어요. unsafe Rust에는 참조와 비슷한 원시 포인터라는 새 타입이 두 개 있어요. 참조와 마찬가지로 원시 포인터는 불변 또는 가변이 가능하며, 각각 *const T와 *mut T로 써요. 별표는 역참조 연산자가 아니라 타입 이름의 일부예요. 원시 포인터 맥락에서 *불변(immutable)*이라는 말은 포인터가 역참조된 뒤 직접 할당할 수 없다는 뜻이에요.
참조나 스마트 포인터와 달리, 원시 포인터는:
- 같은 위치에 불변 포인터와 가변 포인터, 또는 여러 가변 포인터를 두는 등 대여 규칙을 무시할 수 있어요
- 유효한 메모리를 가리킨다고 보장되지 않아요
- null이 될 수 있어요
- 자동 정리를 구현하지 않아요
Rust가 이런 보장을 시행하지 않도록 선택하면, 보장된 안전성을 포기하는 대신 더 높은 성능이나, Rust의 보장이 적용되지 않는 다른 언어·하드웨어와 인터페이스할 수 있는 능력을 얻게 돼요.
Listing 20-1은 불변 원시 포인터와 가변 원시 포인터를 만드는 방법을 보여줘요.
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
}
이 코드에는 unsafe 키워드가 없다는 걸 눈여겨보세요. 안전 코드에서도 원시 포인터를 만들 수 있어요. 다만 대부분의 경우처럼 unsafe 블록 밖에서는 원시 포인터를 역참조할 수 없을 뿐이에요.
원시 대여 연산자(raw borrow operator)로 원시 포인터를 만들었는데, &raw const num은 *const i32 불변 원시 포인터를, &raw mut num은 *mut i32 가변 원시 포인터를 만들어요. 지역 변수에서 직접 만들었기 때문에 이 특정 원시 포인터들은 유효하다는 걸 알지만, 어떤 원시 포인터든 유효하다고 가정할 수는 없어요.
이를 보여주기 위해, 원시 대여 연산자 대신 as 키워드로 값을 캐스팅해 그 유효성을 확신할 수 없는 원시 포인터를 만들어볼게요. Listing 20-2는 메모리의 임의 위치를 가리키는 원시 포인터를 만드는 방법이에요. 임의의 메모리를 사용하는 건 정의되지 않은 동작(undefined)이에요. 그 주소에 데이터가 있을 수도 없을 수도 있고, 컴파일러가 메모리 접근이 없도록 코드를 최적화할 수도 있으며, 프로그램이 세그멘테이션 폴트로 종료될 수도 있어요. 보통 이런 코드를 쓸 이유는 없어요. 특히 원시 대여 연산자를 쓸 수 있는 경우에는요. 하지만 가능하긴 해요.
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
}
안전 코드에서 원시 포인터를 만들 수 있지만, 원시 포인터를 역참조해 가리키는 데이터를 읽을 수는 없다는 걸 떠올려보세요. Listing 20-3에서는 unsafe 블록이 필요한 원시 포인터에 역참조 연산자 *를 사용해요.
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
unsafe {
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
}
포인터를 만드는 것 자체는 해가 없어요. 문제가 생길 수 있는 건 그 포인터가 가리키는 값에 접근하려 할 때죠.
또 Listing 20-1과 20-3에서는 num이 저장된 같은 메모리 위치를 가리키는 *const i32와 *mut i32 원시 포인터를 만들었다는 점도 주목하세요. 만약 num에 대한 불변 참조와 가변 참조를 만들려 했다면 그 코드는 컴파일되지 않았을 거예요. Rust의 소유권 규칙이 가변 참조와 불변 참조를 동시에 허용하지 않기 때문이죠. 원시 포인터로는 같은 위치에 가변 포인터와 불변 포인터를 나란히 두고, 가변 포인터를 통해 데이터를 바꿀 수 있어요. 그러면 데이터 경합(data race)이 생길 수도 있어요. 조심하세요!
이렇게 위험한데 왜 원시 포인터를 쓰는 걸까요? 가장 큰 용도 중 하나는 다음 절에서 볼 C 코드와의 인터페이스예요. 또 다른 경우는 대여 검사기가 이해하지 못하는 안전한 추상화를 만들 때예요. unsafe 함수를 소개한 다음, unsafe 코드를 쓰는 안전한 추상화의 예시를 살펴볼게요.
unsafe 함수 또는 메서드 호출하기 (Calling an Unsafe Function or Method)
unsafe 블록에서 수행할 수 있는 두 번째 유형의 연산은 unsafe 함수를 호출하는 것이에요. unsafe 함수와 메서드는 일반 함수·메서드와 똑같이 생겼지만, 정의의 나머지 앞에 unsafe가 하나 더 붙어요. 이 맥락의 unsafe 키워드는 이 함수를 호출할 때 우리가 지켜야 할 요구사항이 있다는 뜻이에요. Rust가 이 요구사항을 충족했는지 보장할 수 없기 때문이죠. unsafe 블록 안에서 unsafe 함수를 호출하는 건, 함수의 문서를 읽었고 함수의 계약을 지키는 책임을 진다는 뜻이에요.
다음은 본문에서 아무것도 하지 않는 dangerous라는 unsafe 함수예요.
fn main() {
unsafe fn dangerous() {}
unsafe {
dangerous();
}
}
dangerous 함수는 별도의 unsafe 블록 안에서 호출해야 해요. unsafe 블록 없이 호출하려 하면 다음과 같은 오류가 나요.
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
--> src/main.rs:4:5
|
4 | dangerous();
| ^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error
unsafe 블록을 쓰는 건 Rust에 "나는 이 함수의 문서를 읽었고, 제대로 쓰는 법을 이해했으며, 함수의 계약을 지키고 있음을 확인했다"고 단언하는 것과 같아요.
unsafe 함수의 본문 안에서 unsafe 연산을 수행하려면 일반 함수 안에서와 마찬가지로 여전히 unsafe 블록을 써야 해요. 잊어버리면 컴파일러가 경고해 주죠. 이는 unsafe 블록을 가능한 한 작게 유지하는 데 도움이 돼요. 함수 본문 전체에 unsafe 연산이 필요한 건 아닐 수 있으니까요.
unsafe 코드 위에 안전한 추상화 만들기 (Creating a Safe Abstraction over Unsafe Code)
함수에 unsafe 코드가 들어 있다고 해서 함수 전체를 unsafe로 표시해야 한다는 뜻은 아니에요. 사실 unsafe 코드를 안전한 함수로 감싸는 건 흔한 추상화 패턴이에요. 예시로 표준 라이브러리의 split_at_mut 함수를 살펴볼게요. 이 함수는 일부 unsafe 코드가 필요해요. 어떻게 구현할 수 있는지 탐구해보죠. 이 안전한 메서드는 가변 슬라이스에 정의되어 있어요. 슬라이스 하나를 받아 인자로 주어진 인덱스에서 나눠 두 개로 만드는 거죠. Listing 20-4는 split_at_mut을 사용하는 방법을 보여줘요.
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = r.split_at_mut(3);
assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}
이 함수는 안전한 Rust만으로는 구현할 수 없어요. 시도해 보면 Listing 20-5처럼 될 텐데, 컴파일되지 않아요. 단순하게 하기 위해 split_at_mut을 메서드가 아니라 함수로 구현하고, 제네릭 타입 T가 아니라 i32 값의 슬라이스에 대해서만 구현할게요.
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
assert!(mid <= len);
(&mut values[..mid], &mut values[mid..])
}
fn main() {
let mut vector = vec![1, 2, 3, 4, 5, 6];
let (left, right) = split_at_mut(&mut vector, 3);
}
이 함수는 먼저 슬라이스의 전체 길이를 구해요. 그리고 인자로 받은 인덱스가 길이보다 작거나 같은지 확인해 슬라이스 안에 있는지 단언해요. 이 단언 덕분에 슬라이스를 나눌 길이보다 큰 인덱스를 넘기면, 함수는 그 인덱스를 쓰기 전에 패닉이 나요.
그 다음 가변 슬라이스 두 개를 튜플로 반환해요. 하나는 원래 슬라이스의 시작부터 mid 인덱스까지, 다른 하나는 mid부터 슬라이스 끝까지예요.
Listing 20-5의 코드를 컴파일하려 하면 다음과 같은 오류가 나요.
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
--> src/main.rs:6:31
|
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
| - let's call the lifetime of this reference `'1`
...
6 | (&mut values[..mid], &mut values[mid..])
| --------------------------^^^^^^--------
| | | |
| | | second mutable borrow occurs here
| | first mutable borrow occurs here
| returning this value requires that `*values` is borrowed for `'1`
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices
For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error
Rust의 대여 검사기는 우리가 슬라이스의 다른 부분을 대여하고 있다는 걸 이해하지 못해요. 같은 슬라이스에서 두 번 대여한다는 것만 알죠. 슬라이스의 다른 부분을 대여하는 건 두 슬라이스가 겹치지 않으므로 근본적으로 괜찮지만, Rust는 그걸 알 만큼 똑똑하지 않아요. 코드가 괜찮다는 걸 우리는 알지만 Rust가 모를 때, 바로 unsafe 코드를 쓸 때예요.
Listing 20-6은 unsafe 블록, 원시 포인터, unsafe 함수 호출 몇 개로 split_at_mut 구현이 동작하게 만드는 방법을 보여줘요.
use std::slice;
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
fn main() {
let mut vector = vec![1, 2, 3, 4, 5, 6];
let (left, right) = split_at_mut(&mut vector, 3);
}
4장의 "The Slice Type" 절에서, 슬라이스는 어떤 데이터에 대한 포인터와 슬라이스의 길이로 이루어진다는 걸 떠올려보세요. len 메서드로 슬라이스의 길이를 얻고, as_mut_ptr 메서드로 슬라이스의 원시 포인터에 접근해요. 이 경우 i32 값의 가변 슬라이스가 있으므로 as_mut_ptr은 *mut i32 타입의 원시 포인터를 반환하고, 우리는 이를 ptr 변수에 저장해요.
mid 인덱스가 슬라이스 안에 있다는 단언은 유지해요. 그 다음 unsafe 코드로 들어가는데, slice::from_raw_parts_mut 함수는 원시 포인터와 길이를 받아 슬라이스를 만들어요. 이 함수로 ptr에서 시작해 mid개 항목만큼 길이인 슬라이스를 만들어요. 그다음 ptr의 add 메서드를 mid 인자로 호출해 mid에서 시작하는 원시 포인터를 얻고, 그 포인터와 mid 이후의 남은 항목 수를 길이로 사용해 슬라이스를 만들어요.
slice::from_raw_parts_mut 함수는 원시 포인터를 받아 그 포인터가 유효하다고 믿어야 하기 때문에 unsafe해요. 원시 포인터의 add 메서드도 옵셋 위치가 유효한 포인터라고 믿어야 하기 때문에 unsafe해요. 그래서 호출할 수 있도록 slice::from_raw_parts_mut와 add 호출 주위에 unsafe 블록을 둘러야 했어요. 코드를 살펴보고 mid가 len보다 작거나 같아야 한다는 단언을 추가함으로써, unsafe 블록 안에서 쓰이는 모든 원시 포인터가 슬라이스 안의 데이터를 가리키는 유효한 포인터임을 알 수 있어요. 이는 unsafe의 적절하고 적합한 사용이에요.
결과적으로 만들어진 split_at_mut 함수를 unsafe로 표시할 필요는 없고, 안전한 Rust에서 이 함수를 호출할 수 있다는 점도 주목하세요. 우리는 unsafe 코드에 접근할 수 있는 데이터에서 유효한 포인터만 만들기 때문에, 안전한 방식으로 내부의 unsafe 코드를 쓰는 함수 구현으로 unsafe 코드에 대한 안전한 추상화를 만든 거예요.
반면 Listing 20-7의 slice::from_raw_parts_mut 사용은 슬라이스를 사용할 때 아마 크래시가 날 거예요. 이 코드는 임의의 메모리 위치를 가져와 10,000개 항목 길이의 슬라이스를 만들어요.
fn main() {
use std::slice;
let address = 0x01234usize;
let r = address as *mut i32;
let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
우리는 이 임의의 위치의 메모리를 소유하지 않고, 이 코드가 만드는 슬라이스에 유효한 i32 값이 들어 있다는 보장도 없어요. values를 유효한 슬라이스인 것처럼 사용하려 하면 정의되지 않은 동작이 돼요.
extern 함수로 외부 코드 호출하기 (Using extern Functions to Call External Code)
때로는 Rust 코드가 다른 언어로 작성된 코드와 상호작용해야 할 때가 있어요. 이를 위해 Rust에는 extern 키워드가 있는데, 이는 *외부 함수 인터페이스(FFI, Foreign Function Interface)*의 생성과 사용을 돕는 키워드예요. FFI는 한 프로그래밍 언어가 함수를 정의하고 다른(외부) 프로그래밍 언어가 그 함수를 호출할 수 있게 해주는 방식이에요.
Listing 20-8은 C 표준 라이브러리의 abs 함수와 통합을 설정하는 방법을 보여줘요. extern 블록 안에 선언된 함수는 일반적으로 Rust 코드에서 호출하기 unsafe하기 때문에, extern 블록도 unsafe로 표시해야 해요. 그 이유는 다른 언어가 Rust의 규칙과 보장을 지키지 않고, Rust도 이를 검사할 수 없기 때문에 안전성 보장의 책임이 프로그래머에게 넘어오기 때문이에요.
파일명: src/main.rs
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
unsafe extern "C" 블록 안에 다른 언어의 외부 함수 이름과 시그니처를 나열해요. "C" 부분은 외부 함수가 사용하는 *애플리케이션 바이너리 인터페이스(ABI)*를 정의해요. ABI는 어셈블리 수준에서 함수를 호출하는 방법을 정의하죠. "C" ABI가 가장 흔하며 C 프로그래밍 언어의 ABI를 따릅니다. Rust가 지원하는 모든 ABI에 대한 정보는 the Rust Reference에서 확인할 수 있어요.
unsafe extern 블록 안에 선언된 모든 항목은 암묵적으로 unsafe해요. 하지만 일부 FFI 함수는 호출해도 안전하죠. 예를 들어 C 표준 라이브러리의 abs 함수는 메모리 안전성 고려사항이 없고, 어떤 i32로도 호출할 수 있다는 걸 알고 있어요. 이럴 때는 safe 키워드를 써서 이 특정 함수가 unsafe extern 블록 안에 있음에도 호출해도 안전하다고 말할 수 있어요. 그렇게 바꾸면 더 이상 unsafe 블록 없이 호출할 수 있어요. Listing 20-9가 보여주는 것처럼요.
파일명: src/main.rs
unsafe extern "C" {
safe fn abs(input: i32) -> i32;
}
fn main() {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
함수를 safe로 표시한다고 해서 본질적으로 안전해지는 건 아니에요! 오히려 Rust에게 "이건 안전하다."라는 약속을 하는 것과 같아요. 그 약속을 지키는 것도 여전히 여러분의 책임이에요!
다른 언어에서 Rust 함수 호출하기 (Calling Rust Functions from Other Languages)
extern을 사용해 다른 언어가 Rust 함수를 호출할 수 있게 하는 인터페이스도 만들 수 있어요. extern 블록을 통째로 만드는 대신, 해당 함수의 fn 키워드 바로 앞에 extern 키워드와 사용할 ABI를 지정해요. 또 #[unsafe(no_mangle)] 어노테이션을 추가해 Rust 컴파일러가 이 함수의 이름을 맹글링(mangle)하지 말라고 알려줘야 해요. 맹글링은 컴파일러가 우리가 함수에 붙인 이름을, 컴파일 과정의 다른 부분이 소비할 더 많은 정보를 담으면서도 사람이 읽기엔 덜 명확한 다른 이름으로 바꾸는 것이에요. 프로그래밍 언어마다 컴파일러가 이름을 조금씩 다르게 맹글링하므로, Rust 함수가 다른 언어에서 이름으로 불리려면 Rust 컴파일러의 이름 맹글링을 꺼야 해요. 내장된 맹글링이 없으면 라이브러리 간 이름 충돌이 있을 수 있기 때문에 unsafe한 동작이에요. 따라서 맹글링 없이 내보내도 안전한 이름을 고르는 책임은 우리에게 있어요.
다음 예시에서는 call_from_c 함수를 C 코드에서 접근할 수 있게 만들어요. 공유 라이브러리로 컴파일된 뒤 C에서 링크하면 쓸 수 있죠.
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
여기서 extern 사용은 extern 블록이 아니라 어트리뷰트에서만 unsafe가 필요해요.
가변 정적 변수 접근 또는 수정하기 (Accessing or Modifying a Mutable Static Variable)
이 책에서 아직 전역 변수에 대해 이야기하지 않았는데, Rust는 전역 변수를 지원하지만 Rust의 소유권 규칙 때문에 문제가 될 수 있어요. 두 스레드가 같은 가변 전역 변수에 접근하면 데이터 경합이 생길 수 있죠.
Rust에서 전역 변수를 static(정적) 변수라고 불러요. Listing 20-10은 문자열 슬라이스 값을 가진 static 변수의 선언과 사용 예시를 보여줘요.
파일명: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("value is: {HELLO_WORLD}");
}
static 변수는 3장의 "Declaring Constants" 절에서 다룬 상수와 비슷해요. static 변수의 이름은 관례상 SCREAMING_SNAKE_CASE를 써요. static 변수는 'static 라이프타임을 가진 참조만 저장할 수 있어요. 즉 Rust 컴파일러가 라이프타임을 알아낼 수 있고, 명시적으로 어노테이션할 필요가 없다는 뜻이에요. 불변 static 변수에 접근하는 건 안전해요.
상수와 불변 static 변수의 미묘한 차이는, static 변수의 값은 메모리에 고정된 주소를 가진다는 점이에요. 값을 사용하면 항상 같은 데이터에 접근하죠. 반면 상수는 사용할 때마다 데이터가 복제되는 것이 허용돼요. 또 다른 차이는 static 변수는 가변이 될 수 있다는 점이에요. 가변 static 변수에 접근하고 수정하는 건 unsafe한 일이에요. Listing 20-11은 COUNTER라는 가변 static 변수를 선언하고, 접근하고, 수정하는 방법을 보여줘요.
파일명: src/main.rs
static mut COUNTER: u32 = 0;
/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
unsafe {
// SAFETY: This is only called from a single thread in `main`.
add_to_count(3);
println!("COUNTER: {}", *(&raw const COUNTER));
}
}
일반 변수와 마찬가지로 mut 키워드로 가변성을 지정해요. COUNTER를 읽거나 쓰는 어떤 코드든 unsafe 블록 안에 있어야 해요. Listing 20-11의 코드는 싱글 스레드이기 때문에 컴파일되고 예상대로 COUNTER: 3을 출력해요. 여러 스레드가 COUNTER에 접근하면 데이터 경합이 생길 가능성이 높아서 정의되지 않은 동작이 돼요. 그래서 함수 전체를 unsafe로 표시하고 안전성 제한을 문서화해, 함수를 호출하는 누구든 안전하게 뭘 할 수 있고 뭘 할 수 없는지 알게 해야 해요.
unsafe 함수를 쓸 때는 관례적으로 SAFETY로 시작하는 주석을 써서 호출자가 함수를 안전하게 호출하기 위해 무엇을 해야 하는지 설명해요. 마찬가지로 unsafe 연산을 수행할 때도 SAFETY로 시작하는 주석을 써서 안전성 규칙이 어떻게 지켜지는지 설명하는 게 관례예요.
게다가 컴파일러는 기본적으로 컴파일러 린트(lint)를 통해 가변 static 변수에 대한 참조를 만드는 시도를 거부해요. #[allow(static_mut_refs)] 어노테이션을 추가해 그 린트의 보호를 명시적으로 벗어나거나, 원시 대여 연산자 중 하나로 만든 원시 포인터를 통해 가변 static 변수에 접근해야 해요. 여기에는 이 코드 목록의 println!에서처럼 참조가 눈에 보이지 않게 만들어지는 경우도 포함돼요. static 가변 변수에 대한 참조가 원시 포인터로 만들어지도록 요구하는 건, 그 변수를 사용할 때의 안전성 요구사항을 더 명확하게 만드는 데 도움이 돼요.
전역적으로 접근 가능한 가변 데이터에서는 데이터 경합이 없는 걸 보장하기 어렵기 때문에, Rust는 가변 static 변수를 unsafe로 간주해요. 가능하다면 16장에서 다룬 동시성 기법과 스레드 안전 스마트 포인터를 쓰는 편이 더 좋아요. 그러면 컴파일러가 서로 다른 스레드의 데이터 접근이 안전하게 이뤄지는지 검사하거든요.
unsafe 트레이트 구현하기 (Implementing an Unsafe Trait)
unsafe를 사용해 unsafe 트레이트를 구현할 수 있어요. 트레이트의 메서드 중 적어도 하나가 컴파일러가 검증할 수 없는 불변식(invariant)을 가지고 있으면 그 트레이트는 unsafe해요. Listing 20-12에 나온 것처럼 trait 앞에 unsafe 키워드를 붙이고, 트레이트의 구현도 unsafe로 표시해서 트레이트가 unsafe임을 선언해요.
unsafe trait Foo {
// methods go here
}
unsafe impl Foo for i32 {
// method implementations go here
}
fn main() {}
unsafe impl을 쓰는 건 컴파일러가 검증할 수 없는 불변식을 지키겠다고 약속하는 거예요.
예를 들어 16장의 "Extensible Concurrency with Send and Sync" 절에서 다룬 Send와 Sync 마커 트레이트를 떠올려보세요. 이 트레이트들은 우리 타입이 전부 Send와 Sync를 구현하는 다른 타입으로만 이루어져 있으면 컴파일러가 자동으로 구현해 줘요. 하지만 원시 포인터처럼 Send나 Sync를 구현하지 않는 타입을 담는 타입을 만들고, 그 타입을 Send나 Sync로 표시하고 싶다면 unsafe를 써야 해요. Rust는 우리 타입이 "스레드 간 안전하게 보내질 수 있고 여러 스레드에서 안전하게 접근된다"는 보장을 지키는지 검증할 수 없어요. 그래서 그 검사는 직접 해야 하고, unsafe로 그 사실을 나타내야 하죠.
union의 필드에 접근하기 (Accessing Fields of a Union)
unsafe에서만 동작하는 마지막 동작은 union의 필드에 접근하는 것이에요. union은 struct와 비슷하지만, 특정 인스턴스에서 한 번에 선언된 필드 중 하나만 사용돼요. union은 주로 C 코드의 union과 인터페이스하기 위해 사용돼요. union 필드에 접근하는 건 unsafe한데, Rust가 union 인스턴스에 현재 저장된 데이터의 타입을 보장할 수 없기 때문이에요. union에 대해 더 자세히 알고 싶다면 the Rust Reference를 참고하세요.
Miri로 unsafe 코드 검사하기 (Using Miri to Check Unsafe Code)
unsafe 코드를 쓸 때, 작성한 코드가 실제로 안전하고 올바른지 확인하고 싶을 거예요. 이를 위한 가장 좋은 방법 중 하나는 정의되지 않은 동작을 감지하는 공식 Rust 도구인 Miri를 쓰는 거예요. 대여 검사기가 컴파일 시점에 동작하는 정적 도구인 반면, Miri는 런타임에 동작하는 동적 도구예요. 프로그램이나 테스트 스위트를 실행하면서 Rust가 어떻게 동작해야 하는지에 대해 Miri가 이해하는 규칙을 위반하는 순간을 감지해 코드를 검사해요.
Miri를 쓰려면 Rust nightly 빌드가 필요해요(이에 대해선 더 자세히 Appendix G: How Rust is Made and "Nightly Rust"에서 다뤄요). rustup +nightly component add miri를 입력하면 nightly 버전의 Rust와 Miri 도구를 모두 설치할 수 있어요. 이는 프로젝트가 사용하는 Rust 버전을 바꾸지 않고, 원할 때 쓸 수 있도록 시스템에 도구만 추가할 뿐이에요. 프로젝트에서 cargo +nightly miri run 또는 cargo +nightly miri test를 입력해 Miri를 실행할 수 있어요.
이게 얼마나 도움이 되는지 예를 들면, Listing 20-7에 대해 실행했을 때 어떤 일이 벌어지는지 생각해보세요.
$ cargo +nightly miri run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
warning: integer-to-pointer cast
--> src/main.rs:5:13
|
5 | let r = address as *mut i32;
| ^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
|
= help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
= help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
= help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
= help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
= help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning
= note: BACKTRACE:
= note: inside `main` at src/main.rs:5:13: 5:32
error: Undefined Behavior: pointer not dereferenceable: pointer must be dereferenceable for 40000 bytes, but got 0x1234[noalloc] which is a dangling pointer (it has no provenance)
--> src/main.rs:7:35
|
7 | let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
= note: BACKTRACE:
= note: inside `main` at src/main.rs:7:35: 7:70
note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace
error: aborting due to 1 previous error; 1 warning emitted
Miri는 정수를 포인터로 캐스팅하는 것이 문제가 될 수 있다고 올바르게 경고하는데, 포인터가 어떻게 유래했는지 모르기 때문에 문제가 존재하는지 판단할 수 없어요. 그러다 Listing 20-7에서 댕글링 포인터가 있어 definition되지 않은 동작이 있는 지점에서 Miri가 오류를 반환해요. Miri 덕분에 정의되지 않은 동작의 위험이 있다는 걸 알게 되고, 코드를 안전하게 만들 방법을 고민할 수 있어요. 어떤 경우에는 Miri가 오류를 고치는 방법에 대한 권장사항까지 제시해 주기도 해요.
Miri가 unsafe 코드를 쓸 때 저지를 수 있는 모든 실수를 다 잡아주는 건 아니에요. Miri는 동적 분석 도구라서 실제로 실행되는 코드의 문제만 잡아주거든요. 즉 좋은 테스트 기법과 함께 써야 unsafe 코드에 대한 확신을 높일 수 있어요. 또 Miri가 코드가 무건전(unsound)해질 수 있는 모든 가능한 방식을 다 다루지도 않아요.
다시 말하면, Miri가 문제를 잡으면 버그가 있다는 건 알지만, Miri가 버그를 안 잡는다고 문제가 없다는 뜻은 아니에요. 그렇지만 많은 것을 잡아내죠. 이번 장의 다른 unsafe 코드 예시에도 실행해 보고 어떤 말을 하는지 확인해 보세요!
Miri에 대해 더 배우고 싶다면 GitHub 저장소를 참고하세요.
unsafe 코드 올바르게 사용하기 (Using Unsafe Code Correctly)
방금 논의한 다섯 가지 초능력 중 하나를 쓰기 위해 unsafe를 사용하는 건 잘못도 아니고 눈살을 찌푸릴 일도 아니에요. 다만 컴파일러가 메모리 안전성을 지키는 데 도움을 줄 수 없기 때문에 unsafe 코드를 올바르게 만드는 건 더 까다로워요. unsafe 코드를 쓸 이유가 있다면 그렇게 해도 되고, 명시적인 unsafe 어노테이션이 있어 문제가 생겼을 때 원인을 추적하기 더 쉬워져요. unsafe 코드를 쓸 때마다 Miri를 사용하면 작성한 코드가 Rust의 규칙을 지킨다는 확신을 더 높일 수 있어요.
unsafe Rust를 효과적으로 다루는 방법을 더 깊이 탐구하려면, Rust의 공식 unsafe 가이드인 The Rustonomicon을 읽어보세요.
더 알아보기 (Learn more)
- The Rustonomicon — unsafe Rust 심층 가이드
- Miri (GitHub)
- The Rust Reference — Unsafe (외부 블록, union 등)