이 시리즈의 마지막 장은 공유 상태 자체를 없애는 전략을 다룬다. 지금까지 “공유 상태를 보호하는 방법"을 배웠다면, 11장은 “공유하지 않는 방법"을 배운다. 이것이 가장 근본적인 해결책이다. 락은 잘 설계해도 경쟁(contention)과 데드락 위험이 남는다. 반면 “처음부터 공유하지 않는다"는 전략은 그 위험을 설계 단계에서 제거한다 — 대신 메모리와 복사라는 다른 비용을 지불할 뿐이다.
이 장을 읽기 전에
완전한 초보자? 이 장은 10장 「이벤트 아키텍처 II: Proactor와 Half-Sync/Half-Async」까지의 전체 맥락, 특히 01장의 메모리 모델(happens-before, atomic)과 02장의 락 관용구를 알고 있다고 가정합니다. “왜 락이 필요한가"를 이해해야 “왜 락이 필요 없는 구조가 더 좋은가"를 판단할 수 있기 때문입니다.
이 장의 깊이: 이 장은 심화(advanced) 수준입니다. std::shared_ptr의 atomic 교체를 이용한 실전 Copy-on-Write 패턴, thread_local을 활용한 스레드별 자원 관리, 그리고 std::atomic만으로 구현하는 교육용 SPSC(Single-Producer Single-Consumer) Lock-Free 큐를 직접 구현하고 검증하는 것이 목표입니다.
다루지 않는 것: 이 장의 Lock-Free 큐 예제는 교육 목적이며, 프로덕션에서 그대로 사용하기 위한 것이 아닙니다. ABA 문제, 메모리 회수(hazard pointer, epoch-based reclamation), 다중 생산자/다중 소비자(MPMC) 큐, 범용 lock-free 자료구조의 완전한 정확성 증명은 이 장의 범위 밖입니다. 이런 자료구조가 실제로 필요하다면 Boost.Lockfree, Intel TBB, folly 같은 검증된 라이브러리를 사용해야 합니다 — 이 장의 목표는 “lock-free가 왜 어려운지"를 손으로 만들어 보며 체감하는 것입니다.
당신의 수준에 맞는 경로
| 수준 | 읽을 부분 | 핵심 목표 |
|---|---|---|
| 중급자 | “Immutable 패턴” ~ “thread_local” | 기본 공유 회피 기법 습득 |
| 고급자 | 전체, “Copy-on-Write” 섹션 | 패턴 간의 트레이드오프 이해 |
| 성능 전문가 | “패턴 선택 가이드” | 실제 시스템에서 어떤 패턴 쓸지 판단 |
핵심 원리
공유 상태가 없으면 동기화도 필요 없다. 그 대신의 비용은 메모리와 복사다. 이 트레이드오프는 상황마다 다르다.
| 패턴 | 메모리 | 성능 | 복잡도 |
|---|---|---|---|
| Immutable | 높음 | 빠름 (복사 비용) | 낮음 |
| Copy-on-Write | 중간 | 높음 (읽기 최적화) | 높음 |
| thread_local | 높음 | 매우 빠름 | 중간 |
| Lock-Free | 중간 | 매우 높음 (비용) | 매우 높음 |
Immutable 패턴
Immutable 객체: 생성 후 상태가 바뀌지 않는 객체. 여러 스레드에서 동시에 읽어도 완전히 안전하다.
| |
사용:
| |
장점:
- 동기화 불필요
- 스레드 안전성 보증 (타입 수준)
- 함수형 프로그래밍 스타일
단점:
- 매 수정마다 복사 비용
- 메모리 사용량 증가
Copy-on-Write (CoW)
Immutable의 단점을 보완한다. 읽기는 공유, 쓰기는 복사. 핵심 아이디어는 “현재 버전을 가리키는 포인터를 atomic하게 교체"하는 것이다 — std::shared_ptr의 atomic 연산(std::atomic_load/std::atomic_store, C++20부터는 std::atomic<std::shared_ptr<T>>)을 쓰면 락 없이도 안전하게 “버전 스왑"을 할 수 있다.
| |
핵심: 읽기 스레드가 get()으로 얻은 shared_ptr은 그 시점의 불변 스냅샷이다. update()가 새 Config를 만들어 current_를 교체해도, 이미 스냅샷을 들고 있는 읽기 스레드는 그 객체가 (참조 카운트 덕분에) 살아있는 동안 안전하게 사용할 수 있다 — 락도, 데이터 레이스도 없다. 대신 update()마다 전체 Config 복사가 발생하므로, 쓰기가 드물고 읽기가 압도적으로 많은 설정 객체, 라우팅 테이블, 캐시 등에 적합하다.
안전성 검증
current_에 대한 모든 접근이 std::atomic_load_explicit/compare_exchange_weak_explicit를 거치므로, current_ 자체에 대한 데이터 레이스는 없다. 만약 get()이나 update()에서 atomic 연산 없이 current_ = newCfg;처럼 직접 대입한다면, g++ -fsanitize=thread로 빌드한 멀티스레드 실행에서 shared_ptr의 내부 제어 블록 접근 지점에 WARNING: ThreadSanitizer: data race가 보고된다.
어디에 유용한가?
- 읽기가 대부분인 경우 (예: 설정 객체, 라우팅 테이블)
- 드물게 수정되는 경우
thread_local 패턴
각 스레드가 자신만의 복사본을 가진다. 공유 자체가 없으므로 동기화 불필요.
가장 흔한 실전 사례 중 하나는 스레드별 난수 생성기다. std::mt19937는 내부 상태를 갖는 객체이므로, 여러 스레드가 하나의 인스턴스를 공유하면 () 호출이 데이터 레이스가 된다. 매번 mutex로 보호하는 대신, 각 스레드가 자신만의 엔진을 갖게 하면 동기화 자체가 사라진다.
| |
engine은 함수 지역 static thread_local 변수이므로, 각 스레드가 처음 이 함수를 호출할 때 자신만의 엔진을 한 번 초기화하고, 이후 호출은 그 스레드의 엔진을 재사용한다. mutex 없이도 두 스레드가 동시에 호출해도 안전하다 — 서로 다른 메모리 위치에 접근하기 때문에 데이터 레이스의 정의(01장)를 만족하지 않는다.
같은 패턴은 스레드별 출력 버퍼(로그를 모아서 한 번에 flush), 스레드별 메모리 풀/할당자 캐시(malloc 경쟁 회피) 등에도 적용된다.
카운터 예제와 출력 보장
가장 단순한 형태는 스레드별 카운터다.
| |
출력: 각 스레드가 정확히 1000을 출력한다(동기화 불필요). ++count는 여러 스레드에서 동시에 실행되지만, 각 스레드가 서로 다른 count 인스턴스에 접근하므로 01장에서 본 “공유 변수에 대한 읽기-수정-쓰기 레이스"는 처음부터 발생하지 않는다.
사용처:
- 스레드별 통계 (로깅 버퍼, 성능 카운터)
- 스레드별 상태 (random seed, 예외 정보)
- 스레드풀의 워커별 캐시
주의점:
- 메모리: 스레드 수 × 데이터 크기 — 스레드풀처럼 스레드 수가 고정이면 괜찮지만, 스레드를 계속 생성하는 구조에서는 메모리가 누적될 수 있다.
- 초기화: 모든 스레드에서 별도로 초기화 필요 (위 예제처럼 함수-지역
static thread_local로 lazy 초기화하면 이 문제를 피할 수 있다)
Lock-Free 자료구조: 전망
Lock-Free: mutex를 사용하지 않고 atomic으로만 구현. 극도로 높은 성능, 극도로 높은 복잡도. 일반적인 Lock-Free 스택/큐(다중 생산자·다중 소비자, MPMC)는 메모리 회수 문제(ABA, hazard pointer) 때문에 정확하게 구현하기가 매우 어렵다. 하지만 SPSC(Single-Producer Single-Consumer) — “생산자 스레드 하나, 소비자 스레드 하나"로 제한하면 — 문제가 극적으로 단순해진다. 이 제약 덕분에 SPSC 큐는 lock-free 자료구조 중 가장 흔히 손으로 구현되는 형태이며, “atomic만으로 동기화가 어떻게 성립하는가"를 보여주는 좋은 교육 사례다.
주의: 아래 구현은 교육용이다. 고정 크기 배열을 사용하며, 큐가 가득 차면
push가 실패를 반환한다. 프로덕션에서는 Boost.Lockfree의spsc_queue, folly의ProducerConsumerQueue등 검증된 구현을 사용해야 한다.
| |
왜 이것이 “lock-free"인가: push와 pop 어디에도 mutex나 compare_exchange(재시도 루프)가 없다. head_와 tail_은 각각 한쪽 스레드만 쓰고(write), 다른 쪽은 읽기만(read) 하므로 경쟁(contention)이 구조적으로 존재하지 않는다. memory_order_release/memory_order_acquire 쌍이 01장에서 배운 happens-before 관계를 만든다 — tail_.store(release)는 buffer_[tail]에 대한 쓰기가 tail_.load(acquire)를 통해 소비자에게 보이도록 보장한다. 이 release/acquire 쌍이 없다면(예: 둘 다 relaxed라면) 소비자가 tail_이 갱신된 것은 보지만 buffer_[tail]의 새 값은 아직 못 보는 상황이 이론적으로 가능해진다.
안전성 검증
이 큐를 g++ -fsanitize=thread로 빌드해 실행하면 정상 동작 시 TSAN 경고가 없어야 한다. 의도적으로 memory_order_release/acquire를 memory_order_relaxed로 바꿔서 실행해 보면 — TSAN은 이런 미묘한 순서 문제를 항상 잡아내지는 못하지만(원자성 자체는 깨지지 않으므로), 결과 값 오류(“순서 오류” 메시지)가 더 자주 관찰될 수 있다. 이것이 “lock-free가 검증하기 어렵다"는 말의 실체다 — 컴파일되고, 대부분 실행에서 통과하지만, 특정 메모리 순서/CPU/타이밍에서만 깨지는 코드가 만들어지기 쉽다.
현실:
- 일반적인(MPMC) lock-free 자료구조는 책으로는 배우기 어렵고, 보증된 라이브러리 (Boost.Lockfree, TBB, folly)를 사용할 것을 강력히 권장한다.
- 정확성 검증이 매우 어렵다 (10년 경력 해서도 버그 있음). SPSC처럼 제약이 강한 특수 케이스만 손으로 구현을 시도할 가치가 있다.
- 성능 이득이 실제로 필요한 경우는 드물다 (보통 mutex가 충분하다 — 이 시리즈의 02장에서 본 Scoped Locking으로 시작하고, 프로파일링 후에만 lock-free를 고려한다).
패턴 선택 가이드
| |
학습 성과 평가 기준
- Immutable 패턴을 구현하고, 왜 안전한지 설명할 수 있는가?
-
std::shared_ptr의 atomic 교체를 이용한 Copy-on-Write를 구현하고, “읽기 스냅샷이 왜 안전한가"를 설명할 수 있는가? -
thread_local로 스레드별 난수 생성기/버퍼를 구현하고, 언제 쓸지·메모리 비용은 어떻게 되는지 판단할 수 있는가? -
std::atomic만으로 SPSC Lock-Free 큐를 구현하고,memory_order_release/acquire쌍이 왜 필요한지 설명할 수 있는가? - Lock-Free가 왜 어렵고 위험한지, SPSC와 MPMC의 난이도 차이를 이해하는가?
시리즈 완수 평가 기준
이 컬렉션 전체를 완주하면 00장 「시리즈 소개」에서 제시한 목표 — “락을 어디에 넣지?“가 아니라 “이 문제는 어떤 패턴의 변형이지?“라는 어휘로 사고하는 것 — 를 다음 구체적 역량으로 점검할 수 있어야 한다.
- 멀티스레드 문제를 “메모리 모델” 언어로 진단할 수 있다. (01)
- 데이터 레이스를 Scoped Locking, Monitor Object, Guarded Suspension으로 해결할 수 있다. (02~03)
- Producer-Consumer를 Bounded Buffer로 구현하고 backpressure를 제어할 수 있다. (04)
- 읽기 위주 워크로드를 shared_mutex나 call_once로 최적화할 수 있다. (05)
- Thread Pool, Future/Promise, Active Object를 설계하고 구현할 수 있다. (06~08)
-
poll()기반 Reactor와 Proactor(완료 통지) 의미의 차이를 코드로 구현·구분할 수 있다. (09~10) -
condition_variable기반 큐로 비동기 계층과 동기 계층을 분리하는 Half-Sync/Half-Async 구조를 설계할 수 있다. (10) - Immutable, Copy-on-Write, thread_local로 공유를 회피하고, SPSC Lock-Free 큐의 동작 원리를 설명할 수 있다. (11)
- 각 패턴의 트레이드오프(메모리, 성능, 복잡도)를 이해하고, 보호(02, 05)·대기(03)·흐름(04)·실행(06
08)·아키텍처(0910)·회피(11)의 6개 층위로 문제를 분류해 설계 리뷰에서 대안을 제시할 수 있다.
마치며
00장 「시리즈 소개」는 “멀티스레드 코드가 무너지는 순간은 락 문법을 몰라서가 아니라, 어디에 어떤 구조로 동기화를 배치할지 설계하지 않아서 온다"는 문제의식에서 출발했다. 11개 장을 거치며 우리는 그 질문에 답하는 어휘 체계를 하나씩 쌓았다 — 01장의 메모리 모델은 “안전하다"는 말의 정의를 주었고, 0205장은 단일 객체를 보호하는 관용구를, 04·0608장은 스레드 사이의 데이터 흐름과 실행 관리를, 09~10장은 시스템 수준의 이벤트 아키텍처를 다뤘다.
마지막 11장은 이 모든 것의 전제를 뒤집는다: 지금까지 “공유 상태를 어떻게 안전하게 보호할 것인가"를 물었다면, 이제는 “이 상태를 정말 공유해야 하는가"를 먼저 묻는다. Immutable과 Copy-on-Write는 “데이터를 공유하지만 변경 시점을 분리"하고, thread_local은 “데이터 자체를 분리"하며, Lock-Free는 “보호 메커니즘(락)을 atomic 연산으로 대체"한다. 세 전략 모두 02장에서 시작한 “락이 필요한 이유"를 거꾸로 비추는 거울이다 — 락이 왜 필요한지 모르면, 락이 왜 필요 없어지는지도 알 수 없다.
실무에서:
- 작은 시스템: Scoped Locking + Monitor Object로 충분
- 중간 시스템: Thread Pool + Future, Half-Sync/Half-Async 조합
- 대규모 시스템: Event-Driven (Reactor/Proactor) + thread_local + Immutable/CoW 혼합, 핫패스에 한정해 SPSC Lock-Free 큐 검토
무엇보다 중요한 것은 “왜 동기화가 필요한가"를 이해하고, 가장 간단한 패턴부터 시작하는 것이다. 복잡한 패턴은 필요할 때만, 그리고 프로파일링으로 그 필요성이 증명된 뒤에만 도입한다. 이 시리즈에서 익힌 구조적 어휘를 가지고, Low-latency 동시성·멀티스레드 트랙에서 같은 패턴들을 “비용"의 관점으로 다시 보면, 구조와 성능이라는 두 축이 맞물려 입체적인 그림이 완성된다.
참고 및 출처
- Brian Goetz, 『Java Concurrency in Practice』, Chapter 5 — Immutable & Thread-Safe
- Anthony Williams, 『C++ Concurrency in Action』(2nd ed., 2019) — Lock-Free 자료구조와 memory_order 실전 활용
- POSA2 (Schmidt et al.), 전체 — 모든 패턴의 원형
- C++ Standards Committee, 전체 — std::thread, mutex, atomic, shared_ptr, future documentation
- Boost.Lockfree, folly 문서 — 프로덕션 수준 Lock-Free 자료구조 참고
![Featured image of post [Concurrency Patterns] 11. 공유 회피](/post/multithreading-patterns/cpp-avoiding-shared-state-immutable-cow-thread-local/wordcloud_hu_af927a3a04a63b62.webp)
![[Concurrency Patterns] 07. 실행 관리 II: Future와 Promise](/post/multithreading-patterns/cpp-future-promise-async-packaged-task/wordcloud_hu_9444b48845ebd7f7.webp)
![[Concurrency Patterns] 08. 비동기 객체 (Active Object)](/post/multithreading-patterns/cpp-active-object-async-method-invocation/wordcloud_hu_254616021f48b2ef.webp)
![[Concurrency Patterns] 09. 이벤트 아키텍처 I: Reactor](/post/multithreading-patterns/cpp-reactor-event-driven-single-thread/wordcloud_hu_941003b808a578c8.webp)
![[Concurrency Patterns] 10. 이벤트 아키텍처 II: Proactor와 Half-Sync/Half-Async](/post/multithreading-patterns/cpp-proactor-async-io-half-sync-half-async/wordcloud_hu_78c43da928edd651.webp)
![[Concurrency Patterns] 11. 공유 회피](/post/multithreading-patterns/cpp-avoiding-shared-state-immutable-cow-thread-local/wordcloud_hu_aae971fbadfb9cd5.webp)
![[Concurrency Patterns] 01. 동시성 기초와 C++ 메모리 모델](/post/multithreading-patterns/getting-started-cpp-concurrency-fundamentals-memory-model/wordcloud_hu_61c0c60d19b6fa49.webp)
![[Concurrency Patterns] 00. 멀티스레딩 디자인 패턴 시리즈 소개와 커리큘럼](/post/multithreading-patterns/getting-started-multithreading-design-patterns/wordcloud_hu_2452ba41c5c18ed2.webp)
![[Concurrency Patterns] 06. 실행 관리 I: Thread Pool](/post/multithreading-patterns/cpp-thread-pool-work-queue-work-stealing/wordcloud_hu_5141aa5781618afa.webp)
![[Concurrency Patterns] 05. 읽기 최적화와 지연 초기화](/post/multithreading-patterns/cpp-read-write-lock-dclp-call-once-lazy-init/wordcloud_hu_58028050dfa28943.webp)
![[Concurrency Patterns] 02. 락 관용구](/post/multithreading-patterns/cpp-locking-idioms-scoped-locking-thread-safe-interface/wordcloud_hu_fa5a9d576f9499eb.webp)