멀티스레드 공유자원 동기화: std::mutex, lock_guard, unique_lock (Step-by-Step)

이 문서는 제공된 강의 텍스트를 바탕으로 핵심 개념을 흐트러뜨리지 않도록 정리한 완전 학습 파일입니다.
실습용 코드, 단계별 체크리스트, 퀴즈, 요약까지 한 번에 담았습니다.
(C++17 기준, Windows/MSVC 또는 Linux/g++ 환경에서 모두 동작)


0. 학습 목표

  • Race Condition이 무엇이고 왜 발생하는지 벡터 삽입 예시로 이해한다.
  • std::mutex상호배제(Mutual Exclusion) 를 구현해 충돌을 제거한다.
  • RAII 패턴을 적용해 unlock 누락/예외/조기 반환에도 안전하게 만든다.
  • std::lock_guard vs std::unique_lock차이와 사용 위치를 익힌다.
  • 락의 범위 전략(루프-내/루프-외)에 따른 성능·병렬성 트레이드오프를 체득한다.
  • 추가로 std::recursive_mutex의 존재와 주의점을 안다.

1. 준비물 & 빌드

1) Windows (MSVC)

  • 프로젝트 생성: Console App (C++17 이상 권장)
  • 멀티스레드 런타임 기본
  • 그대로 빌드/실행 (Ctrl+F5)

2) Linux / macOS (g++)

g++ -std=c++17 -O2 -pthread main.cpp -o app
./app

2. 문제 상황 재현: 락 없이 vector에 동시 삽입

코드 (Crash 또는 잘못된 size)

#include <iostream>
#include <vector>
#include <thread>
using namespace std;

vector<int> vec;

void Push() {
    for (int i = 0; i < 10'000; ++i) {
        vec.push_back(i);
    }
}

int main() {
    thread t1(Push);
    thread t2(Push);
    t1.join(); t2.join();

    cout << "vec.size() = " << vec.size() << '\n'; // 기대: 20000, 실제: 크래시 or 누락
}

왜 문제인가?

  • std::vector스레드 안전하지 않다.
  • push_back()capacity 증가가 발생하면 새 메모리 할당 → 복사 → 기존 메모리 해제가 이뤄진다.
  • 두 스레드가 동시에 재할당 경로에 진입하면 이미 해제된 메모리를 만지게 되어 크래시(double free/UB).

3. reserve로도 완벽히 해결되지 않는 이유

int main() {
    vec.reserve(20000); // 재할당 회피
    thread t1(Push);
    thread t2(Push);
    t1.join(); t2.join();
    cout << "vec.size() = " << vec.size() << '\n'; // 여전히 20000이 아닐 수 있음
}
  • 재할당은 막아도 동시 인덱스 갱신 경쟁은 남는다.
  • 두 스레드가 같은 위치에 쓰면 요소 누락/덮어쓰기로 size가 어긋난다.

4. 해법 ①: std::mutex로 상호배제

#include <mutex>

vector<int> vec;
mutex m;

void Push() {
    for (int i = 0; i < 10'000; ++i) {
        m.lock();
        vec.push_back(i);
        m.unlock();
    }
}
  • 한 번에 하나의 스레드만 push_back()을 수행 → 안정성 확보.
  • 하지만 unlock() 누락(예: return/break/throw) 시 영구 대기(Deadlock) 위험.

5. 해법 ②: RAII로 안전하게 — 사용자 정의 LockGuard

template <typename T>
class LockGuard {
public:
    explicit LockGuard(T& m) : _mutex(&m) { _mutex->lock(); }
    ~LockGuard() { _mutex->unlock(); }
    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;
private:
    T* _mutex;
};

void Push() {
    for (int i = 0; i < 10'000; ++i) {
        LockGuard<mutex> guard(m); // 생성 시 lock, 스코프 종료 시 자동 unlock
        vec.push_back(i);
        if (i == 5000) {           // 예외/조기 반환/분기에도 자동 해제
            // break; // 주석 해제해도 데드락 없음
        }
    }
}
  • 생성자=lock, 소멸자=unlock예외 안전성 확보.
  • 강의에서 소개한 RAII(Resource Acquisition Is Initialization) 패턴의 정석.

6. 표준 RAII 도구: std::lock_guard & std::unique_lock

6.1 std::lock_guard — 단순·가볍다

