이 장은 공유 상태 자체를 없애는 전략을 다룬다. 지금까지 “공유 상태를 보호하는 방법"을 배웠다면, 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를 고려한다).
패턴 선택 가이드
| |
흔한 오개념
**“멤버가 전부 const거나 읽기 전용 메서드만 있으면 Immutable이다”**는 흔한 오해다. 클래스가 const T* ptr나 std::shared_ptr<T> 같은 참조/포인터 멤버를 들고 있다면, 그 클래스 자체는 값을 바꾸지 않아도 포인터가 가리키는 대상을 다른 코드가 바꿀 수 있다 — 진짜 Immutable은 “이 객체의 멤버가 const다"가 아니라 “이 객체가 도달할 수 있는 모든 상태가 생성 이후 절대 바뀌지 않는다"는 훨씬 강한 보장이다. 이 장의 ImmutableString이 안전한 이유는 const std::string data가 값 타입이라 애초에 외부에서 가리킬 대상이 없기 때문이지, 단순히 const가 붙어서가 아니다.
**"thread_local이면 그 안에 뭘 담든 항상 안전하다”**도 마찬가지로 위험한 일반화다. thread_local이 격리하는 것은 “그 변수 자체가 저장되는 위치"이지, 그 변수가 가리키는 대상까지 자동으로 격리해 주지는 않는다. 예를 들어 thread_local std::shared_ptr<SharedCache> cache;처럼 모든 스레드의 thread_local 포인터가 결국 같은 힙 객체를 가리킨다면, 포인터 자체는 스레드마다 따로 있어도 그 객체에 대한 접근은 여전히 동기화가 필요하다. 이 장의 threadLocalRandomInt 예제가 안전한 이유는 std::mt19937 engine이 값으로 스레드마다 통째로 복제되기 때문이지, thread_local 키워드 자체가 마법을 부려서가 아니다.
학습 성과 평가 기준
- Immutable 패턴을 구현하고, 왜 안전한지 설명할 수 있는가?
-
std::shared_ptr의 atomic 교체를 이용한 Copy-on-Write를 구현하고, “읽기 스냅샷이 왜 안전한가"를 설명할 수 있는가? -
thread_local로 스레드별 난수 생성기/버퍼를 구현하고, 언제 쓸지·메모리 비용은 어떻게 되는지 판단할 수 있는가? -
std::atomic만으로 SPSC Lock-Free 큐를 구현하고,memory_order_release/acquire쌍이 왜 필요한지 설명할 수 있는가? - Lock-Free가 왜 어렵고 위험한지, SPSC와 MPMC의 난이도 차이를 이해하는가?
핵심 정리
지금까지 11개 장을 거치며 “공유 상태를 어떻게 안전하게 보호할 것인가"를 물었다면, 이 장은 그 전제를 뒤집어 “이 상태를 정말 공유해야 하는가"를 먼저 묻는다. Immutable과 Copy-on-Write는 “데이터를 공유하지만 변경 시점을 분리"하고, thread_local은 “데이터 자체를 분리"하며, Lock-Free는 “보호 메커니즘(락)을 atomic 연산으로 대체"한다. 세 전략 모두 02장에서 시작한 “락이 필요한 이유"를 거꾸로 비추는 거울이다 — 락이 왜 필요한지 모르면, 락이 왜 필요 없어지는지도 알 수 없다.
다음 장에서는
12장에서는 지금까지 스레드 기반으로 구현한 07장의 Future/Promise와 08장의 Active Object를 C++20 코루틴으로 다시 써 본다. co_await 문법이 같은 문제를 어떻게 다른 비용 구조로 풀어내는지, 그리고 언제 코루틴이 스레드 기반 구현보다 유리한지를 다룬다. 이 장이 “범위 밖"으로 미뤄 둔 ABA 문제와 메모리 회수(hazard pointer, epoch-based reclamation)는 13장에서 정면으로 다룬다.
참고 및 출처
- 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] 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] 12. 코루틴 기반 비동기 재해석](/post/multithreading-patterns/cpp-coroutine-reinterpretation-future-active-object/wordcloud_hu_4368a5ca216d5256.webp)
![[Concurrency Patterns] 13. Lock-Free 심화: Hazard Pointer와 RCU](/post/multithreading-patterns/cpp-hazard-pointer-rcu-lockfree-reclamation/wordcloud_hu_581f29534d039e3f.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] 08. 비동기 객체 (Active Object)](/post/multithreading-patterns/cpp-active-object-async-method-invocation/wordcloud_hu_254616021f48b2ef.webp)
![[Concurrency Patterns] 00. 멀티스레딩 디자인 패턴 시리즈 소개와 커리큘럼](/post/multithreading-patterns/getting-started-multithreading-design-patterns/wordcloud_hu_2452ba41c5c18ed2.webp)