TL;DR
- 다중 스레드 캐시에서 큰 값을 복제하는 비용을 줄이기 위해
Arc를 사용하되, 내부 값을 복사 없이 다운캐스팅하려면 주소 안정성을 보장하는Pin기반 포인터 래퍼가 필요함. Arc<JSON>는 저렴한 복제를 제공하지만Arc<JSON>에서 내부String을 가리키는Arc<String>으로 직접 변환할 수 없음.- 컨테이너와 내부 참조를 함께 반환하는 방법은 값이 이동하면 포인터가 무효화될 수 있어 안전하지 않음.
Pin<Arc<MustPin<V>>>와 원시 포인터를 묶은PinRef<V, T>로 내부 값의 주소 안정성을 확보함.- 임의의 수명에 적용되는 클로저를 받는
project,filter_project,try_project로 참조 투영과 선택적 다운캐스팅을 구현함.
단순한 동시성 캐시의 문제
- 비용이 큰 함수를 여러 스레드에서 공유하는 캐시 앞에 두는 사례를 검토함. 예시 캐시는
RWLock<HashMap<String, JSON>>를 사용하며, 캐시 적중 시 값을 반환하고 미스 시 HTTP 클라이언트로 값을 가져와 캐시에 삽입함. - 이 구현에는 문자열 입력이 지나치게 많고 오류 처리가 없으며, 동시 요청이 경합해 같은 작업을 중복 수행할 수 있는 등 여러 문제가 있음. 여기서는 값 복제가 가장 큰 문제임.
- 호출마다 캐시 적중 경로에서는 캐시 값을 반환하기 위해, 미스 경로에서는 캐시에 넣기 위해 한 번씩
.clone()을 호출함. 값이 크거나 트리 형태이거나 복제 비용이 높으면 캐시의 이점이 줄어들 수 있음.
복제를 없애기보다 저렴하게 만들기
- 데이터에 읽기 전용 접근만 필요하다면 전체 값을 복제하는 대신 공유 참조를 반환할 수 있지만, 캐시가 뮤텍스 뒤에 있어 참조를 반환할 수 없음.
- 캐시 내용에 접근하는 핸들은 임시 수명만 가지며, 핸들을 반환하면 함수 바깥까지 잠금을 유지하게 됨. 잠금은 매우 짧은 시간만 보유해야 함.
- 필요한 타입의 조건은 데이터를 읽을 수 있고, 수명 매개변수가 없으며, 내부 값이 비싸게 복제되더라도 저렴하게 복제할 수 있는 것임.
- 포인터 선택지는 원시 포인터
*const JSON, 참조 카운트 포인터Rc<JSON>, 원자적 참조 카운트 포인터Arc<JSON>임. 안전성과 동시성을 고려해Arc를 선택함. - 캐시 값을
Arc<JSON>으로 저장하고 반환하면 캐시 적중 시Arc를 복제하고, 미스 시 새Arc를 만들어 캐시에 복제본을 저장함. 내부JSON전체를 복제하지 않으므로 복제 비용이 낮아짐.
Arc에서 다운캐스팅하기
JSON값이나&JSON참조는 패턴 매칭으로String또는&String으로 변환할 수 있음.- 하지만
Arc<JSON>에서 내부 변형을 확인한 뒤Arc<String>을 얻는 방식은 성립하지 않음.value.deref()가 만든 참조는 함수가 끝나면 유효하지 않으며,Arc는 원래 포인터를 관리하므로 내부에서 파생된 포인터를 그대로Arc로 감쌀 수 없음. Arc<JSON>을 복제한 뒤 내부 문자열을 복제하면 목적에 어긋남. 대신 소유 값을 보유하면서 내부를 참조처럼 보여 주고,Deref<Target = String>을 구현하는 별도 타입을 만들 수 있음.
내부 참조와 소유 값 묶기
- 필요한 구조는 소유 값
val과 그 값 내부의 데이터로 향하는 포인터ptr을 함께 보유하는 형태임.val이 살아 있는 동안ptr도 유효하도록 둘을 항상 묶어 두려는 접근임. struct Ref<V, T> { val: V, ptr: &T }처럼 참조를 필드로 두어도 이를 포함 구조체의 수명에 연결할 방법이 없음. 그래서&'static T로 수명을 늘린 뒤transmute하는 시도가 가능해 보일 수 있음.ptr을 별도로 꺼낼 수 없게 하고Deref를 통해서만 접근하게 하더라도 안전하지 않음.val을 함수 인자로 옮기고, 그 안의 참조를 얻은 뒤, 다시 호출자 쪽으로 옮기는 과정에서 주소가 바뀔 수 있음.- 예시에서는 저장된
ptr의 주소가7fff85189b5c,val에서 다시 얻은 실제 주소가7fff85189b88로 서로 다름. 구조체가 이동하면서 내부 포인터가 무효화될 수 있음을 보여 줌.
Pin으로 주소 안정성 보장
- 컨테이너가 래핑된 뒤에는
val: V를 원래 위치에서 이동할 수 없도록 해야 하며, Rust의Pin이 이 주소 안정성을 보장하는 데 사용됨. MustPin<V>는val과PhantomPinned를 보유하고,Arc::pin으로 고정된 값을 생성함.PinRef<V, T>는Pin<Arc<MustPin<V>>>와 내부 데이터를 가리키는*const T를 함께 보유함.PinRef의Deref구현은 포인터를 역참조해 대상에 대한 참조를 반환함. 이 과정은 원시 포인터를 사용하므로unsafe가 필요함.- 이 구성에서
*const T는NonNull<T>보다 필요한 권한이 적어 선택됨. 고정된 데이터를 먼저 만든 뒤 포인터를 별도로 저장하므로MaybeUninit은 필요하지 않음. 또한Pin<Arc<V>>에서V: Unpin이면 고정 보장이 사라질 수 있으므로PhantomPinned가 필요함. - 처음에는
Pin<Arc<V>>와'static참조를 결합하면 충분하다고 생각했지만,Unpin때문에 부족함을 확인해 구현을 수정함.
투영과 다운캐스팅
project는PinRef<V, T>와 모든 수명'a에 대해&'a T를&'a U로 변환하는 클로저를 받아PinRef<V, U>를 반환함.- 임의의 수명에 대해 성립하는 변환이라면
U의 위치가T에 대해 고정된 오프셋을 갖는다는 점을 이용함.T를 가리키는 포인터가 고정된 주소에서 파생되므로 투영된 포인터도 고정된 주소를 가짐. - 수명 매개변수를 클로저 바깥에서 일찍 지정하면 더 짧은 수명을 선택해 내부 가변성이 있는 값을 투영할 수 있으므로, 임의의 수명을 요구하는 시그니처가 중요함.
filter_project는Option<&U>를 반환하는 변환으로 성공할 때만PinRef<V, U>를 반환하고,try_project는Result<&U, E>를 반환하는 변환의 오류를 전달함.JSON::as_str를filter_project에 전달하면Arc::pin으로 고정한JSON::String에서 내부 문자열을 가리키는 값을 얻을 수 있음. 반환값은Deref<Target = str>을 요구하는 함수에 전달할 수 있음.- 원래 캐시에 적용하려면 일반
Arc<JSON>을Pin<Arc<MustPin<JSON>>>로 교체하고Clone구현과?Sized타입 처리 등을 추가해야 함. 전체 구현은 저장소에 있으며, 별도 크레이트로 공개할지는 미정이고 아직 발견하지 못한 정의되지 않은 동작(UB)이 있을 가능성도 남아 있음.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요