C++ mutex: 경쟁 상태, 데드락, lock_guard·scoped_lock 사용 원칙

💡 핵심 개념: 여러 스레드가 같은 변수(구조체 필드 포함)를 읽고 쓰면, ++ 한 줄이라도 data race가 발생할 수 있습니다. std::mutex + lock_guard로 “같이 만지면 안 되는 구간”을 짧게 감싸라. 카운터 하나만이면 atomic(#7-4)도 후보다.

들어가며: 카운터가 왜 깨졌나

”주문 수가 맞지 않아요” - 공유 변수와 race condition

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

당시 코드:

std::atomic<int> counter{0};  // 나중에 atomic으로 바꿨지만, 당시엔 그냥 int
void processOrder(const Order& order) {
    // ... 주문 처리 ...
    counter++;  // 여러 스레드가 동시에 접근
}

당시 counter가 일반 int였을 때, 여러 스레드가 동시에 counter++를 실행하면 읽기·증가·쓰기가 겹쳐서 일부 증가가 반영되지 않습니다. 나중에 std::atomic<int>로 바꾸면 한 변수에 대한 증가는 락 없이 안전하게 할 수 있습니다. 여러 변수를 한꺼번에 일관되게 바꿀 때는 mutex가 필요합니다. 원인:

  • 당시에는 그냥 int counter였으며, counter++를 여러 스레드가 동시에 실행
  • 읽기-수정-쓰기가 원자적이지 않아 일부 증가가 덮어써짐 (data race)
  • 이벤트 트래픽이 커질수록 오차가 커짐 Java의 synchronized·java.util.concurrent 락과 맥락이 같습니다. Java 멀티스레드 글과 함께 보면 “임계 구역·상호 배제”가 어떻게 다른 문법으로 반복되는지 비교하기 좋습니다. 단일 변수의 순서까지 규정하려면 C++ memory order 쪽이 더 가깝습니다. 이런 공유 데이터 접근을 보호하려면 mutex로 “한 번에 한 스레드만” 접근하도록 하거나, 단순 카운터처럼 하나의 변수만 다룰 때는 atomic을 쓰면 됩니다. mutex는 여러 변수를 한꺼번에 일관되게 바꿀 때, atomic은 단일 변수의 읽기·쓰기·증가 등을 락 없이 안전하게 할 때 적합합니다. 정의를 풀어 쓰면 mutex는 “한 번에 한 스레드만 잠금을 잡으며, 나머지는 그 스레드가 풀 때까지 대기하게 하는 장치”라고 생각하면 됩니다.

해결:

  • 카운터는 atomic으로 바꿨고
  • 그 외 공유 구조체(큐, 맵 등)는 mutex로 한 번에 한 스레드만 접근하도록 잠금 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로 공유 데이터를 보호하는 방법을 다룹니다. 단순한 카운터 하나는 나중에 다룰 std::atomic으로도 보호할 수 있지만, 큐·맵·여러 변수를 한 번에 수정해야 할 때는 “한 번에 한 스레드만 이 구간에 들어가게” 하는 mutex가 맞습니다. 락을 잡은 채로 오래 두면 병목이 되므로, 보호가 필요한 구간만 최소한으로 감싸는 것이 좋습니다.

이 글을 읽으면:

  • race condition과 data race가 무엇인지 구분할 수 있습니다.
  • std::mutex와 std::lock_guard, std::unique_lock으로 크리티컬 섹션(critical section—한 번에 한 스레드만 실행해야 하는 코드 구간)을 보호할 수 있습니다.
  • 데드락(deadlock—두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상태)을 피하는 순서 잠금·std::lock() 패턴을 쓸 수 있습니다.
  • RAII로 락을 잡아 예외 시에도 안전하게 풀 수 있습니다.
  • 자주 발생하는 실수와 성능 비교, 프로덕션 패턴을 활용할 수 있습니다.

Race condition과 data race

Race condition

Race condition(레이스 컨디션, 경쟁 조건)이란, 여러 스레드가 같은 데이터에 접근할 때 실행 순서에 따라 결과가 달라지는 상황입니다. 예를 들면 두 사람이 동시에 같은 통장 잔액을 읽고 각자 입금한 뒤 쓸 때, 한 번의 입금이 반영되지 않을 수 있는 것과 비슷합니다. 논리적 오류를 일으킬 수 있습니다. 예: 두 스레드가 counter를 동시에 증가시킬 때, 각각 “현재 값 읽기 → 1 더하기 → 쓰기”를 하는데, 두 스레드가 같은 값을 읽고 각자 1을 더해 쓰면 한 번의 증가가 사라집니다. 기대값은 2인데 1이 나올 수 있습니다.

flowchart LR
  subgraph T1[스레드 1]
    A1[읽기: 0] --> B1[+1] --> C1[쓰기: 1]
  end
  subgraph T2[스레드 2]
    A2[읽기: 0] --> B2[+1] --> C2[쓰기: 1]
  end
  C1 -.->|덮어씀| C2

Data race (C++ 표준 용어)

Data race(데이터 경합)는 한 스레드는 쓰기하고 다른 스레드는 읽기 또는 쓰기를 하며, 그 접근이 동기화되지 않은 경우를 말합니다. C++ 표준상 UB(undefined behavior, 미정의 동작—표준이 결과를 보장하지 않아 예측 불가능한 크래시나 잘못된 값이 나올 수 있음)입니다.

  • 동기화 수단: mutex, atomic, condition_variable 등
  • “멀티스레드에서 값이 가끔 틀린다”는 현상의 대부분은 data race 때문입니다.

mutex의 역할

Mutex(Mutual Exclusion)는 한 번에 한 스레드만 지정한 구간(크리티컬 섹션)을 실행하도록 만듭니다. 락을 잡은 스레드만 그 구간에 들어가고, 나머지는 락이 풀릴 때까지 대기합니다. 따라서 공유 변수에 대한 “읽기-수정-쓰기”가 겹치지 않게 됩니다. 실무: mutex를 잡은 채로 오래 두면 다른 스레드가 모두 대기하므로 처리량이 떨어집니다. 공유 데이터를 읽고 수정하는 부분만 최소한으로 감싸고, I/O나 네트워크 대기는 락 밖에서 하세요. lock_guard나 unique_lock을 쓰면 예외 시에도 소멸자에서 락이 풀립니다.


재고, 로그 버퍼, 작업 큐에서 실제로 깨지는 사례

시나리오 1: 재고 감소 버그

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

// ❌ 문제 코드: 재고가 음수로 떨어질 수 있음
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로 조건 검사와 수정을 원자적으로 묶습니다.

// ✅ 해결: mutex로 검사와 수정을 한 번에
std::mutex stock_mtx;
int stock = 100;
bool purchase(int quantity) {
    std::lock_guard<std::mutex> lock(stock_mtx);
    if (stock >= quantity) {
        stock -= quantity;
        return true;
    }
    return false;
}

시나리오 2: 로그 버퍼 손상

여러 스레드가 공유 로그 버퍼에 동시에 쓰다가, 로그 메시지가 섞이거나 버퍼가 손상되는 문제가 있었습니다.

// ❌ 문제 코드: 로그가 섞이거나 크래시
std::string log_buffer;
void log(const std::string& msg) {
    log_buffer += msg;      // 여러 스레드가 동시에 +=
    log_buffer += "\n";     // 메모리 재할당 중 다른 스레드가 접근 → UB
}

원인: std::string::operator+=는 스레드 안전하지 않습니다. 내부적으로 메모리 재할당이 일어날 때 다른 스레드가 동시에 접근하면 data race로 UB가 발생합니다. 해결: mutex로 로그 쓰기를 보호합니다.

// ✅ 해결: mutex로 로그 쓰기 보호
std::mutex log_mtx;
std::string log_buffer;
void log(const std::string& msg) {
    std::lock_guard<std::mutex> lock(log_mtx);
    log_buffer += msg;
    log_buffer += "\n";
}

시나리오 3: 작업 큐에서 중복 처리

작업 큐에서 여러 워커가 pop할 때, 같은 작업을 두 워커가 가져가는 문제가 있었습니다.

// ❌ 문제 코드: 같은 작업을 두 스레드가 가져갈 수 있음
std::queue<Task> task_queue;
Task getTask() {
    if (!task_queue.empty()) {   // 스레드 A: empty() → false
        Task t = task_queue.front();  // 스레드 B: 여기서 pop하면?
        task_queue.pop();        // 스레드 A: front와 pop 사이에 B가 끼어듦
        return t;
    }
    return {};
}

원인: empty() 검사와 front()/pop() 사이에 다른 스레드가 끼어들 수 있습니다. front()와 pop()도 분리되어 있어, 두 스레드가 같은 front() 결과를 보고 각자 pop()할 수 있습니다. 해결: mutex로 전체 접근을 보호하며, condition_variable으로 “데이터가 있을 때만” 깨우는 패턴을 사용합니다.

// ✅ 해결: mutex + condition_variable (다음 글에서 상세)
std::mutex queue_mtx;
std::queue<Task> task_queue;
std::condition_variable cv;
Task getTask() {
    std::unique_lock<std::mutex> lock(queue_mtx);
    cv.wait(lock, [] { return !task_queue.empty(); });
    Task t = std::move(task_queue.front());
    task_queue.pop();
    return t;
}

시나리오 4: 캐시 통계 왜곡

캐시 서버에서 적중률(hit rate)을 실시간으로 집계할 때, hit_count와 miss_count를 별도로 증가시키다가 두 값의 합이 실제 요청 수와 맞지 않는 버그가 발생했습니다.

// ❌ 문제 코드: hit + miss 합이 요청 수와 불일치
int hit_count = 0;
int miss_count = 0;
void recordHit() {
    hit_count++;   // 스레드 A: hit 읽기
}                  // 스레드 B: miss 읽기 → 동시에 쓰기 시 data race
void recordMiss() {
    miss_count++;
}
// 나중에 hit_rate = hit_count / (hit_count + miss_count) 계산 시
// hit_count와 miss_count가 서로 다른 시점에 읽혀 일관성 없음

원인: hit_count와 miss_count를 원자적으로 함께 갱신하지 않아, 집계 시점에 한쪽만 반영된 상태로 읽을 수 있습니다. 또한 단일 변수라도 hit_count++와 miss_count++가 각각 data race를 일으킬 수 있습니다. 해결: mutex로 두 카운터를 한 번에 보호하거나, 집계 시 락을 잡고 일관된 스냅샷을 읽습니다.

// ✅ 해결: mutex로 hit/miss를 함께 보호
std::mutex stats_mtx;
int hit_count = 0;
int miss_count = 0;
void recordHit() {
    std::lock_guard<std::mutex> lock(stats_mtx);
    hit_count++;
}
void recordMiss() {
    std::lock_guard<std::mutex> lock(stats_mtx);
    miss_count++;
}
double getHitRate() {
    std::lock_guard<std::mutex> lock(stats_mtx);
    int total = hit_count + miss_count;
    return total > 0 ? static_cast<double>(hit_count) / total : 0.0;
}

시나리오 5: 설정값 읽기/쓰기 충돌

런타임에 설정을 자주 읽고 가끔 쓰는 서비스에서, 설정 갱신 중에 읽기 스레드가 반쯤 갱신된 맵을 참조해 잘못된 값을 사용하는 문제가 있었습니다.

// ❌ 문제 코드: 쓰기 중 읽기 → 맵 내부 일관성 깨짐
std::map<std::string, std::string> config;
std::string get(const std::string& key) {
    auto it = config.find(key);  // 쓰기 스레드가 insert/erase 중이면?
    return it != config.end() ? it->second : "";
}
void set(const std::string& key, std::string value) {
    config[key] = std::move(value);  // 읽기와 동시에 수정 → UB
}

원인: std::map은 스레드 안전하지 않습니다. 한 스레드가 쓰는 동안 다른 스레드가 읽으면 data race로 UB가 발생합니다. 해결: std::shared_mutex로 읽기는 여러 스레드가 동시에, 쓰기는 단독으로 수행합니다. (아래 shared_mutex 섹션 참고)


std::mutex의 lock, try_lock과 복사 불가 규칙

기본 사용

counter는 두 스레드 t1, t2가 공유하는 변수이므로, counter++만 하면 “읽기 → 증가 → 쓰기”가 겹쳐서 data race가 납니다. counter_mutex.lock()으로 진입한 스레드만 counter++를 하고 unlock()으로 락을 풀면, 다른 스레드는 lock()에서 대기했다가 차례대로 한 번씩만 증가시킬 수 있어 최종적으로 counter == 200000이 보장됩니다. 다만 lock()과 unlock() 사이에서 예외가 나거나 return하면 unlock()이 호출되지 않아 데드락이 되므로, 실무에서는 아래에서 보듯 lock_guard로 RAII 방식으로 잡는 것이 안전합니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -pthread -o mutex_safe mutex_safe.cpp && ./mutex_safe
#include <mutex>
#include <thread>
#include <iostream>
int counter = 0;
std::mutex counter_mutex;
void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        counter_mutex.lock();
        counter++;
        counter_mutex.unlock();
    }
}
int main() {
    std::thread t1(safeIncrement);
    std::thread t2(safeIncrement);
    t1.join();
    t2.join();
    std::cout << "counter=" << counter << "\n";  // 200000
    return 0;
}

