C++ mutex: 경쟁 상태, 데드락, lock_guard·scoped_lock 사용 원칙
들어가며: 카운터가 왜 깨지나
여러 워커 스레드가 처리한 주문 수를 전역 카운터 하나에 더하는 코드를 생각해 보겠습니다.
int counter = 0;
void processOrder(const Order& order) {
// ... 주문 처리 ...
counter++; // 여러 스레드가 동시에 접근
}
counter++는 한 줄이지만 “현재 값 읽기, 1 더하기, 쓰기”의 세 단계입니다. 두 스레드가 같은 값을 읽고 각자 1을 더해 쓰면 한 번의 증가가 사라지고, 트래픽이 많을수록 집계와 실제 건수의 차이가 커집니다. C++ 표준으로 보면 이것은 data race이고, 결과는 미정의 동작입니다.
이런 공유 데이터를 보호하는 방법은 두 가지입니다. 카운터처럼 변수 하나만 다룬다면 atomic으로 락 없이 안전하게 증가시킬 수 있습니다. 큐, 맵, 또는 함께 바뀌어야 하는 여러 변수를 다룰 때는 mutex로 “한 번에 한 스레드만” 그 구간에 들어가게 해야 합니다. mutex는 한 스레드가 잠금을 잡으면 나머지 스레드가 그 잠금이 풀릴 때까지 기다리게 하는 장치입니다. Java의 synchronized나 java.util.concurrent의 락과 같은 개념이라 Java 멀티스레드 글과 비교해 보면 임계 구역과 상호 배제가 다른 문법으로 어떻게 반복되는지 볼 수 있습니다.
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: 크리티컬 섹션 실행
이 글에서는 race condition과 data race를 구분하고, std::mutex와 lock_guard·unique_lock·scoped_lock으로 크리티컬 섹션(한 번에 한 스레드만 실행해야 하는 코드 구간)을 보호하는 방법, 그리고 데드락을 피하는 잠금 순서와 흔한 실수를 다룹니다. 락을 잡은 채로 오래 두면 병목이 되므로, 보호가 필요한 구간만 최소한으로 감싸는 것이 기본 원칙입니다.
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(데이터 경합)는 두 스레드가 같은 메모리 위치에 접근하고 그중 적어도 하나가 쓰기이며, 둘 다 atomic 연산이 아니고 mutex 같은 동기화로 선후 관계가 정해지지 않은 경우를 말합니다. C++ 표준에서 data race는 미정의 동작(UB)이라, 값이 틀리는 것뿐 아니라 컴파일러 최적화 때문에 예측하기 어려운 동작이 나올 수 있습니다. 두 개념은 겹치지만 같지는 않습니다. 모든 접근을 atomic으로 바꿔 data race를 없애도, 검사와 수정을 따로 하면 race condition은 여전히 남을 수 있습니다(아래 재고 예제).
mutex의 역할
Mutex(Mutual Exclusion)는 한 번에 한 스레드만 지정한 구간(크리티컬 섹션)을 실행하도록 만듭니다. 락을 잡은 스레드만 그 구간에 들어가고 나머지는 락이 풀릴 때까지 대기하므로, 공유 변수에 대한 “읽기-수정-쓰기”가 겹치지 않습니다. 다만 mutex를 잡은 채로 오래 두면 다른 스레드가 모두 대기하므로 처리량이 떨어집니다. 공유 데이터를 읽고 수정하는 부분만 최소한으로 감싸고, I/O나 네트워크 대기는 락 밖에서 하세요. lock_guard나 unique_lock을 쓰면 예외 시에도 소멸자에서 락이 풀립니다.
재고, 로그 버퍼, 작업 큐에서 실제로 깨지는 사례
시나리오 1: 재고 감소 버그
여러 사용자가 동시에 같은 상품을 구매하면 재고가 음수가 될 수 있습니다.
// ❌ 문제 코드: 재고가 음수로 떨어질 수 있음
int stock = 5;
void purchase(int quantity) {
if (stock >= quantity) { // 스레드 A, B 모두 5 >= 5 → true
// A가 검사를 통과한 직후 B도 같은 값을 보고 통과
stock -= quantity; // 두 번 차감되어 stock = -5
}
}
if 조건 검사와 stock -= quantity 사이에 다른 스레드가 끼어들 수 있어 검사 시점과 수정 시점이 분리됩니다. 전형적인 check-then-act race condition이며, stock을 atomic으로 바꿔도 검사와 차감이 별개의 연산이라 해결되지 않습니다. 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++; // 여러 스레드가 동시에 증가 → data race
}
void recordMiss() {
miss_count++;
}
// 나중에 hit_rate = hit_count / (hit_count + miss_count) 계산 시
// hit_count와 miss_count가 서로 다른 시점에 읽혀 일관성 없음
각 카운터의 ++가 data race라 증가가 유실되고, 집계할 때도 두 값을 서로 다른 시점에 읽게 됩니다. 아래처럼 같은 mutex로 증가와 집계를 모두 보호하면 증가 유실이 없고, 집계는 일관된 스냅샷을 봅니다. 대략적인 통계면 충분하다면 두 카운터를 각각 std::atomic으로 바꾸는 것도 방법이지만, 그때는 두 값을 읽는 사이에 다른 증가가 끼어들 수 있다는 점을 감안해야 합니다.
// ✅ 해결: 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은 스레드 안전하지 않습니다. 여러 스레드가 동시에 find만 하는 것은 괜찮지만, 한 스레드가 쓰는 동안 다른 스레드가 읽으면 data race로 UB가 발생하고, 트리를 재조정하는 중이면 읽는 쪽이 크래시할 수도 있습니다. 읽기는 여러 스레드가 동시에, 쓰기는 단독으로 하도록 std::shared_mutex를 쓰는 방법을 아래 패턴 2에서 다룹니다.
std::mutex의 lock, try_lock과 복사 불가 규칙
기본 사용
// 복사해 붙여넣은 뒤: 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이 호출되지 않아 이후 이 mutex를 잡으려는 스레드가 모두 멈추므로, 실무에서는 lock_guard를 쓰는 편이 안전합니다.
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()을 호출해야 하므로, 이 부분도 std::unique_lock<std::mutex> lock(mtx, std::try_to_lock)처럼 RAII로 처리하는 것이 안전합니다. 또 표준은 try_lock 계열이 다른 스레드가 락을 잡고 있지 않아도 실패할 수 있다고 허용하므로(spurious failure), 실패를 “누군가 잡고 있다”는 확실한 증거로 쓰면 안 됩니다.
일정 시간만 기다리다 포기해야 한다면 std::timed_mutex의 try_lock_for/try_lock_until을 씁니다. 잠금 실패 시 재시도나 폴백으로 넘어가야 하는 서버 코드에서 유용합니다. 대기 시간은 try_lock_for(100ms)처럼 기간으로 주는 것이 좋습니다. try_lock_until에 system_clock 시각을 넘기면 대기 중 시스템 시계가 조정될 때 실제 대기 시간이 예상과 달라질 수 있습니다.
std::timed_mutex tmtx;
void tryUpdate() {
using namespace std::chrono_literals;
if (tmtx.try_lock_for(100ms)) {
std::lock_guard<std::timed_mutex> lock(tmtx, std::adopt_lock);
// ... 작업 ...
} else {
// 100ms 안에 못 잡음 → 폴백
}
}
뮤텍스가 보장하는 것과 비용
뮤텍스는 상호 배제뿐 아니라 메모리 가시성도 보장합니다. 한 스레드가 unlock() 이전에 한 모든 쓰기는, 그 뮤텍스를 다음에 lock()한 스레드에게 보인다는 것이 표준의 약속입니다. 동기화 없이 공유한 변수는 컴파일러가 레지스터에 캐시하거나 순서를 바꿔도 되기 때문에, “락을 안 잡고 읽기만 하는” 코드도 다른 스레드의 변경을 영원히 보지 못할 수 있습니다.
비용은 경합 여부에 따라 크게 달라집니다. 경합이 없는 lock()/unlock()은 Linux에서 futex 기반 원자 연산 몇 번이라 커널에 들어가지 않고 끝납니다. 비용이 급격히 커지는 것은 경합이 생겨 기다리는 스레드가 커널에서 잠들었다 깨어나야 할 때입니다. 그래서 “뮤텍스는 느리다”기보다 “많은 스레드가 같은 뮤텍스를 오래 잡으면 느리다”가 정확한 표현입니다.
std::mutex의 복사/이동
std::mutex는 복사도 이동도 할 수 없습니다. 다른 스레드가 기다리고 있을 수 있는 잠금 상태를 복사하거나 옮기는 것은 의미가 불분명하기 때문입니다. 그래서 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이 없어 스코프를 벗어날 때만 풀리므로, 잠그는 구간을 줄이고 싶다면 { } 블록으로 스코프를 좁힙니다.
가드에 이름을 붙이지 않는 실수는 컴파일이 되고 테스트도 대부분 통과해서, 부하가 걸린 뒤에야 레이스로 드러납니다.
std::lock_guard<std::mutex>{mtx}; // ❌ 임시 객체: 이 문장이 끝나는 순간 unlock
std::unique_lock<std::mutex>(mtx); // ❌ 'mtx'라는 새 지역 변수 선언으로 해석: 아무것도 잠그지 않음
std::lock_guard<std::mutex> lock(mtx); // ✅ 이름 있는 가드: 스코프 끝까지 잠금
두 번째 줄은 “선언처럼 보이면 선언” 규칙 때문에 std::unique_lock<std::mutex> mtx;와 같은 뜻이 되어, 바깥의 mtx를 가리는 잠기지 않은 새 객체를 만듭니다. lock_guard로 같은 실수를 하면 기본 생성자가 없어 컴파일 오류가 나므로 오히려 다행입니다. GCC/Clang의 -Wshadow나 clang-tidy의 bugprone-unused-raii 검사가 이런 코드를 잡아 줍니다.
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::wait는 unique_lock만 받습니다.
unique_lock의 정책
| 정책 | 설명 |
|---|---|
| (기본) | 생성 시 즉시 lock() |
std::defer_lock | 생성 시 lock 안 함. 나중에 lock() 호출 |
std::try_to_lock | try_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 <mutex>
#include <string>
#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 구간에서 락을 풀어 둘 수 있습니다. 다만 락을 풀었던 동안 다른 스레드가 shared_data를 바꿨을 수 있으므로, 다시 잠근 뒤 결과를 그대로 덮어써도 되는지는 따로 판단해야 합니다. 그 사이의 변경을 잃으면 안 된다면 버전 번호를 비교하는 식의 확인이 필요합니다.
lock_guard vs unique_lock 선택 가이드
| 상황 | 권장 |
|---|---|
| 구간 보호만 필요 | lock_guard (가볍고 단순) |
| condition_variable 사용 | unique_lock (필수) |
| 중간에 unlock 후 다시 lock | unique_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) {
if (&a == &b) return; // 같은 계좌면 같은 mutex를 두 번 잡게 됨
auto* m1 = &a.mtx;
auto* m2 = &b.mtx;
// 서로 무관한 객체의 포인터를 <, >로 비교하는 것은 표준상 순서가 보장되지 않으므로
// 전체 순서를 보장하는 std::less를 씁니다
if (std::less<std::mutex*>{}(m2, m1)) 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은 하나를 잡고 나머지를 try_lock으로 시도하다가 실패하면 잡은 것을 풀고 다시 시도하는 식의 데드락 회피 알고리즘을 쓰므로, 다른 스레드가 반대 순서로 호출해도 데드락이 생기지 않습니다. defer_lock으로 만든 unique_lock을 넘기면 잠금 소유권이 그 객체들에 있으므로 소멸 시 자동으로 풀립니다. 뮤텍스를 직접 넘겼다면 std::lock_guard<std::mutex> l(m1, std::adopt_lock);처럼 잡은 뒤 가드에 넘겨야 합니다.
해결 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); // 데드락 없이 둘 다 잡음, 소멸 시 자동 해제
// ... 크리티컬 섹션 ...
}
앞의 계좌 이체도 std::scoped_lock lock(a.mtx, b.mtx); 한 줄로 바꾸면 transfer(a, b)와 transfer(b, a)가 동시에 호출되어도 데드락이 생기지 않으므로, 주소 비교 코드가 필요 없습니다.
해결 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
그림: 실험: 반대 순서 개별 잠금은 400번 중 399번 타임아웃, scoped_lock은 400번 모두 완료(일부러 교착이 잘 나게 만든 실험).
lock/unlock 짝 불일치, check-then-act 같은 mutex 실수
실수 1: lock/unlock 쌍을 맞추지 않음
early return이나 예외로 unlock()을 건너뛰면, 다음에 이 mutex를 잡으려는 스레드가 영원히 멈춥니다.
// ❌ 잘못된 예: early return 시 unlock 누락
void process() {
mtx.lock();
if (error) return; // unlock() 호출 안 됨 → 데드락
doWork();
mtx.unlock();
}
// ✅ 올바른 예: lock_guard 사용
void process() {
std::lock_guard<std::mutex> lock(mtx);
if (error) return; // 소멸자에서 자동 unlock
doWork();
}
실수 2: 보호할 데이터와 mutex를 분리
mutex와 데이터가 따로 떨어져 있으면, 누군가 락 없이 데이터에 접근해도 컴파일러가 막아 주지 못합니다.
// ❌ 나쁜 예: mtx와 data가 분리
std::mutex mtx;
int global_counter; // 누군가 mtx 없이 접근할 수 있음
void add() {
global_counter++; // 실수로 락 없이!
}
데이터와 mutex를 한 클래스에 묶고, 데이터는 private으로 두어 락을 잡는 메서드를 통해서만 접근하게 합니다.
// ✅ 좋은 예: 데이터와 mutex를 함께 캡슐화
class ThreadSafeCounter {
public:
void add(int n) {
std::lock_guard<std::mutex> lock(mtx_);
value_ += n;
}
int get() const {
std::lock_guard<std::mutex> lock(mtx_);
return value_;
}
private:
mutable std::mutex mtx_;
int value_ = 0;
};
실수 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); // 데드락!
}
가장 좋은 해결책은 설계를 바꿔 재진입이 필요 없게 하는 것입니다. 흔한 방법은 락을 잡는 공개 함수와, 락이 이미 잡혀 있다고 가정하는 내부 함수를 나누는 것입니다.
// ✅ bar는 락을 잡지 않으며, foo에서만 잡음
void bar() {
// 락 없이 내부 로직만
}
void foo() {
std::lock_guard<std::mutex> lock(mtx);
bar();
}
꼭 같은 스레드에서 다시 잡아야 한다면 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();
}
검사와 수정을 같은 락 구간 안에 둡니다.
// ✅ 올바른 예
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);으로 만들고 if (lock.owns_lock())으로 성공 여부를 확인하면 해제를 소멸자에 맡길 수 있습니다.
실수 7: 반환값으로 공유 데이터 참조 전달
메서드 안에서 락을 잡고 내부 데이터의 참조나 포인터를 반환하면, 메서드가 끝나며 락이 풀린 뒤에도 호출자가 그 참조로 데이터에 접근하므로 data race가 됩니다. 값을 복사해서 반환하거나, 락을 잡은 상태에서 호출자가 넘긴 함수를 실행하는 with_lock(f) 같은 접근 메서드를 제공합니다.
실수 8: 다른 스레드가 쓰는 중에 객체를 소멸시킴
mutex를 멤버로 가진 객체를 다른 스레드가 아직 사용하는 중에 소멸시키면, 잠긴 mutex를 파괴하거나 해제된 메모리에 접근하게 되어 미정의 동작입니다. 멤버 선언 순서로는 해결되지 않습니다. 객체를 공유하는 스레드를 모두 join한 뒤에 소멸시키거나, shared_ptr로 마지막 사용자가 끝날 때까지 수명을 늘려야 합니다.
mutex, atomic, lock-free의 비용 차이
mutex vs atomic vs lock-free
| 방식 | 적합한 상황 | 장점 | 단점 |
|---|---|---|---|
| std::mutex | 여러 변수, 복잡한 조건, 큐/맵 등 | 구현 단순, 범용적 | 경합 시 대기와 컨텍스트 스위치 |
| std::atomic | 단일 변수(카운터, 플래그) | 락 없음, 경합이 적으면 빠름 | 복합 연산은 compare_exchange 루프로 복잡해짐 |
| lock-free 자료구조 | 락 대기 자체를 없애야 할 때 | 한 스레드가 멈춰도 다른 스레드가 진행 | 구현 난이도 높음, ABA 문제, 메모리 회수 문제 |
단일 카운터처럼 변수 하나만 다룰 때는 atomic의 fetch_add가 하드웨어 원자 명령 하나로 끝나므로 보통 mutex보다 가볍습니다. 다만 많은 스레드가 같은 변수를 계속 증가시키면 그 캐시 라인을 코어끼리 주고받는 비용이 커져 atomic도 느려지므로, 실제 차이는 스레드 수와 경합 정도에 따라 달라집니다. 여러 변수를 일관되게 수정해야 하면 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;
}
atomic으로 바꿀 때 흔한 착각은 “변수 하나를 atomic으로 만들었으니 주변 코드도 안전하다”는 것입니다. std::atomic<bool> ready로 준비 여부만 알리고 실제 데이터는 일반 변수로 두면, 데이터 쓰기와 플래그 쓰기 사이의 순서가 memory_order_release/acquire로 맞춰져 있지 않은 한 다른 스레드가 플래그는 보고 데이터는 옛 값을 볼 수 있습니다. “잔액이 충분하면 차감” 같은 check-then-act도 load와 store를 따로 부르면 두 atomic 연산 사이에 다른 스레드가 끼어들므로, compare_exchange 루프로 한 번에 처리하거나 mutex로 묶어야 합니다. 판단 기준은 단순합니다. 지켜야 할 불변식이 변수 하나에 들어가면 atomic, 두 개 이상에 걸쳐 있으면 mutex입니다. 카운터처럼 경합이 매우 심한 경우에는 스레드마다 지역 카운터를 두고 마지막에 한 번만 합치면 atomic이든 mutex든 공유 변수 경합 자체를 피할 수 있습니다.
락 범위와 성능
락을 잡은 구간이 길수록 다른 스레드의 대기 시간이 늘어나 처리량이 떨어집니다.
// ❌ 나쁜 예: 락 범위가 넓음
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;
}
}
스레드 안전 래퍼, 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()을 따로 호출하면 그 사이에 다른 스레드가 끼어들 수 있습니다. “비어있지 않으면 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으로는 여러 스레드가 동시에 읽을 수 있고, unique_lock으로 쓰는 동안에는 다른 읽기와 쓰기가 모두 기다립니다. 읽기가 대부분이고 읽는 구간이 어느 정도 길 때 이득이 있으며, 잠금 자체는 일반 mutex보다 무거우므로 짧은 구간에서는 오히려 느릴 수 있습니다. 표준은 쓰기 스레드가 계속 밀리는 기아(starvation)를 막는 정책을 정하지 않으므로, 쓰기 지연이 중요한 곳이라면 구현의 동작을 확인해야 합니다.
패턴 3: 락 없는 읽기 + 쓰기 시 복사
쓰기가 드물고 읽기가 많을 때, 쓰기 시 전체를 복사한 뒤 포인터를 원자적으로 스왑하는 패턴입니다.
class SnapshotCache {
public:
std::shared_ptr<const Data> get() const {
return std::atomic_load(&data_); // 락 없이 읽기
}
void update(Data new_data) {
// atomic_store는 두 인자의 타입이 같아야 하므로 shared_ptr<const Data>로 맞춤
std::shared_ptr<const Data> copy = std::make_shared<const Data>(std::move(new_data));
std::atomic_store(&data_, std::move(copy)); // 원자적 교체
}
private:
std::shared_ptr<const Data> data_;
};
읽는 쪽은 받은 shared_ptr가 살아 있는 동안 그 스냅샷을 계속 안전하게 쓸 수 있고, 교체된 옛 데이터는 마지막 독자가 놓을 때 해제됩니다. shared_ptr용 std::atomic_load/atomic_store 자유 함수는 C++20에서 deprecated되었으므로, C++20 이상에서는 std::atomic<std::shared_ptr<const Data>> 멤버를 쓰는 편이 좋습니다. 또 표준 라이브러리 구현에 따라 이 연산이 내부적으로 락을 쓸 수도 있습니다.
패턴 4: 모니터링과 데드락 탐지
프로덕션에서는 락 대기 시간을 로깅하거나, 데드락 탐지 도구(ThreadSanitizer, Helgrind)를 활용합니다.
# ThreadSanitizer로 data race 검사 (빌드 시)
g++ -fsanitize=thread -g -O1 -o app app.cpp
# Helgrind로 데드락 검사 (실행 시)
valgrind --tool=helgrind ./app
패턴 5: 스레드 안전 초기화
함수 안의 static 지역 변수(Meyers Singleton)는 C++11부터 초기화가 스레드 안전하게 한 번만 일어나도록 보장되고, 지역 변수로 둘 수 없는 초기화라면 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>(); });
이 두 방법을 두고 직접 “락 없이 한 번 확인하고, 비어 있으면 락을 잡고 다시 확인하는” Double-Checked Locking을 일반 포인터로 구현하는 코드가 아직도 보입니다. 첫 번째 확인이 락 밖에서 일반 포인터를 읽으므로 그 자체가 data race이고, instance = new Singleton()의 포인터 쓰기가 생성자 완료보다 먼저 다른 스레드에 보일 수 있어 초기화가 덜 끝난 객체를 쓰게 됩니다. 꼭 직접 구현해야 한다면 포인터를 std::atomic<Singleton*>로 두고 읽기는 memory_order_acquire, 쓰기는 memory_order_release로 해야 하지만, 대부분은 위의 static 지역 변수나 call_once로 충분합니다.
같이 보면 좋은 글
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
- C++ RAII: 생성자에서 획득하고 소멸자에서 해제하는 자원 관리 클래스 만들기
- C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
- C++ condition_variable 실무 패턴
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
다음 글인 C++ 실전 가이드 #7-3: condition_variable에서 mutex만으로는 표현하기 어려운 “조건이 만족될 때까지 기다렸다가 깨우기” 패턴을 다룹니다.
자주 묻는 질문 (FAQ)
락을 잡은 채로 콜백이나 외부 함수를 호출하면 왜 위험한가요?
호출된 코드가 같은 mutex를 다시 잡으려 하면 자기 자신과 데드락이 되고, 다른 락을 잡으면 다른 스레드와 락 획득 순서가 뒤바뀌어 데드락이 생길 수 있습니다. 외부 코드가 I/O처럼 느린 작업을 하면 그동안 락을 계속 보유해 다른 스레드가 모두 대기하게 됩니다. 락 안에서는 필요한 데이터만 복사해 두고 락을 푼 뒤에 콜백을 호출하는 방식으로, 본문의 락 범위 최소화 원칙을 지키는 것이 안전합니다.