C++ 동시성 · 2편

mutex와 RAII 기반 잠금

여러 스레드가 같은 데이터를 동시에 만지면 왜 망가지는가? race condition을 막는 mutex, 예외에도 안전한 RAII 잠금(lock_guard·unique_lock), 그리고 읽기/쓰기를 구분하는 shared_mutex.

여러 스레드가 같은 데이터를 동시에 만지면 프로그램은 조용히 망가진다. 이 글은 그 문제를 막는 가장 기본 도구인 mutex에서 출발해, C++다운 방식으로 그것을 안전하게 다루는 RAII 잠금, 그리고 읽기/쓰기를 구분하는 shared_mutex까지를 한 흐름으로 정리한다.

왜 mutex가 필요한가 — race condition

다음 코드를 두 스레드가 동시에 실행한다고 하자.

int counter = 0;

void increment() {
    ++counter;  // 위험
}

++counter는 한 줄이지만 기계 입장에서는 “읽고 → 1 더하고 → 쓰는” 세 단계다. 두 스레드가 이 단계들을 엇갈려 실행하면, 둘 다 같은 값을 읽고 같은 값을 써서 증가 한 번이 통째로 사라질 수 있다. 이렇게 여러 스레드의 실행 순서에 따라 결과가 달라지는 문제를 race condition이라 한다.

해결책은 “한 번에 한 스레드만 이 구간에 들어가게” 만드는 것이다. 이 보호 구간을 임계 구역(critical section) 이라 하고, 그걸 강제하는 도구가 mutex다.

mutex의 동작

mutex(Mutual Exclusion, 상호 배제)는 한 번에 하나의 스레드만 잠금을 쥘 수 있는 장치다. 핵심 동작은 lockunlock 둘뿐이다.

#include <mutex>

int counter = 0;
std::mutex mtx;

void increment() {
    mtx.lock();
    ++counter;      // 임계 구역
    mtx.unlock();
}

한 스레드가 lock()을 잡으면, 다른 스레드는 그 스레드가 unlock()할 때까지 lock()에서 멈춰 기다린다. 이렇게 임계 구역을 한 스레드씩만 통과시켜 race condition을 막는다.

직접 lock/unlock의 함정

위 코드에는 깨지기 쉬운 부분이 있다. lock()unlock() 사이에서 예외가 나거나 중간에 return하면, unlock()이 실행되지 않는다.

void process() {
    mtx.lock();
    doSomething();   // 여기서 예외가 던져지면?
    mtx.unlock();    // 이 줄에 영영 도달하지 못한다 → 데드락
}

unlock되지 않은 mutex는 그대로 잠긴 채 남고, 이후 그 mutex를 잡으려는 모든 스레드가 영원히 멈춘다. 정상 경로뿐 아니라 예외·조기 반환 같은 모든 탈출 경로에서 unlock을 보장해야 하는데, 손으로 하면 반드시 어딘가 빠뜨린다.

RAII — 정리를 객체 수명에 묶기

C++의 해법은 RAII(Resource Acquisition Is Initialization, 자원 획득은 초기화다)다. 아이디어는 단순하다. 자원의 수명을 객체의 수명에 묶는다. 생성자에서 자원을 획득하고 소멸자에서 해제하면, 객체가 스코프를 벗어나는 순간 소멸자가 자동으로 호출되어 자원이 확실히 정리된다.

스코프를 어떻게 벗어나든 — 정상 종료든, return이든, 예외든 — 스택이 풀리며 소멸자는 반드시 불린다. 그래서 정리 코드를 빠뜨릴 방법 자체가 없어진다.

mutex에 이걸 적용한 표준 도구가 std::lock_guard다.

void process() {
    std::lock_guard<std::mutex> lock(mtx);  // 생성자: mtx.lock()
    doSomething();                          // 예외가 나도
}                                           // 소멸자: mtx.unlock() 보장

lock_guard는 mutex를 직접 대체하는 게 아니라, mutex를 감싸서 안전하게 여닫아주는 자동 핸들이다. 생성자 인자로 넘긴 mtx를 생성 시 잠그고 소멸 시 푼다.

다른 언어의 try/finally와 목적이 같다.

// Java
lock.lock();
try {
    doSomething();
} finally {
    lock.unlock();   // 쓰는 쪽에서 매번 직접 작성
}

차이는 정리 코드를 누가 쓰느냐다. try/finally는 자원을 쓰는 모든 곳에서 정리 코드를 반복하고, 빠뜨리면 누수가 난다. RAII는 정리 로직이 타입(소멸자) 안에 캡슐화되어 있어 쓰는 쪽이 잊을 수가 없다. 자원이 여러 개여도 객체를 나란히 선언하면 끝이고, 소멸은 선언의 역순으로 자동 처리된다. C++에 finally 키워드가 없는 이유가 이것이다 — 소멸자가 그 일을 대신한다.

RAII는 mutex 전용이 아니다. std::unique_ptr(메모리), std::fstream(파일), std::vector(동적 배열)가 모두 같은 원리로 동작한다.

lock_guard와 unique_lock

RAII 잠금 래퍼는 두 가지가 있다. std::lock_guardstd::unique_lock이다. 둘 다 RAII로 mutex를 관리하지만 유연성과 비용이 다르다.

