Featured image of post [Concurrency Patterns] 05. 읽기 최적화와 지연 초기화

[Concurrency Patterns] 05. 읽기 최적화와 지연 초기화

shared_mutex로 읽기/쓰기 락을 분리하고, DCLP(Double-Checked Locking)의 역사적 함정과 C++11 이후 올바른 구현, call_once를 통한 안전한 지연 초기화까지 실전 코드로 학습합니다.

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의 과도한 직렬화

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Config {
private:
    std::mutex mu;
    std::string data;

public:
    std::string read() {
        std::lock_guard<std::mutex> lock(mu);  // 읽기도 락
        return data;
    }

    void update(const std::string& val) {
        std::lock_guard<std::mutex> lock(mu);  // 쓰기도 락
        data = val;
    }
};

이 코드에서 읽기가 1000번, 쓰기가 1번이라도 모든 읽기는 같은 mutex를 기다려야 한다. CPU 시간 낭비.

Read-Write Lock: shared_mutex

C++17부터 std::shared_mutex를 지원한다. 여러 읽기는 동시에, 쓰기는 배타적으로 진행된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <shared_mutex>

class Config {
private:
    mutable std::shared_mutex mu;
    std::string data;

public:
    std::string read() const {
        std::shared_lock<std::shared_mutex> lock(mu);  // 읽기 락
        return data;
    }

    void update(const std::string& val) {
        std::unique_lock<std::shared_mutex> lock(mu);  // 쓰기 락
        data = val;
    }
};

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를 잠그느냐다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main() {
    Config cfg;

    // 읽기 스레드 4개: 동시 진행
    std::vector<std::thread> readers;
    for (int i = 0; i < 4; ++i) {
        readers.emplace_back([&cfg] {
            for (int j = 0; j < 100; ++j) {
                auto val = cfg.read();
            }
        });
    }

    // 쓰기 스레드 1개: 읽기가 없을 때만 진행
    std::thread writer([&cfg] {
        for (int i = 0; i < 10; ++i) {
            cfg.update(std::to_string(i));
        }
    });

    for (auto& t : readers) t.join();
    writer.join();
    return 0;
}

성능: 읽기는 병렬화되므로 처리량 증가. 쓰기는 여전히 배타적이지만 빈번하지 않으면 괜찮음.

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처럼, 락 타입 자체를 정책으로 분리해두면 나중에 mutexshared_mutex를 교체하며 벤치마크하기 쉽다.

DCLP (Double-Checked Locking Pattern)

DCLP는 초기화 비용을 줄이려는 패턴이지만, 잘못하면 위험하다.

나쁜 예: Mutex 없는 DCLP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Singleton {
private:
    static Singleton* instance;
    Singleton() {}

public:
    static Singleton* getInstance() {
        if (instance == nullptr) {      // 첫 번째 확인
            instance = new Singleton(); // 초기화
        }
        return instance;
    }
};
Singleton* Singleton::instance = nullptr;

문제: 여러 스레드가 동시에 if를 통과해 new Singleton()을 여러 번 호출한다. 메모리 누수 + undefined behavior.

DCLP with Mutex (초급, 부정확)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
static std::mutex mu;

static Singleton* getInstance() {
    if (instance == nullptr) {           // (1) 첫 번째 확인 (락 없음)
        std::lock_guard<std::mutex> lock(mu);
        if (instance == nullptr) {       // (2) 두 번째 확인 (락 있음)
            instance = new Singleton();
        }
    }
    return instance;
}

이 코드는 C++98에서도, C++11 이후에도 여전히 위험하다. “C++11부터 메모리 모델이 생겼으니 그냥 괜찮아지는 것 아닌가"라고 생각하기 쉽지만, instancestd::atomic이 아닌 평범한 포인터인 이상 표준 버전과 무관하게 데이터 레이스다. 왜냐하면:

  1. (1)의 락 없는 읽기와 (2)의 락 있는 쓰기가 같은 non-atomic 변수 instance에 대한 동시 접근이라, 최소 하나가 쓰기이고 최소 하나가 non-atomic이면 데이터 레이스라는 01장의 정의를 그대로 만족한다 — 이는 표준 버전이 무엇이든 항상 미정의 동작이다.
  2. 설령 이 레이스를 눈감아 주더라도, CPU나 컴파일러가 생성자 코드를 instance 대입 이후로 재정렬할 수 있어 다른 스레드가 “생성이 덜 끝난” 객체를 볼 수 있다(메모리 배리어 부족).

C++11이 실제로 바꾼 것은 “이 문제를 고칠 도구(std::atomicmemory_order)가 표준에 생겼다"는 것이지, “이 코드가 저절로 안전해졌다"는 것이 아니다.

올바른 DCLP (C++11 이상)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
static std::atomic<Singleton*> instance(nullptr);
static std::mutex mu;

static Singleton* getInstance() {
    Singleton* ptr = instance.load(std::memory_order_acquire);
    if (ptr == nullptr) {
        std::lock_guard<std::mutex> lock(mu);
        ptr = instance.load(std::memory_order_acquire);
        if (ptr == nullptr) {
            ptr = new Singleton();
            instance.store(ptr, std::memory_order_release);
        }
    }
    return ptr;
}

