C++ Data Race | "Mutex 대신 Atomic을 써야 하는 상황은?" 면접 단골 질문 정리

C++ Data Race | "Mutex 대신 Atomic을 써야 하는 상황은?" 면접 단골 질문 정리

이 글의 핵심

C++ Data Race에 대한 실전 가이드입니다.

들어가며: “Mutex 대신 Atomic을 써야 하는 상황은 언제인가요?“

7번 시리즈를 면접용으로 압축

C++ 실전 가이드 #7-2: mutex와 동기화, #7-4: atomic과 메모리 모델에서 동기화 기초를 다뤘습니다. 이 글은 면접에서 꼬리물기로 나오는 질문에 대비해, Data Race 정의, Mutex vs Atomic 선택 기준, Deadlock 해결, CAS까지 한 번에 정리합니다.

이 글에서 다루는 것:

  • Data Race (C++ 표준 정의), 동기화가 필요한 이유
  • Mutex: 크리티컬 섹션, 한 번에 한 스레드만
  • Atomic: 단일 변수 원자 연산, 락 없이
  • Mutex vs Atomic — 언제 무엇을 쓸지
  • Deadlock과 해결(순서 잠금, std::lock)
  • CAS(Compare-And-Swap—메모리 값이 예상과 같을 때만 새 값으로 바꾸는 원자 연산. 락 없이 동기화할 때 사용) 개념

이 글을 읽으면:

  • Data race의 정확한 정의와 실제 버그 사례를 이해할 수 있습니다.
  • Mutex와 Atomic의 차이를 구분하고, 상황에 맞게 선택할 수 있습니다.
  • Deadlock을 피하는 실전 패턴을 적용할 수 있습니다.
  • CAS의 개념과 compare_exchange 사용법을 알 수 있습니다.

개념을 잡는 비유

Mutex는 은행 창구 한 줄 서기처럼, 한 번에 한 사람만 공유 자원 앞에 서게 합니다. Atomic은 카운터 한 칸만 원자적으로 바꾸는 창구에 가깝고, 짧은 연산에는 줄을 서지 않아도 됩니다.


목차

  1. Data Race란 무엇인가
  2. 문제 시나리오: 실제로 발생하는 Data Race
  3. Mutex의 역할과 한계
  4. Atomic의 역할과 한계
  5. Mutex vs Atomic: 언제 무엇을 쓸까
  6. Deadlock과 해결법
  7. CAS(Compare-And-Swap) 개요
  8. 자주 발생하는 실수와 해결법
  9. 성능 비교: Mutex vs Atomic
  10. 프로덕션 패턴
  11. 면접 Q&A

1. Data Race란 무엇인가

C++ 표준에서의 정의

  • 한 메모리 위치에 대해, 한 스레드는 쓰기하고 다른 스레드는 읽기 또는 쓰기를 하며, 그 접근들이 동기화되지 않은 경우를 data race라고 합니다.
  • Data race가 있으면 C++ 표준상 undefined behavior입니다. “가끔만 틀린다”는 현상의 많은 원인이 data race입니다.

동기화 수단

  • Mutex: 락을 잡은 스레드만 보호된 구간에 들어갈 수 있게 해서, 공유 메모리 접근이 겹치지 않게 함.
  • Atomic: 해당 변수에 대한 읽기·쓰기·읽기-수정-쓰기를 원자적으로 만들어, 그 변수에 대한 data race를 제거함.
  • Condition variable 등: 대기/알림으로 순서를 맞출 때 사용.

면접에서는 “data race는 동기화 없이 한 스레드는 쓰기, 다른 스레드는 읽기/쓰기를 할 때 발생하고, UB다. Mutex나 Atomic 등으로 제거한다”라고 말할 수 있으면 됩니다.


2. 문제 시나리오: 실제로 발생하는 Data Race

시나리오 1: 카운터가 깨지는 경우

이벤트 당일 주문 처리 서버에서 실시간 주문 수를 여러 워커 스레드가 하나의 카운터에 더하는 구조였습니다. 배치가 끝나고 집계를 보니 실제 DB 건수와 수만 건 차이가 났습니다.

// ❌ 문제 코드: counter++가 원자적이지 않음
#include <thread>
#include <iostream>

int counter = 0;  // 공유 변수

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter++;  // 읽기-수정-쓰기가 분리됨 → data race
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << "\n";  // 200000이 아님!
    return 0;
}

왜 깨지는가? counter++는 실제로 세 단계로 나뉩니다:

  1. 메모리에서 counter 값을 읽기
  2. 그 값에 1을 더하기
  3. 결과를 메모리에 쓰기

두 스레드가 동시에 같은 값을 읽으면, 한 스레드의 증가가 다른 스레드에 의해 덮어써집니다.

sequenceDiagram
    participant T1 as 스레드 1
    participant Mem as 메모리
    participant T2 as 스레드 2
    Mem->>T1: 읽기: 0
    Mem->>T2: 읽기: 0
    T1->>T1: +1 → 1
    T2->>T2: +1 → 1
    T1->>Mem: 쓰기: 1
    T2->>Mem: 쓰기: 1
    Note over Mem: 결과 1 (기대값 2)

시나리오 2: 재고 감소 버그 (check-then-act)

온라인 쇼핑몰에서 여러 사용자가 동시에 같은 상품을 구매할 때, 재고가 음수가 되는 버그가 발생한 사례입니다.