counter_mutex.lock()으로 진입한 스레드만 counter++를 하고 unlock()으로 락을 풉니다. 다른 스레드는 lock()에서 대기했다가 차례대로 한 번씩만 증가시키므로 최종값이 200000으로 맞습니다. 다만 lock과 unlock 사이에서 예외나 return이 나면 unlock이 호출되지 않아 데드락이 되므로, 실무에서는 lock_guard를 쓰는 편이 안전합니다. 실행 결과: counter=200000 이 출력됩니다.

  • lock(): 락을 잡을 때까지 대기. 잡으면 그 스레드만 크리티컬 섹션 진입.
  • unlock(): 락을 풀어 다른 스레드가 들어올 수 있게 함. 주의: 중간에 예외가 나거나 return을 해버리면 unlock()이 호출되지 않아 데드락이 됩니다. 따라서 보통은 RAII(Resource Acquisition Is Initialization, 리소스 획득은 초기화)로 락을 잡습니다.

std::mutex의 주요 메서드

메서드설명
lock()락 획득. 이미 다른 스레드가 잡고 있으면 대기. 같은 스레드가 두 번 잡으면 데드락(비재진입).
unlock()락 해제. lock()을 호출한 스레드만 호출 가능.
try_lock()락 시도. 잡으면 true, 못 잡으면 즉시 false 반환(대기 안 함).
// try_lock 예: 락을 못 잡으면 다른 작업 수행
std::mutex mtx;
void maybeUpdate() {
    if (mtx.try_lock()) {
        // 락 획득 성공 → 크리티컬 섹션 실행
        // ... 작업 ...
        mtx.unlock();
    } else {
        // 락 획득 실패 → 스킵하거나 다른 경로로 처리
    }
}

