업데이트 가능한 값을 가리키는 Arc 유사 스마트 포인터인 ArcShift에 대한 소개. 읽기 성능을 최우선으로 하면서도 값 교체(업데이트)를 지원하며, 장점/제약, 성능 특성, no_std 동작, 구현 개요, 주의점과 예제를 다룬다.
ArcShift는 std::sync::Arc와 비슷한 데이터 타입이지만, 가리키는 값을 업데이트할 수 있다는 점이 다릅니다. std::sync::Arc<std::sync::RwLock<T>>의 대체로 사용할 수 있으며, 읽기 접근이 훨씬 빠릅니다.
ArcShift에서 값을 업데이트하는 작업은 std::sync::RwLock에 쓰는 것보다 훨씬 더 비싸므로, ArcShift는 업데이트가 드문 경우에 가장 적합합니다.
rustuse std::thread; let mut arc = ArcShift::new("Hello".to_string()); let mut arc2 = arc.clone(); let j1 = thread::spawn(move||{ println!("Value in thread 1: '{}'", arc.get()); // 'Hello' 출력 arc.update("New value".to_string()); println!("Updated value in thread 1: '{}'", arc.get()); // 'New value' 출력 }); let j2 = thread::spawn(move||{ // 스케줄링에 따라 'Hello' 또는 'New value'를 출력: println!("Value in thread 2: '{}'", arc2.get()); }); j1.join().unwrap(); j2.join().unwrap();
ArcShift::shared_non_reloading_get 함수는 일반 Arc와 비교해 오버헤드 없이 접근할 수 있게 해줍니다(벤치마크에서 Arc와 동일한 성능을 보임).ArcShift<[u8]> 사용 가능).ArcShift는 성능을 얻는 대신 다음과 같은 단점을 감수합니다:
ArcShift::shared_get). 포인터가 최신인 한 성능은 여전히 매우 좋습니다. 하지만 ArcShift 인스턴스가 오래된 상태(업데이트가 발생하여 reload가 필요)라면, 읽기가 RwLock 대비 대략 2배 정도 더 비싸질 수 있습니다. 이를 완화하는 방법 중 하나로 cell::ArcShiftCell을 사용할 수 있습니다. 다만 이 타입은 Sync가 아니고 Send만 됩니다.&mut) 접근으로 사용될 때만 발생합니다(예: ArcShift::get 또는 ArcShift::reload). 이는 장기간 살아 있으면서 절대 reload되지 않는 인스턴스에 대해 ArcShiftWeak 타입을 사용함으로써 부분적으로 완화할 수 있습니다.Arc<Mutex<T>>를 수정하는 것보다 대략 10배 비쌉니다. 다만 정수보다 훨씬 복잡한 것을 저장한다면 ArcShift의 오버헤드는 무시할 만할 수 있습니다.Arc<RwLock<T>> 접근보다 느립니다.Arc<T>와 어떤 방식으로도 호환되지 않습니다.ArcShift::get - 평균 성능이 매우 좋습니다. 새 값을 확인하기 위해 가장 저렴한 종류의 원자적 연산(Ordering::Relaxed) 1회가 필요합니다. x86_64에서는 일반 메모리 접근과 정확히 같은 머신 연산이며, arm에서도 비싼 연산이 아닙니다. 이 접근 비용은, 경쟁이 없는 경우라도 뮤텍스 접근보다 훨씬 작습니다. 실제로 reload가 필요한 경우에는 성능 영향이 큽니다(하지만 현대 머신(2025) 기준 보통 150ns 이하).
다른 인스턴스가 업데이트를 수행했다면 이후 접근에는 페널티가 생깁니다. 이 페널티는 상당할 수 있는데, 이전 값들이 drop되어야 할 수 있기 때문입니다. 하지만 모든 업데이트가 처리되고 나면 이후 접근은 다시 빨라집니다. ArcShift::get 실행이 시작되기 전에 완료된 업데이트는, 그 결과가 반드시 보장되게 됩니다.
ArcShift::shared_get - 값이 오래되지 않은 한 좋은 성능을 냅니다. self가 이전 값을 가리키고 있다면, shared_get을 호출할 때마다 가장 최신 값을 찾기 위해 메모리 구조를 따라가야 합니다.
세 가지 경우가 있습니다:
shared_get 또한 호출되기 전에 완료된 업데이트가 보이도록 보장합니다.
ArcShift::shared_non_reloading_get - 일반 Arc 대비 오버헤드가 없습니다. ArcShift 인스턴스가 오래된 상태여도 reload하지 않습니다. 따라서 오래된 값을 반환할 수 있습니다. 이전에 shared_get을 사용했다면, 이 메서드는 shared_get이 반환했던 값보다 더 오래된 값을 반환할 수도 있습니다.
ArcShift::reload - ArcShift::get과 비슷한 비용입니다.
ArcShift::clone - 빠릅니다. 원자적 증가 1회와 원자적 읽기 1회가 필요합니다. 현재 인스턴스가 오래된 상태라면, clone된 값은 reload되며 비용은 ArcShift::get과 동일합니다.
Drop - 느릴 수 있습니다. 어떤 값의 마지막 소유자가 그 값을 drop하게 됩니다.
ArcShift의 주된 존재 이유는, 읽기 중심 로드에서 일반 Arc에 비해 오버헤드가 거의 없는 수준을 유지하면서도 저장된 값을 업데이트할 수 있는 Arc 버전을 제공하는 것입니다.
ArcShift의 동기를 부여하는 한 가지 사용 사례는 컴퓨터 게임에서의 핫 리로드 가능한 에셋입니다. 정상적인 사용 중에는 에셋이 바뀌지 않습니다. 모든 벤치마크와 플레이 경험은 이 기본 성능에만 의존하게 됩니다. 따라서 이상적으로는 에셋이 업데이트되지 않는 경우에 대한 성능 페널티가 매우 작아야 하며, 일반 std::sync::Arc를 쓰는 것과 비슷해야 합니다.
게임 개발 중에는 아티스트가 에셋을 업데이트할 수 있고, 핫 리로드는 시간을 크게 절약해 주는 기능입니다. 반면 에셋 리로드 중의 성능 저하는 허용 가능합니다. ArcShift는 업데이트 시의 페널티를 받아들이는 대신, 기본 성능을 우선시합니다.
물론 ArcShift는 컴퓨터 게임 외의 다른 영역에서도 유용할 수 있습니다.
drop 구현이 패닉을 일으키더라도, ArcShift는 내부 메모리 구조가 손상되지 않도록 보장합니다. std 라이브러리 없이 실행할 경우 drop 메서드가 패닉할 때마다 일부 메모리 누수가 발생합니다. std 라이브러리를 사용할 경우에는 페이로드 타입이 소유한 메모리만 누수될 수 있습니다.
기본적으로 arcshift는 러스트 표준 라이브러리를 사용합니다. 이는 기본으로 활성화된 ‘std’ 기능(feature)으로 켜져 있습니다. ArcShift는 전체 rust std 라이브러리 없이도 동작할 수 있습니다. 다만 약간의 성능 비용이 듭니다. ‘std’ 기능이 활성화된 경우(기본값), 사용자 제공 drop 메서드가 패닉해도 메모리 구조가 손상되지 않도록 drop 함수를 catch_unwind로 보호합니다. 그러나 std 없이도 같은 보장을 제공하기 위해, arcshift는 현재 메모리 순회(traversal)가 끝난 뒤 drop을 실행할 수 있도록 할당을 임시 박스로 옮깁니다. 이는 여러 번의 할당이 필요하므로 ‘std’ 없이 동작할 때 더 느립니다. 또한 std 없이 패닉하는 drop 메서드는 메모리 누수로 이어질 수 있습니다. 메모리 구조는 온전하게 유지되며, 정의되지 않은 동작(UB)은 발생하지 않습니다. 성능 페널티는 업데이트 중에만 존재합니다.
앞 단락에서 언급한 오버헤드가 용납되지 않고, 최종 바이너리를 panic=abort로 컴파일한다면 이 추가 비용을 줄일 수 있습니다. 이를 위해 “nostd_unchecked_panics” 기능을 활성화하세요. 다만 이는 패닉 이후에도 프로세스 실행이 계속될 가능성이 있는 경우 절대 해서는 안 됩니다. 패닉하는 drop이 있었던 ArcShift 체인에 대해 사실상 메모리 회수가 비활성화될 수 있기 때문입니다. 그러나 어떤 경우에도 UB는 발생하지 않습니다.
ArcShift의 기본 아이디어는 각 ArcShift 인스턴스가 작은 힙 블록을 가리킨다는 것입니다. 이 블록에는 타입 T의 대상 값(pointee), 3개의 참조 카운트, 그리고 ‘prev’/‘next’ 포인터가 들어 있습니다. ‘next’ 포인터는 처음에는 null이지만, ArcShift의 값이 업데이트되면 ‘next’ 포인터가 업데이트된 값을 가리키도록 설정됩니다.
즉, 각 ArcShift 인스턴스는 항상 유효한 타입 T 값을 가리킵니다. 이 값에 접근하기 위해 어떤 락이나 동기화도 필요하지 않습니다. 그래서 ArcShift 인스턴스는 사용이 빠릅니다. 단점은 ArcShift 인스턴스가 존재하는 한, 그것이 가리키는 값은 살아 있어야 한다는 것입니다. ArcShift 인스턴스가 가변으로 접근될 때마다 포인터를 ‘next’ 값으로 업데이트할 기회가 생기며, 이 포인터 갱신 작업을 ‘reload’라고 부릅니다.
특정 값을 가리키는 마지막 ArcShift 인스턴스가 해제되면, 그 값은 drop됩니다.
ArcShiftWeak 인스턴스 역시 위에서 언급한 힙 블록을 가리키는 포인터를 유지하지만, ArcShiftWeak가 유지되는 동안에도 그 블록의 값 T는 drop될 수 있습니다. 이는 ArcShiftWeak 인스턴스가 가리키는 값이 drop된 뒤에는, std::mem::size_of::<T>() 바이트 + 5 워드(word)만큼의 메모리만 소비한다는 뜻입니다. ArcShiftWeak 인스턴스가 reload되거나 drop되면 그 메모리 역시 해제됩니다.
ArcShift는 arc-swap에서 큰 영감을 받았습니다. 두 크레이트는 비슷한 문제에 사용할 수 있습니다. API가 약간 다르기 때문에, 문제에 따라 어느 쪽이 더 자연스럽게 맞을 수 있습니다. ArcShift가 어떤 문제에서는 더 빠를 수 있고, 다른 문제에서는 더 느릴 수 있습니다.
reload되지 않은 채로 그냥 “방치된” ArcShift 인스턴스는 오래된 값을 메모리에 계속 유지하여 메모리를 차지한다는 점을 유의하세요. 이는 ArcShift가 채택한 접근 방식의 근본적인 단점입니다. 한 가지 우회 방법은, 오래 살아 있으면서 드물게만 reload되는 ArcShift 인스턴스를 ArcShiftWeak로 대체하는 것입니다. 이렇게 하면 문제가 완화되지만, 여전히 대략 size_of<T> + 5 워드 만큼의 힙 저장 공간이 사용됩니다.
ArcShift는 참조 카운트에 usize 데이터 타입을 사용합니다. 하지만 일부 메타데이터를 추적하기 위해 2비트를 예약해 둡니다. 따라서 사용할 수 있는 최대 참조 카운트는 usize::MAX/4가 됩니다. 참조 카운트를 두 번 확인할 필요가 없게(증가 전 1회 확인) 하기 위해, 제한을 usize::MAX/8로 설정하고 원자적 연산 후에 카운트를 검사합니다. 그 결과, usize::MAX/8개를 초과하는 스레드가 같은 ArcShift 인스턴스를 동시에 clone하면 불건전성(unsoundness)이 발생할 수 있습니다. 하지만 이는 가능한 동시 스레드 수를 엄청나게 초과하는 큰 안전 여유가 있으므로 수용 가능한 것으로 간주됩니다. 또한 ArcShift 인스턴스 usize::MAX/8개는 usize::MAX 바이트의 메모리를 차지하게 되는데, 이는 실제로 불가능합니다. 다만 ArcShift 인스턴스를 타이트 루프에서 누수(leak)시키면 약한 참조 카운트를 usize::MAX/8까지 올릴 수 있으며, 이 경우 ArcShift는 패닉합니다.
ruststruct CharacterModel { /* 3D 모델, 텍스처 등 */ } struct World { models: Vec<ArcShift<CharacterModel>> } /// 모델을 로드합니다. 주기적으로 파일시스템을 스캔하여 /// 디스크에서 파일이 변경되면 모델을 업데이트합니다. fn load_models() -> Vec<ArcShift<CharacterModel>> { let models: Vec<ArcShift<CharacterModel>> = vec![]; /* 어떤 방식으로든 모델을 로드 */ let mut models_for_reloader = models.clone(); std::thread::spawn(move||{ loop { /* 파일 시스템 변경 감지 */ let changed_model = 0usize; models_for_reloader[changed_model].update(CharacterModel{/* 새로 로드됨 */}); } }); models } fn run_game() { let mut world = World { models: load_models() }; loop { run_game_logic(&mut world); } } fn run_game_logic(world: &mut World) { /* 게임 로직 수행. 여러 스레드에서 World의 서로 다른 부분에 접근할 수 있고, 다른 스레드에서 사용하기 위해 'ArcShift' 인스턴스를 clone할 수도 있음 */ for model in world.models.iter_mut() { // 'get'을 사용해 ArcShift에 접근하면 // 오래된 버전이 RAM에 남아 있지 않도록 보장할 수 있습니다. let model_ref : &CharacterModel = model.get(); // 'model_ref'로 무언가 작업 수행 } }
cell 공유 접근만 가지고도 ArcShift 인스턴스를 reload할 수 있도록 해주는, 편리한 cell 형태의 데이터 구조를 제공하는 모듈입니다.
ArcShift std::sync::Arc와 비슷한 사용 사례를 갖는 스마트 포인터이지만, Arc의 내용을 원자적으로 교체할 수 있는 기능이 추가되어 있습니다. 자세한 내용은 crate 문서를 참고하세요.
ArcShift Weak ArcShiftWeak는 어떤 객체가 할당 해제되는 것을 막지 않으면서 그 객체에 대한 포인터를 유지하는 방법입니다. 순환(cyclic) 데이터 구조를 만들 때 메모리 누수를 피하기 위해 유용할 수 있습니다.
NoLonger Available Marker 최신 버전의 ArcShift에서 제거된 메서드들을 표시하기 위한 마커입니다.
Shared GetGuard ArcShift::shared_get의 반환 값입니다.