Lobsters

The Performance Cost of RwLock in Our Read-Heavy Workload

읽기 위주 작업에서 RwLock이 치르는 성능 비용

한 서비스에서 RwLock과 Crossbeam의 epoch 기반 원자 포인터를 비교한 벤치마크입니다. 16,384개 항목을 순회할 때 항목마다 락을 잡고 푸는 대신, 순회당 epoch를 한 번 고정하자 읽기 처리량이 약 15.9k에서 229.1k로 늘고 지연 시간도 줄었습니다.

AI 요약

읽기가 잦고 쓰기는 한 스레드에서 수행하는 서비스라면 RwLock이 언제나 적합할까요? 이 글은 한 서비스의 데이터 구조와 벤치마크를 살펴보며, 성능 차이가 락 경합보다 읽을 때 반복하는 원자 연산에서 비롯될 수 있음을 설명합니다. 저자는 잠금 방식과 lock-free 방식 중 하나가 항상 낫다고 말하지 않습니다. 동기화 대상과 쓰기 빈도, 읽기 패턴에 따라 선택해야 한다고 강조합니다.

데이터 구조와 두 가지 쓰기 방식

서비스의 Store는 ID를 키로 Data를 저장하고, Block은 Store에 든 Data 일부를 가리키는 참조 목록입니다. 각 읽기 요청은 Block의 모든 항목을 순회하며 계산을 수행합니다. 읽기는 여러 작업에서 동시에 일어나고, 쓰기는 단일 스레드가 담당합니다. Data 안의 Metrics는 매번 값 전체를 교체합니다.

첫 번째 방식은 Metrics를 parking_lot::RwLock으로 감쌉니다. 읽을 때마다 해당 항목의 read lock을 얻고, 값을 읽은 뒤 락을 풉니다. std::sync::RwLock은 읽기와 쓰기 우선순위를 운영체제에 맡기며, tokio::sync::RwLock은 비동기 락이라 획득 과정에서 yield가 일어날 수 있습니다. 이 서비스의 순회는 동기식 계산이므로 저자는 parking_lot을 골랐습니다.

두 번째 방식은 불변 Metrics를 Crossbeam의 Atomic 포인터 뒤에 둡니다. 읽기는 현재 포인터를 불러오고, 쓰기는 새로 할당한 값을 원자적으로 교체합니다. 읽기는 순회가 시작될 때 crossbeam_epoch::pin()으로 epoch guard를 얻습니다. 이 guard가 유지되는 동안 읽던 값은 해제되지 않습니다. 쓰기는 기존 포인터를 교체한 뒤 defer_destroy로 이전 값을 회수 대기 상태에 둡니다. 이전 값을 읽는 작업이 끝나야 안전하게 해제할 수 있습니다.

벤치마크: 순회마다 반복되는 락 비용

테스트는 Apple Silicon MacBook Air에서 진행했습니다. Tokio 작업 50개가 동시에 읽고, 쓰기 작업은 초당 500회 수행합니다. Block에는 16,384개 항목이 있습니다. CPU 예열은 2초, 측정은 10초씩 5회 진행했습니다. 각 읽기 작업은 read()를 반복 호출하고 호출 뒤 yield_now()를 실행합니다.

RwLock 방식은 초당 읽기 15.93k회, 쓰기 499.97회를 기록했습니다. 읽기 지연 시간은 p50 441.42µs, p95 910.18µs, p99 1.81ms입니다. Atomic 방식은 읽기 229.07k회, 쓰기 500회였고, 지연 시간은 각각 22.44µs, 49.84µs, 241.97µs입니다.

차이가 쓰기와 읽기 사이의 락 경합 때문인지 확인하려고 쓰기를 끈 테스트도 했습니다. 읽기 처리량은 쓰기가 있을 때 15.93k, 없을 때 16.01k로 거의 달라지지 않았습니다. p50 지연도 441.42µs에서 431.50µs로 비슷합니다. 저자는 병목을 경합보다 항목마다 락을 획득하고 해제하는 비용에서 찾습니다.

parking_lot은 활성 독자 수를 AtomicUsize로 관리합니다. 읽기 락을 얻을 때 원자적 compare-and-exchange로 독자 수를 늘리고, 락을 풀 때 원자 연산으로 줄입니다. Block 하나를 순회할 때 이 과정이 16,384번 반복됩니다. 초당 약 16,000회 순회하면 락 내부에서 초당 약 5억 2,400만 번의 원자적 갱신이 발생합니다. 경합이 없어도 독자 수를 여러 작업에서 정확히 관리하는 비용은 남습니다.

반면 Crossbeam 방식은 Block 전체를 순회하는 동안 epoch를 한 번 고정합니다. 각 항목에서는 독자 수를 올리고 내리는 대신 원자 포인터를 읽습니다. epoch 고정과 메모리 회수에도 관리 비용은 들지만, 16,384쌍의 락 획득·해제를 순회당 한 번의 epoch 고정과 포인터 읽기로 바꾼 점이 성능 차이를 만들었습니다.

다른 선택지와 적용 범위

