Smart Pointer

Doosan published on
5 min, 933 words

Categories: Post

Rust에는 단일한 SmartPointer trait가 없다. 대신 smart pointer라는 말은 값을 소유하거나 감싸면서 pointer처럼 동작하는 타입들을 가리키는 느슨한 표현이다. 보통 DerefDrop을 통해 그런 동작을 제공한다.

아래에 있는 RefCell<T>, Mutex<T>, Pin<P> 같은 타입은 엄밀히 말하면 interior mutability type이나 pointer wrapper에 더 가깝다. 그래도 실제 Rust 코드에서 owning smart pointer와 자주 조합되기 때문에 함께 정리한다.

Box<T>

Box<T>는 heap에 저장된 값 하나를 소유한다. 다음과 같은 경우에 사용한다.

  • recursive type이나 trait object처럼 compile time에 크기를 알 수 없는 타입을 다룰 때
  • 큰 값을 이동하되 heap allocation 자체는 복사하고 싶지 않을 때
  • 어떤 component가 heap value를 exclusive하게 소유해야 할 때
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));

Box는 recursive field에 알려진 크기를 부여한다. 다른 List를 직접 embedding하는 대신 fixed-size pointer를 저장하기 때문이다.

Rc<T>

Rc<T>는 single thread 안에서 reference counting으로 shared ownership을 제공한다. Rc를 clone하면 내부 값이 복사되는 것이 아니라 reference count만 증가한다.

use std::rc::Rc;

let name = Rc::new(String::from("Ferris"));
let first_owner = Rc::clone(&name);
let second_owner = Rc::clone(&name);

assert_eq!(first_owner.as_str(), "Ferris");
assert_eq!(second_owner.as_str(), "Ferris");
assert_eq!(Rc::strong_count(&name), 3);

syntax tree, GUI tree, graph node처럼 single-threaded structure에서 read-only data를 공유할 때 Rc<T>를 쓴다. Rc<T>SendSync도 아니다. thread boundary를 넘어 ownership을 공유해야 한다면 Arc<T>를 사용한다.

Arc<T>

Arc<T>Rc<T>의 atomic, thread-safe counterpart다. atomic reference counting에는 작은 synchronization cost가 있으므로 thread가 필요 없다면 Rc<T>를 선호한다.

use std::sync::Arc;
use std::thread;

let values = Arc::new(vec![10, 20, 30]);
let mut handles = Vec::new();

for index in 0..values.len() {
    let values = Arc::clone(&values);
    handles.push(thread::spawn(move || values[index]));
}

let results: Vec<i32> = handles
    .into_iter()
    .map(|handle| handle.join().unwrap())
    .collect();

assert_eq!(results, vec![10, 20, 30]);

Arc<T>가 공유하는 것은 ownership이지 mutability가 아니다. shared state를 변경해야 한다면 보통 Mutex<T>RwLock<T>와 함께 사용한다.

RefCell<T>

RefCell<T>는 single thread 안에서 interior mutability를 제공한다. Rust의 borrowing rule을 compile time이 아니라 runtime에 검사한다. 하나의 mutable borrow 또는 여러 immutable borrow는 가능하지만 둘을 동시에 가질 수 없다. 이 규칙을 어기면 panic이 발생한다.

use std::cell::RefCell;

let message = RefCell::new(String::from("Hello"));

message.borrow_mut().push_str(", Rust");

assert_eq!(message.borrow().as_str(), "Hello, Rust");

자주 쓰는 Rc<RefCell<T>> 조합은 single thread 안에서 여러 owner가 같은 값에 mutable access할 수 있게 해 준다. runtime borrow conflict를 피하려면 borrow()borrow_mut() scope를 짧게 유지하는 것이 좋다.

Weak<T>

Weak<T>Rc<T>가 관리하는 allocation에 대한 non-owning reference다. std::sync::Weak<T>Arc<T>와 함께 쓰는 버전이다.

weak reference는 값을 살아 있게 유지하지 않는다. upgrade()를 호출하면 strong owner가 아직 있을 때는 Some(Rc<T>)를 반환하고, 값이 drop된 뒤에는 None을 반환한다.

use std::rc::Rc;

let owner = Rc::new(String::from("cached value"));
let observer = Rc::downgrade(&owner);

assert_eq!(observer.upgrade().unwrap().as_str(), "cached value");

drop(owner);
assert!(observer.upgrade().is_none());

Weak<T>는 back-reference, cache, observer, parent pointer에 사용한다. 또한 RcArc reference cycle을 끊어 memory leak을 막는 표준적인 방법이다.

Cell<T>

Cell<T>는 가벼운 single-threaded interior mutability 타입이다. RefCell<T>와 달리 내부 값에 대한 reference를 반환하지 않는다. T: Copy이면 get()으로 값을 복사해 꺼내고, 아니면 값을 통째로 교체하는 방식으로 사용한다.

use std::cell::Cell;

let request_count = Cell::new(0_u32);

request_count.set(request_count.get() + 1);
request_count.set(request_count.get() + 1);

assert_eq!(request_count.get(), 2);

