본문 바로가기

뮤텍스와 락

공유 상태의 경쟁 조건을 재현하고 mutex와 RAII 락으로 임계 영역을 보호하며 데드락을 예방합니다.

앞 절에서 std::thread를 사용하여 여러 실행 흐름(스레드)을 생성하고 관리하는 기본적인 방법을 학습했습니다.

이제 프로그램이 여러 스레드를 통해 동시에 실행될 때 발생하는 가장 흔하고 위험한 문제 중 하나인 경쟁 조건(Race Condition)과 이를 해결하기 위한 동기화(Synchronization) 메커니즘인 뮤텍스(Mutex) 및 락(Lock)에 대해 심층적으로 다루겠습니다.

동일한 mutex가 공유 invariant의 모든 접근을 직렬화한다

A가 먼저 획득하는 한 가지 가능한 lock→불변식 갱신→unlock→B 획득 순서를 선택해 소유자·공유 상태 접근을 따라갈 수 있다.

C++ · MUTEX CONTRACT

같은 mutex로 공유 상태의 접근을 직렬화한다

data race는 결과가 가끔 틀리는 수준이 아니라 undefined behavior다. 읽기와 쓰기를 같은 mutex 계약 아래 두고 스코프를 벗어나거나 스택 풀기가 진행될 때 RAII lock이 잠금을 해제하게 한다.

같은 mutex 아래의 두 스레드 접근A가 먼저 lock한 한 가능한 순서이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 배우를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다. Amutex공유 상태Block불변식갱신unlockA 해제 뒤lock불변식갱신unlock
같은 mutex 아래의 두 스레드 접근A가 먼저 lock한 한 가능한 순서이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 배우를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다.1 · 가능한 한 실행 순서Amutexlock2 · 가능한 한 실행 순서mutex공유 상태불변식갱신3 · 가능한 한 실행 순서Amutexunlock4 · 가능한 한 실행 순서BmutexA 해제 뒤lock5 · 가능한 한 실행 순서mutex공유 상태불변식갱신6 · 가능한 한 실행 순서Bmutexunlock
  1. 공유 invariant 정의

    서로 함께 참이어야 하는 필드와 연산 범위를 먼저 정합니다.

  2. 같은 mutex 사용

    모든 읽기·쓰기가 동일한 잠금 규칙을 따라야 합니다.

  3. RAII lock

    lock_guard 또는 scoped_lock이 범위를 벗어날 때 자동으로 해제합니다.

  4. 짧은 임계 영역

    sleep, I/O, 긴 계산은 가능하면 잠금 밖으로 옮깁니다.

volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 한 가능한 순서이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명A가 먼저 lock한 가능한 순서 하나이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 참여자를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다.
노드·연결선 라벨1 · 가능한 실행 순서 하나
노드·연결선 라벨2 · 가능한 실행 순서 하나
노드·연결선 라벨3 · 가능한 실행 순서 하나
노드·연결선 라벨4 · 가능한 실행 순서 하나
노드·연결선 라벨5 · 가능한 실행 순서 하나
노드·연결선 라벨6 · 가능한 실행 순서 하나
본문·주석volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 가능한 순서 하나이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A의 lock

가능한 실행 하나에서 A가 먼저 mutex를 얻는다.

  • 소유자: A
  • B: 같은 lock 대기
  • 상호 배제는 같은 임계 구역에 한 소유자만 허용한다.
A의 갱신과 unlock

A가 보호한 상태를 갱신한 뒤 잠금을 푼다.

  • 접근: A의 임계 구역
  • 상태: 불변식 갱신 완료
  • 보호 데이터의 갱신을 마친 뒤 다음 소유자가 접근할 수 있게 한다.
B의 lock

B가 이후 잠금을 얻어 상태를 다룬다.

  • 소유자: B
  • A: 잠금 없음
  • 획득 순서는 이 사례의 한 가능성이지 항상 A가 먼저라는 보장은 아니다.

동일한 mutex가 공유 invariant의 모든 접근을 직렬화한다

A가 먼저 획득하는 한 가지 가능한 lock→불변식 갱신→unlock→B 획득 순서를 선택해 소유자·공유 상태 접근을 따라갈 수 있다.