lock_guard는 단순하다. 생성 시 lock, 소멸 시 unlock, 그게 전부다. 중간에 풀 수도, 소유권을 옮길 수도 없다. 대신 오버헤드가 사실상 없다. 스코프 동안 잠그기만 하면 되는 대부분의 경우엔 이게 정답이다.

unique_lock은 “지금 lock을 쥐고 있는가”라는 상태를 내부에 들고 있어서 더 많은 일을 할 수 있다. 그만큼 그 상태를 관리하는 약간의 비용이 따른다.

수동으로 풀었다 다시 잡기:

std::unique_lock<std::mutex> lock(mtx);
// 임계 구역
lock.unlock();
// 락 없이 처리할 수 있는 무거운 작업
lock.lock();
// 다시 임계 구역

지연 잠금(생성 시점에 잠그지 않기):

std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... 다른 준비 작업 ...
lock.lock();

이 밖에 소유권 이동(move)이 가능해 함수에서 lock을 반환하거나 넘길 수 있고, 무엇보다 condition_variable과 함께 쓰려면 반드시 unique_lock이 필요하다(이 부분은 다음 편에서 다룬다).

선택 기준은 간단하다. 기본은 lock_guard를 쓴다. 중간에 풀어야 하거나, 지연/이동이 필요하거나, 조건 변수를 쓸 때만 unique_lock을 쓴다. 필요 없는데 unique_lock을 쓰면 상태 관리 비용만 더 든다.

여러 mutex를 한꺼번에 잠가야 한다면 C++17의 std::scoped_lock을 쓴다. 데드락을 피하는 순서로 한 번에 안전하게 잠근다.

읽기 쓰기를 구분하기 — shared_mutex

일반 mutex는 읽기든 쓰기든 무조건 한 번에 하나만 통과시킨다. 그런데 읽기만 하는 스레드끼리는 동시에 접근해도 안전하다. 아무도 데이터를 바꾸지 않기 때문이다. 문제는 누군가 쓰는 경우뿐이다.

설정값을 가끔 갱신하고 수많은 스레드가 끊임없이 읽기만 하는 상황을 떠올려 보자. 일반 mutex는 읽기끼리도 불필요하게 줄을 세운다. std::shared_mutex(C++17)는 이 낭비를 없앤다. 두 가지 잠금 모드를 제공한다.

  • 공유 잠금(shared / read lock) — 여러 스레드가 동시에 보유 가능. 읽기 전용.
  • 배타 잠금(exclusive / write lock) — 한 스레드만 보유. 쓰기용. 잡혀 있으면 다른 모든 읽기·쓰기가 대기.

규칙은 “읽기끼리는 공존, 쓰기는 독점”이다.

같은 shared_mutex를 어떤 래퍼로 감싸느냐로 모드가 정해진다. 읽기는 std::shared_lock, 쓰기는 std::unique_lock(또는 lock_guard)이다.

#include <shared_mutex>

std::shared_mutex mtx;
int data = 0;

// 읽기: shared_lock → 여러 스레드 동시 진입 가능
int read() {
    std::shared_lock<std::shared_mutex> lock(mtx);
    return data;
}

// 쓰기: unique_lock → 배타적
void write(int v) {
    std::unique_lock<std::shared_mutex> lock(mtx);
    data = v;
}

shared_lock은 “shared_mutex를 읽기 모드로 잡는 RAII 래퍼”다. 지금까지의 RAII 흐름 그대로, 대상 mutex와 잠금 모드만 달라진 것이다.

다만 공짜가 아니다. shared_mutex는 내부 상태 관리가 복잡해 단일 lock/unlock 비용이 일반 mutex보다 크다. 읽기가 쓰기보다 훨씬 많고 임계 구역이 동시 읽기의 이득을 낼 만큼 충분히 길 때만 이득이다. 읽기/쓰기 비율이 비슷하거나 임계 구역이 아주 짧으면 그냥 일반 mutex가 더 빠른 경우가 많다. 또 구현에 따라 읽기가 계속 들어와 쓰기가 밀리는 writer starvation이 생길 수 있으니 우선순위 정책을 확인해야 할 때가 있다.

정리

  • race condition — 여러 스레드가 공유 데이터를 동시에 만져 결과가 깨지는 문제. mutex로 임계 구역을 한 스레드씩 통과시켜 막는다.
  • 직접 lock/unlock — 예외·조기 반환에서 unlock을 빠뜨려 데드락을 낳기 쉽다. 쓰지 않는다.
  • RAII — 정리 코드를 객체 소멸자에 묶어 스코프 이탈 시 자동 실행. try/finally와 목적은 같되 쓰는 쪽이 잊을 수 없다.
  • lock_guard — 기본 선택. 가볍고 단순하다.
  • unique_lock — 수동 풀기/지연/이동/조건 변수가 필요할 때만.
  • scoped_lock — 여러 mutex를 데드락 없이 한 번에.
  • shared_mutex + shared_lock — 읽기는 동시에, 쓰기는 독점. 읽기가 압도적으로 많을 때 쓴다.

mutex는 “공유 자원을 어떻게 안전하게 잠그는가”를 다룬다. 다음 편에서는 한 걸음 더 나가 “스레드를 어떻게 효율적으로 기다렸다 깨우는가” — condition_variable과 동기화 패턴을 다룬다.