counter, flag, enum state처럼 작은 Copy 값에 적합하다. 더 크거나 Copy가 아닌 값에 borrowed access가 필요하면 RefCell<T>를 사용한다.

Mutex<T>

Mutex<T>는 한 번에 하나의 thread만 mutable access할 수 있게 한다. lock을 잡으면 guard가 반환되고, guard가 drop될 때 자동으로 unlock된다. shared ownership은 보통 Arc로 감싸서 만든다.

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0_u32));
let mut handles = Vec::new();

for _ in 0..4 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        *counter.lock().unwrap() += 1;
    }));
}

for handle in handles {
    handle.join().unwrap();
}

assert_eq!(*counter.lock().unwrap(), 4);

느린 작업이나 .await 지점까지 mutex guard를 들고 가지 않는 것이 좋다. async code에서 guard가 .await를 넘어 살아야 한다면 async runtime이 제공하는 synchronization primitive를 사용한다.

RwLock<T>

RwLock<T>는 여러 reader 또는 하나의 writer를 허용한다. read-heavy workload에서는 concurrency를 높일 수 있지만, Mutex<T>보다 overhead가 크고 scheduling policy는 operating system에 따라 달라진다.

use std::sync::RwLock;

let configuration = RwLock::new(String::from("development"));

{
    let first_reader = configuration.read().unwrap();
    let second_reader = configuration.read().unwrap();
    assert_eq!(*first_reader, *second_reader);
}

*configuration.write().unwrap() = String::from("production");

assert_eq!(configuration.read().unwrap().as_str(), "production");

lock 자체를 여러 thread가 shared ownership으로 들고 있어야 한다면 Arc<RwLock<T>>를 사용한다.

Cow<'a, T>

Cow는 "clone on write"를 뜻한다. 수정이 필요 없을 때는 data를 borrow하고, 변경이 필요할 때만 owned value를 allocate한다.

use std::borrow::Cow;

fn ensure_question_mark(input: &str) -> Cow<'_, str> {
    if input.ends_with('?') {
        Cow::Borrowed(input)
    } else {
        Cow::Owned(format!("{input}?"))
    }
}

let unchanged = ensure_question_mark("Ready?");
let changed = ensure_question_mark("Ready");

assert!(matches!(unchanged, Cow::Borrowed(_)));
assert!(matches!(changed, Cow::Owned(_)));

Cow는 parser, validation, normalization, 그리고 대부분 입력을 그대로 반환하지만 가끔 owned modification이 필요한 API에서 유용하다.

Pin<P>

Pin<P>는 pointer P를 감싸고, 그 pointer가 가리키는 값을 move할 수 있는 access를 제한한다. address-sensitive value에 중요하다. 예를 들면 많은 async future를 위해 생성되는 state machine이나, 조심스럽게 설계된 self-referential type이 있다.

use std::pin::Pin;

fn inspect(value: Pin<&String>) {
    println!("{}", value.get_ref());
}

let value: Pin<Box<String>> = Box::pin(String::from("stable address"));
inspect(value.as_ref());

Pin이 모든 값을 자동으로 immovable하게 만드는 것은 아니다. 대부분의 일반 Rust 타입처럼 Unpin을 구현하는 타입은 여전히 안전하게 move될 수 있다. pinning이 중요해지는 것은 API가 !Unpin 타입과 함께 동작하고, 그 값의 address가 안정적으로 유지된다는 사실에 의존할 때다.

자주 쓰는 조합

Type or combination주 목적Borrow checkingThread 사용
Box<T>heap value의 exclusive ownershipCompile timeT가 허용하면 thread 경계를 넘을 수 있음
Rc<T>shared ownershipCompile timesingle-threaded only
Arc<T>shared ownershipCompile timeT: Send + Sync이면 thread-safe
Weak<T>non-owning observation; cycle 끊기Compile timerc::Weak는 single-threaded, sync::WeakT가 허용하면 thread-safe
Cell<T>shared access로 작은 값 copy/replaceBy-value APIsingle-threaded only
RefCell<T>shared access로 mutable borrowRuntimesingle-threaded only
Rc<RefCell<T>>shared mutable stateRuntimesingle-threaded only
Arc<Mutex<T>>한 accessor만 접근하는 shared mutable stateRuntime lockingmulti-threaded
Arc<RwLock<T>>많은 reader 또는 하나의 writer가 접근하는 shared stateRuntime lockingmulti-threaded
Cow<'a, T>먼저 borrow하고 필요할 때만 cloneCompile timeT에 따라 다름
Pin<P>address-sensitive pointee가 P를 통해 move되지 않게 제한Compile time API restrictionP와 pointee에 따라 다름

가장 자주 쓰는 조합은 다음과 같다.

  • dynamic dispatch가 필요한 exclusive ownership에는 Box<dyn Trait>
  • single thread 안의 shared mutable state에는 Rc<RefCell<T>>
  • thread 사이에서 공유하는 immutable state에는 Arc<T>
  • thread 사이에서 공유하는 mutable state에는 Arc<Mutex<T>>
  • read-heavy shared state에는 Arc<RwLock<T>>
  • non-owning link와 cycle 방지에는 Weak<T>