C++ · MUTEX CONTRACT

같은 mutex로 공유 상태의 접근을 직렬화한다

data race는 결과가 가끔 틀리는 수준이 아니라 undefined behavior다. 읽기와 쓰기를 같은 mutex 계약 아래 두고 스코프를 벗어나거나 스택 풀기가 진행될 때 RAII lock이 잠금을 해제하게 한다.

같은 mutex 아래의 두 스레드 접근A가 먼저 lock한 한 가능한 순서이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 배우를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다. Amutex공유 상태Block불변식갱신unlockA 해제 뒤lock불변식갱신unlock
같은 mutex 아래의 두 스레드 접근A가 먼저 lock한 한 가능한 순서이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 배우를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다.1 · 가능한 한 실행 순서Amutexlock2 · 가능한 한 실행 순서mutex공유 상태불변식갱신3 · 가능한 한 실행 순서Amutexunlock4 · 가능한 한 실행 순서BmutexA 해제 뒤lock5 · 가능한 한 실행 순서mutex공유 상태불변식갱신6 · 가능한 한 실행 순서Bmutexunlock
  1. 공유 invariant 정의

    서로 함께 참이어야 하는 필드와 연산 범위를 먼저 정합니다.

  2. 같은 mutex 사용

    모든 읽기·쓰기가 동일한 잠금 규칙을 따라야 합니다.

  3. RAII lock

    lock_guard 또는 scoped_lock이 범위를 벗어날 때 자동으로 해제합니다.

  4. 짧은 임계 영역

    sleep, I/O, 긴 계산은 가능하면 잠금 밖으로 옮깁니다.

volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 한 가능한 순서이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명A가 먼저 lock한 가능한 순서 하나이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 참여자를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다.
노드·연결선 라벨1 · 가능한 실행 순서 하나
노드·연결선 라벨2 · 가능한 실행 순서 하나
노드·연결선 라벨3 · 가능한 실행 순서 하나
노드·연결선 라벨4 · 가능한 실행 순서 하나
노드·연결선 라벨5 · 가능한 실행 순서 하나
노드·연결선 라벨6 · 가능한 실행 순서 하나
본문·주석volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 가능한 순서 하나이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A의 lock

가능한 실행 하나에서 A가 먼저 mutex를 얻는다.

  • 소유자: A
  • B: 같은 lock 대기
  • 상호 배제는 같은 임계 구역에 한 소유자만 허용한다.
A의 갱신과 unlock

A가 보호한 상태를 갱신한 뒤 잠금을 푼다.

  • 접근: A의 임계 구역
  • 상태: 불변식 갱신 완료
  • 보호 데이터의 갱신을 마친 뒤 다음 소유자가 접근할 수 있게 한다.
B의 lock

B가 이후 잠금을 얻어 상태를 다룬다.

  • 소유자: B
  • A: 잠금 없음
  • 획득 순서는 이 사례의 한 가능성이지 항상 A가 먼저라는 보장은 아니다.

경쟁 조건 (Race Condition)

경쟁 조건은 여러 스레드가 동시에 공유 자원(변수, 데이터 구조, 파일 등)에 접근하여 변경하려고 할 때 발생하는 문제입니다.

스레드들의 실행 순서가 예측 불가능하기 때문에, 최종 결과가 스레드의 실행 순서에 따라 달라질 수 있습니다.

이는 논리적 오류이며, 디버깅하기 매우 어렵습니다.

여기서 실행 순서에 따라 논리적 결과가 달라지는 넓은 의미의 경쟁 조건과 C++의 data race를 구별합니다. 같은 메모리 위치에 충돌하는 접근이 있고, 그중 하나 이상이 쓰기이며, 필요한 원자성·동기화 순서가 없다면 data race가 생길 수 있습니다. 두 스레드가 읽기만 한다는 사실과 한 스레드가 쓰고 다른 스레드가 읽는 경우는 같지 않습니다. 아래 비원자 카운터 예제는 정의되지 않은 동작을 설명하는 코드이므로 실행값을 시험의 정답으로 삼지 않습니다.