왜 이게 안전한가?:

  • acquire 로드와 release 저장이 happens-before을 만든다. 별도로 “기다리는” 로직은 없다 — ptrnullptr이 아닌 값으로 관찰되는 바로 그 순간, release 저장 이전에 있었던 생성자의 모든 쓰기가 이미 이 스레드에 보이도록 acquire-release 쌍이 보장한다.
  • 즉 “아직 생성 중인 객체를 가리키는 포인터"를 보는 일 자체가 없다 — 포인터가 non-null로 보이면 그 객체는 이미 완전히 생성된 상태다.

안전성 검증: ThreadSanitizer로 DCLP 비교

“DCLP with Mutex (초급, 부정확)” 버전과 “올바른 DCLP” 버전을 각각 빌드해 TSAN으로 비교하면 차이가 드러난다.

1
2
3
4
5
6
7
g++ -std=c++20 -pthread -fsanitize=thread -g dclp_broken.cpp -o dclp_broken
./dclp_broken
# WARNING: ThreadSanitizer: data race on instance (또는 heap-use-after-free 가능)

g++ -std=c++20 -pthread -fsanitize=thread -g dclp_fixed.cpp -o dclp_fixed
./dclp_fixed
# 경고 없음

부정확한 버전이 실제 환경(x86)에서는 “우연히” 잘 동작하는 경우가 많다는 점이 더 위험하다. x86은 store-store, load-load 순서를 비교적 강하게 보장하기 때문에 재정렬로 인한 문제가 드물게 발생한다. 하지만 ARM 기반 서버나 모바일 환경, 또는 컴파일러가 더 적극적으로 최적화하는 빌드 옵션(-O3, LTO)에서는 같은 코드가 실패할 수 있다. TSAN 경고가 없다는 것은 “이번 실행에서 못 봤다"는 뜻이지 “안전하다"는 증명이 아니다 — 따라서 memory_order를 직접 다루는 코드는 표준이 보장하는 패턴(call_once, 정적 지연 초기화)으로 대체할 수 있는지부터 검토해야 한다.

하지만 더 좋은 방법이 있다.

std::call_once와 std::once_flag

C++11부터 call_once로 초기화를 간단하고 안전하게 한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Singleton {
private:
    static std::once_flag initFlag;
    static Singleton* instance;
    
    Singleton() {}

public:
    static Singleton* getInstance() {
        std::call_once(initFlag, [] {
            instance = new Singleton();
        });
        return instance;
    }
};

std::once_flag Singleton::initFlag;
Singleton* Singleton::instance = nullptr;

장점:

  1. DCLP의 모든 복잡성을 없앤다.
  2. 예외 안전성 제공.
  3. 표준이 보장하는 안전성.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int main() {
    std::vector<std::thread> workers;
    for (int i = 0; i < 10; ++i) {
        workers.emplace_back([] {
            auto s = Singleton::getInstance();
            // ...
        });
    }
    for (auto& t : workers) t.join();
    return 0;
}

생성자는 정확히 한 번만 호출된다.

Lazy Initialization 패턴 변형

1. Per-Object Initialization

지금까지 본 Singleton의 DCLP는 전역으로 하나뿐인 인스턴스를 지연 생성하는 경우였다. 하지만 실무에서는 “객체마다 하나씩, 필요할 때만 만드는” 리소스가 더 흔하다 — 예를 들어 각 커넥션 객체가 자신만의 캐시나 버퍼를 첫 사용 시점에만 할당하는 경우다. 이때는 정적 변수 하나로 해결할 수 없고, 객체마다 자신의 DCLP 상태(ptr, initMu)를 멤버로 들고 있어야 한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <atomic>
#include <mutex>

void* allocateResource();  // 실제 리소스를 할당하는 함수

class Resource {
private:
    std::atomic<void*> ptr{nullptr};  // 괄호 초기화(ptr(nullptr))는 멤버 선언에서 사용 불가, 중괄호 사용
    mutable std::mutex initMu;

public:
    void* get() {
        void* p = ptr.load(std::memory_order_acquire);
        if (p == nullptr) {
            std::lock_guard<std::mutex> lock(initMu);
            p = ptr.load(std::memory_order_acquire);
            if (p == nullptr) {
                p = allocateResource();
                ptr.store(p, std::memory_order_release);
            }
        }
        return p;
    }
};

2. Static Initialization (가장 간단)

Per-Object Initialization이 필요 없는 “전역 하나뿐” 상황이라면, DCLP를 손으로 구현할 필요조차 없다. C++11부터 표준은 함수 지역 static 변수의 초기화 자체를 스레드 안전하게 만들도록(이른바 “매직 스태틱”, magic statics) 요구한다 — 여러 스레드가 동시에 이 함수에 처음 진입해도, 컴파일러가 내부적으로 락(또는 이와 동등한 메커니즘)을 삽입해 생성자가 정확히 한 번만 실행되도록 보장한다.

1
2
3
4
5
6
7
class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance;  // C++11: 스레드 안전한 정적 초기화
        return instance;
    }
};

이것이 가장 간단하고 표준이 보장한다. (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