주의: try_lock()을 쓸 때는 반드시 성공 시 unlock()을 호출해야 합니다. 이 부분도 RAII로 처리하는 것이 안전합니다.

std::mutex의 복사/이동

  • std::mutex는 복사 불가, 이동 불가입니다.
  • 뮤텍스는 “잠금 상태”를 가진 리소스이므로, 복사나 이동 시 의미가 불명확해 표준에서 막아 두었습니다.
  • 여러 객체가 같은 뮤텍스를 공유하려면 포인터나 참조로 전달합니다.
// ✅ 포인터/참조로 공유
std::mutex g_mtx;
void worker(std::mutex& mtx) {
    std::lock_guard<std::mutex> lock(mtx);
    // ...
}
// std::thread t(worker, std::ref(g_mtx));

lock_guard와 unique_lock: RAII로 락 잡기

RAII란

RAII(Resource Acquisition Is Initialization)는 C++에서 리소스(메모리, 파일, 락 등)를 생성 시 획득하고 소멸 시 해제하는 패턴입니다. RAII 글에서 자세히 다룹니다. 예외가 나거나 early return을 해도 소멸자가 호출되므로, 락을 빼먹는 실수를 막을 수 있습니다.

std::lock_guard

생성 시 락을 잡으며, 소멸 시 자동으로 풀어줍니다. 예외가 나도 소멸자가 호출되므로 안전합니다.