// ❌ 문제 코드: 재고가 음수로 떨어질 수 있음
int stock = 100;

void purchase(int quantity) {
    if (stock >= quantity) {   // 스레드 A: 100 >= 5 → true
        // 여기서 스레드 B가 끼어들어 stock을 읽고 10개 구매
        stock -= quantity;     // 스레드 A: stock = 95, 스레드 B: stock = 85
    }                          // 결과: 15개 팔렸는데 stock은 85? 또는 음수?
}

원인: if 조건 검사와 stock -= quantity 사이에 다른 스레드가 끼어들 수 있어, “검사 시점”과 “수정 시점”이 분리됩니다. check-then-act 패턴의 전형적인 race condition입니다. 이 경우 Mutex로 조건 검사와 수정을 한 덩어리로 보호해야 합니다.

시나리오 3: 플래그와 데이터의 불일치

한 스레드가 데이터를 쓰고 플래그를 설정하는데, 다른 스레드는 플래그만 보고 데이터를 읽습니다. 컴파일러나 CPU가 명령 순서를 바꿀 수 있어 플래그가 먼저 설정되고 데이터는 나중에 쓰일 수 있습니다.

// ❌ 문제 코드: 플래그와 데이터의 순서 보장 없음
bool ready = false;
int data = 0;

void producer() {
    data = 42;      // 1. 데이터 쓰기
    ready = true;   // 2. 플래그 설정 (순서가 바뀔 수 있음!)
}

void consumer() {
    while (!ready)  // 3. 플래그 확인
        ;
    std::cout << data << "\n";  // 4. data가 아직 0일 수 있음
}

해결: std::atomic<bool> readystd::atomic<int> data를 쓰거나, mutex로 두 접근을 묶어야 합니다.

시나리오 4: Double-Checked Locking 실패

싱글톤이나 지연 초기화에서 “락 없이 빠르게 읽기”를 위해 Double-Checked Locking을 쓰다가 잘못 구현한 사례입니다.

// ❌ 문제 코드: DCLP(Double-Checked Locking Problem) - C++11 이전
Singleton* Singleton::instance = nullptr;
std::mutex mtx;

Singleton* Singleton::getInstance() {
    if (instance == nullptr) {           // 1차 검사: 락 없이 (data race!)
        std::lock_guard<std::mutex> lock(mtx);
        if (instance == nullptr) {        // 2차 검사
            instance = new Singleton();   // 쓰기와 생성자 완료 순서가 보장되지 않음
        }
    }
    return instance;  // 다른 스레드가 아직 초기화 중인 객체를 읽을 수 있음
}

원인: instance에 대한 쓰기와 생성자 완료 사이에 메모리 순서가 보장되지 않아, 다른 스레드가 “쓰여진 포인터”를 읽었지만 객체가 아직 완전히 초기화되지 않은 상태를 볼 수 있습니다. C++11 이후에는 std::call_oncestatic 지역 변수로 안전하게 해결합니다.

// ✅ C++11 이후: static 지역 변수 (스레드 안전, 지연 초기화)
Singleton& Singleton::getInstance() {
    static Singleton instance;  // C++11: 스레드 안전하게 한 번만 초기화
    return instance;
}

시나리오 5: 캐시 무효화와 가시성

멀티코어 환경에서 스레드 A가 변수를 수정했는데, 스레드 B가 오래된 캐시 값을 계속 읽는 문제입니다. 동기화 없이는 한 코어의 쓰기가 다른 코어에 즉시 보이지 않을 수 있습니다.

// ❌ 문제 코드: 캐시 일관성 미보장
int shared_value = 0;  // CPU0 캐시에 0, CPU1 캐시에도 0

// 스레드 A (CPU0)
void writer() {
    shared_value = 42;  // CPU0 캐시에만 42
}

// 스레드 B (CPU1)
void reader() {
    std::cout << shared_value << "\n";  // CPU1 캐시의 0을 읽을 수 있음!
}

해결: std::atomic 또는 mutex를 사용하면 적절한 메모리 배리어가 삽입되어, 한 스레드의 쓰기가 다른 스레드에 가시적으로 됩니다.


3. Mutex의 역할과 한계

역할

  • 한 번에 한 스레드만 지정한 구간(크리티컬 섹션)을 실행하게 합니다.
  • 락을 잡은 스레드만 그 구간에 들어가고, 나머지는 락이 풀릴 때까지 대기합니다.
  • 여러 변수를 한꺼번에 수정하거나, 복잡한 조건으로 읽고 쓸 때 적합합니다. (예: 큐에 넣고 빼기, 맵 수정)
sequenceDiagram
    participant T1 as 스레드 1
    participant M as mutex
    participant T2 as 스레드 2
    T1->>M: lock()
    M-->>T1: 획득
    T2->>M: lock()
    Note over T2: 대기
    T1->>M: 크리티컬 섹션 실행
    T1->>M: unlock()
    M-->>T2: 획득
    T2->>M: 크리티컬 섹션 실행

Mutex로 카운터 보호하기

// ✅ Mutex로 data race 제거
#include <mutex>
#include <thread>
#include <iostream>

int counter = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);  // RAII: 자동 unlock
        counter++;
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << "\n";  // 항상 200000
    return 0;
}

코드 설명:

  • std::lock_guard: 생성 시 mtx.lock(), 소멸 시 mtx.unlock()을 자동 호출하는 RAII 래퍼.
  • 크리티컬 섹션은 counter++ 한 줄뿐이므로, 락 범위를 최소화한 좋은 예입니다.
  • 예외가 발생해도 lock_guard 소멸자에서 락이 풀리므로 안전합니다.