void Push() {
    for (int i = 0; i < 10'000; ++i) {
        std::lock_guard<std::mutex> lock(m);
        vec.push_back(i);
    }
}
  • 생성 즉시 lock, 스코프 종료 시 자동 unlock.
  • 가볍고 빠르지만 시점 제어가 필요하면 한계.

6.2 std::unique_lock — 유연하다

void Push() {
    for (int i = 0; i < 10'000; ++i) {
        std::unique_lock<std::mutex> lk(m, std::defer_lock); // 아직 lock 안 함
        // ... 선행 작업 ...
        lk.lock();   // 원하는 지점에 lock
        vec.push_back(i);
    } // 소멸 시 자동 unlock
}

지원 태그

  • std::defer_lock : 생성 시 잠그지 않음 → 나중에 lock()
  • std::try_to_lock: 바로 잠그기 시도, 실패해도 대기하지 않음
  • std::adopt_lock : 이미 잠겨 있음을 가정(외부에서 lock한 뒤 인수로 넘길 때)

unique_lock은 내부 상태가 추가되어 lock_guard보다 무겁지만 유연(move 가능 등).


7. 락 범위 전략 (성능 vs 병렬성)

7.1 반복마다 잠그기 (병렬성 ↑, 락 오버헤드 ↑)

for (...) {
    std::lock_guard<std::mutex> lock(m);
    vec.push_back(...);
}

7.2 전체 루프를 한 번에 잠그기 (오버헤드 ↓, 병렬성 ↓)

{
    std::lock_guard<std::mutex> lock(m);
    for (...) {
        vec.push_back(...);
    }
}
  • 정답은 없다: 데이터 크기/경합 정도/임계구역 작업량에 따라 선택.

팁: std::chrono로 시간을 재어 자신의 워크로드에서 실제로 비교해보자.


8. (보너스) std::recursive_mutex 개요

  • 같은 스레드가 같은 mutex를 중첩해서 잠그는 상황이 필요할 때 사용.
  • 일반 std::mutex는 재귀 잠금 시 데드락이 날 수 있다.
  • 단, 재귀적 설계는 잠금 상태 추적 비용/복잡도를 키울 수 있어 남용 금지.

9. 실습 묶음 (Step-by-Step)

각 Step은 독립적으로 빌드/실행 가능하도록 작성했습니다.
아래에서 원하는 Step을 복사해 main.cpp에 붙여넣고 실행하세요.

Step A — 크래시/누락 재현

  • 2장 코드로 실행 → 크래시 또는 비정상 size 확인
  • reserve(20000) 추가 후 실행 → 크래시는 사라져도 size 불일치 확인

Step B — 수동 lock/unlock (위험 시나리오 체험)

  • 4장의 코드 사용
  • 루프 중간에 break 넣어 unlock 누락 → 프로그램이 끝나지 않음 확인

Step C — 사용자 정의 LockGuard로 RAII

  • 5장의 LockGuard 클래스 추가
  • 동일 시나리오에서 정상 종료 확인 (조기 반환/예외에도 안전)

Step D — 표준 lock_guard로 치환

  • 6.1 코드 사용
  • 결과 동일 확인, 코드 간결성 비교

Step E — unique_lock + defer_lock

  • 6.2 코드 사용
  • 잠금 시점 제어가 필요한 케이스(사전 검증/준비작업) 상상해 적용

Step F — 락 범위 성능 비교

  • 7.1 vs 7.2 두 버전으로 각각 10회 반복 실행 시간 측정
  • 환경/데이터 크기에 따라 어떤 버전이 유리한지 기록

Step G — (선택) recursive_mutex 실험

#include <mutex>
#include <iostream>
using namespace std;

recursive_mutex rm;

void Recur(int d) {
    if (d == 0) return;
    rm.lock();
    cout << "depth=" << d << '\n';
    Recur(d-1);
    rm.unlock();
}

int main() {
    Recur(3); // 정상 (mutex면 데드락)
}

10. 퀴즈 (정답은 문서 맨 아래)

1) std::vector에 동시 push_back이 위험한 두 가지 이유는?
2) reserve가 크래시를 줄여도 정확한 size를 보장하지 못하는 이유는?
3) lock/unlock 수동 관리의 가장 큰 위험은?
4) RAII가 예외 안전성을 제공하는 방식은?
5) lock_guardunique_lock차이점 두 가지를 적어라.
6) std::adopt_lock은 언제 쓰는가?
7) 락을 루프 안에서 잡을 때와 루프 밖에서 잡을 때의 트레이드오프는?
8) std::recursive_mutex가 필요한 경우와 주의점은?