#include <mutex>
std::mutex mtx;
int shared_value = 0;
void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        shared_value++;
        // lock 소멸 시 자동으로 mtx.unlock()
    }
}

std::lock_guard<std::mutex> lock(mtx)는 생성 시 mtx.lock()을 호출하며, 스코프를 벗어날 때 소멸자에서 mtx.unlock()을 호출합니다. 예외가 나거나 return해도 소멸자가 불리므로 락이 풀리고, 수동 unlock을 빼먹어 데드락이 나는 일을 막을 수 있습니다.

  • lock_guard는 수동 unlock이 없음. 스코프를 벗어날 때만 풀림.
  • 락을 잡은 채로 오래 두면 성능이 떨어지므로, 보호가 필요한 최소 구간만 감싸는 것이 좋습니다. 실행 예: g++ -std=c++17 -pthread -o demo demo.cpp && ./demo — 두 스레드가 각 10만 번 lock_guard로 증가하면 counter=200000이 보장됩니다.

std::lock_guard의 adopt_lock

이미 lock()으로 락을 잡은 상태에서, 소멸 시 unlock()만 해주고 싶을 때 std::adopt_lock을 사용합니다.

mtx.lock();
// ... 어떤 이유로 이미 락을 잡은 상태 ...
{
    std::lock_guard<std::mutex> lock(mtx, std::adopt_lock);
    // 락을 다시 잡지 않으며, 소멸 시 unlock만 수행
}

std::unique_lock

lock_guard보다 유연합니다. 수동으로 lock/unlock 할 수 있으며, std::condition_variable과 함께 쓸 때도 필요합니다.

std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
// ... 크리티컬 섹션 ...
lock.unlock();  // 필요하면 일찍 풀기
// ... 락 없이 다른 작업 (I/O 대기 등) ...
lock.lock();    // 다시 잡기 가능

unique_lock은 생성 시 락을 잡으며, 중간에 lock.unlock()으로 풀었다가 lock.lock()으로 다시 잡을 수 있습니다. 락이 필요 없는 구간(예: I/O 대기)에서는 unlock 해 두면 다른 스레드가 진입할 수 있어 병목을 줄일 수 있으며, condition_variable과 함께 쓸 때도 unique_lock이 필요합니다.

  • defer_lock으로 생성한 뒤 나중에 lock() 호출하는 패턴도 자주 씁니다.
  • 다음 글의 condition_variable에서는 반드시 unique_lock을 사용합니다.

unique_lock의 정책

정책설명
(기본)생성 시 즉시 lock()
std::defer_lock생성 시 lock 안 함. 나중에 lock() 호출
std::try_to_locktry_lock() 시도. 실패 시 락 없이 생성
std::adopt_lock이미 락을 잡은 상태. 소멸 시 unlock()만
// defer_lock: std::lock()과 함께 사용
std::unique_lock<std::mutex> lock1(m1, std::defer_lock);
std::unique_lock<std::mutex> lock2(m2, std::defer_lock);
std::lock(lock1, lock2);  // 데드락 없이 둘 다 잡음

unique_lock 완전 예제: 중간 unlock으로 I/O 분리

// unique_lock으로 락 범위를 세밀하게 제어
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
std::string shared_data;
void processWithIO() {
    std::unique_lock<std::mutex> lock(mtx);
    // 1. 공유 데이터 읽기
    std::string local_copy = shared_data;
    lock.unlock();  // 2. I/O 전에 락 해제 → 다른 스레드가 접근 가능
    // 3. 락 없이 무거운 작업 (파일 I/O, 네트워크 등)
    // simulateIO(local_copy);
    lock.lock();   // 4. 결과 반영을 위해 다시 락
    shared_data = local_copy;
}
int main() {
    std::thread t1(processWithIO);
    std::thread t2(processWithIO);
    t1.join();
    t2.join();
    return 0;
}