Mutex로 여러 변수 일관되게 보호하기

// ✅ 여러 변수를 한 번에 수정할 때는 Mutex가 적합
#include <mutex>
#include <map>
#include <string>

std::map<std::string, int> inventory;
std::mutex inv_mtx;

void addStock(const std::string& item, int quantity) {
    std::lock_guard<std::mutex> lock(inv_mtx);
    inventory[item] += quantity;  // 맵 조회와 수정이 원자적으로
}

bool purchaseIfAvailable(const std::string& item, int quantity) {
    std::lock_guard<std::mutex> lock(inv_mtx);
    auto it = inventory.find(item);
    if (it != inventory.end() && it->second >= quantity) {
        it->second -= quantity;
        return true;
    }
    return false;
}

왜 Atomic이 아닌가? inventorystd::map으로, 여러 노드가 연결된 복잡한 자료구조입니다. “재고 확인”과 “재고 감소”를 한 덩어리로 보호해야 하므로 Mutex가 맞습니다.

unique_lock과 try_lock: 조건부 대기

lock_guard는 생성 시 무조건 락을 잡고, 소멸 시 풉니다. unique_lock은 락을 나중에 풀었다가 다시 잡거나, try_lock으로 “바로 잡을 수 있으면 잡고, 아니면 포기”하는 패턴을 구현할 수 있습니다.

// ✅ unique_lock + try_lock: 조건부 락
#include <mutex>
#include <chrono>
#include <queue>

std::mutex mtx;
std::queue<int> task_queue;

// try_lock: 즉시 반환, 대기 없음 (std::mutex)
bool tryProcessOne(int& task) {
    std::unique_lock<std::mutex> lock(mtx, std::try_to_lock);
    if (!lock.owns_lock())
        return false;  // 락 획득 실패
    if (!task_queue.empty()) {
        task = task_queue.front();
        task_queue.pop();
        return true;
    }
    return false;
}

// timed_mutex: 타임아웃 대기 (try_lock_for)
std::timed_mutex timed_mtx;
std::queue<int> timed_queue;

bool tryGetTaskWithTimeout(int& task, int timeout_ms) {
    std::unique_lock<std::timed_mutex> lock(
        timed_mtx, std::chrono::milliseconds(timeout_ms));
    if (lock.owns_lock() && !timed_queue.empty()) {
        task = timed_queue.front();
        timed_queue.pop();
        return true;
    }
    return false;  // 타임아웃 또는 큐 비어있음
}

lock_guard vs unique_lock:

  • lock_guard: 가볍고 단순. 생성 시 lock, 소멸 시 unlock만 지원.
  • unique_lock: lock(), unlock(), try_lock(), try_lock_for() 등 유연한 제어. condition_variable과 함께 쓸 때 필수.

Mutex 종류 요약

뮤텍스특징사용 상황
std::mutex기본 상호 배제일반적인 크리티컬 섹션
std::recursive_mutex같은 스레드가 여러 번 lock 가능재귀 호출에서 같은 락 필요할 때
std::timed_mutextry_lock_for, try_lock_until 지원타임아웃이 필요한 대기
std::shared_mutex (C++17)읽기(shared) / 쓰기(exclusive) 분리읽기 다수, 쓰기 소수일 때

한계/주의

  • 락을 오래 잡으면 다른 스레드가 대기하는 시간이 길어져 병목이 됩니다. 보호가 필요한 구간만 최소한으로 잡는 것이 좋습니다.
  • 여러 락을 잡을 때 순서가 엉키면 데드락이 발생할 수 있습니다.

4. Atomic의 역할과 한계

역할

  • 단일 변수에 대한 읽기·쓰기·읽기-수정-쓰기(예: fetch_add)를 원자적으로 만듭니다.
  • 락을 걸지 않고 CPU 수준의 원자 연산(또는 락 프리 구조)으로 구현되므로, 단순 카운터·플래그처럼 변수 하나만 보호할 때 Mutex보다 가볍고 경합이 적습니다.

한계

  • 한 변수 단위만 보호합니다. “A와 B를 동시에 바꿔야 한다” 같은 여러 변수의 일관성은 Atomic만으로는 보장하기 어렵고, 그런 경우 Mutex가 적합합니다.
  • 복잡한 조건(if로 읽고 수정하고 쓰기)은 여러 atomic 연산으로 나누면 여전히 race가 생길 수 있어, 그런 구간은 Mutex로 한 번에 감싸는 편이 안전합니다.

Atomic으로 카운터 보호하기

// ✅ Atomic: 락 없이 단일 변수 보호
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter.fetch_add(1);  // 원자적 증가
        // 또는 counter++;  // atomic은 ++도 원자적
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter.load() << "\n";  // 항상 200000
    return 0;
}

코드 설명:

  • std::atomic<int>: 해당 변수의 읽기·쓰기·fetch_add 등이 한 번에 수행됩니다.
  • counter++atomic에서는 원자적으로 동작하므로 fetch_add(1)과 동일합니다.
  • Mutex에 비해 락 대기가 없어 경합이 심한 카운터에서 성능이 좋습니다.

Atomic 플래그로 동기화하기

// ✅ Atomic 플래그: 데이터 준비 완료 신호
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<bool> ready{false};
std::atomic<int> data{0};