11. 체크리스트 (요약)

  • 컨테이너는 기본적으로 스레드 안전 아님(특히 재할당/내부 상태 변화)
  • 동시 수정에는 std::mutex 기반 락이 필수
  • RAII(락 가드)unlock 누락을 원천 차단
  • 단순하면 lock_guard, 유연하면 unique_lock
  • defer_lock/try_to_lock/adopt_lock 용도를 구분해 사용
  • 락의 범위(루프-내 vs 루프-외)를 의도적으로 선택
  • 재귀 잠금이 필요하면 recursive_mutex, 하지만 남용 금지
  • 가능한 경우, 불변(immutable) 설계 / 사전 복제 / 작업 분할로 락 경쟁을 줄여라

12. 자주 보는 실수 & 팁

  • unlock() 누락 → 영구 대기(Deadlock)
    ✅ RAII 사용 (lock_guard/unique_lock)

  • ❌ 임계 구역에 느린 I/O(디스크/네트워크/콘솔) 넣기
    ✅ 임계 구역은 최소화, 계산/검증은 밖에서

  • ❌ 여러 mutex를 서로 다른 순서로 잠그기
    ✅ 항상 고정된 순서로 잠그거나 std::scoped_lock/std::lock(다중 잠금) 사용
    (강의 범위를 벗어나므로 참고만)

  • ❌ 컨테이너를 공유 상태로 설계
    스레드별 버퍼 → 마지막에 병합 같은 구조 고려


13. 참고: 실습 통합 예제

#include <bits/stdc++.h>
using namespace std;

vector<int> v;
mutex m;

void Push_lock_guard() {
    for (int i = 0; i < 10'000; ++i) {
        lock_guard<mutex> lk(m);
        v.push_back(i);
    }
}

void Push_unique_defer() {
    for (int i = 0; i < 10'000; ++i) {
        unique_lock<mutex> lk(m, defer_lock);
        // ... 준비 작업 ...
        lk.lock();
        v.push_back(i);
    }
}

int main() {
    v.clear();
    thread t1(Push_lock_guard);
    thread t2(Push_unique_defer);
    t1.join(); t2.join();
    cout << "v.size() = " << v.size() << endl;
}

14. 예상 질문(FAQ)

Q. std::atomic으로 vector를 보호할 수 없나요?
A. 원자적 load/store는 가능하지만, vector의 복잡한 내부 동작(재할당/복사/size 갱신)은 보호할 수 없습니다. mutex가 필요합니다.

Q. 왜 unique_lock이 더 무겁나요?
A. 상태를 더 많이 보유(소유/잠금 상태/소유권 이동 등)하기 때문입니다. 반대로 유연성이 장점입니다.

Q. 어디까지 락을 감싸야 하나요?
A. 데이터 무결성이 깨지지 않는 최소 범위를 기본으로 하되, 성능/경합을 측정해 조절합니다.


15. 퀴즈 정답

1) (a) 재할당 경합으로 해제된 메모리 접근 위험, (b) 재할당이 없어도 동시 인덱스 갱신 충돌(덮어쓰기/누락).
2) 동시 push_back같은 인덱스를 쓰며 덮어쓰기/누락이 발생.
3) unlock 누락 시 영구 대기. 예외/조기 반환에서도 누락될 수 있음.
4) 객체 소멸자에서 자동 unlock → 예외/return/break에도 해제 보장.
5) lock_guard: 가볍고 단순(즉시 lock, 이동 불가). unique_lock: 유연(지연 lock, move 가능, 상태 보유).
6) 외부에서 이미 lock()한 mutex를 소유권만 인계할 때(adopt_lock).
7) 루프-내: 병렬성↑/오버헤드↑. 루프-외: 오버헤드↓/병렬성↓. 상황에 맞게 선택.
8) 같은 스레드에서 중첩 잠금 필요 시 사용. 하지만 복잡도↑, 남용 금지.


한 줄 요약 (TL;DR)

컨테이너 동시 수정 = mutex 필수. lock_guard/unique_lock으로 RAII 적용, 락 범위는 의도적으로 선택!

profile
李家네_공부방

0개의 댓글