핵심: unique_lock은 unlock() 후 lock()으로 다시 잡을 수 있어, I/O 구간에서 락을 풀어 병목을 줄일 수 있습니다.

lock_guard vs unique_lock 선택 가이드

상황권장
구간 보호만 필요lock_guard (가볍고 단순)
condition_variable 사용unique_lock (필수)
중간에 unlock 후 다시 lockunique_lock
여러 뮤텍스 std::lock()으로 한 번에unique_lock + defer_lock

락 순서 통일과 scoped_lock으로 데드락 피하기

데드락: 두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상황. 예: 스레드 A는 mutex1 → mutex2 순서로 잡으며, 스레드 B는 mutex2 → mutex1 순서로 잡으면, A가 1 잡고 2를 기다리는 동안 B가 2 잡고 1을 기다릴 수 있습니다.

sequenceDiagram
  participant A as 스레드 A
  participant B as 스레드 B
  participant M1 as mutex1
  participant M2 as mutex2
  A->>M1: lock()
  B->>M2: lock()
  A->>M2: lock() - 대기
  B->>M1: lock() - 대기
  Note over A,B: 데드락! 서로가 서로를 기다림

해결 1: 락 순서를 통일

항상 같은 순서로 락을 잡습니다. 예: 항상 mutex1 → mutex2.

// 좋은 예: 모든 스레드가 같은 순서 (먼저 m1, 그다음 m2)
std::lock_guard<std::mutex> l1(mutex1);
std::lock_guard<std::mutex> l2(mutex2);

모든 스레드가 항상 mutex1을 먼저 잡으며, 그다음 mutex2를 잡으면, 한 스레드가 1을 잡은 상태에서 2를 기다리는 동안 다른 스레드는 1을 기다리므로 서로가 서로를 기다리는 데드락이 생기지 않습니다. 락 순서를 코드/문서로 통일해 두는 것이 중요합니다. 실무 팁: 뮤텍스에 주소를 기준으로 순서를 정하면, 동적으로 여러 객체의 뮤텍스를 잡을 때도 일관된 순서를 유지할 수 있습니다.

// 주소 기준 순서: 항상 낮은 주소 먼저
void transfer(Account& a, Account& b, int amount) {
    auto* m1 = &a.mtx;
    auto* m2 = &b.mtx;
    if (m1 > m2) std::swap(m1, m2);
    std::lock_guard<std::mutex> l1(*m1);
    std::lock_guard<std::mutex> l2(*m2);
    // ...
}

해결 2: std::lock()으로 한 번에 잡기

여러 뮤텍스를 데드락 없이 한꺼번에 잡고 싶을 때 std::lock()을 사용합니다.

std::mutex m1, m2;
void both() {
    std::unique_lock<std::mutex> lock1(m1, std::defer_lock);
    std::unique_lock<std::mutex> lock2(m2, std::defer_lock);
    std::lock(lock1, lock2);  // 데드락 없이 둘 다 잡음
    // ... 두 락 모두 잡힌 상태 ...
}

std::lock(lock1, lock2)는 두 뮤텍스를 데드락 없이 한꺼번에 잡습니다. 내부적으로 데드락 회피 알고리즘을 사용해 순서와 관계없이 안전하게 잡으며, defer_lock으로 생성한 unique_lock을 넘기면 잡은 뒤 adopt 상태가 되어 소멸 시 자동으로 풀립니다. std::lock(m1, m2, ...)은 내부적으로 데드락 회피 알고리즘을 쓰므로, 순서와 상관없이 안전하게 잡을 수 있습니다. 잡은 뒤에는 lock_guard나 unique_lock으로 adopt해서 소멸 시 자동으로 풀리게 하는 식으로 사용합니다.

해결 2-1: std::scoped_lock (C++17) — 권장

C++17부터 std::scoped_lock은 여러 뮤텍스를 한 번에 데드락 없이 잡으며, RAII로 소멸 시 자동 해제합니다. std::lock + lock_guard를 한 번에 처리하는 편의 클래스입니다.

// C++17: scoped_lock으로 여러 뮤텍스 한 번에
#include <mutex>
std::mutex m1, m2;
void safeOperation() {
    std::scoped_lock lock(m1, m2);  // 데드락 없이 둘 다 잡음, 소멸 시 자동 해제
    // ... 크리티컬 섹션 ...
}

계좌 이체 예: transfer(a,b)와 transfer(b,a)가 동시에 호출되어도 std::scoped_lock으로 데드락 없이 안전합니다. 주소 순서(m1 > m2)로 일관된 락 순서를 유지할 수 있습니다.

해결 3: 락 범위 최소화