경쟁 조건 예시
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>

volatile int counter = 0; // 여러 스레드가 공유할 전역 변수 (volatile은 동기화·원자성을 제공하지 않음)

void increment_counter() {
    for (int i = 0; i < 100000; ++i) {
        counter++; // 이 부분에서 경쟁 조건 발생
    }
}

int main() {
    std::cout << "Initial counter value: " << counter << std::endl;

    // 10개의 스레드를 생성하여 counter를 10만 번씩 증가
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment_counter);
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter value: " << counter << std::endl;
    // 예상 값: 10 * 100000 = 1,000,000
    // data race에 따른 정의되지 않은 동작: 출력값을 예측하거나 성공 조건으로 삼지 않음

    return 0;
}

실행 결과 분석: 이 코드는 비원자 변수에 동기화 없이 충돌하는 접근을 하므로 data race이며 정의되지 않은 동작입니다. 1,000,000보다 작은 값은 가능한 직관적 설명일 뿐 보장된 관측 결과가 아닙니다.

왜 그럴까요?

counter++ 연산은 실제로는 여러 단계로 이루어집니다.

counter의 현재 값을 레지스터로 로드.

레지스터의 값을 1 증가.

증가된 값을 다시 counter에 저장.

만약 스레드 A가 counter를 로드한 후 아직 저장하기 전에 스레드 B가 counter를 로드하고 증가시키면, 스레드 A의 증가된 값이 스레드 B의 증가된 값을 덮어쓰게 되어 갱신이 누락될 수 있습니다.

이를 손실된 갱신(Lost Update) 문제라고 합니다.


임계 영역 (Critical Section)

경쟁 조건이 발생하는 코드 영역, 즉 여러 스레드가 동시에 접근해서는 안 되는 공유 자원 접근 코드를 임계 영역(Critical Section)이라고 합니다.

위의 예시에서 counter++가 임계 영역입니다.

임계 영역에는 한 번에 하나의 스레드만 접근하도록 보장해야 합니다.


뮤텍스 (Mutex)

뮤텍스(Mutex)는 Mutual Exclusion(상호 배제)의 약자로, 임계 영역에 대한 접근을 제어하여 경쟁 조건을 방지하는 가장 기본적인 동기화 도구입니다.

뮤텍스는 잠금(lock)과 해제(unlock) 기능을 제공합니다.

작동 방식

스레드가 임계 영역에 진입하기 전에 뮤텍스를 잠급니다(lock).

뮤텍스가 이미 잠겨 있다면, 해당 스레드는 뮤텍스가 해제될 때까지 대기합니다.

스레드가 임계 영역 작업을 마친 후 뮤텍스를 해제합니다(unlock).

C++ 표준 라이브러리는 <mutex> 헤더에 std::mutex를 제공합니다.

std::mutex를 이용한 경쟁 조건 해결
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // std::mutex를 위해

int counter_mutex = 0; // 공유 변수
std::mutex mtx;        // 뮤텍스 객체

void increment_counter_safe() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();    // 임계 영역 진입 전에 뮤텍스 잠금
        counter_mutex++; // 임계 영역 (오직 하나의 스레드만 접근 가능)
        mtx.unlock();  // 임계 영역 벗어난 후 뮤텍스 해제
    }
}

int main() {
    std::cout << "Initial counter_mutex value: " << counter_mutex << std::endl;

    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment_counter_safe);
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter_mutex value: " << counter_mutex << std::endl;
    // 이제 예상 값인 1,000,000이 정확히 출력될 것입니다.

    return 0;
}

이제 counter_mutex의 최종 값은 항상 1,000,000이 될 것입니다.

뮤텍스가 임계 영역에 대한 접근을 직렬화(serialize)하여 한 번에 하나의 스레드만 counter_mutex++ 연산을 수행하도록 보장했기 때문입니다.


락 (Lock) - RAII 기반의 안전한 뮤텍스 관리

앞의 직렬화 그림은 직접 lock()/unlock()을 짝지어 호출하는 대신, RAII 락 객체가 스코프 종료 시점에 자동으로 잠금을 해제하는 흐름을 보여줍니다.

