Lobsters

Pining for Arc Downcasting in Rust

Rust에서 Arc를 다운캐스팅하고 싶다면

비싼 값을 캐시에 저장할 때 Arc를 쓰면 값 전체를 복제하지 않고 공유할 수 있지만, Arc 안의 특정 변형만 가리키는 Arc를 만들기는 어렵습니다. 글은 Pin과 자체 참조 구조체를 이용한 투영 방법을 제안하며, 댓글에서는 투영 함수의 soundness 문제와 Arc만으로 주소 안정성을 확보하는 방안을 지적합니다.

AI 요약

여러 스레드가 함께 쓰는 캐시에서 값이 크거나 복제 비용이 높으면, 캐시 적중과 삽입 때 발생하는 clone()이 캐시의 이점을 줄일 수 있습니다. 글은 먼저 캐시 값을 Arc로 감싸 복제 비용을 참조 카운트 증가 수준으로 낮춥니다. 읽기 전용 접근만 필요하다면 Arc<JSON>을 반환하는 방식으로 값 전체를 복제하지 않아도 됩니다.

Arc 안의 값은 어떻게 다운캐스팅할까요?

JSON이 문자열 변형인지 확인한 뒤 String을 빌리는 일은 &JSON에서 &String으로 패턴 매칭하면 됩니다. 하지만 Arc<JSON>에서 Arc<String>을 같은 방식으로 얻을 수는 없습니다. Arc는 자신이 관리하는 원래 포인터를 알아야 참조 카운트를 유지할 수 있습니다. 내부 값에서 파생한 포인터만 넘기면 그 포인터가 가리키는 대상의 수명을 Arc와 묶을 수 없습니다. 값을 복제해 새 Arc<String>을 만들면 처음 피하려던 복제 비용이 다시 생깁니다.

글은 원래 값을 소유하는 포인터와 그 안의 일부를 가리키는 포인터를 한 구조체에 묶는 방식을 살펴봅니다. 참조 필드에는 구조체 내부 값의 수명을 표현할 방법이 없으므로, 처음에는 참조 수명을 'static으로 바꾸는 transmute를 시도합니다. 하지만 값을 함수 안으로 옮긴 뒤 다시 바깥으로 이동하면 주소가 달라질 수 있습니다. 글의 예제에서도 저장해 둔 포인터와 실제 값의 주소가 달랐습니다. 수명을 늘리는 것만으로는 이동 뒤 포인터의 유효성을 보장하지 못합니다.

Pin으로 주소를 고정하고 투영하기

이 문제를 피하려고 글은 값을 Pin<Arc<MustPin<V>>> 안에 둡니다. MustPin에는 PhantomPinned를 넣어 Unpin이 되어 고정 보장이 사라지는 상황을 막습니다. PinRef는 고정된 값의 소유권과 그 안을 가리키는 원시 포인터를 함께 보관합니다. Deref 구현은 원시 포인터를 참조로 돌려주며, 포인터가 값 안에 있고 Pin 보장으로 주소와 값의 유효성이 유지된다는 점을 안전성 근거로 제시합니다.

그다음 project는 for<'a> FnOnce(&'a T) -> &'a U 형태의 함수를 받아 T에서 U를 가리키는 포인터를 얻습니다. filter_project와 try_project는 각각 Option과 Result를 반환하는 경우를 처리합니다. JSON::as_str을 filter_project에 넘기면 Arc가 소유한 JSON 문자열을 복제하지 않고도 문자열을 참조처럼 사용할 수 있는 PinRef로 표현합니다. 예제에서는 결과를 복제해 함수에 전달한 뒤, 원래 결과도 다시 사용합니다.

글은 이 설계를 완성된 라이브러리로 내놓기 전에 아직 정의되지 않은 동작(Undefined Behavior, UB)이 남았을 수 있다고 덧붙입니다. 실제로 댓글에서 PinRef::project의 수명 조건이 sound하지 않다는 최소 재현 사례가 제시됩니다. 작성자는 처음에 놓친 문제가 있다는 점을 인정하고, T: 'static 조건으로 문제가 막힐지 검토하겠다고 답합니다.

설계 대안과 검증

yoke를 언급한 댓글은 비슷한 설계가 이미 있으며, 제로 카피 역직렬화 외의 용도에도 쓸 수 있다고 제안합니다. 작성자는 yoke가 비표준 라이브러리의 StableDeref trait를 사용한다고 설명하며, 표준 라이브러리만으로 직접 이해하고 구현하려는 집착이 없었다면 yoke를 썼을 것이라고 답합니다.

다른 댓글은 Arc의 대상이 강한 참조가 남아 있는 동안 주소를 유지한다는 문서 내용을 근거로, 이 용도에 Pin 없이 Arc만으로 충분할 수 있다고 제안합니다. 작성자도 Arc의 주소 안정성이 보장된다는 점을 확인한 뒤 이를 간소화 방안으로 받아들입니다. 다만 Deref 포인터 전반에서 작동하게 하려면 Pin이나 StableDeref 같은 보장이 필요하다는 답글도 이어집니다. Arc::unwrap_or_clone()이 대안 아니냐는 질문에는 댓글에서 답변이 달리지 않았습니다.

