스마트 포인터를 타입 목록으로 외우면 Box, Rc, Arc 가운데 무엇을 골라야 할지 자주 막힙니다. 먼저 값의 소유자를 점으로, 수명을 유지하는 관계를 강한 간선으로 그려 보십시오. 소유자가 하나인지, 같은 값을 여러 곳이 함께 소유해야 하는지, 그 소유자가 스레드 경계를 넘는지부터 정하면 선택지가 줄어듭니다.
기본값은 스마트 포인터가 아닙니다. 한 곳이 값을 소유하면 직접 T를 둡니다. 다른 코드가 잠시 접근하기만 하면 &T나 &mut T로 빌립니다. 간접 참조나 공동 소유가 실제로 필요할 때만 더 강한 도구를 추가합니다.
1. 소유 그래프
Endpoint Monitor의 관계를 그릴 때는 다음 순서로 묻습니다.
| 관계 | 우선 선택 | 소유 그래프에서의 뜻 |
|---|---|---|
| 한 곳이 명확히 소유함 | T |
직접 소유 |
| 다른 코드가 잠시 접근함 | &T, &mut T |
소유권을 늘리지 않는 빌림 |
| 한 소유자와 고정 크기 간접 참조가 필요함 | Box<T> |
하나의 강한 소유 간선 |
| 한 스레드에서 여러 부분이 함께 소유함 | Rc<T> |
여러 강한 소유 간선 |
| 여러 스레드가 함께 소유함 | Arc<T> |
원자적으로 집계하는 여러 강한 소유 간선 |
| 대상을 살려 두지 않고 탐색함 | std::rc::Weak<T>, std::sync::Weak<T> |
만료될 수 있는 비소유 간선 |
두 함수가 같은 값을 읽는다고 해서 곧바로 Rc<T>가 필요하지는 않습니다. 안정된 한 소유자에게서 두 번 빌릴 수 있다면 &T가 관계를 더 정확하게 표현합니다. Rc와 Arc는 접근권이 아니라 “누가 이 값을 계속 살려 두는가”에 답합니다.
2. Box와 간접 참조
Box<T>는 힙 할당을 유일하게 소유합니다. 다만 T가 0 크기 타입이면 Box::new가 실제 할당을 하지 않을 수 있습니다. 참조 카운트를 두지 않으며 공동 소유도 만들지 않습니다. Endpoint Monitor 예제에서는 재귀적인 검사 계획의 크기를 유한하게 만드는 데 씁니다.
#[derive(Debug, PartialEq, Eq)]
pub enum CheckPlan {
Check(&'static str),
Then {
check: &'static str,
next: Box<Self>,
},
}
impl CheckPlan {
#[must_use]
pub const fn check(name: &'static str) -> Self {
Self::Check(name)
}
#[must_use]
pub fn then(name: &'static str, next: Self) -> Self {
Self::Then {
check: name,
next: Box::new(next),
}
}
#[must_use]
pub fn checks(&self) -> Vec<&'static str> {
let mut checks = Vec::new();
let mut current = self;
loop {
match current {
Self::Check(name) => {
checks.push(*name);
return checks;
}
Self::Then { check, next } => {
checks.push(*check);
current = next;
}
}
}
}
}
CheckPlan::Then이 다음 CheckPlan을 직접 담으면 타입의 크기를 계산하는 과정이 끝나지 않습니다. Box<Self>는 크기가 알려진 포인터를 enum 안에 두고 다음 노드는 간접 참조하게 합니다. 여전히 각 노드의 소유자는 하나입니다.
반대로 재시도 횟수 u8이나 포트 u16처럼 보통 크기의 필드를 Box로 감쌀 근거는 없습니다. Box는 참조 카운트 장부가 없지만 힙 간접 참조 자체는 남습니다. 값이 크다는 인상만으로 선택하지 말고 재귀 타입, trait object, API 소유 경계처럼 구체적인 이유를 요구해야 합니다.
3. Rc와 공동 소유
한 스레드 안에서 대시보드와 스케줄러가 같은 불변 정책을 각각 소유해야 하고 어느 쪽이 마지막까지 남을지 컴파일 시점에 정하기 어렵다면 Rc<T>가 맞습니다. Rc::clone은 정책을 복제한 스냅샷을 만들지 않습니다. 같은 할당을 가리키는 강한 소유자를 하나 늘립니다.
#[test]
fn rc_shares_one_immutable_policy_until_the_last_owner_drops() {
use std::rc::Rc;
let policy = Rc::new(EndpointPolicy::new("office", 3));
let dashboard = Rc::clone(&policy);
assert!(Rc::ptr_eq(&policy, &dashboard));
assert_eq!(dashboard.name(), "office");
assert_eq!(Rc::strong_count(&policy), 2);
drop(dashboard);
assert_eq!(Rc::strong_count(&policy), 1);
}
마지막 강한 Rc가 사라질 때 내부 값이 drop됩니다. 테스트의 strong_count는 이 동작을 눈으로 확인하는 증거일 뿐, 카운트를 조회해 애플리케이션의 소유권을 결정하라는 뜻은 아닙니다. 타입과 필드 방향이 소유 관계를 드러내야 합니다.
Rc<T>는 기본적으로 내부 값의 가변 참조를 주지 않습니다. 참조 카운팅은 수명 문제를 풀 뿐 공유 변경 규칙까지 정하지 않기 때문입니다. RefCell, Mutex, RwLock을 포함한 공유 변경은 19편에서 다룹니다.
4. Arc와 스레드 공유
같은 불변 정책의 소유자가 스레드 경계를 넘어야 하면 Arc<T>를 선택합니다. Arc는 강한 참조 카운트를 원자 연산으로 갱신합니다. 이 장치는 교차 스레드 공동 소유에 맞습니다. 한 스레드 안에서만 공유한다면 Rc가 더 직접적인 선택입니다.
예제의 run_workers는 각 작업자에게 같은 Arc<EndpointPolicy>의 소유권을 하나씩 전달합니다. 결과를 엔드포인트 이름으로 정렬해 출력 순서를 고정합니다.
pub fn run_workers(
policy: &std::sync::Arc<EndpointPolicy>,
endpoints: &[&str],
) -> Result<Vec<WorkerReport>, WorkerPanicked> {
let workers: Vec<_> = endpoints
.iter()
.map(|endpoint| {
let endpoint = (*endpoint).to_owned();
let policy = std::sync::Arc::clone(policy);
std::thread::spawn(move || WorkerReport {
endpoint,
policy: policy.name().to_owned(),
retries: policy.retries(),
worker_id: std::thread::current().id(),
})
})
.collect();
let mut reports = Vec::with_capacity(workers.len());
for worker in workers {
reports.push(worker.join().map_err(|_| WorkerPanicked)?);
}
reports.sort_unstable_by(|left, right| left.endpoint.cmp(&right.endpoint));
Ok(reports)
}
여기서는 Arc가 스레드 안전하게 만드는 대상을 구분해야 합니다. 대상은 참조 카운트의 갱신입니다. 내부 T에 스레드 안전성을 덧붙이지 않습니다. Arc<T>가 Send와 Sync이려면 T도 해당 조건을 만족해야 합니다. 그래서 Arc<RefCell<T>>로 감싸도 RefCell<T>는 스레드 안전해지지 않습니다.
이 글에서는 Send를 스레드 사이로 소유권을 옮길 수 있는 조건, Sync를 여러 스레드에서 참조를 공유해도 안전한 조건으로만 사용합니다. thread::spawn의 소유권 이동과 두 trait의 상세한 동작은 21편으로 미룹니다. 공유 변경과 동기화는 19편의 범위입니다.
Rc를 작업자 스레드로 옮기는 예제는 컴파일되지 않습니다. 다음 명령은 crate 디렉터리에서 의도적으로 실패하는 소스를 검사합니다.
rustc --edition=2024 --error-format=short fixtures/rc_across_thread.rs --out-dir target/compile-fail
다음은 short 형식의 진단 전문입니다.
fixtures/rc_across_thread.rs:12:19: error[E0277]: `Rc<EndpointPolicy>` cannot be sent between threads safely: `Rc<EndpointPolicy>` cannot be sent between threads safely
error: aborting due to 1 previous error
5. Weak와 비소유 간선
어떤 간선은 대상을 찾아갈 수 있어야 하지만 대상의 수명을 늘리면 안 됩니다. 이때 강한 포인터와 같은 계열의 Weak를 씁니다. Rc::downgrade는 std::rc::Weak<T>를 만들고 Arc::downgrade는 std::sync::Weak<T>를 만듭니다. 두 타입을 서로 바꿔 쓸 수는 없습니다.
예제의 관찰자는 로컬 정책을 소유하지 않습니다.
#[derive(Debug, Clone)]
pub struct PolicyObserver {
policy: std::rc::Weak<EndpointPolicy>,
}
impl PolicyObserver {
#[must_use]
pub fn new(policy: &std::rc::Rc<EndpointPolicy>) -> Self {
Self {
policy: std::rc::Rc::downgrade(policy),
}
}
#[must_use]
pub fn upgrade(&self) -> Option<std::rc::Rc<EndpointPolicy>> {
self.policy.upgrade()
}
}
upgrade()는 Option<Rc<T>>를 반환합니다. 강한 소유자가 남아 있으면 Some으로 임시 강한 소유권을 얻습니다. 내부 값이 이미 drop됐다면 None입니다. std::sync::Weak<T>의 upgrade()도 같은 방식으로 Option<Arc<T>>를 반환합니다. 만료는 댕글링 포인터가 아니라 호출자가 처리해야 하는 정상 상태입니다.
수명은 두 층으로 구분해야 합니다. Weak는 내부 값을 살려 두지 않습니다. 다만 약한 포인터가 남아 있는 동안 참조 카운트 같은 메타데이터를 담는 backing allocation은 남을 수 있습니다. “Weak는 아무 메모리도 유지하지 않는다”라고 설명하면 이 차이가 사라집니다.
6. 순환과 불필요한 간접 참조
참조 카운팅은 강한 카운트가 0이 될 때 값을 정리합니다. 모든 간선이 강한 닫힌 순환을 만들면 외부 소유자가 사라진 뒤에도 각 카운트가 0에 도달하지 않아 값이 drop되지 않습니다. 이는 메모리 안전성 위반은 아니지만 메모리 누수입니다. Rc와 Arc 모두 강한 순환을 만들 수 있습니다.
그래프 모양이라는 사실만으로 누수가 생기지는 않습니다. 부모가 자식을 강하게 소유하고 자식끼리 다시 부모를 소유하지 않는 DAG(방향성 비순환 그래프)는 강한 간선만으로도 정리됩니다. 부모에서 자식으로 향하는 관계가 실제 소유이고 자식의 부모 링크가 탐색용이라면, 앞쪽은 Rc 또는 Arc, 뒤쪽은 그에 맞는 Weak로 두어 순환을 끊습니다. 반대로 도메인상 대상의 수명을 반드시 유지해야 하는 간선을 기계적으로 약하게 바꾸면 안 됩니다. 그 경우에는 소유 그래프 자체를 다시 설계해야 합니다.
전체 Rust 2024 예제에는 외부 의존성이 없습니다. 다음 명령은 프로젝트 디렉터리에서 실행합니다. 마지막 rustc 명령만 E0277로 실패해야 정상입니다.
cd examples/article-18-smart-pointers
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
Rust 1.98.1과 Cargo 1.98.1에서 다섯 Cargo 명령은 종료 코드 0을 반환해야 합니다. 테스트 모음에는 라이브러리 단위 테스트 4개, compile-fail 통합 테스트 1개, 출력 통합 테스트 1개가 있습니다. 실행 출력은 다음과 같습니다.
box: dns -> tcp -> http
rc: policy=office owners=2
weak: expired after owner drop
arc: endpoint=admin.internal policy=production retries=4
arc: endpoint=api.internal policy=production retries=4
검토 순서는 짧습니다. 직접 T나 빌린 &T로 충분한지 먼저 봅니다. 크기 경계가 필요하면 Box, 같은 스레드의 공동 소유면 Rc, 스레드 경계를 넘는 공동 소유면 조건을 만족하는 Arc를 고릅니다. 수명을 늘리지 않아야 하는 간선에만 같은 계열의 Weak를 둡니다. 이 순서를 거치면 순환뿐 아니라 필요 없는 힙 할당과 참조 카운팅도 함께 걷어낼 수 있습니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 18 프로젝트에서 확인할 수 있습니다.
출처
- The Rust Programming Language: Smart Pointers
- The Rust Programming Language: Using Box<T> to Point to Data on the Heap
- The Rust Programming Language: Rc<T>, the Reference-Counted Smart Pointer
- The Rust Programming Language: Reference Cycles Can Leak Memory
- Rust 표준 라이브러리: Box
- Rust 표준 라이브러리: std::rc
- Rust 표준 라이브러리: Rc
- Rust 표준 라이브러리: std::rc::Weak
- Rust 표준 라이브러리: Arc
- Rust 표준 라이브러리: std::sync::Weak
- Rust 표준 라이브러리: std::sync
- Rust 표준 라이브러리: Send
- Rust 표준 라이브러리: Sync
- The Rust Programming Language: Extensible Concurrency with Send and Sync
답글 남기기