std::mutex의 lock()과 unlock() 호출 방식은 동작 원리를 이해하는 데 중요하지만, 실제 코드에서는 실수에 취약합니다.

만약 lock() 후에 unlock()을 호출하는 것을 잊거나, 임계 영역 내에서 예외가 발생하면 unlock()이 호출되지 않아 뮤텍스가 영구적으로 잠겨 버리는 교착 상태(Deadlock)나 프로그램 정지 상태에 빠질 수 있습니다.

이러한 문제점을 해결하기 위해 C++는 RAII(Resource Acquisition Is Initialization) 원칙을 기반으로 하는 락 클래스들을 제공합니다.

즉시 잠금 생성자를 쓴 락 객체는 생성 시 뮤텍스를 잠그고, 잠금을 소유한 상태로 소멸하면 자동으로 해제합니다. 뒤의 defer_lock처럼 생성 시 잠그지 않는 방식이나 unique_lock의 수동 해제를 사용한 경우에는 현재 소유 상태도 함께 봐야 합니다.

따라서 스코프를 벗어나거나 예외가 발생하여 스택 풀기(Stack Unwinding)가 일어나더라도 뮤텍스가 항상 안전하게 해제됨을 보장합니다.

주요 락 클래스는 다음과 같습니다.

std::lock_guard
  • 가장 기본적인 RAII 락입니다.
  • 생성 시 뮤텍스를 잠그고, 소멸 시 잠금을 해제합니다.
  • 뮤텍스의 소유권을 이전하거나 잠금을 해제할 수 없습니다. (RAII 목적에 충실)
  • 단순한 임계 영역 보호에 가장 적합합니다.
std::unique_lock
  • std::lock_guard보다 훨씬 더 유연합니다.
  • 뮤텍스를 잠그거나 해제하는 시점을 개발자가 제어할 수 있습니다 (lock(), unlock()).
  • 뮤텍스 소유권을 다른 unique_lock 객체로 이동(std::move)할 수 있습니다.
  • std::condition_variable과 함께 사용될 때 필수적입니다.
  • 약간의 오버헤드가 더 있습니다.
std::lock_guard를 이용한 안전한 뮤텍스 관리
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // std::mutex, std::lock_guard를 위해

int counter_lock_guard = 0;
std::mutex mtx_guard;

void increment_counter_lock_guard() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx_guard); // 생성 시 mtx_guard 잠금
        counter_lock_guard++;
        // 스코프를 벗어나면 lock 객체 소멸, 자동으로 mtx_guard 잠금 해제
    }
}

int main() {
    std::cout << "Initial counter_lock_guard value: " << counter_lock_guard << std::endl;

    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment_counter_lock_guard);
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter_lock_guard value: " << counter_lock_guard << std::endl;
    // 항상 1,000,000이 출력될 것입니다.

    return 0;
}

std::lock_guard는 unlock()을 명시적으로 호출할 필요가 없어 코드가 간결하고, 예외 안전성을 보장합니다.

std::unique_lock을 이용한 뮤텍스 관리
#include <chrono>
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx_unique;

void process_data(int id) {
    std::unique_lock<std::mutex> lock(mtx_unique); // 생성 시 잠금
    std::cout << "Thread " << id << " acquired lock.\n";
    // 필요한 경우, 잠금을 일시적으로 해제하고 다른 작업을 수행할 수 있음
    lock.unlock(); // 수동으로 잠금 해제

    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 다른 스레드에게 기회

    lock.lock(); // 다시 잠금
    std::cout << "Thread " << id << " re-acquired lock.\n";
    // 스코프를 벗어나면 lock 객체 소멸, 자동으로 mtx_unique 잠금 해제
}

int main() {
    std::thread t1(process_data, 1);
    std::thread t2(process_data, 2);

    t1.join();
    t2.join();

    return 0;
}

std::unique_lock은 std::condition_variable과 함께 사용될 때 더욱 강력해집니다.

두 필드를 하나의 상태로 읽기

