`Rc<T>`, 참조 카운트 스마트 포인터
단일 스레드에서 Rc로 불변 데이터의 소유권을 공유하고 clone·drop에 따라 강한 참조 카운트가 변하는 과정을 추적합니다.
대부분의 경우에서 소유권은 명확합니다.
즉 어떤 변수가 주어진 값을 소유하는지 정확히 압니다.
그러나 하나의 값이 여러 개의 소유자를 가질 수 있는 경우도 있습니다.
예를 들어, 그래프 데이터 구조에서 여러 에지가 동일한 노드를 가리킬 수도 있고, 그 노드는 개념적으로 해당 노드를 가리키는 모든 에지에 의해 소유됩니다.
노드는 어떠한 에지도 이를 가리키지 않아 소유자가 하나도 없는 상태가 아니라면 메모리 정리가 되어서는 안 됩니다.
이 절에서 단일 스레드의 공유 소유권을 표현할 때는 러스트의 Rc<T> 타입을 이용합니다. Rc는 참조 카운팅(reference counting)의 약자입니다.
Rc<T> 타입은 같은 내부 값을 가리키는 강한 참조의 개수를 추적합니다.
강한 참조 카운트가 0이 되면 공유하던 내부 값이 drop됩니다.
3장에서 배운 소유권 규칙은 각각의 Rc 값에도 적용됩니다. Rc를 복제하면 같은 내부 값을 가리키는 별도의 Rc 값이 생깁니다. 각 Rc 값은 따로 소유되고, 공유하는 내부 값의 수명은 남아 있는 강한 참조가 함께 책임집니다.
Rc<T>를 거실의 TV라고 상상해봅시다.
한 사람이 TV를 보러 들어올 때 TV를 켭니다.
다른 사람들은 거실로 들어와서 TV를 볼 수 있습니다.
마지막 사람이 거실을 나선다면, TV는 더 이상 사용되고 있지 않으므로 끕니다.
만일 누군가 계속 TV를 보고 있는 중에 어떤 이가 꺼버리면, 남아있던 TV 시청자들로부터 엄청난 소란이 있겠죠!
Rc<T> 타입은 프로그램의 여러 부분에서 읽을 데이터를 힙에 할당하고 싶은데 컴파일 타임에는 어떤 부분이 그 데이터를 마지막에 이용하게 될지 알 수 없는 경우 사용됩니다.
만일 어떤 부분이 마지막으로 사용하는지 알았다면, 그냥 그 해당 부분을 데이터의 소유자로 만들면 되고, 보통의 소유권 규칙이 컴파일 타임에 검사됩니다.
여기서 수명을 결정하는 것은 마지막으로 읽은 순간이 아니라 마지막 강한 Rc 소유자가 버려지는 순간입니다. Rc::strong_count(&a)를 호출하거나 &a를 잠깐 빌려 읽는 것만으로 새 강한 소유자가 생기지는 않습니다. 앞 절의 이동·대여와 Drop의 정리 시점을 구분하면 이후의 카운트를 따라가기 쉽습니다.
Rc<T>는 단일 스레드용이며 Send와 Sync를 구현하지 않습니다. 다른 스레드로 보내거나 여러 스레드에서 공유할 수 있는 타입으로 취급해서는 안 됩니다.
15장에서 동시성(concurrency)에 대한 논의를 할 때, 멀티스레드 프로그램에서 참조 카운팅을 하는 방법을 다루겠습니다.
Rc<T>를 사용하여 데이터 공유하기
예제 14-5의 콘스 리스트 예제로 돌아가 봅시다.
Box<T>를 이용해서 이를 정의했던 것을 상기합시다.
이번에는 두 개의 리스트를 만들고 이 둘이 모두 세 번째 리스트의 소유권을 공유하도록 하겠습니다.
개념적으로는 그림 14-3처럼 생겼습니다.
a, b, c가 같은 리스트를 공유한다
b와 c의 다음 포인터는 같은 5를 가리킨다. 각 Cons의 두 칸은 값과 다음 포인터다.
a의 소유권을 공유하는
두 리스트 b와 c
먼저 5와 10을 담은 리스트 a를 만들겠습니다.
그런 다음 두 개의 리스트를 더 만들 것입니다.
3으로 시작하는 b와 4로 시작하는 c를 말이죠.
그리고서 b와 c 리스트 둘 모두 5와 10을 가지고 있는 첫 번째 a 리스트로 계속되도록 하겠습니다.
바꿔 말하면, 두 리스트 모두 5와 10을 담고 있는 첫 리스트를 공유하게 될 것입니다.
예제 14-17과 같이 Box<T>를 가지고 정의한 List를 이용하여 이
시나리오의 구현을 시도하면 작동하지 않을 것입니다.
Box<T>를 이용한 두 리스트가 세 번째
리스트에 대한 소유권을 공유하는 시도는 허용되지 않음을 보이는 예
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
let b = Cons(3, Box::new(a));
let c = Cons(4, Box::new(a));
}
이 코드를 컴파일하면 다음과 같은 에러를 얻습니다.
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
--> src/main.rs:11:30
|
9 | let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
| - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 | let b = Cons(3, Box::new(a));
| - value moved here
11 | let c = Cons(4, Box::new(a));
| ^ value used here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` due to previous error
Cons 배리언트는 자신이 들고 있는 데이터를 소유하므로, b 리스트를 만들 때 a는 b 안으로 이동되어 b의 소유가 됩니다.
그다음 c를 생성할 때 a를 다시 사용하려 할 경우는 허용되지 않는데, 이미 a가 이동되었기 때문입니다.
Cons의 정의를 변경하여 참조자를 대신 들고 있도록 할 수도 있지만, 그러면 라이프타임 매개변수를 명시해야 할 것입니다.
라이프타임 매개변수를 명시함으로써, 리스트 내의 모든 요소가 최소한 전체 리스트만큼 오래 살아있도록 지정할 것입니다.
이는 예제 14-17의 요소와 리스트에 해당하는 경우일 뿐, 모든 시나리오에 맞는 것은 아닙니다.
그 대신 예제 14-18과 같이 Box<T>의 자리에 Rc<T>를 이용하는 형태로 List의 정의를 바꾸겠습니다.
각각의 Cons 배리언트는 이제 어떤 값과 List를 가리키는 Rc<T>를 갖게 될 것입니다.
b를 만들 때는 a의 소유권을 얻는 대신, a를 가지고 있는 Rc<List>를 클론할 것인데, 이는 참조자의 개수를 하나에서 둘로 증가시키고 a와 b가 Rc<List> 안에 있는 데이터의 소유권을 공유하도록 해줍니다.
또한 c를 만들 때도 a를 클론할 것인데, 이로써 참조자의 개수가 둘에서 셋으로 늘어납니다.
Rc::clone이 호출될 때마다 같은 데이터를 가리키는 강한 참조 카운트가 증가합니다. 마지막 강한 참조가 사라지면 공유하던 내부 값이 drop됩니다.
Rc<T>를 이용하는 List
정의
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a));
let c = Cons(4, Rc::clone(&a));
}
Rc<T>는 프렐루드에 포함되어 있지 않으므로 이를 스코프로 가져오려면 use 구문을 추가해야 합니다.
main은 Rc::new로 Cons(5, …)를 보관할 할당을 만들고, 이를 가리키는 Rc<List> 값을 a에 저장합니다. 여기서 할당은 내부 값을 보관하기 위해 확보한 저장 공간을 뜻합니다.
그다음 b와 c를 만들 때는 Rc::clone 함수를 호출하고 a의 Rc<List>에 대한 참조자를 인수로서 넘깁니다.
이 예제에서 a는 Rc<List> 값이고, b와 c는 List::Cons 값입니다. b와 c의 두 번째 필드에 각각 들어간 Rc 값이 a와 같은 Cons(5, …) 할당을 가리킵니다. Cons(10, …)과 Nil은 다른 Rc::new 호출이 만든 할당에 있으므로 리스트 전체를 하나의 할당으로 세지는 않습니다.
Rc::clone(&a) 대신 a.clone()을 호출할 수도 있지만, 위의 경우 러스트의 관례는 Rc::clone을 이용하는 것입니다.
Rc::clone의 구현체는 대부분의 타입들에 대한 clone 구현체들이 그러하듯 모든 데이터에 대한 깊은 복사(deep copy)를 하지 않습니다.
Rc::clone(&a)는 같은 할당을 가리키는 새 Rc 값을 반환하고 그 할당의 강한 참조 카운트를 하나 늘립니다. 내부 List를 복제하는 것이 아니므로 내부 값에 Clone 구현을 요구하지도 않습니다.
데이터의 깊은 복사는 많은 시간이 걸릴 수 있습니다.
참조 카운팅을 위해 Rc::clone을 사용함으로써 깊은 복사 종류의 클론과 참조 카운트를 증가시키는 종류의 클론을 시각적으로 구별할 수 있습니다.
깊은 복사와 Rc::clone을 구분하면 성능을 조사하기 쉽습니다. Rc::clone은 payload를 복제하지 않지만 카운트 갱신 비용이 있으므로 실제 호출 빈도와 측정 결과에 따라 함께 살펴봅니다.
Rc<T>를 클론하는 것은 참조 카운트를 증가시킵니다
Rc 강한 참조 카운트 수명
생성/clone/drop으로 strong 카운트 1→2→1→0, T 정리와 Weak 남은 관리 할당/마지막 해제의 자원 상태이다. 본문 1→2→3→2 예와 일반 모델을 구분한다.
SEQUENCE · SINGLE-THREAD SHARED OWNER
Rc::clone은 데이터를 복사하지 않고 강한 소유자를 늘린다
Rc::clone은 같은 할당의 강한 소유자를 하나 늘린다. 강한 카운트가 0이 되면 내부 T가 drop되지만 Weak가 남아 있으면 할당의 관리 영역은 더 오래 유지될 수 있다.
- 첫 소유자
Rc::new로 강한 소유자 1개가 생깁니다.
- 얕은 clone
Rc::clone은 같은 할당의 강한 카운트를 늘리고 T를 깊게 복제하지 않습니다.
- 중첩 소유자 종료
복제본이 끝나면 강한 카운트가 줄지만 T는 아직 살아 있습니다.
- 마지막 강한 소유자
강한 카운트가 0이 되면 내부 T가 drop됩니다.
- Weak 꼬리
Weak가 남아 있으면 관리 할당은 유지되며 마지막 Weak가 끝날 때 해제됩니다.
본문 예제 14-19 · a가 가리키는 공유 리스트의 카운트
main 끝에서는 b 안의 Rc가 사라져 1, 이어 a가 사라져 0이 됩니다. 위 시퀀스는 중첩 clone 하나의 일반 모델이며 본문 숫자와 구분합니다. Weak 관리 수명은 다음 절의 확장 설명입니다.
Rc는 비원자적 단일 스레드 공유 소유권이며 Send도 Sync도 아니다. 공유 변경에는 Cell·RefCell이 필요하고, 유일 접근의 get_mut 또는 복사-후-쓰기 make_mut는 별도 경로다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4참여자와 7메시지를 세로 도표에서는 7개의 독립적인 송신/수신 쌍으로 반복 표현한다. 번호는 시간 순서이고 화살표는 실제 메시지 방향이며 그룹 사이에 새 간선은 없다. 마지막 Weak→Rc 할당의 반환 방향도 유지한다. 중첩 clone 하나의 일반 모델 1→2→1→0이며 본문 List의 1→2→3→2와 구별한다. Weak가 남았을 때 T는 정리됐어도 관리 할당은 유지될 수 있고 마지막 Weak가 종료될 때 해제된다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 강한 소유 시작 일반 모델의 Rc와 Weak를 준비한다. |
|
|
| clone·drop 강한 핸들 복제와 정리로 카운트를 바꾼다. |
|
|
| 마지막 강한 소유 strong 0에서 T를 정리하되 Weak가 있으면 관리 할당은 남는다. |
|
|
Rc 강한 참조 카운트 수명
생성/clone/drop으로 strong 카운트 1→2→1→0, T 정리와 Weak 남은 관리 할당/마지막 해제의 자원 상태이다. 본문 1→2→3→2 예와 일반 모델을 구분한다.
SEQUENCE · SINGLE-THREAD SHARED OWNER
Rc::clone은 데이터를 복사하지 않고 강한 소유자를 늘린다
Rc::clone은 같은 할당의 강한 소유자를 하나 늘린다. 강한 카운트가 0이 되면 내부 T가 drop되지만 Weak가 남아 있으면 할당의 관리 영역은 더 오래 유지될 수 있다.
- 첫 소유자
Rc::new로 강한 소유자 1개가 생깁니다.
- 얕은 clone
Rc::clone은 같은 할당의 강한 카운트를 늘리고 T를 깊게 복제하지 않습니다.
- 중첩 소유자 종료
복제본이 끝나면 강한 카운트가 줄지만 T는 아직 살아 있습니다.
- 마지막 강한 소유자
강한 카운트가 0이 되면 내부 T가 drop됩니다.
- Weak 꼬리
Weak가 남아 있으면 관리 할당은 유지되며 마지막 Weak가 끝날 때 해제됩니다.
본문 예제 14-19 · a가 가리키는 공유 리스트의 카운트
main 끝에서는 b 안의 Rc가 사라져 1, 이어 a가 사라져 0이 됩니다. 위 시퀀스는 중첩 clone 하나의 일반 모델이며 본문 숫자와 구분합니다. Weak 관리 수명은 다음 절의 확장 설명입니다.
Rc는 비원자적 단일 스레드 공유 소유권이며 Send도 Sync도 아니다. 공유 변경에는 Cell·RefCell이 필요하고, 유일 접근의 get_mut 또는 복사-후-쓰기 make_mut는 별도 경로다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4참여자와 7메시지를 세로 도표에서는 7개의 독립적인 송신/수신 쌍으로 반복 표현한다. 번호는 시간 순서이고 화살표는 실제 메시지 방향이며 그룹 사이에 새 간선은 없다. 마지막 Weak→Rc 할당의 반환 방향도 유지한다. 중첩 clone 하나의 일반 모델 1→2→1→0이며 본문 List의 1→2→3→2와 구별한다. Weak가 남았을 때 T는 정리됐어도 관리 할당은 유지될 수 있고 마지막 Weak가 종료될 때 해제된다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 강한 소유 시작 일반 모델의 Rc와 Weak를 준비한다. |
|
|
| clone·drop 강한 핸들 복제와 정리로 카운트를 바꾼다. |
|
|
| 마지막 강한 소유 strong 0에서 T를 정리하되 Weak가 있으면 관리 할당은 남는다. |
|
|
예제 14-18의 작동하는 예제를 변경하여 a 내부의 Rc<List>에 대한
참조자가 생성되고 버려질 때 참조 카운트가 변하는 것을 볼 수 있도록 해봅시다.
예제 14-19에서는 main을 변경하여 안쪽의 스코프가 리스트 c를 감싸도록 하겠습니다;
그러면 c가 스코프 밖으로 벗어났을 때 참조 카운트가 어떻게 바뀌는지 볼 수 있습니다.
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
println!("count after creating a = {}", Rc::strong_count(&a));
let b = Cons(3, Rc::clone(&a));
println!("count after creating b = {}", Rc::strong_count(&a));
{
let c = Cons(4, Rc::clone(&a));
println!("count after creating c = {}", Rc::strong_count(&a));
}
println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
프로그램 내 참조 카운트가 변하는 각 지점에서 Rc::strong_count 함수를 호출하여 얻은 참조 카운트 값을 출력합니다.
이 함수가 count가 아니고 strong_count라는 이름이 된 이유는 Rc<T> 타입이 weak_count도 갖고 있기 때문입니다; weak_count가 뭘 위해서 사용되는지는 ‘순환 참조 방지하기: Rc<T>를 Weak<T>로 바꾸기’ 절에서 알아보겠습니다.
이 코드는 다음을 출력합니다.
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished dev [unoptimized + debuginfo] target(s) in 0.45s
Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2
a의 Rc<List>는 초기 참조 카운트 1을 갖고 있음을 볼 수 있습니다; 그 후 clone을 호출할 때마다 카운트가 1씩 증가합니다.
c가 스코프를 벗어나면 그 안에 저장된 Rc 값이 drop되어 강한 참조 카운트가 3에서 2로 줄어듭니다. a와 b 안의 Rc 값이 남아 있으므로 공유하던 Cons(5, …) 값은 아직 drop되지 않습니다.
Rc::clone을 호출하여 참조 카운트를 증가시켜야 했던 것과 달리 참조 카운트를 감소시키기 위해 어떤 함수를 호출할 필요는 없습니다.
Rc<T> 값이 스코프 밖으로 벗어나면 Drop 트레이트의 구현체가 자동으로 참조 카운트를 감소시킵니다.
main이 끝나면 먼저 b 안의 Rc 값이 drop되어 카운트가 2에서 1로 줄고, 이어 a의 Rc 값이 drop되어 0이 됩니다. 이때 공유하던 Cons(5, …) 값도 drop됩니다. 이 마지막 1→0은 현재 코드의 println! 출력에 나오지 않으며, 남은 강한 참조를 따라 설명한 수명 변화입니다.
Rc<T>를 이용하면 단일 값이 복수 소유자를 갖도록 할 수 있고, 그 개수는 소유자 중 누구라도 계속 존재하는 한 해당 값이 계속 유효하도록 보장해 줍니다.
Rc<T>는 불변 참조자를 통하여 읽기 전용으로 프로그램의 여러 부분에서 데이터를 공유하도록 해줍니다.
이 설명은 여러 강한 소유자가 같은 값에 접근하는 경로입니다. 다른 Rc와 Weak가 없는 유일한 할당이면 Rc::get_mut(&mut rc)가 Some(&mut T)를 줄 수 있습니다. 공유 상태의 임의 변경과 유일한 접근에서의 변경을 같은 제약으로 묶지는 마세요. get_mut의 유일성 계약은 다음 절의 내부 가변성과 별도 경로입니다.
만일 Rc<T>가 여러 개의 가변 참조자도 만들도록 해준다면, 3장에서 논의했던 대여 규칙 중 하나를 위반할지도 모릅니다.
동일한 위치에서 여러 개의 가변 대여는 데이터 경합 및 데이터 불일치를 야기할 수 있습니다.
하지만 데이터의 변형을 가능하게 하는 것은 매우 유용하죠!
다음 절에서는 이러한 불변성 제약과 함께 동작하도록 하기 위한 내부 가변성(interior mutability) 패턴 및 Rc<T>와 같이 결합하여 사용할 수 있는 RefCell<T> 타입에 대해 논의하겠습니다.
Rc를 사용할 때는 참조 카운트가 늘고 줄어드는 시점, 변경 가능성, 순환 참조 위험을 함께 확인해야 합니다.
예시
새 전체 프로그램에서 Rc 자체의 이동은 clone과 다르다는 점을 확인할 수 있습니다.
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared"));
let b = Rc::clone(&a);
let c = b;
println!("count={}", Rc::strong_count(&a));
assert!(Rc::ptr_eq(&a, &c));
drop(c);
println!("count={}", Rc::strong_count(&a));
}예상 출력은 count=2, count=1입니다. b의 Rc를 c로 이동해도 소유자 수는 늘지 않으며 b를 다시 쓸 수는 없습니다. ptr_eq는 내부 문자열 내용이 같은지만 비교하는 대신 같은 할당을 가리키는지 검사합니다.
스스로 강한 카운트 세기
Weak나 순환은 없고 a를 생성한 뒤 순서대로 b를 clone, b를 c로 이동, c를 d로 clone, c를 drop, d를 drop합니다. 각 단계 뒤 a 할당의 강한 카운트를 구하세요. a의 보통 공유 참조 &a를 함수에 잠깐 넘기는 것도 새 Rc clone 하나로 세어야 하나요?
카운트 확인
차례로 1, 2, 2, 3, 2, 1입니다. Rc 값의 이동은 기존 소유자를 옮기며, Rc::clone만 새 강한 Rc를 더합니다. 보통 공유 참조를 전달하는 것은 그 Rc를 빌리는 것이므로 그 자체로 강한 카운트를 증가시키지 않습니다. 카운트는 a가 가리키는 한 할당의 것이며 문자열 내부 저장 공간이나 리스트의 다른 Rc 할당까지 합친 숫자가 아닙니다.
Rc<T>, 참조 카운트 스마트 포인터 학습 판단 기준표
예제 14-19에서 a가 가리키는 Cons(5, …) 할당을 먼저 고정하고, 그 할당을 소유하는 Rc 값을 a, b의 두 번째 필드, c의 두 번째 필드에서 찾아 카운트를 세어 보세요. 이동은 개수를 유지하고 clone은 하나를 더하며, 각 Rc의 drop은 하나를 뺍니다. 내부 값이 정리되는 1→0과 c만 사라지는 3→2를 구별해 설명할 수 있어야 합니다.