누설 (漏泄)
소유권 기반 자원 관리는 합성을 쉽게 하기 위한 것입니다. 객체를 만들 때 자원을 획득하고, 객체가 소멸될 때 자원을 반납합니다. 소멸이 자동으로 처리되므로, 여러분이 자원을 반납하는 것을 까먹는 일이 없다는 뜻이고, 또한 최대한 빨리 반납이 이루어진다는 것입니다! 분명 이건 완벽하고 우리의 모든 문제는 해결된 것입니다.
모든 것은 끔찍하고 우리는 이제 새롭고 괴이한 문제들을 마주해야 합니다.
많은 사람들이 러스트가 자원 누설을 제거한다고 믿고 싶어합니다. 현실적으로, 이것은 맞는 말입니다. 안전한 러스트 프로그램이 통제되지 않는 방법으로 자원을 누설한다면 아마 놀랄 겁니다.
하지만 이론적인 측면에서 보자면 전혀 사실이 아니며, 이것은 어떻게 보든 상관없습니다. 엄밀하게 보자면, "누설"이라는 것은 너무나도 모호해서 방지할 수가 없습니다. 어떤 컬렉션을 프로그램의 시작 때에 초기화하고, 소멸자가 있는 객체들 덩어리로 채우고, 그것을 전혀 사용하지 않는 무한 이벤트 반복문에 들어가는 것은 꽤나 흔한 일입니다. 그 컬렉션은 쓸모 없이 앉아서 시간이나 때우겠죠, 프로그램이 종료될 때까지 귀중한 자원을 쥐고서요 (그 때에는 어차피 운영체제가 그 모든 자원들을 회수할 테니까요).
우리는 좀더 제한된 형태의 누설을 생각할 수 있습니다: 접근할 수 없는 값을 해제하는 데 실패하는 것이죠. 러스트는 이것도 막지 않습니다. 사실 러스트에는 이것을 하는 함수가 있습니다: mem::forget
입니다.
이 함수는 전달된 값을 소비하고 그 소멸자를 실행하지 않습니다.
예전에는 mem::forget
이 사용되지 말라는 뜻에서 unsafe
로 표시되었는데, 소멸자를 호출하는 데 실패하는 것은 일반적으로 좋은 행동은 아니기 때문입니다 (어떤 불안전한 코드에서는 유용해도 말이죠).
하지만 이것은 비논리적인 행동으로 드러났습니다: 안전한 코드에서 소멸자를 호출하는 데 실패하는 방법은 엄청나게 많거든요. 가장 유명한 예제는 서로를 가리키는 Rc<RefCell<T>>
같은 것을 만드는 것입니다.
안전한 코드는 소멸자 누설이 발생하지 않는다고 가정하는 게 합리적인데, 소멸자를 누설하는 프로그램은 아마도 잘못된 것이기 때문입니다. 하지만 불안전한 코드는 안전하기 위해서 소멸자가 실행하는 것에 의지할 수는 없습니다.
대부분의 타입은 이것과 상관이 없습니다: 만약 소멸자를 누설하면 그 타입은 정의에 의해 접근할 수 없게 되고, 따라서 별 문제가 없게 됩니다, 그렇죠?
예를 들어, 만약 Box<u8>
을 누설한다면 메모리를 좀 낭비하기는 하겠지만 메모리 안전성을 침해할 일은 거의 없을 겁니다.
하지만 대리 타입에서는 소멸자 누설을 주의해야 합니다. 이 타입들은 외부의 객체를 접근하는 것을 관리하지만, 그것을 실제로 소유하지는 않는 타입입니다. 대리 타입은 꽤 희귀합니다. 여러분이 신경써야 할 대리 객체들은 더더욱 희귀합니다. 하지만 우리는 표준 라이브러리에 있는 3개의 흥미로운 예제에 집중하겠습니다:
vec::Drain
Rc
thread::scoped::JoinGuard
Drain
drain
은 컨테이너 타입을 소비하지 않고 그 컨테이너에서 데이터를 이동하는 컬렉션 API입니다. 이것을 이용하면 Vec
의 내용물을 모두 회수한 뒤에 그 할당된 메모리를 재사용할 수 있게 되죠.
이것은 Vec
의 내용물을 값으로 반환하는 반복자(Drain
)을 만들어 냅니다.
이제 Drain
을 반복하던 도중을 생각해 봅시다: 어떤 값들은 이동되었고, 나머지는 아닙니다. 이 뜻은 Vec
의 일부분은 이제 논리적으로 미초기화된 데이터로 가득 차 있다는 겁니다!
우리는 값을 제거할 때마다 Vec
의 모든 원소들을 앞으로 당길 수도 있겠지만, 그러면 꽤나 치명적인 성능 저하가 나타날 겁니다.
그 대신, 우리는 Drain
이 해제될 때 Vec
의 할당된 메모리를 고치는 게 좋겠습니다. 이 소멸자는 Drain
자신의 반복을 끝내고, 삭제되지 않은 모든 원소들을 앞으로 당기고, Vec
의 len
을 고칩니다.
이것은 심지어 되감기에도 안전합니다! 쉽군요!
이제 다음의 코드를 생각해 보세요:
let mut vec = vec![Box::new(0); 4];
{
// `drain`을 시작합니다, `vec`은 더 이상 접근할 수 없습니다
let mut drainer = vec.drain(..);
// 2개의 원소들을 꺼내서 바로 해제시킵니다
drainer.next();
drainer.next();
// `drainer`를 없애지만, 소멸자를 호출하지는 않습니다
mem::forget(drainer);
}
// 이런, `vec[0]` 은 해제되었는데, 우리는 해제된 메모리를 가리키는 포인터를 읽고 있습니다!
println!("{}", vec[0]);
이건 꽤나 분명히 좋지 않습니다. 불행하게도, 우리는 진퇴양난의 상황에 빠졌습니다: 모든 단계에서 안정적인 상태를 유지하는 것은 엄청난 비용을 유발합니다 (그리고 API의 모든 장점을 상쇄하겠죠). 안정적인 상태를 유지하지 못하면 안전한 코드에서 미정의 동작이 나올 겁니다 (API가 불건전해지겠죠).
그럼 우리는 어떻게 할까요? 음, 우리는 자명하게 안정적인 상태를 고를 수 있습니다: 반복을 시작할 때 Vec
의 len
을 0으로 만들고, 소멸자에서 필요하다면 len
을 고치는 겁니다.
이 방법이라면, 만약 모든 것이 평소처럼 동작한다면 우리는 최소의 비용으로 원하는 동작을 얻어냅니다. 하지만 만약 누군가가 반복 중간에 mem::forget
을 사용할 대담함이 있다면, 그것이 초래하는 결과는 더한 누설입니다
(그리고 Vec
을 예상 밖이지만 안정적이기는 한 상태로 만듭니다). 우리는 mem::forget
을 안전하다고 받아들였으므로, 이것은 명확하게 안전합니다. 우리는 이렇게 누설이 더한 누설을 부르는 것을 누설 증폭이라고 부릅니다.
Rc
Rc
는 흥미로운 경우인데, 처음 볼 때는 이것이 대리 타입이라고는 전혀 생각되지 않기 때문입니다. 어쨌든 이것은 가리키는 데이터를 관리하고, 모든 그 값의 Rc
를 해제하면 그 값이 해제될 것이기 때문입니다.
Rc
를 누설하는 것이 그렇게 위험할 것 같지는 않은데요. 참조 횟수를 영원히 증가시킨 상태로 방치할 것이고 데이터가 해제되는 것을 막겠지만, 그건 Box
의 경우와 같잖아요, 그렇죠?
땡, 틀렸습니다.
Rc
의 간단한 구현을 생각해 볼까요:
struct Rc<T> {
ptr: *mut RcBox<T>,
}
struct RcBox<T> {
data: T,
ref_count: usize,
}
impl<T> Rc<T> {
fn new(data: T) -> Self {
unsafe {
// heap::allocate가 이렇게 작동한다면 정말 좋지 않을까요?
let ptr = heap::allocate::<RcBox<T>>();
ptr::write(ptr, RcBox {
data,
ref_count: 1,
});
Rc { ptr }
}
}
fn clone(&self) -> Self {
unsafe {
(*self.ptr).ref_count += 1;
}
Rc { ptr: self.ptr }
}
}
impl<T> Drop for Rc<T> {
fn drop(&mut self) {
unsafe {
(*self.ptr).ref_count -= 1;
if (*self.ptr).ref_count == 0 {
// 데이터를 해제합니다
ptr::read(self.ptr);
heap::deallocate(self.ptr);
}
}
}
}
이 코드는 암묵적이고 잘 보이지 않는 가정을 포함하고 있습니다: usize::MAX
보다 많은 양의 Rc
가 메모리에 있을 수 없기 때문에, ref_count
가 usize
안에 들어갈 거라는 겁니다.
하지만 이 자체도 또한 ref_count
가 메모리에 있는 Rc
들의 갯수를 정확히 반영한다는 가정을 하고 있는데, 우리는 mem::forget
의 존재 때문에 이것이 거짓이라는 것을 압니다.
mem::forget
을 사용하면 ref_count
를 오버플로우할 수 있고, 그 다음 Rc
들을 해제하면 ref_count
는 0이 되지만 메모리에는 엄청난 양의 Rc
가 있게 됩니다.
그럼 우리는 행복하게 Rc
안의 데이터를 해제 후 사용하게 됩니다. 젠장, 좋지 않군요.
이 문제는 ref_count
를 검사하고 무언가를 하는 것으로 해결할 수 있습니다. 표준 라이브러리의 방식은 그냥 강제종료하는 것인데, 그 프로그램이 끔찍하게 타락했기 때문입니다.
그리고 맙소사 이건 정말 우스꽝스러운 특수 경우이군요.
thread::scoped::JoinGuard
주의: 이 API는 표준 라이브러리에서 이미 삭제되었습니다. 더 자세한 내용은 issue #24292를 참고하세요. 이 섹션이 여기 남아 있는 이유는 이것이 표준 라이브러리의 일부분이든 아니든, 이 예제가 여전히 중요하다고 여기기 때문입니다.
thread::scoped
API는 모체의 스택에 있는 데이터를 동기화 없이 참조하는 스레드를 만들 수 있도록 설계되었는데, 이것은 공유된 데이터의 수명이 끝나기 전에 스레드가 모체로 돌아가는 것을 보장하기 때문에 가능합니다.
pub fn scoped<'a, F>(f: F) -> JoinGuard<'a>
where F: FnOnce() + Send + 'a
여기서 f
는 스레드가 실행할 함수입니다. F: Send + 'a
라고 하는 것은 'a
만큼 사는 data
를 참조한다는 뜻이고,
그 data
를 가지고 있거나 아니면 data
가 Sync
라는 말입니다 (이 말은 곧 &data
는 Send
라는 말을 함축합니다).
JoinGuard
가 수명을 가지므로, JoinGuard
는 모체에서 빌려온 모든 데이터를 보관합니다. 이 말은 JoinGuard
는 모체의 데이터보다 오래 살 수 없다는 말입니다.
JoinGuard
가 해제가 될 때 모체를 대기시키는데, 빌려온 데이터가 모체의 범위를 벗어나기 전에 스레드가 종료하는 것을 보장합니다.
사용 방법은 이와 같았습니다:
let mut data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
{
let mut guards = vec![];
for x in &mut data {
// 가변 레퍼런스를 함수 안으로 이동시키고, 다른 스레드에서 실행시킵니다.
// 함수의 수명은 그 안에 저장된 가변 레퍼런스 `x`의 수명에 제한됩니다.
// 그 결과로 반환되는 `JoinGuard`는 함수의 수명을 할당받으므로,
// `JoinGuard`도 `x`처럼 `data`를 가변으로 빌립니다.
// 즉 우리는 `JoinGuard`가 해제될 때까지 `data`를 접근할 수 없습니다.
let guard = thread::scoped(move || {
*x *= 2;
});
// 나중을 위해 `JoinGuard`를 저장합니다
guards.push(guard);
}
// 모든 `JoinGuard`는 해제되는데, 스레드들이 실행을 끝마치고 모체에 합류하도록 강제합니다
// (이 스레드, 즉 모체는 다른 스레드들이 종료할 때까지 대기합니다).
// 스레드들이 합류하고 나면, 대여가 만기되고 `data`는 모체에서 다시 접근할 수 있게 됩니다.
}
// `data`는 여기서 변경됩니다.
원칙적으로는 이 코드는 잘 동작합니다! 러스트의 소유권 시스템은 이것의 안전성을 완벽하게 보장합니다! ...안전하려면 소멸자가 꼭 실행되어야 한다는 사실만 빼면요.
let mut data = Box::new(0);
{
let guard = thread::scoped(|| {
// 이것은 최선의 경우 데이터 경합입니다. 최악의 경우, 역시 해제 후 사용이고요.
*data += 1;
});
// `JoinGuard`가 잊혀졌기 때문에, 모체에서 대기되어야 하는 스레드를 자유롭게 풀어버립니다.
mem::forget(guard);
}
// 따라서 `Box`는 여기서 해제되지만 생성된 스레드는 이 `Box`를 접근하려 시도할지도 모릅니다.
이런. 여기서 소멸자의 실행은 API에 있어서 무척 근본이 되었기 때문에, API는 완전히 다른 디자인으로 바뀌어야 했습니다.