balance = 100과 reserved = 0에 대해 항상 balance >= reserved를 유지한다고 가정합니다. 예약 함수가 같은 뮤텍스 아래에서 두 필드를 바꿔도, 조회 함수가 잠금 없이 두 필드를 읽으면 그 조회까지 보호한 것이 아닙니다. 쓰기 하나와 충돌할 수 있는 읽기를 포함해, 이 불변식에 참여하는 모든 접근이 같은 잠금 계약을 따라야 합니다. 여러 읽기 락을 허용하는 shared_mutex에서도 변경은 배타적으로, 조회는 공유 락 아래에서 수행합니다.

독립적으로 잠금 상태 확인하기

한 스레드가 std::unique_lock<std::mutex> lk(mtx, std::defer_lock)을 만든 뒤 lk.lock(), lk.unlock()을 차례로 호출했다고 하겠습니다. 각 단계에서 lk.owns_lock()은 무엇인가요? 잠금을 해제한 구간에서 공유 상태를 읽어도 되는지, 그 상태를 지역 변수로 복사한 뒤 긴 계산을 하는 경우와 비교하세요. 두 번째 질문으로, A는 M1을 가진 채 M2를 기다리고 B는 M2를 가진 채 M1을 기다린다는 조건에서 같은 전역 획득 순서를 적용해야 하는 시점은 언제인가요?

풀이 확인

지연 생성 뒤 false, lock() 성공 뒤 true, unlock() 뒤 false입니다. 보호받지 않는 공유 상태를 해제 구간에서 직접 읽으면 잠금 계약이 끊깁니다. 필요한 값을 잠금 아래에서 지역 복사한 뒤 그 복사본만 계산하는 방법은 공유 상태 접근과 구별할 수 있습니다. 단, 최신성이나 여러 필드의 일관성이 필요하면 복사 범위도 불변식 전체를 포함해야 합니다. 잠금 순서는 처음 획득하기 전부터 모든 경로가 지켜야 합니다. 이미 서로 기다리는 두 스레드에게 그 규칙을 설명하는 것만으로 기존 잠금이 풀리지는 않습니다.


다양한 종류의 뮤텍스 및 락

C++11/14/17은 다양한 동기화 시나리오를 위한 추가 뮤텍스 및 락을 제공합니다.

  • std::timed_mutex / std::recursive_mutex
    • std::timed_mutex: try_lock_for() (시간 제한), try_lock_until() (특정 시점까지)을 통해 일정 시간 동안만 잠금을 시도할 수 있습니다.
    • std::recursive_mutex: 동일 스레드 내에서 여러 번 잠글 수 있는 뮤텍스입니다. 일반적으로는 권장되지 않으며, 재귀적 잠금이 필요하다면 디자인을 재고하는 것이 좋습니다.
  • std::shared_mutex (C++17)
    • 읽기/쓰기 락(Read-Write Lock) 또는 공유/배타적 락(Shared/Exclusive Lock)이라고도 합니다.
    • 여러 스레드의 동시 읽기 접근을 허용하지만(공유 락), 쓰기 접근은 한 번에 하나의 스레드만 허용합니다(배타적 락).
    • 읽기 작업이 쓰기 작업보다 훨씬 많은 시나리오에서 성능 향상에 도움이 됩니다.
  • std::shared_lock (C++14)
    • std::shared_mutex와 함께 사용하여 공유(읽기) 락을 획득할 때 사용합니다.
std::shared_mutex와 std::shared_lock을 이용한 읽기/쓰기 예시
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <shared_mutex> // std::shared_mutex, std::shared_lock을 위해
#include <chrono>

int shared_data = 0;
std::shared_mutex shared_mtx; // 공유 뮤텍스