void producer() {
    data.store(42);           // 1. 데이터 쓰기
    ready.store(true);        // 2. 플래그 설정 (seq_cst로 순서 보장)
}

void consumer() {
    while (!ready.load())     // 3. 플래그 확인
        ;
    std::cout << data.load() << "\n";  // 4. 항상 42
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

주의: readydata가 모두 atomic이어야 합니다. 하나만 atomic이면 여전히 data race입니다.

Atomic 연산 전체 목록

연산설명예시
load()현재 값 읽기counter.load()
store(val)값 쓰기flag.store(true)
fetch_add(n)n 더하고 이전 값 반환counter.fetch_add(1)
fetch_sub(n)n 빼고 이전 값 반환stock.fetch_sub(qty)
fetch_and(val)비트 AND 후 이전 값 반환mask.fetch_and(0xFF)
fetch_or(val)비트 OR 후 이전 값 반환flags.fetch_or(FLAG_X)
exchange(val)val로 바꾸고 이전 값 반환old = ptr.exchange(new_ptr)
compare_exchange_strong/weakCAS 연산조건부 업데이트
// ✅ Atomic fetch_add/fetch_sub: 단순 카운터
#include <atomic>

std::atomic<int> total_sold{0};
void recordSale(int quantity) {
    total_sold.fetch_add(quantity, std::memory_order_relaxed);
}

// CAS로 "재고 충분할 때만 감소" (조건부 업데이트)
std::atomic<int> stock{100};
bool purchaseIfAvailable(int quantity) {
    int expected = stock.load(std::memory_order_relaxed);
    do {
        if (expected < quantity) return false;
    } while (!stock.compare_exchange_weak(expected, expected - quantity));
    return true;
}

// exchange: 포인터 스왑 (lock-free)
std::atomic<Node*> head{nullptr};
void push(Node* new_node) {
    new_node->next = head.load(std::memory_order_relaxed);
    while (!head.compare_exchange_weak(new_node->next, new_node))
        ;
}

memory_order 요약 (면접 참고)

std::atomic 연산에 memory_order를 지정하면, “다른 변수에 대한 가시성·순서”를 조절할 수 있습니다. 기본값은 seq_cst (순차 일관성)이고, 성능이 중요할 때만 완화합니다.

memory_order용도
seq_cst기본값. 읽기/쓰기 순서가 단일 전체 순서처럼 보임. 가장 안전, 상대적으로 비용 큼.
acquireload에 사용. “이 로드 이후”의 메모리 접근은 “이 로드보다 앞서” 다른 스레드에서 store된 값이 보임. (락 획득)
releasestore에 사용. “이 store 이전”의 메모리 접근이 “이 store 이후” 다른 스레드의 acquire load에 보임. (락 해제)
relaxed동기화 없음. 해당 변수만 원자적. 다른 변수와의 순서 보장 없음. 카운터 등 단순 연산에만.
// relaxed: 카운터 등 단순 연산에만 사용
counter.fetch_add(1, std::memory_order_relaxed);

// acquire-release: 동기화가 필요한 플래그
ready.store(true, std::memory_order_release);
// 다른 스레드
while (!ready.load(std::memory_order_acquire))
    ;

면접에서 “atomic에 memory_order를 쓰는 이유”를 물으면: “기본은 seq_cst인데, acquire/release로 동기화 범위를 줄이거나 relaxed로 순서 보장을 포기해 성능을 낼 수 있다. 다만 relaxed는 논리적 오류가 나기 쉬우므로 단순 카운터 등에만 쓴다” 정도로 답할 수 있습니다.


5. Mutex vs Atomic: 언제 무엇을 쓸까

Mutex를 써야 하는 상황

  • 여러 변수를 한 번에 수정해야 할 때 (예: std::map에 넣고 빼기).
  • 읽기-조건 확인-쓰기가 한 덩어리로 보호돼야 할 때 (예: “값이 0이면 1로 바꾸기”를 원자적으로).
  • 복잡한 자료구조(큐, 리스트, 트리)를 여러 스레드가 같이 수정할 때.

Atomic을 써야 하는 상황

  • 단일 변수(카운터, 플래그, 포인터 하나)만 스레드 안전하게 다룰 때.
  • 락 오버헤드와 경합을 줄이고 싶을 때. (예: 수많은 스레드가 하나의 카운터만 올리는 경우)
flowchart TD
    subgraph 선택["Mutex vs Atomic 선택"]
        A[보호할 대상이?] --> B{단일 변수?}
        B -->|Yes| C{단순 연산?}
        C -->|Yes| D[Atomic]
        C -->|No| E[복잡한 조건?]
        E -->|Yes| F[Mutex]
        B -->|No| F
        D --> G[카운터, 플래그]
        F --> H[맵, 큐, 여러 변수]
    end

한 줄 요약

  • 한 변수만 보호하면 되고, 단순 연산(증가, 플래그 설정)이면 Atomic.
  • 여러 변수·복잡한 로직이면 Mutex.

면접에서 “Mutex 대신 Atomic을 써야 하는 상황은?” → “보호 대상이 단일 변수이고, 단순한 읽기-수정-쓰기(증가, 플래그)만 할 때입니다. 여러 변수를 일관되게 바꿔야 하거나 복잡한 조건이 있으면 Mutex를 써야 합니다.”


6. Deadlock과 해결법

Deadlock이란

  • 두 스레드가 서로가 가진 락을 기다리며 영원히 대기하는 상황입니다.
  • 예: 스레드 1은 락 A를 잡고 락 B를 기다리고, 스레드 2는 락 B를 잡고 락 A를 기다림.
sequenceDiagram
    participant T1 as 스레드 1
    participant A as 락 A
    participant B as 락 B
    participant T2 as 스레드 2
    T1->>A: lock() ✓
    T2->>B: lock() ✓
    T1->>B: lock() ... 대기
    T2->>A: lock() ... 대기
    Note over T1,T2: 데드락! 영원히 대기

해결법 1: 순서 잠금

모든 스레드가 같은 순서로 락을 잡습니다. (예: 항상 A → B) 그러면 “A 잡고 B 기다리기”와 “B 잡고 A 기다리기”가 동시에 발생하지 않아 데드락이 생기지 않습니다.

// ✅ 순서 잠금: 항상 A → B
void thread1() {
    std::lock_guard<std::mutex> lockA(mutexA);
    std::lock_guard<std::mutex> lockB(mutexB);
    // ...
}

void thread2() {
    std::lock_guard<std::mutex> lockA(mutexA);  // 같은 순서!
    std::lock_guard<std::mutex> lockB(mutexB);
    // ...
}

해결법 2: std::lock

두 개 이상의 락을 한꺼번에 잡고 싶을 때 std::lock(mutexA, mutexB) 를 쓰면, 데드락을 피하는 방식으로 두 락을 모두 잡아 줍니다. 그 다음 std::lock_guardadopt_lock을 넘겨 소유권만 넘기면 됩니다.

std::lock(mutexA, mutexB) 는 두 뮤텍스를 동시에 잡되, 다른 스레드가 A→B 또는 B→A 순서로 잡는 상황과 엇갈려 데드락이 나지 않도록 내부적으로 순서를 조정합니다. 이미 잡은 뒤이므로 lock_guard 생성 시 std::adopt_lock 을 넘겨 “이미 잠긴 뮤텍스의 소유권만 가져간다”고 알려 주어, 소멸 시에만 unlock 이 호출되게 합니다.

// 컴파일: g++ -std=c++17 -pthread -o deadlock_avoid deadlock_avoid.cpp && ./deadlock_avoid
#include <mutex>
#include <thread>
#include <iostream>

std::mutex mutexA, mutexB;

void useBothLocks() {
    std::lock(mutexA, mutexB);  // 데드락 없이 둘 다 획득
    std::lock_guard<std::mutex> lockA(mutexA, std::adopt_lock);
    std::lock_guard<std::mutex> lockB(mutexB, std::adopt_lock);
    std::cout << "락 획득 완료\n";
}

int main() {
    std::thread t1(useBothLocks);
    std::thread t2(useBothLocks);
    t1.join();
    t2.join();
    std::cout << "정상 종료\n";
    return 0;
}

실행 결과: 데드락 없이 두 락을 순서대로 획득한 뒤 정상 종료됩니다.


7. CAS(Compare-And-Swap) 개요

개념

  • Compare-And-Swap은 “이 메모리 위치의 값이 예상 값이면 새 값으로 바꾸고, 아니면 바꾸지 않는다”는 원자적 연산입니다. 반환값으로 “바꿨는지 여부” 또는 “바꾸기 전 값”을 알 수 있습니다.
  • Lock-free 자료구조에서 자주 쓰입니다. “값이 A일 때만 B로 바꾼다”를 한 번에 수행해, 락 없이 조건부 업데이트를 할 수 있습니다.

C++에서

  • std::atomiccompare_exchange_strong / compare_exchange_weak 가 CAS에 해당합니다.
  • expected 값을 넣어 두고, 현재 값이 expected와 같으면 desired로 바꾸고 true를 반환하고, 다르면 현재 값을 expected에 써 넣고 false를 반환합니다. (실패 시 expected를 업데이트해 루프에서 다시 시도하는 패턴이 많습니다.)
// CAS로 락 없는 스핀락 구현
#include <atomic>

std::atomic<bool> lock_flag{false};

void lock() {
    bool expected = false;
    while (!lock_flag.compare_exchange_strong(expected, true)) {
        expected = false;  // 실패 시 expected가 현재 값으로 바뀌므로 리셋
    }
}

void unlock() {
    lock_flag.store(false);
}

compare_exchange_strong vs weak:

  • strong: 실패 시 “spurious failure”(가짜 실패)가 없음. 루프에서 한 번만 시도할 때 적합.
  • weak: 일부 플랫폼(특히 ARM)에서 spurious failure가 발생할 수 있음. 루프에서 재시도할 때는 weak가 더 효율적일 수 있어, lock-free 자료구조에서 자주 사용합니다.
// Lock-free 스택의 push (개념 예시)
void push(Node* new_node) {
    new_node->next = head.load(std::memory_order_relaxed);
    while (!head.compare_exchange_weak(new_node->next, new_node,
                                        std::memory_order_release,
                                        std::memory_order_relaxed))
        ;  // CAS 실패 시 new_node->next가 현재 head로 갱신됨
}

면접에서는 “CAS는 ‘같으면 바꾸고, 아니면 바꾸지 않는’ 원자 연산이고, lock-free 구조에서 조건부 업데이트에 쓴다. C++에서는 atomic의 compare_exchange_strong/weak가 그에 해당한다” 정도로 말할 수 있으면 됩니다.


8. 프로덕션 패턴

실무에서 자주 쓰는 스레드 안전 패턴을 정리합니다.

패턴 1: 스레드 안전 싱글톤 (Meyers’ Singleton)

// ✅ C++11: static 지역 변수 - 스레드 안전, 지연 초기화
class Config {
public:
    static Config& instance() {
        static Config cfg;  // 한 번만 초기화, 스레드 안전
        return cfg;
    }
    // 복사/이동 금지
    Config(const Config&) = delete;
    Config& operator=(const Config&) = delete;
private:
    Config() = default;
};

패턴 2: std::call_once로 무거운 초기화

// ✅ 한 번만 실행되는 초기화 (DB 연결, 캐시 워밍 등)
#include <mutex>

std::once_flag init_flag;
Database* db = nullptr;

void initDatabase() {
    std::call_once(init_flag,  {
        db = new Database("connection_string");
        db->connect();
    });
}

패턴 3: 스레드 안전 큐 (Mutex + Condition Variable)

// ✅ 생산자-소비자 패턴: 조건 변수로 대기
#include <mutex>
#include <condition_variable>
#include <queue>

template<typename T>
class ThreadSafeQueue {
    std::queue<T> queue_;
    std::mutex mtx_;
    std::condition_variable cv_;
public:
    void push(T value) {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            queue_.push(std::move(value));
        }
        cv_.notify_one();  // 락 밖에서 알림 (성능)
    }
    bool pop(T& value, int timeout_ms = -1) {
        std::unique_lock<std::mutex> lock(mtx_);
        if (timeout_ms < 0) {
            cv_.wait(lock, [this]{ return !queue_.empty(); });
        } else if (!cv_.wait_for(lock, std::chrono::milliseconds(timeout_ms),
                                 [this]{ return !queue_.empty(); })) {
            return false;  // 타임아웃
        }
        value = std::move(queue_.front());
        queue_.pop();
        return true;
    }
};

