본문으로 건너뛰기

안동민 개발노트

본문 시작

`Rc<T>`, 참조 카운트 스마트 포인터

단일 스레드에서 Rc로 불변 데이터의 소유권을 공유하고 clone·drop에 따라 강한 참조 카운트가 변하는 과정을 추적합니다.

대부분의 경우에서 소유권은 명확합니다.

즉 어떤 변수가 주어진 값을 소유하는지 정확히 압니다.

그러나 하나의 값이 여러 개의 소유자를 가질 수 있는 경우도 있습니다.

예를 들어, 그래프 데이터 구조에서 여러 에지가 동일한 노드를 가리킬 수도 있고, 그 노드는 개념적으로 해당 노드를 가리키는 모든 에지에 의해 소유됩니다.

노드는 어떠한 에지도 이를 가리키지 않아 소유자가 하나도 없는 상태가 아니라면 메모리 정리가 되어서는 안 됩니다.

명시적으로 복수 소유권을 가능하게 하려면 러스트의 Rc<T> 타입을 이용해야 하는데, 이는 참조 카운팅(reference counting) 의 약자입니다.

Rc<T> 타입은 어떤 값의 참조자 개수를 계속 추적하여 해당 값이 계속 사용 중인지를 판단합니다.

만일 어떤 값에 대한 참조자가 0개라면 이 값의 메모리 정리를 하더라도 유효하지 않은 참조자가 발생하지 않을 수 있습니다.

Rc<T>를 거실의 TV라고 상상해봅시다.

한 사람이 TV를 보러 들어올 때 TV를 켭니다.

다른 사람들은 거실로 들어와서 TV를 볼 수 있습니다.

마지막 사람이 거실을 나선다면, TV는 더 이상 사용되고 있지 않으므로 끕니다.

만일 누군가 계속 TV를 보고 있는 중에 어떤 이가 꺼버리면, 남아있던 TV 시청자들로부터 엄청난 소란이 있겠죠!

Rc<T> 타입은 프로그램의 여러 부분에서 읽을 데이터를 힙에 할당하고 싶은데 컴파일 타임에는 어떤 부분이 그 데이터를 마지막에 이용하게 될지 알 수 없는 경우 사용됩니다.

만일 어떤 부분이 마지막으로 사용하는지 알았다면, 그냥 그 해당 부분을 데이터의 소유자로 만들면 되고, 보통의 소유권 규칙이 컴파일 타임에 수행되어 효력을 발생시킬 겁니다.

Rc<T>는 오직 싱글스레드 시나리오용이라는 점을 주의하세요.

16장에서 동시성(cuncurrency)에 대한 논의를 할 때, 멀티스레드 프로그램에서 참조 카운팅을 하는 방법을 다루겠습니다.


Rc<T>를 사용하여 데이터 공유하기

예제 14-5의 콘스 리스트 예제로 돌아가 봅시다.

Box<T>를 이용해서 이를 정의했던 것을 상기합시다.

이번에는 두 개의 리스트를 만들고 이 둘이 모두 세 번째 리스트의 소유권을 공유하도록 하겠습니다.

개념적으로는 그림 14-3처럼 생겼습니다.

Rc를 사용해 리스트 b와 c가 리스트 a의 소유권을 공유하는 구조 그림 14-3: 세 번째 리스트 a의 소유권을 공유하는 두 리스트 bc

먼저 5와 10을 담은 리스트 a를 만들겠습니다.

그런 다음 두 개의 리스트를 더 만들 것입니다.

3으로 시작하는 b와 4로 시작하는 c를 말이죠.

그리고서 bc 리스트 둘 모두 5와 10을 가지고 있는 첫 번째 a 리스트로 계속되도록 하겠습니다.

바꿔 말하면, 두 리스트 모두 5와 10을 담고 있는 첫 리스트를 공유하게 될 것입니다.

예제 14-17과 같이 Box<T>를 가지고 정의한 List를 이용하여 이 시나리오의 구현을 시도하면 작동하지 않을 것입니다.

