C++ mutex와 lock_guard·unique_lock·scoped_lock: 임계 영역 보호와 데드락 방지
이 글의 핵심
lock()/unlock()을 직접 호출하면 예외 한 번에 뮤텍스가 잠긴 채 남습니다. lock_guard·unique_lock·scoped_lock을 언제 고르는지, 두 뮤텍스를 다른 순서로 잠가 생기는 데드락과 std::lock·scoped_lock의 해법, 이름 없는 임시 가드처럼 컴파일은 되지만 아무것도 잠그지 않는 실수까지 예제로 정리합니다.
들어가며
Mutex(뮤텍스)는 상호 배제(Mutual Exclusion)를 위한 동기화 도구입니다. 여러 스레드가 공유 데이터에 동시 접근하는 것을 방지하여 데이터 레이스를 막습니다.
C++에서 데이터 레이스는 “가끔 값이 틀리는” 수준의 문제가 아니라 미정의 동작입니다. 컴파일러는 한 스레드만 변수를 건드린다고 가정하고 최적화하므로, 동기화 없이 공유한 변수는 레지스터에 캐시되어 다른 스레드의 변경을 영원히 보지 못하거나, 쓰기 순서가 바뀌어 보일 수 있습니다. 뮤텍스는 상호 배제와 함께 이런 메모리 가시성도 보장합니다. unlock() 이전에 한 모든 쓰기는 그 뮤텍스를 다음에 lock()한 스레드에게 보인다는 것이 표준의 약속입니다.
뮤텍스의 비용도 짐작해 둘 만합니다. 경합이 없는 lock()/unlock()은 Linux에서 futex 기반 원자 연산 몇 번이라 커널에 들어가지 않고 끝납니다. 비용이 급격히 커지는 것은 경합이 생겼을 때입니다. 이때는 기다리는 스레드가 커널에서 잠들었다가 깨어나야 하므로 문맥 전환 비용이 붙습니다. 그래서 “뮤텍스는 느리다”기보다 “많은 스레드가 같은 뮤텍스를 오래 잡으면 느리다”가 정확한 표현이고, 임계 구역을 짧게 유지하는 것이 성능의 핵심입니다.
std::mutex로 임계 영역 보호하기
여러 스레드가 같은 변수를 동시에 읽고 쓰면, CPU 명령어 수준에서 연산이 겹치면서 값이 예측 불가능하게 깨지는 데이터 레이스가 발생합니다. std::mutex는 “한 번에 한 스레드만 이 코드를 실행할 수 있다”는 상호 배제를 강제해, 여러 스레드가 동시에 공유 데이터를 건드리지 못하게 막아주는 가장 기본적인 동기화 도구입니다.
lock()과 unlock()으로 감싸기
아래 예제에서 sharedData는 두 스레드가 동시에 접근하는 공유 변수입니다. mtx.lock()을 호출한 스레드만 ++sharedData가 있는 임계 영역(critical section)에 진입할 수 있고, 다른 스레드는 mtx.unlock()이 호출될 때까지 대기합니다. lock()과 unlock()을 짝지어 직접 호출하는 이 방식은 뮤텍스의 동작 원리를 이해하기에는 좋지만, 다음 절에서 보듯 예외 안전성 문제를 안고 있어 실무에서는 그대로 쓰지 않습니다.
#include <mutex>
#include <thread>
#include <iostream>
// std::mutex: 상호 배제(Mutual Exclusion)를 위한 뮤텍스
// 여러 스레드가 동시에 접근하지 못하도록 보호
std::mutex mtx;
int sharedData = 0; // 공유 데이터 (여러 스레드가 접근)
void increment() {
// mtx.lock(): 뮤텍스 잠금 (다른 스레드는 대기)
// 이미 잠긴 경우 unlock될 때까지 블로킹
mtx.lock();
// 임계 영역 (Critical Section): 한 번에 한 스레드만 실행
++sharedData;
// mtx.unlock(): 뮤텍스 해제 (다른 스레드가 진입 가능)
mtx.unlock();
}
int main() {
// 두 스레드가 동시에 increment 실행
std::thread t1(increment);
std::thread t2(increment);
// join(): 스레드 종료 대기
t1.join();
t2.join();
// 뮤텍스 덕분에 데이터 레이스 없이 안전하게 증가
std::cout << "sharedData: " << sharedData << std::endl; // 2
return 0;
}
예외가 나면 unlock()에 도달하지 못한다
lock()/unlock()을 직접 호출하는 방식의 가장 큰 약점은, 두 호출 사이에서 예외가 발생하거나 조기 return이 실행되면 unlock()에 도달하지 못한다는 것입니다. 아래 unsafeFunction은 mtx.lock() 이후 예외가 던져지면 뮤텍스가 잠긴 채로 함수를 빠져나가고, 이후 같은 뮤텍스를 기다리는 다른 스레드는 영원히 대기하게 됩니다.
#include <mutex>
#include <stdexcept>
#include <iostream>
std::mutex mtx;
void unsafeFunction() {
mtx.lock();
// 예외 발생 시 unlock 안 됨!
if (someCondition) {
throw std::runtime_error("에러");
}
mtx.unlock(); // 실행 안 됨
}
int main() {
try {
unsafeFunction();
} catch (...) {
std::cout << "예외 발생, 뮤텍스 잠김!" << std::endl;
}
return 0;
}
문제의 본질: unsafeFunction 중간에 예외가 나면 unlock 줄에 도달하지 못해 뮤텍스가 영구히 잠긴 상태로 남습니다. 이후 같은 뮤텍스를 기다리는 다른 스레드는 모두 멈추고, 같은 스레드가 다시 lock()하면 미정의 동작(대개 즉시 교착)입니다. 예외뿐 아니라 나중에 누군가 중간에 return을 추가하는 것만으로도 같은 버그가 생기므로, 직접 lock()/unlock()을 부르는 코드는 유지보수 과정에서 깨지기 쉽습니다.
lock_guard로 RAII 잠금
앞서 본 예외 안전성 문제를 해결하는 C++의 표준 해법이 RAII(Resource Acquisition Is Initialization)입니다. std::lock_guard는 생성될 때 뮤텍스를 잠그고, 스코프를 벗어나 소멸자가 호출될 때 자동으로 잠금을 해제하도록 설계된 얇은 래퍼 클래스입니다. 이렇게 자원 관리를 객체의 수명에 묶어두면, 함수가 정상적으로 끝나든 예외로 중간에 빠져나가든 소멸자는 반드시 호출되므로 뮤텍스 해제를 잊어버릴 걱정이 사라집니다.
스코프를 벗어나면 자동 해제
safeIncrement 함수에서 std::lock_guard<std::mutex> lock(mtx);는 생성 즉시 mtx를 잠그고, lock 변수가 함수 끝에서 스코프를 벗어나며 소멸될 때 자동으로 unlock()을 호출합니다. lock_guard는 생성자에서 잠그고 소멸자에서 풀기 때문에, 함수 중간에 return이나 예외가 발생해도 해제가 보장됩니다. 가드 객체의 수명을 최소 임계 구역으로만 제한하면 스레드 간 경합(contention)도 함께 줄일 수 있습니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx;
int sharedData = 0;
void safeIncrement() {
std::lock_guard<std::mutex> lock(mtx);
++sharedData;
// 소멸자에서 자동 unlock
}
int main() {
std::thread t1(safeIncrement);
std::thread t2(safeIncrement);
t1.join();
t2.join();
std::cout << "sharedData: " << sharedData << std::endl; // 2
return 0;
}
C++17부터는 클래스 템플릿 인자 추론(CTAD) 덕분에 std::lock_guard lock(mtx);처럼 템플릿 인자를 생략할 수 있습니다.
lock_guard를 쓸 때 가장 흔하고 찾기 어려운 실수는 가드에 이름을 붙이지 않는 것입니다. 저도 코드 리뷰에서 이 실수를 여러 번 봤는데, 컴파일은 되고 테스트도 대부분 통과해서 부하가 걸린 뒤에야 레이스로 드러납니다.
std::lock_guard<std::mutex>{mtx}; // ❌ 임시 객체: 이 문장이 끝나는 순간 unlock
std::unique_lock<std::mutex>(mtx); // ❌ 'mtx'라는 새 지역 변수 선언으로 해석: 아무것도 잠그지 않음
std::lock_guard<std::mutex> lock(mtx); // ✅ 이름 있는 가드: 스코프 끝까지 잠금
두 번째 줄은 C++의 “선언처럼 보이면 선언” 규칙 때문에 std::unique_lock<std::mutex> mtx;와 같은 뜻이 되어, 바깥의 mtx를 가리는 잠기지 않은 새 객체를 만듭니다. lock_guard로 같은 실수를 하면 기본 생성자가 없어 컴파일 오류가 나므로 오히려 다행입니다. GCC/Clang의 -Wshadow나 clang-tidy의 bugprone-unused-raii 검사가 이런 코드를 잡아 줍니다.
예외가 나도 풀리는 잠금
이번에는 앞서 본 unsafeFunction을 lock_guard로 다시 작성해 비교해봅니다. safeFunction 내부에서 예외가 던져지면 스택 되감기(stack unwinding) 과정에서 lock 객체의 소멸자가 호출되어 mtx가 자동으로 해제되므로, unlock()을 명시적으로 호출하는 코드가 아예 필요 없어집니다. 이 차이 하나만으로도 실무에서는 mutex.lock()/unlock()을 직접 쓰는 대신 lock_guard를 기본값으로 삼아야 하는 이유가 충분합니다.
#include <mutex>
#include <stdexcept>
#include <iostream>
std::mutex mtx;
void safeFunction() {
std::lock_guard<std::mutex> lock(mtx);
// 예외 발생해도 자동 unlock
if (someCondition) {
throw std::runtime_error("에러");
}
// lock 소멸자에서 unlock
}
int main() {
try {
safeFunction();
} catch (...) {
std::cout << "예외 발생, 뮤텍스 자동 해제" << std::endl;
}
return 0;
}
unique_lock으로 잠금 시점 제어하기
lock_guard는 단순하고 빠르지만 잠근 뒤 스코프가 끝날 때까지 계속 잠긴 상태를 유지해야 한다는 제약이 있습니다. std::unique_lock은 lock_guard와 마찬가지로 RAII 기반이면서도 중간에 수동으로 lock()/unlock()을 호출하거나, 처음부터 잠그지 않은 상태로 생성하는 등 훨씬 유연한 제어를 제공합니다. 다만 이런 유연함은 내부적으로 잠금 상태를 추적하는 오버헤드로 이어지므로, 특별한 이유가 없다면 여전히 lock_guard를 우선 고려하는 것이 좋습니다.
unique_lock이 반드시 필요한 대표적인 경우는 std::condition_variable입니다. cv.wait(lock, pred)는 대기하는 동안 잠금을 풀었다가 깨어날 때 다시 잠가야 하므로, 잠금을 해제·재획득할 수 있는 unique_lock만 받습니다. 또 unique_lock은 이동 가능하므로 잠금을 함수에서 반환하거나 다른 객체로 넘길 수 있고, try_to_lock으로 잠금을 시도만 해 볼 수도 있습니다.
중간에 unlock했다가 다시 lock
아래 flexibleLock 함수는 unique_lock으로 잠근 뒤 “작업 1”을 수행하고, lock.unlock()으로 명시적으로 잠금을 풀어 락 없이 안전한 다른 작업을 수행한 다음, 다시 lock.lock()으로 재잠금하여 “작업 2”를 이어갑니다. 이렇게 임계 구역을 함수 전체가 아니라 필요한 구간에만 정밀하게 적용할 수 있다는 점이 unique_lock이 lock_guard보다 유연한 핵심 이유입니다.
다만 잠금을 풀었다 다시 잡는 사이에는 다른 스레드가 공유 상태를 바꿀 수 있다는 점을 잊으면 안 됩니다. “작업 1”에서 확인한 조건(예: 큐가 비어 있지 않다)이 “작업 2” 시점에도 참이라고 가정하면 전형적인 check-then-act 레이스가 됩니다. 재잠금 뒤에는 필요한 조건을 다시 확인해야 합니다.
#include <mutex>
#include <iostream>
std::mutex mtx;
void flexibleLock() {
std::unique_lock<std::mutex> lock(mtx);
// 작업 1
std::cout << "작업 1" << std::endl;
// 수동 해제
lock.unlock();
// 락 없이 작업
std::cout << "락 없는 작업" << std::endl;
// 다시 잠금
lock.lock();
// 작업 2
std::cout << "작업 2" << std::endl;
// 소멸자에서 자동 unlock
}
int main() {
flexibleLock();
return 0;
}
defer_lock으로 잠금 미루기
std::defer_lock 태그를 생성자에 넘기면 unique_lock 객체는 만들어지지만 실제 잠금은 나중으로 미룰 수 있습니다. 이 패턴은 조건에 따라 잠금이 필요할 수도, 필요하지 않을 수도 있는 상황에서 유용한데, 무조건 잠갔다가 조건이 맞지 않으면 즉시 풀어주는 것보다 애초에 필요할 때만 잠그는 편이 불필요한 락 획득·해제 비용을 줄여줍니다. 다음 절에서 다루는 std::lock과 함께 사용하면 여러 뮤텍스를 데드락 없이 조합해서 잠그는 데도 활용됩니다.
#include <mutex>
#include <iostream>
std::mutex mtx;
void deferredLock() {
// 생성 시 잠금 안함
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// 조건부 잠금
if (needLock) {
lock.lock();
}
// 작업 수행
}
int main() {
deferredLock();
return 0;
}
여러 뮤텍스를 잠글 때의 데드락
계좌 이체처럼 두 개 이상의 공유 자원을 동시에 갱신해야 할 때는 여러 개의 뮤텍스를 함께 잠가야 하는데, 이때 잠그는 순서를 스레드마다 다르게 하면 데드락이라는 새로운 위험이 생깁니다.
잠금 순서가 다른 두 함수
badTransfer는 mtx1을 먼저 잠근 뒤 mtx2를 잠그고, anotherBadTransfer는 반대로 mtx2를 먼저 잠근 뒤 mtx1을 잠급니다. 만약 한 스레드가 mtx1을 잠근 순간 다른 스레드가 mtx2를 잠갔다면, 첫 번째 스레드는 mtx2를, 두 번째 스레드는 mtx1을 서로 기다리며 영원히 풀리지 않는 순환 대기, 즉 데드락에 빠집니다. 이 문제는 잠금 순서가 스레드마다 다를 때만 발생하므로, 항상 같은 순서로 잠그도록 강제하거나 아래에서 볼 std::lock을 사용하면 해결됩니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx1, mtx2;
int balance1 = 100, balance2 = 200;
// ❌ 데드락 가능
void badTransfer() {
// Thread 1: mtx1 → mtx2
mtx1.lock();
mtx2.lock();
balance1 -= 50;
balance2 += 50;
mtx2.unlock();
mtx1.unlock();
}
void anotherBadTransfer() {
// Thread 2: mtx2 → mtx1 (데드락!)
mtx2.lock();
mtx1.lock();
balance2 -= 30;
balance1 += 30;
mtx1.unlock();
mtx2.unlock();
}
std::lock으로 한 번에 잠그기
std::lock은 여러 뮤텍스를 한 번의 호출로 모두 잠가, 어떤 스레드가 어떤 순서로 인자를 넘기든 데드락이 발생하지 않도록 보장하는 알고리즘입니다. 구현은 보통 하나를 잠근 뒤 나머지를 try_lock으로 시도하고, 실패하면 잡은 것을 모두 풀고 다른 순서로 다시 시도하는 방식(try-and-back-off)이라, 호출하는 쪽에서 잠금 순서를 신경 쓸 필요가 없습니다. 다만 std::lock으로 잠근 뮤텍스는 이미 잠겨 있으므로, 그 뒤에 lock_guard로 다시 감쌀 때는 추가로 잠그지 말라는 의미의 std::adopt_lock 태그를 함께 넘겨 소유권만 인수하도록 해야 합니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx1, mtx2;
int balance1 = 100, balance2 = 200;
// ✅ std::lock (데드락 방지)
void safeTransfer() {
std::lock(mtx1, mtx2); // 원자적으로 둘 다 잠금
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
balance1 -= 50;
balance2 += 50;
std::cout << "이체 완료: " << balance1 << ", " << balance2 << std::endl;
}
int main() {
std::thread t1(safeTransfer);
std::thread t2(safeTransfer);
t1.join();
t2.join();
return 0;
}
C++17 scoped_lock
std::lock + adopt_lock 조합은 안전하지만 매번 세 줄을 써야 해서 번거롭습니다. C++17에서 도입된 std::scoped_lock은 이 패턴을 하나의 클래스로 캡슐화해, 생성자에 여러 뮤텍스를 나열하기만 하면 내부적으로 데드락 없는 순서로 모두 잠그고 소멸자에서 한꺼번에 해제해줍니다. 실무에서 두 개 이상의 뮤텍스를 함께 다뤄야 한다면 std::lock을 직접 쓰기보다 scoped_lock을 사용하는 것이 더 간결하고 실수할 여지도 적습니다. 뮤텍스가 하나일 때도 쓸 수 있어서, C++17 이상 코드베이스에서는 lock_guard 대신 scoped_lock을 기본으로 쓰는 팀도 많습니다.
scoped_lock이 모든 데드락을 없애 주는 것은 아닙니다. 한 번에 동시에 잡는 뮤텍스들 사이의 순서 문제만 해결합니다. 스레드 A가 scoped_lock(m1)을 잡은 상태에서 함수를 호출하고, 그 함수가 다시 scoped_lock(m2)를 잡는 식으로 중첩해서 잠그면 여전히 순서 규칙이 필요합니다. 특히 락을 잡은 채로 콜백, 가상 함수, 로깅 훅처럼 “안에서 무엇을 할지 모르는 코드”를 호출하는 것이 실무에서 가장 흔한 데드락 원인입니다. 계좌 이체처럼 같은 타입의 객체 두 개를 잠글 때는 transfer(a, b)와 transfer(b, a)가 동시에 불리는 경우를 생각해야 하며, 이때 scoped_lock(a.mtx, b.mtx)는 인자 순서가 달라도 안전하게 동작합니다. 단, a와 b가 같은 객체면 같은 뮤텍스를 두 번 잠그게 되어 미정의 동작이므로 먼저 걸러 내야 합니다.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx1, mtx2;
// ✅ C++17: scoped_lock (더 간결)
void modernTransfer() {
std::scoped_lock lock(mtx1, mtx2);
// 작업 수행
std::cout << "이체 수행" << std::endl;
// 소멸자에서 자동 unlock
}
int main() {
std::thread t1(modernTransfer);
std::thread t2(modernTransfer);
t1.join();
t2.join();
return 0;
}
recursive_mutex와 timed_mutex
표준 라이브러리는 std::mutex 외에도 특수한 상황을 위한 몇 가지 변형을 제공합니다. 재귀 잠금이 필요한 경우와 잠금 시도에 시간 제한을 두어야 하는 경우가 대표적입니다.
recursive_mutex
일반 std::mutex는 이미 잠근 스레드가 다시 잠그려고 하면 데드락에 빠집니다. 재귀 호출 구조에서 같은 스레드가 같은 뮤텍스를 여러 번 잠글 필요가 있다면 std::recursive_mutex를 사용해야 하는데, 아래 예제처럼 func2가 뮤텍스를 잠근 상태에서 내부적으로 func1을 호출해 같은 뮤텍스를 다시 잠가도 문제없이 동작합니다. 다만 재귀 뮤텍스가 필요하다는 것은 종종 설계가 복잡해졌다는 신호이므로, 가능하면 잠금 범위를 재구성해 일반 뮤텍스로 해결하는 것이 더 바람직합니다.
#include <mutex>
#include <iostream>
std::recursive_mutex rmtx;
void func1() {
std::lock_guard<std::recursive_mutex> lock(rmtx);
std::cout << "func1" << std::endl;
}
void func2() {
std::lock_guard<std::recursive_mutex> lock(rmtx);
std::cout << "func2" << std::endl;
func1(); // OK: 재귀 뮤텍스
}
int main() {
func2();
return 0;
}
timed_mutex
일반 뮤텍스의 lock()은 잠금을 획득할 때까지 무한정 대기하지만, std::timed_mutex는 try_lock_for나 try_lock_until로 일정 시간만 대기하다가 포기할 수 있습니다. 아래 예제는 100밀리초 동안만 잠금을 시도하고, 그 안에 획득하지 못하면 실패로 처리해 다른 작업을 계속 진행합니다. 이런 시간 제한 잠금은 응답 지연을 절대 허용할 수 없는 UI 스레드나, 잠금 실패 시 재시도·폴백 로직으로 넘어가야 하는 서버 코드에서 유용합니다. try_lock_for에는 steady_clock 기반 기간을 쓰고, try_lock_until에 system_clock 시각을 넘기면 시스템 시계가 조정될 때 대기 시간이 예상과 달라질 수 있다는 점도 알아 두세요. 또 try_lock 계열은 타임아웃이 없어도 실패할 수 있다고 표준이 허용하므로(spurious failure), 실패를 “누군가 잡고 있다”의 확실한 증거로 쓰면 안 됩니다.
읽기가 쓰기보다 훨씬 많은 데이터라면 C++17의 std::shared_mutex도 고려할 만합니다. 읽는 쪽은 std::shared_lock으로 여러 스레드가 동시에 들어가고, 쓰는 쪽만 std::unique_lock으로 독점합니다. 다만 임계 구역이 아주 짧으면 공유 잠금의 관리 비용이 일반 뮤텍스보다 커서 오히려 느릴 수 있으므로, 측정 후에 바꾸는 편이 안전합니다. 자세한 내용은 shared_mutex 글에서 다룹니다.
#include <mutex>
#include <thread>
#include <iostream>
std::timed_mutex tmtx;
void tryLockFor() {
// 100ms 동안 시도
if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
std::cout << "락 획득" << std::endl;
// 작업 수행
std::this_thread::sleep_for(std::chrono::milliseconds(50));
tmtx.unlock();
} else {
std::cout << "락 획득 실패 (타임아웃)" << std::endl;
}
}
int main() {
std::thread t1(tryLockFor);
std::thread t2(tryLockFor);
t1.join();
t2.join();
return 0;
}
스레드 안전 카운터 클래스 만들기
지금까지 배운 lock_guard를 실제 클래스 설계에 적용한 예제입니다. ThreadSafeCounter는 내부 카운터 count에 접근하는 모든 메서드(increment, decrement, get, reset) 각각에서 std::lock_guard로 뮤텍스를 잠가, 클래스를 사용하는 쪽에서 동기화를 신경 쓰지 않아도 항상 안전하게 동작하도록 캡슐화했습니다. get()처럼 값을 읽기만 하는 const 메서드에서도 잠금이 필요하기 때문에 mtx를 mutable로 선언한 점에 주목할 만한데, 이는 읽기 연산도 다른 스레드의 쓰기와 겹치면 데이터 레이스가 될 수 있기 때문입니다. 아래 main에서는 10개 스레드가 각각 1000번씩 증가시켜 총 1만 번의 증가가 데이터 레이스 없이 정확히 반영되는지 확인합니다.
#include <mutex>
#include <thread>
#include <vector>
#include <iostream>
// 타입 정의
class ThreadSafeCounter {
mutable std::mutex mtx;
int count = 0;
public:
void increment() {
std::lock_guard<std::mutex> lock(mtx);
++count;
}
void decrement() {
std::lock_guard<std::mutex> lock(mtx);
--count;
}
int get() const {
std::lock_guard<std::mutex> lock(mtx);
return count;
}
void reset() {
std::lock_guard<std::mutex> lock(mtx);
count = 0;
}
};
int main() {
ThreadSafeCounter counter;
std::vector<std::thread> threads;
// 10개 스레드가 각각 1000번 증가
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 1000; ++j) {
counter.increment();
}
});
}
for (auto& t : threads) {
t.join();
}
std::cout << "최종 카운트: " << counter.get() << std::endl; // 10000
return 0;
}
이 클래스는 메서드 하나하나는 안전하지만, 메서드를 조합한 연산까지 안전하게 만들어 주지는 않습니다. 예를 들어 if (counter.get() == 0) counter.increment();는 get()과 increment() 사이에 다른 스레드가 끼어들 수 있어서, 두 스레드가 동시에 0을 보고 둘 다 증가시킬 수 있습니다. “확인하고 바꾸기”가 한 덩어리여야 한다면 bool incrementIfZero()처럼 그 연산 자체를 잠금 안에서 수행하는 메서드를 제공해야 합니다. 스레드 안전 클래스를 설계할 때 가장 자주 놓치는 부분입니다.
카운터 하나만 보호한다면 뮤텍스보다 std::atomic<int>가 더 간단하고 빠릅니다. 뮤텍스가 빛을 발하는 것은 여러 필드를 함께 일관되게 바꿔야 할 때, 예를 들어 잔액과 거래 내역을 동시에 갱신하는 경우입니다.
뮤텍스와 락 정리
핵심 요약
- mutex: 상호 배제, 공유 데이터 보호
- lock_guard: 간단한 RAII 락
- unique_lock: 유연한 락 (수동 제어)
- scoped_lock: 여러 뮤텍스 (C++17)
- recursive_mutex: 재귀 잠금 가능
- timed_mutex: 시간 제한 잠금
lock 종류 비교
| Lock | 특징 | 오버헤드 | 사용 시기 |
|---|---|---|---|
| lock_guard | 간단, RAII | 낮음 | 기본 |
| unique_lock | 유연, 수동 제어 | 중간 | 조건부 잠금 |
| scoped_lock | 여러 뮤텍스 | 낮음 | 다중 뮤텍스 (C++17) |
이어서 볼 글
- C++ atomic: 락 없는 카운터, 메모리 순서, 언제 mutex 대신 쓰나
- C++ condition_variable로 작업 큐 만들기: 허위 깨움 처리와 스레드 풀
- C++ Data Race: Mutex 대신 Atomic을 써야 하는 상황
같이 보면 좋은 글
- C++ async & launch
- C++ atomic: 락 없는 카운터, 메모리 순서, 언제 mutex 대신 쓰나
- C++20 std::jthread: 자동 join과 stop_token으로 스레드 협조적 중단하기
- C++ thread_local: 스레드별 카운터·버퍼, 초기화 시점과 소멸 순서
- C++ shared_mutex
자주 묻는 질문 (FAQ)
Q. recursive_mutex는 언제 쓰고, 왜 남용하면 안 되나요?
A. 같은 스레드가 이미 잠근 std::mutex를 다시 lock()하면 미정의 동작이고 보통 데드락이 되지만, std::recursive_mutex는 잠금 횟수를 세어 같은 스레드의 재진입을 허용합니다. 락을 잡는 공개 멤버 함수끼리 서로 호출하는 기존 코드처럼 구조를 당장 바꾸기 어려울 때 제한적으로 씁니다. 다만 어디서 락이 잡혀 있는지와 불변식이 언제 깨지는지 추적하기 어려워지므로, 락을 잡지 않는 private 헬퍼 함수로 분리하는 리팩터링이 대부분 더 나은 해법입니다.