패턴 4: 읽기 다수 / 쓰기 소수 — shared_mutex (C++17)

// ✅ 읽기는 동시에, 쓰기는 배타적으로
#include <shared_mutex>
#include <map>
#include <string>

class Cache {
    std::map<std::string, std::string> data_;
    mutable std::shared_mutex mtx_;  // mutable: const 메서드에서 lock
public:
    std::string get(const std::string& key) const {
        std::shared_lock lock(mtx_);  // 읽기 락 (여러 스레드 동시 허용)
        auto it = data_.find(key);
        return it != data_.end() ? it->second : "";
    }
    void set(const std::string& key, const std::string& value) {
        std::unique_lock lock(mtx_);  // 쓰기 락 (배타적)
        data_[key] = value;
    }
};

패턴 5: Per-Thread 카운터 (락 경합 회피)

// ✅ 스레드별 로컬 카운터 + 주기적 합산 (경합 최소화)
#include <atomic>
#include <thread>
#include <vector>

thread_local int64_t local_count = 0;  // 스레드마다 별도
std::atomic<int64_t> global_total{0};

void worker() {
    for (int i = 0; i < 1000000; ++i)
        ++local_count;  // 락 없음
    global_total.fetch_add(local_count, std::memory_order_relaxed);
}

int64_t getTotal() {
    return global_total.load(std::memory_order_relaxed);
}