예제 14-17: Box<T>를 이용한 두 리스트가 세 번째 리스트에 대한 소유권을 공유하는 시도는 허용되지 않음을 보이는 예
src/main.rs
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 리스트를 만들 때 ab 안으로 이동되어 b의 소유가 됩니다.

그다음 c를 생성할 때 a를 다시 사용하려 할 경우는 허용되지 않는데, 이미 a가 이동되었기 때문입니다.

Cons의 정의를 변경하여 참조자를 대신 들고 있도록 할 수도 있지만, 그러면 라이프타임 매개변수를 명시해야 할 것입니다.

라이프타임 매개변수를 명시함으로써, 리스트 내의 모든 요소가 최소한 전체 리스트만큼 오래 살아있도록 지정할 것입니다.

이는 예제 14-17의 요소와 리스트에 대한 경우지, 모든 시나리오에 맞는 것은 아닙니다.

그 대신 예제 14-18과 같이 Box<T>의 자리에 Rc<T>를 이용하는 형태로 List의 정의를 바꾸겠습니다.

각각의 Cons 배리언트는 이제 어떤 값과 List를 가리키는 Rc<T>를 갖게 될 것입니다.

b를 만들 때는 a의 소유권을 얻는 대신, a를 가지고 있는 Rc<List>를 클론할 것인데, 이는 참조자의 개수를 하나에서 둘로 증가시키고 abRc<List> 안에 있는 데이터의 소유권을 공유하도록 해줍니다.

또한 c를 만들 때도 a를 클론할 것인데, 이로써 참조자의 개수가 둘에서 셋으로 늘어납니다.

Rc::clone가 호출될 때마다 그 Rc<List>가 가지고 있는 데이터에 대한 참조 카운트는 증가할 것이고, 그 데이터는 참조자가 0개가 되지 않으면 메모리가 정리되지 않을 것입니다.

예제 14-18: Rc<T>를 이용하는 List 정의
src/main.rs
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 안에서 5와 10을 가지고 있는 리스트가 만들어지고 이것이 a의 새로운 Rc<List>에 저장됩니다.

그다음 bc를 만들 때는 Rc::clone 함수를 호출하고 aRc<List>에 대한 참조자를 인수로서 넘깁니다.

Rc::clone(&a) 대신 a.clone()을 호출할 수도 있지만, 위의 경우 러스트의 관례는 Rc::clone를 이용하는 것입니다.

Rc::clone의 구현체는 대부분의 타입들에 대한 clone 구현체들이 그러하듯 모든 데이터에 대한 깊은 복사(deep copy)를 하지 않습니다.

Rc::clone의 호출은 오직 참조 카운트만 증가시키는데, 이는 시간이 얼마 걸리지 않습니다.

데이터의 깊은 복사는 많은 시간이 걸릴 수 있습니다.

참조 카운팅을 위해 Rc::clone을 사용함으로써 깊은 복사 종류의 클론과 참조 카운트를 증가시키는 종류의 클론을 시각적으로 구별할 수 있습니다.

코드에서 성능 문제를 찾는 중이라면 깊은 복사 클론만 고려할 필요가 있고 Rc::clone 호출은 무시할 수 있습니다.


Rc<T>를 클론하는 것은 참조 카운트를 증가시킵니다

Rc 생성, 얕은 clone, 중첩 소유자 drop, 마지막 강한 소유자 drop, 내부 값 정리와 Weak 할당 꼬리를 시간순으로 보여 주는 시퀀스

SEQUENCE · SINGLE-THREAD SHARED OWNER

Rc 강한 참조 카운트 수명

Rc::clone은 같은 할당의 강한 소유자를 하나 늘린다. 강한 카운트가 0이 되면 내부 T가 drop되지만 Weak가 남아 있으면 할당의 관리 영역은 더 오래 유지될 수 있다.