ArcSwap도 읽기 위주 데이터에 쓸 수 있지만, 이 읽기 패턴에서는 Block의 각 항목마다 ArcSwap::load()를 호출할 때 비용이 발생합니다. Crossbeam 방식은 순회 전체에 epoch guard 하나를 씁니다. left-right는 상태를 복제하고 쓰기 쪽에 작업을 더 맡겨 읽기 처리량을 높일 수 있지만, 운영 데이터가 메모리에서 약 4GB였기 때문에 복제하면 메모리 사용량이 크게 늘어납니다. 이미 Crossbeam 방식으로 처리량과 지연 시간 요구를 충족해 이 선택지는 사용하지 않았습니다.

Block 자체는 RwLock으로 감쌌습니다. Block에 새 항목을 넣는 빈도는 초당 한두 번뿐이었기 때문입니다. 이 구성의 읽기 처리량은 204.40k회, 쓰기는 500.01회, 삽입은 초당 1.06회였습니다. p50은 26.17µs, p95는 52.59µs, p99는 258.83µs였습니다. 자주 바뀌는 Metrics에는 Crossbeam Atomic을 쓰고, 드물게 바뀌는 Vec에는 단순한 RwLock을 쓰는 식으로 동기화 방식을 나눴습니다.

결과는 이 작업 부하에 한정됩니다. lock-free가 항상 빠르다는 뜻은 아닙니다. 이 사례에서는 수천 개의 읽기 락을 반복해서 잡고 푸는 비용을 줄이는 일이 도움이 됐고, 변경이 드문 Block에는 RwLock이 충분했습니다. 글의 기준은 잠금 여부 자체보다 동기화가 어디에서 얼마나 자주 일어나는지입니다.

Lobsters 반응

  • @noncrab — 직장에서 지표를 다룰 때 비슷한 일을 겪었습니다. 다만 저희는 지표 스크레이프가 읽기 락을 잡은 채 여러 수집기 스크립트를 실행했고, 수집기가 느릴 수도 있었습니다. 지표를 갱신하려면 쓰기 락이 필요했죠. 결국 불변 컬렉션을 가리키는 원자 포인터로 바꿔 사실상 스냅샷 읽기를 했습니다.
  • @alper — 고급 Rust 책을 꽤 읽었는데도 여기서 무슨 일이 벌어지는지 설명해주는 책은 하나도 없었습니다. Crossbeam 문서도 별로 도움이 안 됩니다.
    • @pervognsen — 이건 Rust보다는 lock-free 프로그래밍, 구체적으로는 epoch 기반 메모리 회수에 관한 내용입니다. Crossbeam_epoch API의 초기 사전 출시 버전을 설명한 글이 있지만, 이후 몇 가지가 바뀌었습니다. 가장 단순하면서도 여전히 유용한 예는 불변 객체를 가리키는 공유 원자 포인터 하나를 쓰는 경우입니다. 새 객체를 공개하려면 원자적 CAS를 사용할 수 있습니다. CAS에 성공하면 적절한 메모리 순서를 전제로 새로 포인터를 읽는 작업이 새 객체를 보게 됩니다. 다른 작업이 먼저 포인터를 갱신하지 않았다는 점도 알 수 있습니다. 그러면 이전 객체는 ‘퇴역’ 상태가 됩니다. 하지만 이전 객체를 읽는 작업이 아직 있을 수 있으므로 바로 회수할 수는 없습니다. Epoch 기반 회수는 이런 문제를 동기화 비용을 가능한 한 낮추면서 처리하려는 여러 기법 가운데 하나입니다. 동시성 가비지 컬렉션의 특수한 형태로 볼 수 있습니다. 그냥 Arc를 써도 되는 경우가 많습니다. 하지만 소수 독자에게 카운터를 나눠주는 트리 같은 대리 구조를 쓰지 않으면 Arc는 초당 독자 트랜잭션 수에 확장 한계를 둡니다. 일부 애플리케이션에서는 문제가 될 수 있습니다. Arc의 큰 장점은 마지막 동시 독자가 작업을 마치자마자 퇴역 객체를 회수할 수 있다는 점입니다. EBR 같은 대안은 가비지 컬렉터와 비슷하게 회수되지 않은 메모리가 남는 절충을 합니다. Rust 관점에서 EBR의 큰 단점은 Arc처럼 완전히 안전한 API 추상화를 제공하지 않는다는 점입니다. Crossbeam_epoch는 최대한 안전하게 만들려 합니다. Aaron Turon의 글에 나온 초안 API에는 soundness 문제가 있었지만 이후 수정됐다고 생각합니다.
  • @pflanze — 좋은 글입니다. Crossbeam_epoch를 알게 되어 흥미로웠습니다. Metrics는 자주 바뀌지만 Block에 항목을 추가하는 일은 초당 한두 번뿐이라 간단한 RwLock이면 충분했다고 합니다. 코드를 직접 확인하지는 않았지만, Block 쓰기 빈도는 여기서 별 상관이 없어 보입니다. 글에서 언급했듯 초당 500회 쓰기도 Metrics 읽기에는 영향을 주지 않았습니다. 훨씬 자주 일어나는 읽기가 문제였죠. Block을 락으로 감싼 다음 안에서 n개 항목을 순회하면 Metrics 접근 횟수는 n배가 됩니다. n이 16,384라면 Block 락 횟수는 그만큼 적어서 부담이 되지 않는다는 설명으로 읽힙니다.

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