void reader(int id) {
    for (int i = 0; i < 5; ++i) {
        std::shared_lock<std::shared_mutex> lock(shared_mtx); // 읽기 락 획득 (여러 스레드 동시 가능)
        std::cout << "Reader " << id << ": " << shared_data << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
}

void writer(int id) {
    for (int i = 0; i < 2; ++i) {
        std::unique_lock<std::shared_mutex> lock(shared_mtx); // 쓰기 락 획득 (단독 접근)
        shared_data++;
        std::cout << "Writer " << id << ": Increment to " << shared_data << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

int main() {
    std::vector<std::thread> readers;
    for (int i = 0; i < 3; ++i) {
        readers.emplace_back(reader, i + 1);
    }

    std::vector<std::thread> writers;
    for (int i = 0; i < 2; ++i) {
        writers.emplace_back(writer, i + 1);
    }

    for (auto& t : readers) t.join();
    for (auto& t : writers) t.join();

    std::cout << "Final shared_data: " << shared_data << std::endl;
    return 0;
}

이 예시에서는 여러 reader 스레드가 동시에 shared_data를 읽을 수 있지만, writer 스레드는 단독으로 접근하여 shared_data를 수정합니다.


데드락 (Deadlock)

C++ · DEADLOCK PREVENTION

순환 대기를 식별하고 획득 전에 예방한다

보유한 mutex와 기다리는 mutex의 관계를 따로 읽는다. 아래 예방 방법은 잠금 획득을 시작하기 전에 적용한다.

두 mutex의 요청과 보유자 순환Thread A는 M1을 보유하고 M2를 기다린다. M2의 보유자는 B이고, B는 M1을 기다리며 그 보유자는 A이다. 스레드에서 자원으로 가는 화살표는 요청·대기, 자원에서 스레드로 가는 화살표는 보유자를 뜻한다. 둘 다 첫 mutex를 놓지 않는 상태에서 순환이 닫힌다.요청 · 대기보유자요청 · 대기보유자Thread AM1 보유M2를 기다림M2Thread B가 보유Thread BM2 보유M1을 기다림M1Thread A가 보유
두 mutex의 요청과 보유자 순환Thread A는 M1을 보유하고 M2를 기다린다. M2의 보유자는 B이고, B는 M1을 기다리며 그 보유자는 A이다. 스레드에서 자원으로 가는 화살표는 요청·대기, 자원에서 스레드로 가는 화살표는 보유자를 뜻한다. 둘 다 첫 mutex를 놓지 않는 상태에서 순환이 닫힌다.요청·대기보유자요청·대기보유자Thread AM1 보유 · M2 대기M2Thread B가 보유Thread BM2 보유 · M1 대기M1Thread A가 보유

순환 대기 · A → M2 → B → M1 → A

A는 M1을 보유하고 M2를 기다린다. M2를 가진 B는 M1을 기다리며 M1은 A가 보유한다. 둘 다 진행하지 못한다.

예방 · C++17 scoped_lock

필요한 mutex를 획득하기 전에 한 객체에 함께 전달한다.

std::scoped_lock lock(mtx1, mtx2);

교착 회피 획득과 스코프 종료 시 RAII 해제를 묶는다. 이미 막힌 스레드를 풀어 주는 연산은 아니다.

본문 대안 · defer_lock + std::lock

락 객체를 잠그지 않고 만든 뒤 교착 회피 알고리즘으로 획득한다.

std::unique_lock<std::mutex> a(
  mtx1, std::defer_lock);
std::unique_lock<std::mutex> b(
  mtx2, std::defer_lock);
std::lock(a, b);

다른 방법은 모든 코드 경로가 같은 전역 잠금 순서를 지키는 것이다.

복수 mutex 획득은 원자적인 동시 획득이 아니다. std::lock은 일부 획득·해제와 재시도를 포함할 수 있다. timeout만으로 livelock·starvation까지 예방하지는 못한다.

그림의 scoped_lock은 C++17에서 복수 mutex 획득과 RAII 해제를 묶는 대안입니다. 아래 코드는 같은 획득 문제를 std::lock과 지연 unique_lock으로 보여줍니다.

데드락(Deadlock)은 두 개 이상의 스레드가 서로가 점유하고 있는 자원을 얻기 위해 영원히 대기하는 상태를 말합니다.

가장 흔한 시나리오는 두 스레드가 서로 다른 뮤텍스를 잠그고, 상대방이 가진 뮤텍스를 기다리는 경우입니다.

데드락 발생 조건

상호 배제 (Mutual Exclusion): 자원은 한 번에 하나의 스레드만 사용할 수 있습니다.

점유 및 대기 (Hold and Wait): 자원을 점유한 상태에서 다른 자원을 기다립니다.

비선점 (No Preemption): 자원을 강제로 뺏을 수 없습니다.

순환 대기 (Circular Wait): 스레드들이 자원을 순환적으로 대기합니다 (A가 B를 기다리고, B가 A를 기다리는 등).

데드락 방지/해결 전략
  • 잠금 순서 일치: 모든 스레드가 동일한 순서로 뮤텍스를 잠그도록 합니다. (가장 효과적)
  • std::lock() 사용: 여러 뮤텍스를 동시에 잠글 때 std::lock(mtx1, mtx2, ...)를 사용하면 교착 회피 알고리즘으로 모든 잠금을 획득해 반환합니다. 내부에서 일부를 획득·해제하며 재시도할 수 있으므로 원자적인 동시 획득은 아닙니다.
  • std::unique_lock + std::defer_lock: unique_lock을 생성하되 바로 잠그지 않고, std::lock()과 함께 사용합니다.
  • 타임아웃 잠금: try_lock_for(), try_lock_until()을 사용하여 일정 시간 동안만 잠금을 시도하고, 실패하면 다른 작업을 수행하거나 재시도합니다.
  • 계층적 잠금: 자원에 우선순위를 부여하여 낮은 우선순위 자원을 잠그기 전에 높은 우선순위 자원을 먼저 잠그도록 합니다.
std::lock()을 이용한 데드락 방지 예시
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtx1;
std::mutex mtx2;

void thread_func1() {
    // std::lock(mtx1, mtx2)는 두 뮤텍스를 동시에 잠그려고 시도하며,
    // 데드락 없이 안전하게 잠금을 획득하거나 실패합니다.
    // unique_lock을 지연 잠금으로 만들고 획득 후 RAII로 해제합니다.
    std::unique_lock<std::mutex> lk1(mtx1, std::defer_lock);
    std::unique_lock<std::mutex> lk2(mtx2, std::defer_lock);

    std::lock(lk1, lk2); // 두 뮤텍스를 동시에 잠금 시도 (데드락 방지)

    std::cout << "Thread 1: Acquired mtx1 and mtx2.\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 작업
    std::cout << "Thread 1: Releasing mtx1 and mtx2.\n";
} // 스코프 벗어나면 lk1, lk2 소멸하며 자동 해제

void thread_func2() {
    std::unique_lock<std::mutex> lk2(mtx2, std::defer_lock);
    std::unique_lock<std::mutex> lk1(mtx1, std::defer_lock);

    std::lock(lk2, lk1); // 두 뮤텍스를 동시에 잠금 시도

    std::cout << "Thread 2: Acquired mtx2 and mtx1.\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 작업
    std::cout << "Thread 2: Releasing mtx2 and mtx1.\n";
} // 스코프 벗어나면 lk2, lk1 소멸하며 자동 해제

int main() {
    std::cout << "Starting threads...\n";
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

    t1.join();
    t2.join();

    std::cout << "Threads finished.\n";
    return 0;
}

예를 들어 std::lock() 대신 첫 스레드는 mtx1→mtx2, 둘째 스레드는 mtx2→mtx1 순서로 잠그면, 각자 첫 뮤텍스를 가진 채 상대가 가진 둘째 뮤텍스를 기다리는 데드락이 생길 수 있습니다. 두 스레드가 같은 전역 순서로 잠그는 경우와 구별해야 하며, 데드락의 발생 시점이나 확률을 보장하는 것은 아닙니다.

이 예제의 std::lock()은 두 락을 데드락 없이 획득합니다. Releasing 출력 시에는 아직 락을 소유하고 있으며, 각 함수의 범위가 끝나 unique_lock 객체가 소멸할 때 뮤텍스를 해제합니다.

뮤텍스와 락은 공유 데이터의 불변식을 지키는 도구입니다.

단일 락은 RAII로 수명을 묶고, 여러 락은 순서를 통일하거나 std::lock()으로 함께 획득하는 습관을 들여야 합니다.