패턴 6: Graceful Shutdown 플래그

// ✅ 종료 요청 플래그 (atomic)
std::atomic<bool> shutdown_requested{false};

void workerThread() {
    while (!shutdown_requested.load(std::memory_order_acquire)) {
        doWork();
    }
}

void requestShutdown() {
    shutdown_requested.store(true, std::memory_order_release);
}

9. 자주 발생하는 실수와 해결법

실수 1: Atomic 하나만 쓰고 나머지는 일반 변수

// ❌ 잘못된 예: data는 일반 int → data race
std::atomic<bool> ready{false};
int data = 0;  // 일반 변수!

void producer() {
    data = 42;
    ready.store(true);
}

void consumer() {
    while (!ready.load())
        ;
    std::cout << data << "\n";  // data race! 42가 아닐 수 있음
}

해결: datastd::atomic<int>로 바꾸거나, mutex로 두 접근을 묶습니다.

실수 2: “단순해 보이는” 연산을 Atomic으로

// ❌ 잘못된 예: "값이 0이면 1로"를 여러 atomic으로 나누면 race
std::atomic<int> flag{0};

void setIfZero() {
    if (flag.load() == 0) {      // 1. 읽기
        flag.store(1);           // 2. 쓰기 (사이에 다른 스레드가 끼어들 수 있음!)
    }
}

해결: compare_exchange_strong으로 한 번에 처리하거나, mutex로 감쌉니다.

// ✅ compare_exchange로 원자적 "0이면 1로"
void setIfZero() {
    int expected = 0;
    flag.compare_exchange_strong(expected, 1);
}

실수 3: Mutex를 잡은 채로 오래 두기

// ❌ 잘못된 예: I/O를 락 안에서 수행
void processRequest() {
    std::lock_guard<std::mutex> lock(mtx);
    auto data = shared_queue.front();
    shared_queue.pop();
    sendToNetwork(data);  // 네트워크 대기 동안 다른 스레드 전부 블록!
}

해결: 락 범위를 최소화합니다.