Rc 강한 참조 카운트 수명 Rc 생성, 얕은 clone, 중첩 소유자 drop, 마지막 강한 소유자 drop, 내부 값 정리와 Weak 할당 꼬리를 시간순으로 보여 주는 시퀀스 Rc::new · strong = 1 Rc::clone · strong = 2 drop nested clone · 1 drop final strong · 0 drop inner T Weak keeps allocation tail drop final Weak · deallocate OWNERS Rc 소유자 집합 CONTROL Rc 할당 VALUE 내부 T WEAK Weak 관찰자 NON-ATOMIC · SINGLE THREAD · STRONG ZERO DROPS T BEFORE THE FINAL WEAK TAIL ENDS
  1. 첫 소유자

    Rc::new로 강한 소유자 1개가 생깁니다.

  2. 얕은 clone

    Rc::clone은 같은 할당의 강한 카운트를 늘리고 T를 깊게 복제하지 않습니다.

  3. 중첩 소유자 종료

    복제본이 끝나면 강한 카운트가 줄지만 T는 아직 살아 있습니다.

  4. 마지막 강한 소유자

    강한 카운트가 0이 되면 내부 T가 drop됩니다.

  5. Weak 꼬리

    Weak가 남아 있으면 관리 할당은 유지되며 마지막 Weak가 끝날 때 해제됩니다.

Rc는 비원자적 단일 스레드 공유 소유권이며 Send도 Sync도 아니다. 공유 변경에는 Cell·RefCell이 필요하고, 유일 접근의 get_mut 또는 복사-후-쓰기 make_mut는 별도 경로다.

예제 14-18의 작동하는 예제를 변경하여 a 내부의 Rc<List>에 대한 참조자가 생성되고 버려질 때 참조 카운트가 변하는 것을 볼 수 있도록 해봅시다.

예제 14-19에서는 main을 변경하여 안쪽의 스코프가 리스트 c를 감싸도록 하겠습니다; 그러면 c가 스코프 밖으로 벗어났을 때 참조 카운트가 어떻게 바뀌는지 볼 수 있습니다.

예제 14-19: Printing the reference count
src/main.rs
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

aRc<List>는 초기 참조 카운트 1을 갖고 있음을 볼 수 있습니다; 그 후 clone을 호출할 때마다 카운트가 1씩 증가합니다.

c가 스코프 밖으로 벗어날 때는 카운트가 1 감소합니다.

Rc::clone를 호출하여 참조 카운트를 증가시켜야 했던 것과 달리 참조 카운트를 감소시키기 위해 어떤 함수를 호출할 필요는 없습니다.

Rc<T> 값이 스코프 밖으로 벗어나면 Drop 트레이트의 구현체가 자동으로 참조 카운트를 감소시킵니다.

main의 끝부분에서 b와 그다음 a가 스코프 밖을 벗어나서, 카운트가 0이 되고, 그 시점에서 Rc<List>가 완전히 메모리 정리되는 것은 이 예제에서 볼 수 없습니다.

Rc<T>를 이용하면 단일 값이 복수 소유자를 갖도록 할 수 있고, 그 개수는 소유자 중 누구라도 계속 존재하는 한 해당 값이 계속 유효하도록 보장해 줍니다.

Rc<T>는 불변 참조자를 통하여 읽기 전용으로 프로그램의 여러 부분에서 데이터를 공유하도록 해줍니다.

만일 Rc<T>가 여러 개의 가변 참조자도 만들도록 해준다면, 4장에서 논의했던 대여 규칙 중 하나를 위반할지도 모릅니다.

동일한 위치에서 여러 개의 가변 대여는 데이터 경합 및 데이터 불일치를 야기할 수 있습니다.

하지만 데이터의 변형을 가능하게 하는 것은 매우 유용하죠!

다음 절에서는 이러한 불변성 제약과 함께 동작하도록 하기 위한 내부 가변성(interior mutability) 패턴 및 Rc<T>와 같이 결합하여 사용할 수 있는 RefCell<T> 타입에 대해 논의하겠습니다.

Rc를 사용할 때는 참조 카운트가 늘고 줄어드는 시점, 변경 가능성, 순환 참조 위험을 함께 확인해야 합니다.


Rc<T>, 참조 카운트 스마트 포인터 학습 판단 기준표

Rc<T>, 참조 카운트 스마트 포인터에서는 소유권, 타입, 오류 처리 경계를 실제 코드에서 어떤 순서로 확인할지 정리합니다.