05장은 읽기 작업이 대부분인 시나리오의 최적화를 다룬다. 많은 시스템에서 쓰기(변경)는 드물고 읽기는 대부분이다. 기존의 단일 mutex는 읽기들까지 직렬화하므로 비효율적이다. 이 장에서는 읽기와 쓰기를 분리하고, 초기화 비용을 한 번만 치르는 패턴을 배운다.
이 장을 읽기 전에
완전한 초보자? 이 장은 02장: 락 관용구에서 다룬 std::mutex, std::lock_guard, 그리고 01장의 memory_order_acquire/memory_order_release를 전제로 합니다. 특히 DCLP 섹션은 01장의 happens-before 개념 없이는 “왜 위험했는지"가 와닿지 않으니, 아직이라면 01장과 02장을 먼저 보세요.
이 장의 깊이: 이 장은 중급–전문가까지를 포괄합니다. std::shared_mutex로 읽기/쓰기를 분리하는 기본기부터 시작해, DCLP의 역사적 함정과 C++11 이후의 올바른 구현, 그리고 call_once/정적 지연 초기화까지 다룹니다. 전문가 구간에서는 shared_mutex가 실제로 손해인 상황과 플랫폼별 구현 차이까지 다룹니다. 다루지 않는 것: lock-free 읽기 전용 자료구조의 구현은 이 장에서 다루지 않습니다. 11장이 교육용 SPSC 큐로 입문을 다루고, 13장 「Lock-Free 심화: Hazard Pointer와 RCU」에서 Hazard Pointer와 RCU를 직접 구현하며 본격적으로 다룹니다.
당신의 수준에 맞는 경로
| 수준 | 읽을 부분 | 핵심 목표 |
|---|---|---|
| 중급자 | “문제” ~ “std::call_once” | shared_mutex와 call_once 사용법 |
| 고급자 | 전체, 특히 “DCLP” 섹션 | DCLP의 위험성과 올바른 구현 이해 |
| 성능 전문가 | “DCLP” ~ “성능 비교” | 각 기법의 성능 트레이드오프 분석 |
문제: Mutex의 과도한 직렬화
| |
이 코드에서 읽기가 1000번, 쓰기가 1번이라도 모든 읽기는 같은 mutex를 기다려야 한다. CPU 시간 낭비.
Read-Write Lock: shared_mutex
C++17부터 std::shared_mutex를 지원한다. 여러 읽기는 동시에, 쓰기는 배타적으로 진행된다.
| |
shared_lock vs unique_lock:
std::shared_lock<std::shared_mutex> | std::unique_lock<std::shared_mutex> | |
|---|---|---|
| 동시 보유 | 여러 스레드 (읽기 락, “shared” 상태) | 단 하나 (쓰기 락, “exclusive” 상태) |
| 다른 shared_lock과 공존 | 가능 | 불가능 |
| 다른 unique_lock과 공존 | 불가능 | 불가능 |
| 대응 락 함수 | mu.lock_shared() / unlock_shared() | mu.lock() / unlock() |
| 02장의 대응 | std::lock_guard<std::mutex>와 동일한 RAII 모델 | 동일 |
두 타입 모두 02장에서 배운 RAII 락 가드와 동일한 원칙을 따른다 — 생성 시 잠기고 소멸 시 풀린다. 차이는 오직 어떤 모드로 shared_mutex를 잠그느냐다.
| |
성능: 읽기는 병렬화되므로 처리량 증가. 쓰기는 여전히 배타적이지만 빈번하지 않으면 괜찮음.
shared_mutex의 실제 비용과 RW 락이 손해인 경우
shared_mutex는 “공짜로 빠른 락"이 아니다. 내부적으로 “현재 몇 명이 읽고 있는가"를 추적하는 카운터를 원자적으로 갱신해야 하므로, 읽기 락 자체의 획득/해제 비용은 일반 std::mutex의 lock/unlock보다 오히려 높다. 여러 코어가 동시에 이 카운터를 증가/감소시키면 그 카운터가 들어 있는 캐시 라인을 두고 코어 간 핑퐁(false sharing과 유사한 현상)이 발생한다.
따라서 shared_mutex가 이득인 조건은 명확하다.
- 임계 구역이 충분히 길다: 읽기 작업 자체(데이터 복사, 계산)가 락 오버헤드보다 훨씬 커야 카운터 경합 비용을 상쇄한다.
- 읽기/쓰기 비율이 매우 높다: 읽기가 99% 이상이고 동시 읽기 스레드 수가 많을 때 병렬성의 이득이 카운터 비용을 압도한다.
반대로 다음 경우에는 일반 std::mutex가 더 빠르다:
- 임계 구역이 매우 짧다 (예:
int하나를 읽고 반환): mutex의 lock/unlock 한 쌍이 shared_mutex의 lock_shared/unlock_shared보다 싸다. - 쓰기가 빈번하다 (10–20% 이상): 쓰기 락은 모든 읽기를 막아야 하므로, 읽기 병렬화의 이득보다 쓰기 대기 비용이 커진다.
- writer starvation 위험: 표준은
shared_mutex의 writer 우선순위를 규정하지 않는다. 구현에 따라 읽기 스레드가 끊임없이 들어오면 쓰기 스레드가 계속 뒤로 밀릴 수 있다(특히 일부 libstdc++/Windows 구현에서 보고된 사례). 쓰기 지연이 SLA에 영향을 준다면 벤치마크로 직접 확인해야 한다.
결론적으로 “읽기가 많으니 일단 shared_mutex로 바꾼다"는 직관만으로 결정하지 말고, 임계 구역의 크기와 실제 읽기/쓰기 비율을 프로파일링한 뒤 적용하라. 02장에서 배운 Strategized Locking처럼, 락 타입 자체를 정책으로 분리해두면 나중에 mutex ↔ shared_mutex를 교체하며 벤치마크하기 쉽다.
DCLP (Double-Checked Locking Pattern)
DCLP는 초기화 비용을 줄이려는 패턴이지만, 잘못하면 위험하다.
나쁜 예: Mutex 없는 DCLP
| |
문제: 여러 스레드가 동시에 if를 통과해 new Singleton()을 여러 번 호출한다. 메모리 누수 + undefined behavior.
DCLP with Mutex (초급, 부정확)
| |
이 코드는 C++98에서도, C++11 이후에도 여전히 위험하다. “C++11부터 메모리 모델이 생겼으니 그냥 괜찮아지는 것 아닌가"라고 생각하기 쉽지만, instance가 std::atomic이 아닌 평범한 포인터인 이상 표준 버전과 무관하게 데이터 레이스다. 왜냐하면:
- (1)의 락 없는 읽기와 (2)의 락 있는 쓰기가 같은 non-atomic 변수
instance에 대한 동시 접근이라, 최소 하나가 쓰기이고 최소 하나가 non-atomic이면 데이터 레이스라는 01장의 정의를 그대로 만족한다 — 이는 표준 버전이 무엇이든 항상 미정의 동작이다. - 설령 이 레이스를 눈감아 주더라도, CPU나 컴파일러가 생성자 코드를
instance대입 이후로 재정렬할 수 있어 다른 스레드가 “생성이 덜 끝난” 객체를 볼 수 있다(메모리 배리어 부족).
C++11이 실제로 바꾼 것은 “이 문제를 고칠 도구(std::atomic과 memory_order)가 표준에 생겼다"는 것이지, “이 코드가 저절로 안전해졌다"는 것이 아니다.
올바른 DCLP (C++11 이상)
| |
왜 이게 안전한가?:
acquire로드와release저장이 happens-before을 만든다. 별도로 “기다리는” 로직은 없다 —ptr이nullptr이 아닌 값으로 관찰되는 바로 그 순간,release저장 이전에 있었던 생성자의 모든 쓰기가 이미 이 스레드에 보이도록 acquire-release 쌍이 보장한다.- 즉 “아직 생성 중인 객체를 가리키는 포인터"를 보는 일 자체가 없다 — 포인터가 non-null로 보이면 그 객체는 이미 완전히 생성된 상태다.
안전성 검증: ThreadSanitizer로 DCLP 비교
“DCLP with Mutex (초급, 부정확)” 버전과 “올바른 DCLP” 버전을 각각 빌드해 TSAN으로 비교하면 차이가 드러난다.
| |
부정확한 버전이 실제 환경(x86)에서는 “우연히” 잘 동작하는 경우가 많다는 점이 더 위험하다. x86은 store-store, load-load 순서를 비교적 강하게 보장하기 때문에 재정렬로 인한 문제가 드물게 발생한다. 하지만 ARM 기반 서버나 모바일 환경, 또는 컴파일러가 더 적극적으로 최적화하는 빌드 옵션(-O3, LTO)에서는 같은 코드가 실패할 수 있다. TSAN 경고가 없다는 것은 “이번 실행에서 못 봤다"는 뜻이지 “안전하다"는 증명이 아니다 — 따라서 memory_order를 직접 다루는 코드는 표준이 보장하는 패턴(call_once, 정적 지연 초기화)으로 대체할 수 있는지부터 검토해야 한다.
하지만 더 좋은 방법이 있다.
std::call_once와 std::once_flag
C++11부터 call_once로 초기화를 간단하고 안전하게 한다.
| |
장점:
- DCLP의 모든 복잡성을 없앤다.
- 예외 안전성 제공.
- 표준이 보장하는 안전성.
| |
생성자는 정확히 한 번만 호출된다.
Lazy Initialization 패턴 변형
1. Per-Object Initialization
지금까지 본 Singleton의 DCLP는 전역으로 하나뿐인 인스턴스를 지연 생성하는 경우였다. 하지만 실무에서는 “객체마다 하나씩, 필요할 때만 만드는” 리소스가 더 흔하다 — 예를 들어 각 커넥션 객체가 자신만의 캐시나 버퍼를 첫 사용 시점에만 할당하는 경우다. 이때는 정적 변수 하나로 해결할 수 없고, 객체마다 자신의 DCLP 상태(ptr, initMu)를 멤버로 들고 있어야 한다.
| |
2. Static Initialization (가장 간단)
Per-Object Initialization이 필요 없는 “전역 하나뿐” 상황이라면, DCLP를 손으로 구현할 필요조차 없다. C++11부터 표준은 함수 지역 static 변수의 초기화 자체를 스레드 안전하게 만들도록(이른바 “매직 스태틱”, magic statics) 요구한다 — 여러 스레드가 동시에 이 함수에 처음 진입해도, 컴파일러가 내부적으로 락(또는 이와 동등한 메커니즘)을 삽입해 생성자가 정확히 한 번만 실행되도록 보장한다.
| |
이것이 가장 간단하고 표준이 보장한다. (C++11 이후)
성능 비교
| 기법 | 첫 호출 | 이후 읽기 | 메모리 |
|---|---|---|---|
| mutex | 느림 | 느림 | 적음 |
| DCLP (atomic) | 느림 | 빠름 | 적음 |
| call_once | 느림 | 빠름 | 적음 |
| static (C++11) | 느림 | 매우 빠름 | 적음 |
권장: 단순 싱글톤은 static 사용. 복잡한 초기화는 call_once.
학습 성과 평가 기준
- shared_mutex에서 shared_lock (읽기)과 unique_lock (쓰기)의 차이를 설명할 수 있는가?
- 왜 DCLP가 C++98에서 위험했고, C++11에서 atomic을 사용하면 안전한가?
- call_once와 once_flag를 사용해 스레드 안전한 싱글톤을 구현할 수 있는가?
- 읽기 위주 vs 쓰기 위주 시나리오에서 어떤 락을 선택해야 하는가?
다음 장에서는
06장 「실행 관리 I: Thread Pool」에서는 스레드 풀, 작업 큐, 그리고 work stealing 알고리즘을 다룬다.
참고 및 출처
- Scott Meyers & Andrei Alexandrescu, “C++ and the Perils of Double-Checked Locking” (2004)
- Anthony Williams, 『C++ Concurrency in Action』, Chapter 3 & 7
- C++ Standards Committee, Static storage duration initialization rules
![Featured image of post [Concurrency Patterns] 05. 읽기 최적화와 지연 초기화](/post/multithreading-patterns/cpp-read-write-lock-dclp-call-once-lazy-init/wordcloud_hu_95d432b9f02f15a4.webp)
![[Concurrency Patterns] 03. 대기와 조정](/post/multithreading-patterns/cpp-condition-variable-monitor-object-guarded-suspension/wordcloud_hu_3fa6659dfc8981c1.webp)
![[Concurrency Patterns] 04. 데이터 흐름: Producer-Consumer](/post/multithreading-patterns/cpp-producer-consumer-bounded-buffer-backpressure/wordcloud_hu_5af444c86ef999cd.webp)
![[Concurrency Patterns] 05. 읽기 최적화와 지연 초기화](/post/multithreading-patterns/cpp-read-write-lock-dclp-call-once-lazy-init/wordcloud_hu_58028050dfa28943.webp)
![[Concurrency Patterns] 06. 실행 관리 I: Thread Pool](/post/multithreading-patterns/cpp-thread-pool-work-queue-work-stealing/wordcloud_hu_5141aa5781618afa.webp)
![[Concurrency Patterns] 07. 실행 관리 II: Future와 Promise](/post/multithreading-patterns/cpp-future-promise-async-packaged-task/wordcloud_hu_9444b48845ebd7f7.webp)
![[Concurrency Patterns] 01. 동시성 기초와 C++ 메모리 모델](/post/multithreading-patterns/getting-started-cpp-concurrency-fundamentals-memory-model/wordcloud_hu_61c0c60d19b6fa49.webp)
![[Concurrency Patterns] 13. Lock-Free 심화: Hazard Pointer와 RCU](/post/multithreading-patterns/cpp-hazard-pointer-rcu-lockfree-reclamation/wordcloud_hu_581f29534d039e3f.webp)
![[Concurrency Patterns] 11. 공유 회피](/post/multithreading-patterns/cpp-avoiding-shared-state-immutable-cow-thread-local/wordcloud_hu_aae971fbadfb9cd5.webp)
![[Concurrency Patterns] 00. 멀티스레딩 디자인 패턴 시리즈 소개와 커리큘럼](/post/multithreading-patterns/getting-started-multithreading-design-patterns/wordcloud_hu_2452ba41c5c18ed2.webp)