// ✅ 락 범위 최소화
void processRequest() {
    Data data;
    {
        std::lock_guard<std::mutex> lock(mtx);
        data = shared_queue.front();
        shared_queue.pop();
    }  // 락 해제
    sendToNetwork(data);  // 락 밖에서 I/O
}

실수 4: 락 순서 불일치

// ❌ 스레드마다 락 순서가 다름 → 데드락
void thread1() {
    std::lock_guard<std::mutex> a(mutexA);
    std::lock_guard<std::mutex> b(mutexB);
}
void thread2() {
    std::lock_guard<std::mutex> b(mutexB);  // B 먼저!
    std::lock_guard<std::mutex> a(mutexA);
}

해결: 모든 스레드에서 동일한 순서(예: 항상 A → B)로 락을 잡습니다.

실수 5: ThreadSanitizer 없이 디버깅

Data race는 재현이 어렵고, “가끔만” 잘못된 결과가 나올 수 있습니다. ThreadSanitizer(TSan)를 사용하면 실행 시 data race를 감지할 수 있습니다.

# ThreadSanitizer로 컴파일 및 실행
g++ -std=c++17 -fsanitize=thread -g -o race_test race_test.cpp
./race_test

TSan이 보고하는 경고를 따라가면 동기화가 누락된 위치를 찾을 수 있습니다. CI 파이프라인에 TSan 빌드를 추가하면 회귀를 방지할 수 있습니다.

실수 6: 재귀 락 없이 같은 뮤텍스를 두 번 잡기

// ❌ 같은 스레드가 이미 잡은 뮤텍스를 다시 lock → 데드락 (표준 mutex)
std::mutex mtx;
void recursiveWork(int n) {
    std::lock_guard<std::mutex> lock(mtx);
    if (n > 0) recursiveWork(n - 1);  // 데드락! 자기 자신이 락을 기다림
}

해결: 재귀 호출에서 같은 락이 필요하면 std::recursive_mutex를 사용합니다.

// ✅ recursive_mutex: 같은 스레드가 여러 번 lock 가능
std::recursive_mutex rec_mtx;
void recursiveWork(int n) {
    std::lock_guard<std::recursive_mutex> lock(rec_mtx);
    if (n > 0) recursiveWork(n - 1);  // OK
}

실수 7: 락 해제 없이 반환

// ❌ lock() 후 early return 시 unlock 누락
void process() {
    mtx.lock();
    if (error_condition) return;  // unlock 안 함 → 데드락
    // ...
    mtx.unlock();
}

해결: lock_guardunique_lock을 사용해 RAII로 자동 해제를 보장합니다.


10. 성능 비교: Mutex vs Atomic

카운터 증가 벤치마크 (개념)

방식8스레드, 100만 회 증가특징
Mutex~50–100ms락 경합 시 대기 발생
Atomic~10–20ms락 없이 CPU 원자 명령
relaxed Atomic~8–15msmemory_order 완화로 추가 최적화

요약: 단일 변수에 대한 단순 연산은 Atomic이 Mutex보다 수 배 빠른 경우가 많습니다. 여러 변수나 복잡한 로직은 Mutex가 맞고, 그때는 락 범위를 최소화하는 것이 성능의 핵심입니다.

주의: Atomic이 “항상” Mutex보다 빠른 것은 아닙니다. 락 경합이 거의 없는 경우(예: 스레드 수가 적고 크리티컬 섹션이 짧을 때) Mutex의 오버헤드가 작을 수 있습니다. 반대로 수십 개 스레드가 하나의 카운터를 동시에 올릴 때는 Atomic이 압도적으로 유리합니다. 실제 부하로 벤치마크한 뒤 선택하는 것이 좋습니다.

성능 최적화 팁