락을 잡은 채로 오래 두지 않으면, 데드락에 걸릴 “시간 창”이 줄어듭니다. I/O나 무거운 연산은 락 밖에서 수행합니다.

// ❌ 나쁜 예: 락 안에서 I/O
{
    std::lock_guard<std::mutex> lock(mtx);
    data = compute();
    saveToFile(data);  // 디스크 I/O - 락을 오래 잡음
}
// ✅ 좋은 예: 락은 최소 구간만
Data data;
{
    std::lock_guard<std::mutex> lock(mtx);
    data = shared_data;  // 복사만 하고
}
saveToFile(data);  // 락 밖에서 I/O

lock/unlock 짝 불일치, check-then-act 같은 mutex 실수

실수 1: lock/unlock 쌍을 맞추지 않음

증상: 데드락, 프로그램이 멈춤.

// ❌ 잘못된 예: early return 시 unlock 누락
void process() {
    mtx.lock();
    if (error) return;  // unlock() 호출 안 됨 → 데드락
    doWork();
    mtx.unlock();
}

해결: RAII 사용.

// ✅ 올바른 예: lock_guard 사용
void process() {
    std::lock_guard<std::mutex> lock(mtx);
    if (error) return;  // 소멸자에서 자동 unlock
    doWork();
}

실수 2: 보호할 데이터와 mutex를 분리

증상: 실수로 락 없이 공유 데이터에 접근.

// ❌ 나쁜 예: mtx와 data가 분리
std::mutex mtx;
int global_counter;  // 누군가 mtx 없이 접근할 수 있음
void add() {
    global_counter++;  // 실수로 락 없이!
}

해결: 데이터와 mutex를 한 구조체/클래스에 묶으며, 메서드를 통해서만 접근.

// ✅ 좋은 예: 데이터와 mutex를 함께 캡슐화
struct ThreadSafeCounter {
    std::mutex mtx;
    int value = 0;
    void add(int n) {
        std::lock_guard<std::mutex> lock(mtx);
        value += n;
    }
};

실수 3: 같은 스레드에서 같은 mutex를 두 번 잡음

증상: 데드락. std::mutex는 비재진입(non-reentrant)이므로, 같은 스레드가 이미 잡은 락을 다시 잡으면 자기 자신을 기다리며 멈춥니다.

// ❌ 잘못된 예
std::mutex mtx;
void foo() {
    std::lock_guard<std::mutex> lock(mtx);
    bar();  // bar() 안에서 다시 mtx를 잡으면?
}
void bar() {
    std::lock_guard<std::mutex> lock(mtx);  // 데드락!
}

해결 1: 설계를 바꿔 재진입이 필요 없게 함. (권장)

// ✅ bar는 락을 잡지 않으며, foo에서만 잡음
void bar() {
    // 락 없이 내부 로직만
}
void foo() {
    std::lock_guard<std::mutex> lock(mtx);
    bar();
}

해결 2: 꼭 같은 스레드에서 다시 잡아야 한다면 std::recursive_mutex 사용. (설계 재검토 권장)

std::recursive_mutex mtx;
void bar() {
    std::lock_guard<std::recursive_mutex> lock(mtx);  // 같은 스레드면 OK
}

실수 4: 락 안에서 외부 코드 호출

증상: 데드락 또는 성능 저하. 외부 코드가 다른 락을 잡거나, 콜백에서 다시 같은 락을 잡을 수 있습니다.

// ❌ 위험: 락 안에서 콜백 호출
std::mutex mtx;
void process(Data& data, std::function<void()> callback) {
    std::lock_guard<std::mutex> lock(mtx);
    modify(data);
    callback();  // callback이 다시 process를 호출하면? 또는 다른 락?
}

해결: 락 밖에서 콜백 호출. 필요한 데이터는 락 안에서 복사해 두며, 락을 풀은 뒤 콜백 호출.

// ✅ 안전: 락 밖에서 콜백
void process(Data& data, std::function<void()> callback) {
    {
        std::lock_guard<std::mutex> lock(mtx);
        modify(data);
    }
    callback();  // 락 풀린 상태에서 호출
}

실수 5: 조건 검사와 수정 분리 (check-then-act)

증상: race condition. “검사”와 “수정” 사이에 다른 스레드가 끼어듦.

// ❌ 잘못된 예
if (!queue.empty()) {
    // 여기서 다른 스레드가 pop할 수 있음
    auto item = queue.front();
    queue.pop();
}

해결: mutex로 검사와 수정을 한 구간에 묶기.

// ✅ 올바른 예
std::lock_guard<std::mutex> lock(mtx);
if (!queue.empty()) {
    auto item = std::move(queue.front());
    queue.pop();
}

실수 6: try_lock 성공 시 unlock 누락

try_lock()으로 락을 잡은 뒤 unlock()을 호출하지 않으면 데드락. 해결: std::unique_lock<std::mutex> lock(mtx, std::try_to_lock)으로 RAII 사용.

실수 7: 반환값으로 공유 데이터 참조 전달

락을 풀은 뒤 반환된 참조로 접근하면 data race. 해결: 복사로 반환하거나 forEach처럼 접근 메서드를 제공.

