러스트의 참조 타입이 제공하는 고유성·읽기 전용 보장을 ‘계약’으로 보고, MIR 수준에서 검증과 메모리 락으로 UB를 탐지하고 최적화를 가능하게 하는 제안을 설명한다. 예제와 검증 규칙(acquire/suspend/release), UnsafeCell 처리, unsafe 코드 취급, miri 기반 구현 현황을 다룬다.
지난 몇 주 동안의 제 인턴십 기간에 저는 “Unsafe Code Guidelines”에 대한 제안을 준비해 왔습니다. Mozilla All-Hands에서 아주 고무적인 피드백을 받았고, 이제 이 제안을 공개해 논의를 시작하고자 합니다. 경고: 아주 긴 글입니다. 정말요.
이 주제에 대한 배경이 더 필요하다면, 먼저 제 글 unsafe code guidelines와 undefined behavior를 읽어 보시길 권합니다.
다음과 같은 간단한 함수를 보세요:
fn simple(x: &mut i32, y: &mut f32) -> i32 {
*x = 3;
*y = 4.0;
*x
}
이전 글에서 논의했듯이, 이 함수를 에일리어싱된 포인터로 호출하는 unsafe 러스트 프로그램은 UB가 되도록 하고 싶습니다. 저는 이 경우 UB가 되는 근본 이유가 함수의 타입, 특히 인자 타입에 있다고 제안하고 싶습니다. &mut는 유일성(독점성)을 표현하므로, &mut가 가리키는 메모리는 다른 어떤 것과도 에일리어싱되어서는 안 됩니다. 관련 분야의 용어를 조금 빌리면, 함수 타입을 하나의 _계약_으로 볼 수 있습니다. 즉, &mut는 simple이 비에일리어싱(non-aliasing) 포인터로 호출될 것으로 기대한다는 뜻입니다. 이 계약을 어기면 무엇이든 일어날 수 있습니다.
이 아이디어를 실현하려면, 모든 타입마다 그 타입에 연관된 계약이 무엇인지 정의해야 합니다. 어떤 저장소가 어떤 타입에서 _유효_한지는 언제 결정되는가? 몇몇 타입은 간단합니다: i32는 완전히 초기화된 4바이트 값이면 됩니다. bool은 1바이트이며 0(false) 또는 1(true)입니다. (T, U) 같은 합성 타입은 구성 타입으로 정의됩니다: 첫 번째 성분은 유효한 T, 두 번째 성분은 유효한 U여야 합니다.
흥미로워지는 지점은 참조 타입입니다. &mut T는 (널이 아니고 정렬이 맞는) 포인터를 의미합니다. 포인터가 가리키는 대상은 유효한 T여야 합니다. 더 나아가, 이 포인터가 커버하는 메모리 범위(T의 크기로 결정됨)는 이 함수에 의해 배타적으로 접근됩니다. 즉, 다른 어떤 스레드도 접근하지 않으며, 우리가 알 수 없는 코드를 호출하더라도 그 참조를 넘기지 않는다면 그 코드 역시 이 메모리에 접근하지 않는다고 가정할 수 있습니다.
마찬가지로, &T의 경우 누구도 이 메모리에 쓰기 접근을 하지 않는다고 가정할 수 있습니다. 동시에, &T의 계약은 우리 자신도 이 메모리에 쓰지 않겠다고 약속한다는 뜻입니다. 이것은 계약이 양방향이라는 좋은 예입니다. 함수는 (호출자) 다른 쪽이 유효한 인자를 제공함으로써 자신의 역할을 했다고 믿을 수 있지만, 함수 역시 스스로 보장을 해야 합니다. 함수가 제공하는 또 다른 보장의 예는, 종료 시 유효한 반환값을 제공하겠다는 약속입니다.
따라서 제가 제안하는 unsafe code guidelines는 함수 타입이 서술하는 계약을 함수 경계에서 검증하고, 그 계약이 위배되면 UB가 되게 하는 것입니다. 특히 simple은 호출 직후 x와 y를 검증하며, 이들이 에일리어싱되면 UB가 됩니다.
저는 이전에 정의되지 않은 동작(UB)에 대한 생각을 이렇게 쓴 바 있습니다:
UB를 _탐지_하는 도구가 지극히 중요하다고 생각합니다 […]. 사실, 동적 UB 체커를 명세하는 것은 UB를 명세하는 아주 좋은 방법입니다! 그런 명세는 런타임에 추가로 필요한 상태를 기술하고, 각 연산에서 우리가 UB에 빠지는지 _검사_하도록 합니다.
이에 따라, 아래에서는 러스트의 타입들이 표현하는 계약을 위반했는지 어떻게 검사할 수 있을지 설명하겠습니다. 저는 MIR 프로그램을 가정합니다. 러스트 프로그램을 검사하려면 먼저 MIR로 변환해야 합니다. (왜 이것이 좋은 접근이라고 생각하는지에 대해서는 이 글을 참고하세요.) 참조 타입 검증(특히 배타성/읽기 전용 특성)에는 약간의 “계측된” 상태가 필요합니다. 이 점은 이전 글에서도 다뤘습니다.1 제가 제안하는 추가 상태는 모든 메모리 위치마다 리더-라이터 락과 유사한 것을 갖는 형태입니다. 우선 이러한 락과 그것이 프로그램 동작에 미치는 영향을 설명하고, 그 다음 계약 검증을 설명하겠습니다.
이것은 아직 완전한 명세가 아닙니다. 몇 가지 중요한 세부 사항은 여전히 열려 있습니다. 목표는 논의의 기반이 될 프레임워크를 제시하는 것입니다. 많은 부분은 이미 기대대로 동작하며, 남은 문제들은 세부 조정을 통해 해결되길 바랍니다.
모든 위치는 추가적인 리더-라이터 “락”을 하나씩 가집니다. 이 락들은 동시성과는 무관하며, 사실상 RefCell에 더 가깝습니다. 락은 참조가 제공하는 보장을 검증하는 데 사용됩니다. 공유 참조는 어떤 누구도 그 메모리를 변경하지 않음을, 가변 참조는 그 메모리가 (읽기/쓰기) 누구에 의해서도 다른 사람에 의해 접근되지 않음을 뜻합니다. 단, 락이 걸리지 않은(unlocked) 메모리는 누구나 자유롭게 접근할 수 있습니다. 그 메모리에 대해 특별한 가정이 없기 때문입니다.
락을 추적하기 위해, 각 위치에 대해 locks: Vec<LockInfo>를 저장합니다. LockInfo는 다음과 같습니다:
struct LockInfo {
write_lock: bool,
frame: usize,
lft: Option<Lifetime>,
}
(이 명세의 구현은 여기의 방식과 다른 방법으로 락을 추적해도 됩니다. 실질적인 동작이 같기만 하면 됩니다. 여기에서 구체 타입을 제시하는 이유는 명세를 더 명확히 하기 위해서입니다.)
락을 획득할 때, 어떤 스택 프레임이 이 락의 소유자인지 기록합니다(main을 0으로 시작해 증가). 이제 모든 읽기 접근에서, _다른 함수_가 이 위치에 대해 쓰기 락을 보유하고 있는지 확인합니다. 그렇다면 이 접근은 UB입니다. 또한, 쓰기 접근은 읽기 락이 보유 중이거나 또는 다른 함수가 쓰기 락을 보유 중이면 UB입니다. 마지막으로, 락 획득은 새 락이 기존 락과 충돌하지 않음을 확인합니다. 읽기 락 획득은 누가 되었든(요청한 함수 자신 포함) 쓰기 락이 보유 중이면 UB이며, 쓰기 락 획득은 누가 되었든 어떤 락이라도 보유 중이면 UB입니다.
락은 보통 만료될 때 자동으로 해제됩니다. 이를 위해 락을 획득하는 함수는 선택적으로 락의 _수명(lifetime)_을 지정할 수 있습니다. 수명을 지정하지 않으면 함수가 반환할 때까지 락을 보유합니다. 이 명세의 목적상, 수명이 정확히 무엇인지는 중요하지 않으며, _언제 끝나는지_만 중요합니다. 다행히 MIR에는 최근 EndRegion 문이 추가되어 이를 사용할 수 있습니다. EndRegion(lft)가 실행되면, 수명이 lft인 모든 락이 해제됩니다. 이 외에도, 쓰기 락은 락의 수명이 끝나기 전에, 락을 획득했던 함수가 명시적으로 해제할 수 있습니다. (제 제안에서는 읽기 락을 이렇게 해제해야 할 필요는 없습니다.)
또 하나 도입해야 할 메커니즘이 있습니다. 획득된 쓰기 락은 특정 수명 동안 _일시 정지(suspend)_될 수 있습니다. 이는 락을 잠시 풀어두고 이후 다시 획득할 예정이라는 뜻입니다. 이를 위해 LockInfo를 해당 위치의 locks에서 제거하여 프로그램 메모리와는 별도로 저장된 HashMap<Lifetime, (Location, LockInfo)>에 넣습니다. 지정된 수명에 해당하는 EndRegion에 도달하면, 그 락을 다시 획득하여 해당 위치의 locks에 되돌려 놓습니다.
락 메커니즘이 마련되었으니, 이제 MIR에서 타입 검증을 수행할 수 있습니다. 이를 위해 언어에 새로운 Validate 문을 추가합니다. 이 문은 검증할 (Lvalue, Ty) 쌍들의 목록과 하나의 _검증 연산_을 받습니다. 연산은 획득(acquire), 일시 정지(suspend·수명 지정) 또는 _해제(release)_가 될 수 있으며, 이는 메모리 락을 획득/일시 정지/해제할지를 나타냅니다. 일반적인 타입 검증을 설명하기 전에, 몇 가지 예시를 먼저 보겠습니다.
아래는 앞서의 예시에 검증을 명시적으로 주석으로 달아놓은 코드입니다:
fn simple(x: &mut i32, y: &mut f32) -> i32 {
Validate(Acquire, [x, y]);
*x = 3;
*y = 4.0;
*x
}
간단히 하기 위해, 검증 명령이 MIR뿐 아니라 표면 러스트에도 있다고 가정하겠습니다. 또한 검증은 쌍(lvalue와 타입)을 받지만, 타입은 추론될 수 있으므로 여기서는 lvalue만 나열합니다.
이제 x와 y가 에일리어싱되는 경우를 봅시다. 위에서 이 경우를 UB로 만들고 싶다고 했습니다. 두 저장(store)을 재배치하고 싶기 때문입니다. 실제로, 여기서 기술한 검증은 이 경우 UB를 발생시킵니다.
함수가 호출된 직후 인자에 대한 획득 검증을 수행합니다. 이는 호출자와의 함수 계약을 검증하는 것입니다. x는 참조 타입이므로, 검증은 참조를 따라가 *x가 타입 i32에서 유효한지 재귀적으로 검증합니다. 재귀 호출은 *x에 대해 쓰기 락을 획득하고, 메모리가 초기화되어 있는지 확인합니다. 이 락은 참조의 수명이 함수보다 길다는 점을 바탕으로, 함수 호출 전체 기간 동안 획득됩니다. 이렇게 x에 대한 검증이 완료됩니다. y의 검증도 정확히 같은 방식으로 진행됩니다. 특히 *y에 대해서도 쓰기 락이 획득됩니다. 하지만 x와 y가 에일리어싱되므로, 이는 *x에 대해 이미 획득된 쓰기 락과 겹칩니다! 이것이 우리가 원하던 UB입니다.
다시 말해, 참조를 통해 도달할 수 있는 모든 것에 락을 획득하는 과정이 묵시적으로 에일리어싱을 검사하여, 가변 참조가 다른 어떤 것과도 겹치지 않도록 보장합니다.
두 번째 예시는 검증, 특히 메모리 락이 알려지지 않은 외부 함수의 동작에 대해 어떻게 추론하게 해주는지를 보여줍니다:
// 다른 곳에 정의됨: fn fun(x: &i32, z: &mut (i32, i32)) { ... }
fn example(x: &i32, y: &mut i32, z: &mut (i32, i32)) {
Validate(Acquire, [x, y, z]);
let x_val = *x;
let y_val = *y;
Validate(Release, [x, z]);
fun(x, {z});
let x_val_2 = *x;
*y = y_val;
}
우리는 이 코드에서 함수 끝의 y에 대한 쓰기를 제거하고 싶습니다. 이미 저장된 값을 그대로 다시 쓰고 있다고 주장할 수 있기 때문입니다. 그렇게 주장하는 이유는 우리가 *y에 대한 배타적 참조를 보유하고 있기 때문입니다. y를 fun에 넘기지 않았고, 이 메모리에 대한 다른 어떤 참조도 없어야 하므로 fun은 y를 건드릴 수 없습니다. 실제로, 검증 규칙에 따르면 fun이 y에 접근하면 UB입니다.
또한, 끝부분에서 x를 다시 읽는 것을 제거하고 대신 x_val을 재사용하고 싶습니다. x는 공유 참조이므로, nop이 그 값을 변경하는 것은 UB이기 때문입니다.
example의 시작에서, 획득 검증을 수행하며 *x에 대해 읽기 락을, *y와 *z에 대해 쓰기 락을 획득합니다. 가변 참조를 fun에 넘길 때, 우리는 fun이 이 메모리를 수정할 권리가 있음을 명시적으로 인정합니다. 이는 우리가 x나 z에 보유 중인 쓰기 락을 _해제_해야 함을 의미하며, 이는 모든 함수 인자에 대해 해제 검증을 수행함으로써 이루어집니다. x에 대해서는 읽기 락만 보유 중이므로 실제로 아무 일도 일어나지 않지만, z의 쓰기 락은 여기서 해제됩니다. (여기서는 z에 대한 묵시적 재차용이 일어나지 않는다고 가정합니다. 중괄호는 z가 빌려지는 것이 아니라 fun으로 _이동_된다는 것을 의미합니다.) 따라서 fun이 호출될 때, 우리는 여전히 x에 대한 읽기 락과 y에 대한 쓰기 락을 보유하고 있습니다. 그러므로 fun이 y에 접근하거나 x에 쓰기를 하면 UB이며, 우리는 그런 일이 일어나지 않는다고 믿고 원하는 최적화를 수행할 수 있습니다.
이 최적화는 C 같은 언어에서는 극도로 어렵습니다. fun이 y에 접근할 수 없다는 것을 알기 위해서는, 컴파일러가 전체 프로그램 에일리어싱 분석(악명이 높은 어려운 문제)을 해야 하기 때문입니다. 반면 러스트의 타입 시스템은 다른 함수의 코드를 전혀 보지 않고도 이러한 최적화를 함수 내부 차원에서 가능하게 해줍니다.
이전 예시에서는 논의를 단순화하기 위해 z를 의도적으로 _이동_하여 fun에 넘겼습니다. 하지만 보통은 실제로 z를 _재차용(reborrow)_할 것입니다. 이 예시에서 보겠지만, (재)차용된 메모리의 소유권을 다른 함수로 적절히 이전하려면, 참조를 만드는 연산 주변에서 일시 정지 및 획득 검증을 수행하게 됩니다. 이를 더 잘 보여주기 위해, fun이 실제로 z와 같은 수명을 가진 참조를 반환한다고도 합시다. 전체 코드는 이제 다음과 같습니다(모든 재차용을 명시적으로 표시):
// 다른 곳에 정의됨: fn fun<'z>(x: &i32, z: &'z mut (i32, i32)) -> &'z mut i32 { ... }
fn example(x: &i32, y: &mut i32, z: &mut (i32, i32)) {
Validate(Acquire, [x, y, z]);
let x_val = *x;
let y_val = *y;
{ // 'inner 시작
Validate(Suspend('inner), [z]);
let z_for_fun = &'inner mut z;
Validate(Acquire, [z_for_fun]);
Validate(Release, [x, z_for_fun]);
let z_inner : &'inner mut i32 = fun(x, {z_for_fun});
Validate(Acquire, [z_inner]);
*z_inner = 42;
} // 'inner 종료
let x_val_2 = *x;
*y = y_val;
}
여기서도 우리는 y에 대한 쓰기와 x에 대한 읽기를 제거하고 싶습니다. 특히 y에 대한 쓰기를 중복으로 판정하려면, z_inner에 대한 쓰기가 *y에 영향을 줄 수 없다는 것을 증명해야 합니다.
언제나처럼, 시작에서 *x에 대한 읽기 락과 *y, *z에 대한 쓰기 락을 획득합니다. z가 수명 'inner로 fun에 넘기기 위해 재차용될 때, 우리는 *z에 대한 락을 이 수명 동안 _일시 정지_합니다. 이는 새 참조로 무엇을 하든 'inner가 끝날 때 우리의 배타적 접근을 되찾는다는 사실을 모델링합니다. 일시 정지 후에는 이제 *z_for_fun에 대한 락을 더 이상 보유하지 않으므로, 즉시 다시 쓰기 락을 획득합니다. 하지만 z_for_fun의 타입을 따라, 이 락은 수명 'inner에 대해서만 획득합니다. 이를 위해 검증은 lvalue의 지워지지 않은 타입(수명 정보 포함)을 알아야 하며, 그래야 올바른 수명으로 락을 획득할 수 있습니다. (쓰기 락을 일시 정지한 뒤 재획득하는 것은 다소 중복처럼 보일 수 있습니다. 하지만 공유 참조를 취득하는 경우에는 이제 읽기 락을 획득했을 것이며, 여기서도 전체 효과는 결코 공짜가 아닙니다. 일시 정지된 락은 나중에 재획득된 락이 해제되더라도 남아 있습니다. 사실 다음 단계가 바로 그런 경우입니다.)
다음으로 fun을 호출합니다. 이 시점에서, 가변 참조를 함수에 넘길 때 통상 그렇듯 *z_for_fun에 대한 쓰기 락을 해제합니다. (z_for_fun에 대한 획득-해제는 여기서는 실제로 중복입니다. 다만 이 두 검증 문은 서로 다른 MIR 연산에서 비롯됩니다. 참조 취득과 함수 호출이죠. 이 두 연산이 우연히 연달아 있을 뿐입니다.) 하지만 *z의 일시 정지된 락은 그대로입니다. 즉, 수명 'inner 동안 z(즉 z_for_fun)에 대한 통제를 넘겼더라도, 'inner가 끝나면 통제를 되찾게 된다는 사실을 여전히 알고 있습니다! 이제 fun은 *y에 대한 우리의 쓰기 락과 *x에 대한 읽기 락을 위반하지 않는 한 무엇이든 할 수 있습니다.
fun이 반환할 때, 우리는 새로운 가변 참조를 얻습니다. 이 시점에 다시 획득 검증을 수행합니다. 이 경우, z_inner가 y와 에일리어싱되지 않음을 보장하려는 것입니다. 락은 fun이 y를 바꿀 수 없도록 보장하지만, 우리는(example) 바꿀 수 있습니다. 만약 z_inner에 쓰기가 *y를 바꾼다면, 그 접근은 y에 대한 락을 보유한 스택 프레임에서 발생한 것이므로 허용될 것입니다! 하지만 우리는 여전히 함수 끝의 y에 대한 쓰기를 중복으로 최적화해 없애고 싶습니다. 이를 위해, 다른 함수로부터 반환된 모든 데이터를 획득-검증하여, 우리가 이런 방식으로 얻는 참조들이 이미 보유하고 있는 다른 참조들과 충돌하지 않음을 확인합니다. z_inner의 타입을 따라, 이 락은 'inner에 대해서만 획득합니다.
반환값을 획득 검증하는 또 다른 관점은, 우리가 fun과 맺은 계약을 검증하고 있다는 것입니다. 그 계약은 fun이 &mut의 유일성 보장이 붙은 참조를 반환해야 한다고 말하므로, fun이 정말로 약속한 것을 넘겨주었는지 확인하는 편이 낫습니다.
마지막으로 'inner가 끝납니다. 이제 여러 일이 일어납니다. 먼저 z_inner에 대한 우리의 락이 만료되어 해제됩니다. (z_for_fun의 락은 이미 우리가 해제했으므로 더 할 일이 없습니다.) 다음으로, 우리가 'inner에 대해 z의 락을 일시 정지했음을 기억하세요. 이제 그 락이 재획득됩니다. 즉, z에서의 재차용 수명이 만료되면서, 그 수명에 묶인 메모리에 대한 모든 락을 해제하고, 곧바로 z에 대한 배타적 접근을 완전히 되찾습니다.
*y에 대한 쓰기에 도달했을 때, 이 쓰기가 중복임을 알 수 있습니다. (a) 우리는 내내 *y에 대한 쓰기 락을 보유하고 있었으므로, 다른 누구도 UB 없이 여기에 쓸 수 없었고, (b) z_inner가 y와 에일리어싱되지 않음을 알고 있으므로(그렇지 않았다면 이미 UB였을 겁니다), 우리 자신도 *y에 쓰지 않았습니다. 따라서 이 쓰기를 최적화로 제거할 수 있습니다.
이 예시들로 Validate가 무엇을 하는지 감을 잡으셨을 겁니다. 이제 이 문을 좀 더 깊이 들여다봅시다. 이 문은 검증 연산과 lvalue-타입 쌍의 목록을 인수로 받습니다. 가장 중요한 검증 연산은 _획득 검증(acquire validation)_입니다. 이름에서 알 수 있듯이, 적절한 락을 획득하는 것이 핵심입니다. 또한 포인터가 NULL이 아니고 정렬이 맞는지 같은 값의 유효성도 함께 확인합니다.
획득 검증의 핵심은 대략 다음과 같은 시그니처를 가진 연산입니다:
fn acquire_validate(lval: Lvalue, ty: Ty, mutbl: Mutability, lft: Option<Lifetime>)
여기서 왜 가변성과 수명이 등장할까요? 그 이유는 참조 타입이 타입 계층 아래로 “전이적” 효과를 갖기 때문입니다. &T는 T가 &mut를 포함하더라도, 이 참조를 통해 도달 가능한 모든 것을 읽기 전용으로 만듭니다. 마찬가지로, 중첩된 참조의 경우 접근이 허용되는 수명은 가장 바깥쪽 참조가 결정합니다. (컴파일러는 내부 참조의 수명이 항상 외부 참조보다 길도록 보장합니다.)
예시에서 본 것처럼, acquire_validate는 타입 계층을 재귀적으로 순회합니다. 원시 타입(예: i32)을 만나면, lval에서 시작해 size_of::<ty> 바이트에 대해 수명 lft로 락을 획득합니다. 이때 락은 mutbl이 가변인지 여부에 따라 쓰기 또는 읽기 락이 됩니다.2 다시 말해, 메모리는 타입 구조의 “리프”에서 락이 걸리며, 참조 타입을 만났다고 즉시 락을 거는 것이 아닙니다. 왜 그렇게 해야 하는지는 뒤에서 다시 설명합니다. 또한 유효성 검사는 해당 타입에서 값이 유효한지도 확인합니다. u32의 경우 메모리가 제대로 초기화되어 있는지 확인하는 정도입니다.
튜플, struct, enum 같은 합성 타입의 경우, 필드에 대해 재귀적으로 검증이 진행됩니다. 특히 enum의 판별자(discriminant)가 범위 내인지 확인합니다(특히, 빈 enum 타입에서는 어떤 것도 검증을 통과할 수 없습니다). 다만 재귀적 검증은 실제 필드가 덮는 메모리에만 락을 걸 것이므로, 이 지점에서 패딩 바이트와 enum 판별자에 대해서도 적절한 락을 획득해야 합니다.
마지막으로 box나 참조 타입을 만나면 두 가지가 일어납니다. 우선 포인터 그 자체가 어딘가 메모리에 저장됩니다. 이 메모리는 i32를 검증할 때 정수를 저장하는 메모리에 락을 거는 것처럼 락이 걸려야 합니다. 또한 포인터가 NULL이 아니고 가리키는 타입에 맞는 정렬인지 확인합니다. 그리고 나서 _역참조_하여 재귀적으로 검증을 진행합니다. 특히, 참조를 검증하는 경우에는 이 재귀 호출에서 mutbl과 lft가 타입을 고려해 조정됩니다. lft가 None이고 이 참조의 수명이 함수 안에서 끝난다면(즉, 해당 EndRegion이 존재한다면) 이제 그 수명으로 설정됩니다. mutbl이 가변이었다면, 공유 참조를 따라갈 때는 비가변으로 바뀝니다.
획득 검증은 매 함수의 시작(함수 인자 획득)과 다른 함수로부터 반환될 때(반환값 획득)에 수행됩니다. 이 시점들이 바로 다른 함수가 우리에게 유효한 데이터를 제공한다고 기대하는 곳입니다.
획득 검증 외에도 두 가지 검증 연산이 있습니다. 해제 검증(release validation)과 일시 정지 검증(suspend validation)입니다. 두 연산 모두 획득 검증과 똑같이 타입을 재귀적으로 따라가며, 방문하는 모든 메모리에 대해 쓰기 락을 해제/일시 정지합니다. 읽기 락은 건드리지 않습니다. 이는 공유 참조가 Copy이므로 결코 “포기”되지 않음을 반영합니다. 공유 참조는 수명이 만료될 때까지 항상 사용 가능하며, 그 시점에 읽기 락이 자동으로 해제됩니다.
해제 검증은 함수를 호출하기 전에 인자에 대해 수행됩니다. 이는 우리가 넘기는 메모리에 대해 함수에게 쓰기 접근을 부여합니다.
일시 정지 검증은 참조를 취득하기 전에, 새 참조의 수명으로 수행됩니다. 이는 그 수명이 끝날 때(그리고 락이 재획득될 때) 우리가 그 메모리의 배타적 소유자임을 다시 인코딩합니다.
Update: 다른 방식으로 값이 함수로 되돌아오는 경로가 가변 참조를 통한 것이라는 지적을 받았습니다. 좋은 지적입니다! 락을 재획득할 때(쓰기 락 재획득 시)도 획득 검증을 수행하도록 검증 장치를 확장할 필요가 있습니다. /Update
요약하면, MIR 생성 중 다음 위치에서 검증 문이 삽입됩니다:
UnsafeCell이제 메모리 락과 검증이 러스트 타입에 인코딩된 가정을 어떻게 강제하는지 감을 잡으셨길 바랍니다. 여기서 특별히 다뤄야 하는 주제가 내부 가변성입니다. 함수가 x: &Cell<i32>를 받는다면, 위 규칙을 따르면 함수 호출 기간 동안 *x에 대해 읽기 락을 획득하여 *x를 불변으로 만들어 버립니다. 분명히 우리는 그렇게 하고 싶지 않습니다. x.set을 호출하면 실제로 *x가 변경되며, 공유 참조를 통해 변경하는 것이 바로 Cell을 쓰는 이유니까요!
다행히 컴파일러는 이미 내부 가변성은 UnsafeCell을 통해서만 허용한다고 규정합니다. 우리는 이를 이용할 수 있습니다. 내부 가변성에 대한 검증을 조정하기 위해, 비가변 모드로 들어와 있는 동안에는 UnsafeCell에 도달했을 때 재귀 하강을 _중단_하고 아무것도 하지 않습니다. 즉, &mut UnsafeCell은 해당되지 않습니다. 특히 어떤 락도 획득하지 않습니다. 이는 공유 참조에서 set을 호출해 값이 바뀌는 것을 정당화합니다. 물론, 이로 인해 앞서 논의한 일부 최적화를 할 수 없게 됩니다. 하지만 사실 그것이 정확히 우리가 원하는 바입니다! 내부 가변성이 있는 경우에는 이러한 최적화가 사운드하지 않습니다.
이를 위해서는, 참조를 만났을 때가 아니라, 타입 구조의 “리프”에서 락을 획득해야 한다는 점이 중요합니다. 예를 들어 &(i32, Cell<i32>) 타입에서 무언가를 검증할 때, 첫 번째 성분에 대해서는 읽기 락을 획득하지만, 두 번째 성분에 대해서는 획득하지 않아야 합니다.
마지막으로, 글을 (드디어) 마치기 전에 한 가지 더 다루고 싶은 이슈가 있습니다. unsafe 코드입니다. 지금까지는 오로지 안전한 코드만 고려했으며, 검증과 메모리 락이 참조 타입이 제공하는 보장을 어떻게 강제하는지를 보았습니다. 타입 검사기는 안전한 코드가 결코 락 실패를 일으키지 않도록 이미 보장합니다. 실제로, 검증 문을 일종의 “동적 차용 검사”라고 볼 수도 있으며, 이는 차용 검사기가 정적으로 강제하는 것과 동일한 규칙을 런타임에 강제합니다. 이런 검사가 실패할 유일한 방법은 unsafe 코드가 “잘못된” 값을 안전한 코드에 건네줄 때이며, 바로 이것이 제안하는 “unsafe code guidelines”가 안전한 코드와 상호작용할 때 unsafe 코드가 할 수 있는 일을 제한하는 이유입니다.
하지만 unsafe 코드 _내부_에서는 프로그래머에게 약간 더 많은 재량권을 줘야 합니다. 안전한 코드에서처럼 항상 강한 가정을 할 수는 없습니다. 예를 들어 mem::swap을 보겠습니다(약간 디슈거링):
// 다른 곳에 정의됨: unsafe fn ptr::swap_nonoverlapping<T>(x: *mut T, y: *mut T) { ... }
pub fn swap<T>(x: &mut T, y: &mut T) {
Validate(Acquire, [x, y]);
let x = x as *mut T;
let y = y as *mut T;
unsafe {
Validate(Release, [x, y]);
ptr::swap_nonoverlapping(x, y, 1);
}
}
처음에 우리는 x와 y에 대해 쓰기 락을 획득하여 두 포인터가 에일리어싱되지 않음을 확립합니다. 이는 정확히 기대한 바입니다. 그러나 ptr::swap_nonoverlaping에 인자를 해제하여 넘길 때 실제로는 아무 일도 일어나지 않습니다. 우리는 어떤 보장도 없는 생 포인터(raw pointer)만 넘기고 있기 때문입니다. 특히 x와 y에 대한 쓰기 락은 그대로 유지됩니다! 이제 문제가 생깁니다. ptr::swap_nonoverlaping은 두 값을 스왑하므로 둘 다에 쓰기를 하며, 이는 UB를 유발할 것입니다.
우선, 이 예시는 우리가 차용 검사기의 보장을 정확히 검증하고 있음을 보여줍니다. 차용 검사기의 관점에서 x와 y는 그 누구에게도 차용되거나 이동되지 않았으며, 내내 swap이 완전히 소유하고 있었습니다.
문제는 ptr::swap_nonoverlaping으로의 소유권 이전이 타입에 의해 기술되지 않으며, 따라서 컴파일러가 이를 알 수 없다는 데 있습니다. 이는 ptr::swap_nonoverlapping이 unsafe 함수이기 때문에 타입이 말해주는 것보다 더 많은 가정을 할 수 있고, 그 가정을 호출자가 지켜줄 것에 의존할 수 있기 때문입니다. 컴파일러는 그 가정이 무엇인지 알 방법이 없습니다.
이런 코드를 수용하기 위해, 저는 unsafe 블록을 포함하는 함수에 대해서는 MIR을 빌드할 때 더 적은 검증 문을 내보내자고 제안합니다. 예를 들어, 함수에 unsafe 블록이 있다면, 보통의 검증 대신 함수 시작에서 한 번 획득을 하고 곧바로 해제를 수행하는 정도만 한다고 상상해 볼 수 있습니다. 이는 우리의 인자가 올바른 형식임을 확인하는 한편, 그 함수 내부에서 소유권이 어떻게 흐르는지 우리가 알 수 없음을 인정하는 것입니다. 물론 이는 일부 최적화를 비활성화하기도 합니다. 하지만 어쩌면 unsafe 코드에서는 어차피 그런 최적화를 하고 싶지 않을 수도 있습니다. 프로그램을 망가뜨릴 위험이 있기 때문입니다.
어쨌든, 제안의 이 부분은 아직 최종안이 아닙니다. 이 주제는 많은 사람들의 의견이 필요합니다. 사람들이 작성하는 unsafe 코드를 안전하게 포괄하면서도(바라건대) 기대하는 최적화를 허용할 수 있는 방안을 모색해야 합니다. 잠정적인 절충안으로는, 어떤 검증 문이 삽입될지를 제어하는 함수 속성을 두거나, 프로그래머가 명시적으로 검증을 수행할 수 있는 형태의 intrinsic을 제공하는 방법이 있을 수 있습니다.
저는 러스트의 타입이 표현하는 계약, 특히 참조 타입이 제공하는 유일성 및 읽기 전용 보장을 검증하는 메커니즘을 설명했습니다. 이 검증은 (안전한) 코드가 런타임에 자신의 변수에 대해 무엇을 가정할 수 있는지를 서술하며, 전체 프로그램 에일리어싱 분석이 필요했던 일부 코드 모션 최적화를 가능하게 합니다.
앞서 저는 UB를 _탐지_할 수 있는 방법을 제공하는 것이 중요하다고 했고, 위의 프레임워크는 이를 염두에 두고 설계되었습니다. 실제로 저는 이 메커니즘의 miri 기반 구현을 작업해 왔습니다. 이 rustc 브랜치는 MIR에 새로운 Validate 문을 추가하고, 위에서 설명한 대로 검증 문을 삽입하는 MIR 패스를 추가합니다. 한편 이 miri 브랜치는 miri에 메모리 락을 추가하고 타입 기반 검증을 구현합니다. (두 브랜치는 빈번히 리베이스되므로 추적하신다면 force push를 예상하세요.) 구현은 아직 완전하지 않고, 몇몇 용어도 바뀌었습니다(글로 쓰다 보면 좋은 이름을 찾는 데 도움이 되더군요 :). 그래서 조금 달라 보이더라도 놀라지 마세요. 그 외에는 마음껏 사용해 보시고 피드백을 주세요. 이 구현이 unsafe 코드가 여기서 제시한 규칙을 따르는지, 또 이 규칙이 프로그래머의 직관과 맞는지를 확인하는 데 도움이 되길 바랍니다. (사실 저는 이렇게 해서 mem::swap이 가장 엄격한 해석의 규칙을 위반한다는 것을 알아냈습니다. libstd의 unsafe 코드 어딘가에서 위반이 있을 거라고는 예상했지만, 어디서 드러날지는 몰랐죠.) 규칙을 미세 조정하는 데 도움을 주는 테스트 스위트도 갖출 수 있을 것입니다.
또 “프로그래머의 직관이 […] 컴파일러와 일치하도록 노력해야 한다”고도 했습니다. 이는 물론 측정하기 어렵지만, 이 제안에서 포인터가 “얇게(thin)” 남을 수 있다는 점은 크게 도움이 된다고 생각합니다. 이 주제만 별도 글로 써야 할지도 모르겠지만, 간단히 말해 이 제안에서는 어떤 함수가 특정 메모리 영역에 접근할 권한을 갖고 있다면, 어떻게 접근하느냐는 중요하지 않습니다. 그 영역을 가리키는 포인터가 여럿 있더라도 모두 똑같이 유효합니다. 이는 예컨대 C의 restrict나 타입 기반 에일리어싱 분석처럼 특정 메모리 영역에 대한 접근에 _어떤 포인터_를 사용했는지에 따라 구분하는 것과 대조됩니다. 그런 모델은 검사기를 작성하기가 꽤 어렵습니다. 이를 보여주듯 그런 용도의 sanitizer가 존재하지 않습니다. 게다가 보통(제가 본 바로는) 정수-포인터 캐스트를 지원하는 데 어려움을 겪습니다. 포인터가 가리키는 주소 외에 “정체성”을 가진다면, 정수에서 캐스트한 포인터는 어떤 “정체성”을 갖게 될까요? 해결이 불가능하다는 뜻은 아니지만, 복잡성의 원천입니다. 또한 프로그래머의 혼란도 쉽게 야기합니다. 제 경험상, 정수에서 캐스트한 포인터를 쓰는 프로그래머들은 포인터를 가리키는 주소 외에는 “정체성”이 없는 “그저 정수”로 생각하는 경향이 있으며(그리고 그런 캐스트가 작동한다는 사실은 그들이 옳다는 징후이기도 합니다), 만약 우리가 이 멘탈 모델이 실제로 옳도록 만들 수 있다면 unsafe 러스트 코드가 최적화에 의해 깨질 것에 대한 걱정이 훨씬 줄어들 것입니다. C가 있다고들 잘못 생각하는 “단순한 포인터 모델”을 러스트의 장점 목록에 추가할 수 있다면 얼마나 좋을까요? 이는 러스트의 슬로건 “겁 없이 해킹하기(Hacking without fear)”와도 잘 맞는다고 생각합니다. 타협 없이요. 최적화도 그대로 얻으니까요!
축하합니다! 이 블로그 포스트의 끝에 도달했습니다. 읽어주셔서 감사합니다. 의견이 있으시면 꼭 남겨 주세요. 이 글의 목적 전체가 제안을 공개해 러스트 프로그래머와 컴파일러 개발자 모두의 니즈에 이 접근이 부합할 수 있는지 확인하려는 것이니까요. 설명이 빠졌거나 혼란스러운 부분이 있다고 생각되면 그것도 알려 주세요. 필요하다면 이 글을 업데이트하거나 포럼에 정정 노트를 추가하겠습니다. 구체적인 제안은 여기저기 손볼 부분이 분명히 있을 겁니다. 아직 완성되지도 않았고요. 앞서 말한 unsafe 코드 주변의 열린 질문이 있습니다. 앞으로의 과제로는 static 변수와 NonZero 타입도 포함됩니다. 그럼에도, 계약 같은 타입 주도 검증 메커니즘이라는 일반적인 접근이 유용하길 기대합니다. 그러니 의견을 계속 보내 주세요. 그리고 안전한 해킹 하세요.
Update: 글을 조금 리팩터링하며 2.2와 2.3 절의 순서를 바꿨습니다. 흐름이 더 좋길 바랍니다. 많은 유용한 피드백에 감사드립니다!