설명
락 범위 최소화lock_guard 범위를 크리티컬 섹션만 감싸도록. I/O, 계산은 락 밖에서.
세분화된 락하나의 큰 락 대신 여러 작은 락으로 나누면 경합 감소. (데드락 주의)
읽기 다수shared_mutex로 읽기는 동시에, 쓰기만 배타적으로.
로컬 버퍼스레드별로 로컬에 모았다가 주기적으로 전역에 합산 (Per-Thread 패턴).
memory_order카운터는 relaxed, 동기화 플래그는 acquire/release로 완화.
캐시 라인 분리alignas(64)로 false sharing 방지 (다음 글 #34-2 참고).
// ❌ 나쁜 예: 락 안에서 무거운 연산
void process() {
    std::lock_guard<std::mutex> lock(mtx);
    auto data = fetchFromQueue();  // 큐 접근만 보호
    auto result = heavyComputation(data);  // 락 안에서 계산 → 병목!
    saveResult(result);
}

// ✅ 좋은 예: 락 범위 최소화
void process() {
    Data data;
    {
        std::lock_guard<std::mutex> lock(mtx);
        data = fetchFromQueue();
    }
    auto result = heavyComputation(data);  // 락 밖에서
    {
        std::lock_guard<std::mutex> lock(mtx);
        saveResult(result);
    }
}

언제 무엇을 쓸지 요약표

상황권장이유
카운터, 참조 카운트Atomic단일 변수, 단순 연산
플래그 (준비 완료 등)Atomic단일 변수
맵/큐/리스트 수정Mutex여러 변수, 복잡한 구조
”0이면 1로” 같은 조건부CAS 또는 Mutex원자적 조건 검사 필요
여러 락 필요std::lock 또는 순서 통일데드락 방지

실전 구현 체크리스트

멀티스레드 코드를 작성할 때 아래 항목을 확인하면 data race와 deadlock을 줄일 수 있습니다.

  • 공유 변수 식별: 여러 스레드가 접근하는 메모리 위치를 모두 파악했는가?
  • 쓰기 여부 확인: 최소 한 스레드가 쓰기하면 동기화가 필요하다.
  • 단일 변수 vs 복합: 카운터·플래그는 Atomic, 맵·큐·여러 변수는 Mutex.
  • 락 범위 최소화: I/O, 네트워크 대기는 락 밖에서 수행하는가?
  • 락 순서 통일: 여러 락을 쓸 때 모든 스레드에서 같은 순서로 잡는가?
  • RAII 사용: lock_guard·unique_lock으로 예외 시에도 락 해제가 보장되는가?
  • Atomic 쌍 확인: 플래그와 데이터를 함께 쓸 때 둘 다 atomic이거나 mutex로 묶었는가?
  • 프로덕션 패턴 적용: 싱글톤은 static 지역 변수, 초기화는 call_once, 읽기 다수는 shared_mutex.
  • ThreadSanitizer: CI에서 -fsanitize=thread 빌드로 data race 회귀 검사.

11. 면접 Q&A

Q: Data race가 뭔가요?

  • “한 메모리 위치에 한 스레드는 쓰기, 다른 스레드는 읽기 또는 쓰기를 하는데, 그 접근이 동기화되지 않은 경우입니다. C++에서는 undefined behavior이고, Mutex나 Atomic 등으로 동기화해 제거합니다.”

Q: Mutex 대신 Atomic을 써야 하는 상황은?

  • “보호할 대상이 단일 변수이고, 단순한 연산(증가, 플래그 설정)만 할 때입니다. 여러 변수를 일관되게 바꿔야 하거나 복잡한 조건이 있으면 Mutex가 맞습니다.”

Q: Deadlock을 어떻게 피하나요?

  • 락을 잡는 순서를 모든 스레드에서 동일하게 하거나, std::lock으로 여러 락을 한 번에 잡아 데드락을 피하는 방식을 씁니다.”

Q: CAS가 뭔가요?

  • “Compare-And-Swap의 약자로, ‘현재 값이 예상 값이면 새 값으로 바꾸고, 아니면 바꾸지 않는’ 원자 연산입니다. Lock-free 구조에서 조건부 업데이트에 쓰고, C++에서는 atomiccompare_exchange_strong/weak가 해당합니다.”

Q: memory_order는 언제 바꾸나요?

  • “기본 seq_cst로 두고, 성능 프로파일링에서 atomic이 병목일 때만 acquire/release나 relaxed를 검토합니다. relaxed는 카운터 등 단순 연산에만 쓰고, 동기화가 필요한 플래그에는 쓰지 않습니다.”

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ Lock-Free 프로그래밍 실전 | CAS·ABA·메모리 순서·고성능 큐 [#34-3]
  • C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
  • C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)

이 글에서 다루는 키워드 (관련 검색어)

data race, mutex atomic, C++ 동기화, 데드락 해결, CAS compare_exchange 등으로 검색하시면 이 글이 도움이 됩니다.

정리

  • Data race: 동기화 없이 한 스레드는 쓰기, 다른 스레드는 읽기/쓰기 → UB. Mutex 또는 Atomic 등으로 제거.
  • Mutex: 여러 변수·복잡한 구간을 한 번에 한 스레드만 실행하게 보호. Atomic: 단일 변수의 원자 연산, 락 없이.
  • Deadlock: 락 순서 통일 또는 std::lock으로 회피.
  • CAS: “같으면 바꾼다” 원자 연산. atomic::compare_exchange_*로 사용.
  • 실수 방지: Atomic은 단일 변수에만, 복잡한 조건은 Mutex 또는 CAS, 락 범위는 최소화.
  • 프로덕션 패턴: 싱글톤(static), call_once, ThreadSafeQueue, shared_mutex, Per-Thread 카운터.

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 동기화 면접: Data Race, Mutex vs Atomic 언제 쓸지, Deadlock 해결, CAS(Compare-And-Swap). 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다. 카운터·플래그는 Atomic, 맵·큐·여러 변수는 Mutex를 기본으로 두고, 성능 프로파일링 후에 최적화합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다. mutex와 동기화, atomic과 메모리 모델을 먼저 읽으면 이해가 빠릅니다.

Q. 더 깊이 공부하려면?

A. cppreferencestd::atomic, std::mutex 문서와 해당 라이브러리 공식 문서를 참고하세요. 메모리 모델과 lock-free 자료구조는 고급 주제이므로, 기본기를 다진 뒤 단계적으로 공부하는 것을 권합니다.

한 줄 요약: Data race는 mutex·atomic으로 막고, 데드락은 lock 순서 통일로 피할 수 있습니다. 다음으로 캐시·메모리 정렬(#34-2)를 읽어보면 좋습니다.

다음 글: [C++ 면접 심화 #34-2] 캐시 히트(Cache Hit)를 높이는 C++ 메모리 정렬과 패딩

이전 글: C++ shared_ptr 순환 참조 완전 정복 (#33-4)


관련 글

  • C++ 캐시 히트(Cache Hit)를 높이는 메모리 정렬과 패딩 | False Sharing 해결
  • C++ Lock-Free 프로그래밍 실전 | CAS·ABA·메모리 순서·고성능 큐 [#34-3]
  • C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
  • C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]
  • C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]