실수 8: 뮤텍스와 데이터 수명 불일치

뮤텍스가 먼저 소멸되면 락 없이 접근 가능. 해결: 뮤텍스를 먼저 선언해 데이터보다 나중에 소멸되게 함 (std::mutex mtx; std::string data;).


mutex, atomic, lock-free의 비용 차이

mutex vs atomic vs lock-free

방식적합한 상황장점단점
std::mutex여러 변수, 복잡한 조건, 큐/맵 등구현 단순, 범용적락 오버헤드, 대기 시 컨텍스트 스위치
std::atomic단일 변수(카운터, 플래그)락 없음, 빠름복합 연산은 compare_exchange 등으로 복잡
lock-free극한 성능 필요 시대기 최소화구현 난이도 높음, ABA 문제 등

벤치마크 예시 (참고용)

단순 카운터 1,000,000회 증가를 4스레드로 수행했을 때 대략적인 상대 시간:

방식상대 시간
mutex + lock_guard1.0 (기준)
std::atomic약 0.1~0.2
lock-free (CAS 루프)약 0.15~0.25

해석: 단일 변수만 다룰 때는 atomic이 mutex보다 훨씬 빠릅니다. 여러 변수를 일관되게 수정해야 하면 mutex가 필요하며, 그때는 락 범위를 최소화하는 것이 성능에 중요합니다.

// 단순 카운터: atomic이 적합
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
// 여러 변수: mutex 필요
std::mutex mtx;
int a, b;
{
    std::lock_guard<std::mutex> lock(mtx);
    a++;
    b = a * 2;
}

락 범위와 성능

락을 잡은 구간이 길수록 다른 스레드의 대기 시간이 늘어나 처리량이 떨어집니다.

// ❌ 나쁜 예: 락 범위가 넓음
for (int i = 0; i < N; ++i) {
    std::lock_guard<std::mutex> lock(mtx);
    auto x = fetchFromNetwork();  // 네트워크 I/O를 락 안에서!
    shared_data += x;
}
// ✅ 좋은 예: 락은 최소 구간만
for (int i = 0; i < N; ++i) {
    auto x = fetchFromNetwork();  // 락 밖에서 I/O
    {
        std::lock_guard<std::mutex> lock(mtx);
        shared_data += x;
    }
}

mutex 사용 원칙 요약표

원칙권장
RAII 우선lock()/unlock() 대신 lock_guard, unique_lock, scoped_lock 사용
락 범위 최소화I/O·네트워크·무거운 연산은 락 밖에서 수행
데이터·뮤텍스 캡슐화공유 데이터와 mutex를 한 클래스에 묶고 메서드로만 접근
여러 뮤텍스std::scoped_lock(C++17) 또는 일관된 락 순서
읽기 많음std::shared_mutex + shared_lock 고려
단일 변수std::atomic 우선 검토
락 안에서 외부 호출금지—콜백·가상 함수는 락 밖에서
검사 도구ThreadSanitizer(-fsanitize=thread), Helgrind 활용

스레드 안전 래퍼, shared_mutex, 복사 후 쓰기 패턴

패턴 1: 스레드 안전 래퍼 클래스

공유 데이터를 클래스로 감싸고, 모든 접근을 메서드를 통해서만 하도록 합니다.

template <typename T>
class ThreadSafeQueue {
public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mtx_);
        queue_.push(std::move(value));
    }
    bool try_pop(T& value) {
        std::lock_guard<std::mutex> lock(mtx_);
        if (queue_.empty()) return false;
        value = std::move(queue_.front());
        queue_.pop();
        return true;
    }
    bool empty() const {
        std::lock_guard<std::mutex> lock(mtx_);
        return queue_.empty();
    }
private:
    mutable std::mutex mtx_;
    std::queue<T> queue_;
};

주의: empty()와 try_pop()을 따로 호출하는 것은 여전히 race가 있을 수 있습니다. “비어있지 않으면 pop”을 원자적으로 하려면 try_pop 하나로 처리하는 것이 맞습니다.

패턴 2: 읽기/쓰기 락 (shared_mutex)

읽기는 여러 스레드가 동시에, 쓰기는 단독으로 하고 싶을 때 std::shared_mutex(C++17)를 사용합니다.

#include <shared_mutex>
// 타입 정의
class Config {
public:
    std::string get(const std::string& key) const {
        std::shared_lock<std::shared_mutex> lock(mtx_);
        auto it = data_.find(key);
        return it != data_.end() ? it->second : "";
    }
    void set(const std::string& key, std::string value) {
        std::unique_lock<std::shared_mutex> lock(mtx_);
        data_[key] = std::move(value);
    }
private:
    mutable std::shared_mutex mtx_;
    std::map<std::string, std::string> data_;
};
  • shared_lock: 읽기용. 여러 스레드가 동시에 shared_lock 가능.
  • unique_lock: 쓰기용. exclusive. 다른 shared/unique 모두 대기. 읽기/쓰기 락 동작: shared_lock은 여러 스레드가 동시에 읽기 가능, unique_lock은 쓰기 시 단독 접근. 읽기 다수·쓰기 소수일 때 성능에 유리합니다.