Lobsters 반응

  • @fractalbeauty — 흥미롭네요! yoke도 비슷한 일을 하는 것 같습니다. 제로 카피 역직렬화를 위해 설계됐지만 같은 용도로 쓸 수 있을 것 같습니다. Deref 대신 get() 메서드가 지역 수명을 반환해서 Pin은 쓰지 않는 듯한데, 세부 사항은 확실하지 않습니다.
    • @polywolf — 네, 설계가 매우 비슷합니다. 안전성을 위해 비표준 라이브러리 crate의 StableDeref trait를 쓰는 것 같습니다. 그 crate는 Pin이 정착하기 전에 나왔고, Pin과 비슷한 불변 조건을 제공하는 듯합니다. 표준 라이브러리만 쓰고 모든 걸 직접 이해하려고 고집하지 않았다면 yoke를 그냥 썼어야 했을지도 모르겠네요 :P
    • @snej — 좋네요! 알려주셔서 감사합니다. 제가 작성 중인 코드에서 yoke가 유용하게 쓰이겠습니다.
  • @T6 — 지금 작성된 PinRef::project는 sound하지 않습니다. 최소 재현 사례를 만들었습니다. impl for<'a> FnOnce(&'a T) -> &'a U가 실제로 '모든 수명 'a에 대해'를 뜻하지는 않습니다. T가 모든 수명 'a에 대해 유효할 수는 없기 때문입니다. 대신 T보다 오래 살지 않는 모든 'a를 뜻합니다. 따라서 'y가 'x보다 오래 살고 PinRef<_, &'x _>가 있다면, 'y 수명의 참조를 반환하는 함수로 투영할 수 있습니다. 이 악용 사례는 T: 'static으로 막힐 수 있다고 보지만, 그 조건만으로 API가 sound해지는지는 분명하지 않습니다. 함수가 &T에서 임의의 수명 'a에 대해 &U를 반환한다고 해서 U의 주소가 T 안의 고정 오프셋이라는 설명도 정확하지 않습니다. 함수가 입력에서 파생되지 않은 'static 참조를 반환할 수도 있기 때문입니다. 대신 함수에 전달할 수명을 임의로 선택할 수 있으므로 val의 수명으로 선택할 수 있다는 식으로 논리를 세우면 타당해 보입니다. 다만 수명 조건이 실제로 모든 수명을 뜻해야 합니다.
    • @polywolf — 재현 사례를 공유해 주셔서 감사합니다. 뭔가 놓쳤다는 건 알았는데 그 부분이었네요. 'x에서 더 오래 사는 'y로 가는 것 자체는 문제라기보다 이상한 일처럼 느껴집니다. 투영 결과로 'static 참조를 반환하는 것 자체도 sound하지 않은 일은 아닙니다. 대신 Deref 구현이 T의 수명 조건을 따르지 않는 점이 문제라고 생각합니다. 지금 형태로 그 조건을 trait에 표현할 수 있는지는 모르겠지만, T: 'static이면 같은 틈을 막을 수 있을 것 같습니다. 설명도 더 잘 정리됐네요. 업데이트에서 출처를 밝혀도 될까요?
  • @dov — 정말 흥미롭네요! Pin은 필요하지 않을 수도 있겠습니다. Arc의 대상은 이미 주소가 안정적이어야 합니다. Arc::as_ptr 문서에는 참조 카운트를 바꾸거나 Arc를 소비하지 않으며, 강한 참조가 있는 동안 포인터가 유효하다고 나옵니다. 이 점이 만드는 다른 unsoundness를 놓친 것일 수도 있지만, Arc만 쓰는 버전을 만들어 봤습니다.
    • @polywolf — 아, 그게 보장된다는 걸 몰랐네요! 좋습니다. 원래는 특정 Arc가 아니라 Deref 포인터라면 무엇이든 작동하게 작성했다가, 그 방식은 너무 복잡하다는 걸 깨닫고 그만뒀습니다. 이 단순화가 더 좋네요.
    • @dov — 그렇겠네요. Deref 포인터에서도 그냥 작동하게 만드는 게 목표라면 Pin이나 StableDeref가 맞겠습니다.
  • @addison — 곧 Arc::map이 나오면 이 악몽도 끝나겠네요. 아, 제가 생각한 건 mappable-rc였군요. 이 기능은 제가 생각한 걸 하지는 않네요.
  • @junon — Arc::unwrap_or_clone()은 여기서 안 되나요? 이미 Arc를 값으로 받고 있는데 왜 이 방법이 필요한지 잘 모르겠습니다. 제가 용도를 제대로 이해하지 못한 걸 수도 있습니다.

원문: wolfgirl.dev / 번역·요약: Trawling