Rust - Memory Management
운영체제에서 메모리 관리하는 방식과 Rust가 그것을 어떻게 활용, 구현하는지 알아보겠습니다.
Rust 맛보기
- 1. Rust - 입문하기
- 2. Rust - 타입 모음
- 3. Rust - Memory Ownership
- 4. Rust - Control Flow
- 5. Rust - Structured Data Types
- 6. Rust - Project Organization
- 7. Rust - Error Handling
- 8. Rust - Collections
- 9. Rust - Generics
- 10. Rust - Test Automation
- 11. Rust - Functional Programming
- 12. Rust - Memory Management
- 13. Rust - I/O Management
- 14. Rust - Process & Thread Management
- 15. Rust - Inter-Process Communication
Memory Allocation
Low-level 언어에서는 memory allocation이 프로그래머에 의해 명시적으로 행해지며, 메모리 관리에 대한 책임을 집니다. 또한 두 가지 유형이 있습니다.
-
Fixed-sized allocation
컴파일 타임에 크기가 결정되는 경우입니다(정수, boolean, fixed-size 배열 등). 이는 stack에 할당됩니다.
-
Dynamically-sized allocation
런타임에 크기가 변하고 위치가 변경될 수 있는 경우입니다. 이는 heap에 할당됩니다.
Memory Management Lifecycle
Memory release
C, C++와 같은 언어에서는 free()
함수를 사용하여 메모리를 해제합니다. Rust에서는 ownership system을 통해 메모리를 관리하죠.
Memory tracking
커널은 각 프로세스가 영역별로 얼마나 메모리를 사용하고 있는지 추적합니다. 이를 통해 메모리가 부족한 경우, 메모리를 할당할 수 없는 경우 등을 판단합니다. 커널에 의해 호출되는 System call이, 프로세스당 메모리 할당과 해제를 추적하는데 사용됩니다.
Swapping/paging
커널은 메모리가 부족한 경우, 메모리를 디스크로 swap하거나 페이지를 할당하여 이용 가능한 memory resource를 abstract하는 Virtual Memory 기능을 제공합니다. 이를 통해 메모리 부족 문제를 해결할 수 있습니다.
Process Memory Layout
일반적으로 대부분의 OS에서 메모리의 layout은 Kernel space와 User space, 두 가지로 나뉩니다. Kernel space는 OS 커널에 관련된 모든 데이터와 코드를 저장하며 어떤 유저 프로세스도 접근 불가합니다. 만약 유저 프로세스가 접근을 시도하면 segmentation fault가 발생합니다.
그리고 user space가 바로 유저 프로세스에 의해 사용되는 실제 메모리 공간입니다.
User Space Memory Segments
User space의 메모리 영역은 다음과 같이 나뉩니다.
-
Text segment
프로그램의 코드 및 string literal 및 상수 파라미터(주의) 등 읽기 전용 데이터가 저장되는 공간입니다. (read-only 섹션입니다.)
-
Data segment
0이 아닌 값으로 초기화되는 전역 변수, static 변수, heap 등이 저장되는 공간입니다. (read-write 섹션입니다.)
-
BSS segment
아직 초기화되지 않은(내부적으로 0으로 초기화된)전역 변수, static 변수 등이 저장되는 공간입니다.
이 섹션(또는 segment)들의 크기는 뒤에 소개할 Heap, Stack(잘 알려진 것처럼 확장과 축소가 가능)과 달리 고정되어 있습니다.
-
Stack, Heap
Heap과 Stack에 대해서는 더 이상의 자세한 설명은 생략하겠습니다. 이것만 짚고 넘어가겠습니다.
Heap과 Stack은 서로 반대 방향으로 크기가 늘어나는 양상을 보이기 때문에, OS는 두 영역이 서로 겹치게 되지 않도록 보장해야 합니다. 만약 겹치게 되면 Stack overflow error가 발생할 거예요.
-
Stack과 Heap 사이 영역
Stack과 heap 사이에는 공유 메모리, 공유 라이브러리, 또는 메모리 맵 파일(디스크 내 파일을 가리키는 메모리 영역) 등이 선택적으로 저장될 수 있습니다.
Rust 역시 이런 현대 OS의 메모리 정책을 따라갑니다. 다음과 같이 대응 관계를 설명할 수 있습니다. 참고로 사진에는 큰 메모리 주소가 위쪽에 있지만, 저는 반대 방향으로 메모리를 그리는 걸 선호합니다.
Rust에서 Stack, Data, BSS에 배치되는 값들은 자동으로 결정되지만, Heap에 대한 할당은 프로그래머에 의해 수동으로 이루어집니다.
전에 다뤘던 Collection이나, Smart Pointer 등 관련된 API 함수를 호출함으로써 이를 수행할 수 있습니다.
Smart Pointers in Rust
스마트 포인터는 Rust의 reference처럼 동작하나 추가적인 메터데이터와 기능을 갖고 있는 자료구조입니다. Rust는 표준 라이브러리에 다양한 smart pointer를 정의하고 있습니다.
예를 들어 reference counting smart pointer 타입은 데이터의 owner가 여러 명이 되는 것을 허용하고, owner의 수를 추적하며, owner가 남아 있지 않을 때 자동으로 데이터를 삭제합니다.
일반적인 reference와 가장 큰 차이점은, smart pointer는 대부분의 경우 가리키는 data를 소유한다는 것입니다. 이런 이유로 String
과 Vec<T>
는 메모리 공간을 점유하고, 내용물의 수정을 허용한다는 측면에서 스마트 포인터로 볼 수 있습니다.
스마트 포인터는 다음 두 가지의 trait을 구현합니다.
Deref
: reference처럼 행동하도록 하여 스마트 포인터가 reference를 대체할 수 있도록 합니다.
Drop
: 스마트 포인터가 범위를 벗어나게 될 때, 실행되는 코드를 정의합니다.
Box<T>
단일 데이터를 heap에 저장하는 스마트 포인터입니다. Box<T>
는 heap에 데이터를 저장하고, stack에는 그 데이터를 가리키는 스마트 포인터인 Box<T>
자체가 저장됩니다. Box<T>
는 다음과 같은 상황에서 자주 활용됩니다. Box<T>
는 데이터를 heap에 저장하는 것 외에 특별한 기능을 제공하지 않기 때문에 추가적인 runtime overhead가 없습니다.
-
크기가 큰 데이터를 갖고 있고 데이터 복사 없이 ownership 이동이 필요할 때
-
컴파일 타임에 크기를 알 수 없는 타입을 다룰 때(예: recursive types)
Box::new()
메소드로 Box<T>
를 생성할 수 있습니다.
let b = Box::new(5);
println!("b = {}", b);
위와 같이 println!
함수로 Box<T>
타입을 출력하면, Box<T>
가 담고 있는 데이터가 출력됩니다.
Box<T>
가 범위를 벗어나면 힙의 데이터와, (자명하지만)스택의 Box<T>
모두가 할당 해제됩니다.
Box를 사용하여 Recursive Type 다루기
recursive type의 값은 자기 자신의 일부로써 같은 타입의 다른 값을 가질 수 있습니다. 이러한 value nesting은 이론적으로 무한히 깊어질 수 있습니다. 이러한 경우, Rust의 컴파일러는 타입의 크기를 알 수 없기 때문에 컴파일 에러를 발생시킵니다.
Reucrsive type의 예: cons list
함수형 프로그래밍 언어에서 흔히 사용되는 타입입니다.
cons list의 각 아이템은 두 element를 갖습니다: 현재 아이템의 value와 다음 아이템입니다.
enum List {
Cons(i32, List),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
하지만 이를 컴파일하려 하면 다음과 같은 에러가 발생합니다.
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
...
enum
타입의 크기는 가장 큰 variant를 저장하기 위해 필요한 공간인데, 위에서 정의한 List
는 Cons
variant가 다른 List
를 가리키기 때문에 무한히 큰 크기를 갖게 되기 때문입니다.
이를 해결하기 위해 Box<T>
를 사용할 수 있습니다. Rust 컴파일러는 heap 메모리에 할당된 데이터의 크기를 컴파일 타임에 신경쓰지 않기 때문입니다. 그리고 enum
에 데이터 자체보다 포인터를 저장하면 고정된 크기를 정의할 수 있죠. String
을 enum의 variant로 사용할 수 있는 원리도 사실 동일합니다. String
타입의 경우 3개의 usize
(64비트) 값을 저장할 공간만 (stack에서)필요로 하고 실제 문자열은 heap에 저장되기 때문입니다.
위에서 정의한 cons list의 경우 두 번째 element를 Box<List>
로 변경하면 됩니다.
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil)))));
}
이렇게 되면 최상위 List
는 stack에 저장되고, 나머지는 heap에 저장됩니다. List
의 크기가 고정되어(32비트 + 64비트) 무한히 큰 크기를 갖지 않게 됩니다.
Deref
trait
Deref
trait은 dereference operator *
의 동작을 customize하는 것을 허용합니다. 이를 통해 스마트 포인터가 reference처럼 동작하도록 할 수 있습니다.
fn main() {
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
}
fn main() {
let x = 5;
let y = Box::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
Smart pointer인 Box<T>
에도, Box<T>
가 Deref
trait을 구현했기 때문에 위와 같이 *
를 사용할 수 있습니다.
Deref
trait을 구현하기 위해 먼저 std
라이브러리에서 정의를 살펴봅시다.
pub trait Deref {
type Target: ?Sized;
// Required method
fn deref(&self) -> &Self::Target;
}
type Target = T;
는 Deref
trait이 사용할 관련 타입을 정의합니다.
구현해야 하는 deref()
메소드는 &self
를 받아서 &Self::Target
을 반환합니다. 이는 *
연산자를 사용하여 접근하고 싶은 값에 대한 reference를 반환하는 것입니다. 다음과 같이 Deref
trait을 구현할 수 있습니다.
use std::ops::Deref;
struct MyBox<T>(T); // tuple struct로 선언
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x) // Box<T>와 달리 데이터를 heap이 아닌 stack에 저장
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
위와 같이 .0
으로 tuple struct의 첫 번째 값에 접근한 후, 그것에 대한 immutable reference를 반환합니다. 왜 값이 아닌 reference를 반환하는지는 아래에서 설명하겠습니다.
이제 *
연산자를 사용 가능한데, 사실 *
연산자는 내부적으로 다음과 같이 동작합니다.
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *(y.deref()));
}
deref()
를 통해 MyBox
에 대한 reference가 아닌 T
에 대한 reference를 반환하고, T
는 이미 Deref
trait을 구현하고 있는 타입이기 때문에 *
연산자를 통해 해당 값을 얻을 수 있습니다. 이때 deref()
로의 호출은 한 번만 일어납니다. 두 번째 *
은 deref()
에 대한 호출을 또 발생시키지 않습니다.
deref()
가 값을 직접적으로 반환하지 않는 이유는, 값의 ownership을 유지하기 위함입니다. deref()
함수 내에서는 reference만 사용하기 때문에 ownership transfer가 발생하지 않죠.
Deref
Coercions
Rust는 Deref
trait을 사용하여 reference를 다른 타입에 대한 reference로 변환(아까 MyBox
의 deref()
가 그랬던 것처럼)하는 것을 허용합니다. 이를 deref coercions이라고 합니다.
coercion: (무력·협박에 의한) 강제[강압] 출처: 네이버 사전
Rust는 Deref
trait에 구현된 deref()
메소드를 참조해 Deref
trait을 구현한 타입의 reference를 얻을 경우 자동으로 reference를 deref()
메소드가 반환하는 타입(즉, 값에 대한 reference)로 변환합니다. 예를 들어 &String을 &str로 변환하는 것입니다. String
이 &str
을 반환하는 Deref
trait을 구현하고 있기 때문이죠.
&
과 *
의 사용 빈도를 크게 줄여줘서 유용하며, reference와 smart pointer 모두에 대해 동작하는 코드를 작성하는 것도 가능하게 합니다.
예시
fn hello(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&m);
}
&m
은 &MyBox<String>
타입이지만, 이 reference를 얻으려고 하면 Deref
trait을 통해 내부적으로 .deref()
메소드를 호출해서 &String
으로 변환됩니다. 그리고 &String
은 다시 .deref()
메소드 호출로 &str
으로 변환됩니다. 그래서 hello
의 parameter type인 &str
과 일치하게 됩니다.
DerefMut
trait
Deref
trait은 immutable reference를 반환하는 반면, DerefMut
trait은 mutable reference를 반환합니다. 이는 *
연산자를 사용하여 mutable reference를 얻을 수 있게 합니다. DerefMut
를 구현하려면 먼저 Deref
trait을 반드시 구현해야 합니다.
use std::ops::DerefMut;
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T> DerefMut for MyBox<T> {
//왜 type Target = T;를 정의하지 않는지?
fn deref_mut(&mut self) -> &mut T {
&mut self.0
}
}
Deref
Coercion with Mutability
mutable reference를 immutable reference로 변환, immutable reference를 imuutable reference로 변환, mutable reference를 mutable reference로 변환하는 것은 모두 가능합니다. 그러나 immutable reference를 mutable reference로 변환하는 것은 불가능합니다. 이는 Rust의 borrowing rule을 지키기 위함입니다.
Drop
trait
Drop
trait은 스마트 포인터가 범위를 벗어나면 실행되는 코드를 정의합니다. 이는 C++의 소멸자와 유사합니다. Rust는 Drop
trait을 구현한 타입의 인스턴스가 범위를 벗어나면 자동으로 drop()
메소드를 호출합니다.
변수들은 생성된 순서의 반대 순서로 drop()
메소드가 호출됩니다. 이는 스택에 저장된 변수들이 범위를 벗어나는 순서와 일치합니다.
Drop
trait을 구현할 때에는 self
의 mutable reference를 받는 drop()
메소드를 구현할 필요가 있습니다.
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
} // 메모리 해제를 위한 코드는 아님(어차피 스택에 할당된 이상 범위를 벗어나면 자동으로 해제됨, 그때 추가적으로 실행할 동작을 정의하는 것일 뿐)
일찍 drop하기
std::mem::drop
함수를 사용하여 변수를 일찍 drop할 수 있습니다. Lock을 관리할때 특히 유용합니다.
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
drop(y); // y를 일찍 drop
// assert_eq!(5, *y); // y는 이미 drop되었으므로 에러 발생
}
대신 Rust는 Drop
trait에 구현된 drop()
메소드를 직접적으로 호출하는 것을 허용하지 않습니다.
Rc<T>
(Reference Counting)
때때로, 다수의 owner를 갖는 단일 value가 존재하는 경우가 있습니다. 예를 들어, 그래프 자료 구조는 여러 edge가 하나의 node를 가리키고, 해당 노드는 개념적으로 그걸 가리키는 모든 edge에 의해 소유됩니다. 해당 node는 edge가 모두 소멸되기 전까지 살아있어야 하죠.
이러한 경우, Rc<T>
타입을 사용할 수 있습니다. Rc<T>
는 reference counting smart pointer로, 데이터의 owner가 여러 명이 되는 것을 허용하고, owner의 수를 추적하며, owner가 남아 있지 않을 때 자동으로 데이터를 삭제합니다.
물론, immutable reference를 여러 개 사용함으로써 비슷한 기능을 하도록 하는 것도 가능합니다. 하지만 이 경우 lifetime을 일일이 명시해야 하고, reference의 수를 추적하는 것은 프로그래머의 책임이 됩니다.
Rc<T>
는 thread safety를 위해 single-threaded scenario에서만 사용 가능합니다.
Rc::clone()
메소드를 사용하여 Rc<T>
의 reference count를 증가시킬 수 있습니다. 이는 무거워 보이는 이름과 달리 실제로는 데이터를 복사하지 않고 단지 Rc<T>
의 reference count를 증가시키기만 합니다. 그래서 computational cost가 추가적으로 발생하지 않습니다.
use std::rc::Rc;
use crate::List::{Cons, Nil};
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a)); // a에 대한 reference 복사
let c = Cons(4, Rc::clone(&a));
}
이 리스트의 구조를 도식화하면 아래와 같습니다.
그런데 그림에 살짝 오류가 있습니다. b
와 c
를 포인터처럼 묘사했지만 코드상에선 그냥 Cons
로 선언되어 있으므로, b
와 c
는 그 자체로 Cons
타입의 값이 됩니다.
Rc
는 위와 같이 immutable reference를 통해 데이터를 프로그램의 여러 부분에서 공유하도록 할 수 있습니다.
Interior Mutability Pattern
Rust에서의 design pattern으로, immutable reference가 존재하는 경우에도 데이터를 변경할 수 있도록 하기 위해서 사용합니다.
이 패턴은 자료 구조 내에 unsafe
코드를 삽입해서 compile time의 borrowing rule 검사를 우회합니다.
Interior mutability를 사용하기 위해서는 런타임에 borrowing rule이 지켜지는 것을 보장할 수 있을 때만 가능합니다. 이때 unsafe
code는 safe API에 의해 랩핑됩니다.
RefCell<T>
Box
와 비슷한 기능을 하지만, 컴파일 타임이 아닌 런타임에 borrowing rule을 강제하는 한 자료구조입니다. Deref
와 Drop
trait을 구현하고 있지 않으므로 기술적으로 smart pointer는 아닙니다. 하지만 실제 사용은 그냥 smart pointer처럼 하면 됩니다. 만약 runtime에 borrowing rule을 위반하면 panic이 발생하게 됩니다.
runtime checking에 의해 더 유연한 프로그래밍이 가능하지만, panic의 가능성이 존재하고 약간의 성능 저하가 유발될 수 있습니다.
Immutable 변수의 Mutable borrowing
원래는 let
키워드로 선언한 immutable 변수는 값의 변경이 불가하지만, RefCell<T>
등을 사용하면 interior mutability를 얻어와 값의 변경을 허용할 수 있습니다.
use std::cell::RefCell;
fn main() {
let x = RefCell::new(42);
let mut y = x.borrow_mut();
*y = 13; // x의 값이 변경됨
}
trait에 정의된 메소드를 구현할 때, 해당 메소드가 immutable reference를 parameter로 하고 있는데 테스팅을 위해 임시로 구현한 mock object가 해당 메소드를 통해 argument의 값을 변경할 필요가 있는 경우 등에 유용합니다.
RefCell<T>
의 borrow()
메소드와 borrow_mut()
는 각각 스마트 포인터 Ref<T>
와 RefMut<T>
를 리턴하는데 내부적으로 이 reference들의 count를 관리합니다. 이를 통해 borrowing rule의 runtime checking을 가능하게 합니다.
Multipler Owners on Mutable data
Rc<T>
는 데이터에 대한 immutable reference만 허용하지만, RefCell<T>
를 Rc<T>
가 가리키게 하면 결과적으로 RefCell<T>
가 가리키는 mutable data가 multiple owner를 가지게 할 수 있습니다.
위에서 정의한 List
enum의 Cons
variant 정의를 수정하면, 리스트의 모든 원소를 수정할 수 있죠.
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
*value.borrow_mut() += 10;
//borrow_mut로 얻어진 mutable reference에 대해 automatic dereference가 발생하여 실제 i32 값에 10이 더해진다.
println!("a after = {:?}", a);
println!("b after = {:?}", b);
println!("c after = {:?}", c);
}
아까랑 비교해보면 Rc
가 가리키는 값이 그냥 정수였는데 이제 RefCell
이 가리키는 값이 되었습니다. 지금까지 설명한 것처럼, RefCell
은 mutable reference를 얻을 수 있도록 해주는 smart pointer이기 때문에 *
연산자를 사용하여 값을 변경할 수 있습니다.
그리고 주의해야 할 부분은
*value.borrow_mut() += 10;
여긴데, 주석에서도 간단하게 설명했지만 *
연산자 하나로 정수 값에 바로 접근할 수 있는 것은 dereference가 다음과 같은 순서로 발생하기 때문입니다.
-
value
의borrow_mut()
메소드를 호출할 때, 원래value
는Rc<RefCell<i32>>
타입이지만RefCell<i32>
타입으로 자동 dereference됩니다.왜 그런지는 바로 뒤에서 설명하겠습니다.
-
RefCell<i32>
의borrow_mut()
메소드를 호출하면,RefMut<i32>
타입을 리턴합니다. -
RefMut<i32>
타입에 대해*
연산자를 사용하면,i32
타입을 리턴합니다.
여기까지 알아본 스마트 포인터(RefCell<T>
는 아니지만)들의 특성을 표로 정리해 보겠습니다.
Type | Ownership | Mutability | Borrowing rule enforcement | Reference Counting | Thread Safety |
---|---|---|---|---|---|
Box<T> |
Single | Mutable and Immutable | Compile time | No | Yes |
Rc<T> |
Multiple | Immutable | Compile time | Yes | No |
RefCell<T> |
Single | Mutable and Immutable | Runtime | No | No |
위 표에 나온 것처럼 Rc<T>
와 RefCell<T>
는 thread safety를 보장하지 않습니다. 따라서 multi-threaded scenario에서는 사용할 수 없습니다.
멀티쓰레딩 상황에서 사용할 수 있는 대안은 쓰레딩 관련 게시글에서 따로 다뤄보겠습니다.
Automatic Referencing and Dereferencing
어떤 객체의 메소드를 object.something()
과 같이 호출할 때, Rust는 object
의 타입에 따라 자동으로 &
, &mut
, *
를 추가하여 object가 method signature와 일치하도록 해줍니다. 이를 automatic referencing and dereferencing라고 합니다.
예를 들어 위 코드에서 value.borrow_mut()
라고 작성하면 자동으로 *
가 추가되어 RefCell
의 메소드를 호출할 수 있도록 해줍니다.
객체의 메소드를 일반적으로 호출할 때에도 사실 메소드의 파라미터는 &Self
타입인데 객체에 대한 reference가 아닌 객체 자체로 메소드를 호출할 수 있는 것은 이러한 기능 덕분이라고 할 수 있죠.
Reference Cycles Can Leak Memory
Rust의 memory safety는 의도치 않게 unreleased memory를 생성하는 것(a.k.a. memory leak)이 어렵도록 합니다. 하지만 불가능하게 하는 것은 아닙니다.
Rc<T>
와 RefCell<T>
을 사용하여 reference cycle을 생성할 수 있습니다. 이는 두 Rc<T>
가 서로를 가리키는 경우입니다. 이러한 경우, reference count가 0이 되지 않아 메모리가 해제되지 않습니다.
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item), // 바로 List의 2번째 element 반환
Nil => None,
}
}
}
자 보시면 또 List
의 정의가 바뀌었습니다. 첫 번째 element가 아닌 두 번째 element가 RefCell
로 감싸져 있죠?
Reference Cycle을 생성하는 코드는 다음과 같습니다.
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() { // a가 Nil이 아니라면
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
// Uncomment the next line to see that we have a cycle;
// it will overflow the stack
// println!("a next item = {:?}", a.tail()); // Some(RefCell{value : Cons(10, RefCell{value: Cons(5, ...)})} -> 무한히 반복
}
위와 같이 cycle이 생기면 메모리 공간을 무한히 점유하게 될 수 있고, stack overflow가 발생할 수 있습니다.
그래서 RefCell
로 Rc
를 감싸는 경우, reference cycle이 생기지 않도록 주의해야 합니다.
Preventing Reference Cycles: Weak<T>
Weak<T>
는 Rc<T>
의 reference count(strong_count
)를 증가시키지 않는 reference 타입입니다. 이를 통해 reference cycle을 방지할 수 있습니다.
Rc::downgrade()
함수를 호출하면 Rc<T>
를 받아 Weak<T>
를 리턴합니다.
그리고 이 함수는 strong_count
대신 weak_count
를 1 증가시킵니다.
이 weak_count
는 Rc<T>
인스턴스의 해제를 위해 꼭 0이 될 필요가 없습니다.
Rc<T>
와 달리 Weak<T>
는 그것이 가리키는 value의 lifetime을 보장하지 않습니다. 따라서 Weak<T>
를 사용할 때는 upgrade()
메소드를 사용하여 Rc<T>
로 변환한 후 사용해야 합니다. 이 메소드는 Option<Rc<T>>
를 리턴하는데, 이미 값이 drop된 후라면 None
을 리턴합니다.
이를 활용할 수 있는 좋은 자료구조의 예로 트리 구조가 있습니다.
트리 내의 노드가 자신의 부모와 자식을 모두 파악 가능한 트리 구조에서, 부모가 자식을 가리키고 자식이 부모를 가리키는 reference cycle이 생기는 경우가 있습니다. 이때 Weak<T>
를 사용하여 reference cycle을 방지할 수 있습니다. 그런데 어떤 포인터에 Weak<T>
을 사용해야 할까요?
먼저 부모가 자식을 가리키는 경우를 생각해 봅시다. 자식은 부모에 종속적이기 때문에 부모가 drop되면 자식도 drop되어야 합니다. 반면, 부모는 자식에 종속되지 않습니다. 자식이 drop된다고 해서 부모가 같이 drop되지는 않습니다. 따라서 부모가 자식을 가리키는 포인터는 Rc<T>
를 사용하고, 자식이 부모를 가리키는 포인터는 Weak<T>
를 사용하는 것이 좋습니다. 소스 코드로는 다음과 같이 작성할 수 있겠네요.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
이제 Rc::downgrade()
함수를 이용하여 Weak<Node>
를 생성하는 예시를 살펴보겠습니다.
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
// 이때 `Weak`이 가리키는 값이 없기 때문에 `None`이 출력됩니다.
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
// 이때 `Weak`이 가리키는 값이 있기 때문에 `Some(Node { value: 5, ... })`이 출력됩니다.
}
이제 leaf
가 자신의 parent
에 접근할 수 있습니다.
weak_count
랑 strong_count
세는 문제가 기말고사에 출제될 것 같다.
참고 문헌
-
고려대학교 컴퓨터학과 오상은 교수님의 시스템 프로그래밍(COSE322) 과목 강의자료
Rust 맛보기
- 1. Rust - 입문하기
- 2. Rust - 타입 모음
- 3. Rust - Memory Ownership
- 4. Rust - Control Flow
- 5. Rust - Structured Data Types
- 6. Rust - Project Organization
- 7. Rust - Error Handling
- 8. Rust - Collections
- 9. Rust - Generics
- 10. Rust - Test Automation
- 11. Rust - Functional Programming
- 12. Rust - Memory Management
- 13. Rust - I/O Management
- 14. Rust - Process & Thread Management
- 15. Rust - Inter-Process Communication