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)이 있을 가능성도 남아 있음.