패턴 3: 락 없는 읽기 + 쓰기 시 복사

쓰기가 드물고 읽기가 많을 때, 쓰기 시 전체를 복사한 뒤 포인터를 원자적으로 스왑하는 패턴입니다.

class SnapshotCache {
public:
    std::shared_ptr<const Data> get() const {
        return std::atomic_load(&data_);  // 락 없이 읽기
    }
    void update(Data new_data) {
        auto copy = std::make_shared<Data>(std::move(new_data));
        std::atomic_store(&data_, copy);  // 원자적 스왑
    }
private:
    std::shared_ptr<const Data> data_;
};

패턴 4: 모니터링과 데드락 탐지

프로덕션에서는 락 대기 시간을 로깅하거나, 데드락 탐지 도구(ThreadSanitizer, Helgrind)를 활용합니다.

# ThreadSanitizer로 data race 검사 (빌드 시)
g++ -fsanitize=thread -g -O1 -o app app.cpp
# Helgrind로 데드락 검사 (실행 시)
valgrind --tool=helgrind ./app

패턴 5: 스레드 안전 초기화

  • Meyers Singleton: static 지역 변수는 C++11부터 스레드 안전 초기화
  • std::call_once: 한 번만 초기화가 필요할 때 std::call_once + std::once_flag 사용 권장
// Meyers Singleton
static Singleton& instance() { static Singleton inst; return inst; }
// std::call_once
std::once_flag flag;
std::call_once(flag, [] { resource = std::make_unique<HeavyResource>(); });

데이터와 mutex를 묶고 락 구간을 짧게 두기

보호할 데이터와 mutex를 묶어 두기

공유 데이터와 그 데이터를 보호하는 mutex를 한 구조체/클래스에 두면, 락 없이 그 데이터에 접근하는 실수를 줄일 수 있습니다.

struct ThreadSafeCounter {
    int value = 0;
    std::mutex mtx;
    void add(int n) {
        std::lock_guard<std::mutex> lock(mtx);
        value += n;
    }
    int get() {
        std::lock_guard<std::mutex> lock(mtx);
        return value;
    }
};

value와 mtx를 한 구조체에 두면, add와 get에서만 락을 잡고 접근하도록 강제할 수 있어, 실수로 락 없이 value에 접근하는 일을 줄일 수 있습니다. public으로 노출하지 않고 메서드를 통해서만 접근하게 하면 mutex 사용이 일관됩니다.

락 범위를 짧게

락을 잡은 채로 I/O나 무거운 연산을 하면 다른 스레드가 오래 대기합니다. 최소한의 구간만 락을 잡으며, 그 바깥에서는 로컬 복사본으로 작업하는 편이 좋습니다.

재진입(recursive) mutex

같은 스레드가 같은 mutex를 두 번 잡으면 일반 std::mutex에서는 데드락입니다. 같은 스레드에서 한 락을 다시 잡아야 하는 설계라면 std::recursive_mutex를 쓸 수 있지만, 보통은 설계를 바꿔서 재진입이 필요 없게 만드는 것이 더 낫습니다.


mutex 코드 리뷰 체크리스트

mutex 기반 동기화를 적용할 때 확인할 항목:

  • 공유 데이터와 mutex를 한 구조체/클래스에 묶었는가?
  • lock_guard, unique_lock, scoped_lock으로 RAII를 사용하는가?
  • 락 범위를 최소화했는가? (I/O는 락 밖에서)
  • 여러 뮤텍스를 잡을 때 std::scoped_lock 또는 std::lock()을 사용하는가?
  • 읽기 많음 → shared_mutex + shared_lock 사용을 고려했는가?
  • 조건 검사와 수정을 락 안에서 원자적으로 수행하는가?
  • 단일 변수만 다룰 때는 atomic 사용을 고려했는가?
  • 락 안에서 콜백·외부 코드를 호출하지 않는가?

같이 보면 좋은 글


다음 글: C++ 실전 가이드 #7-3: condition_variable에서 mutex만으로는 표현하기 어려운 “조건이 만족될 때까지 기다렸다가 깨우기” 패턴을 다룹니다.


자주 묻는 질문 (FAQ)

Q. 락을 잡은 채로 콜백이나 외부 함수를 호출하면 왜 위험한가요?

A. 호출된 코드가 같은 mutex를 다시 잡으려 하면 자기 자신과 데드락이 되고, 다른 락을 잡으면 다른 스레드와 락 획득 순서가 뒤바뀌어 데드락이 생길 수 있습니다. 외부 코드가 I/O처럼 느린 작업을 하면 그동안 락을 계속 보유해 다른 스레드가 모두 대기하게 됩니다. 락 안에서는 필요한 데이터만 복사해 두고 락을 푼 뒤에 콜백을 호출하는 방식으로, 본문의 락 범위 최소화 원칙을 지키는 것이 안전합니다.

참고 자료