1. 하나의 std::lock_guard만으로는 충분하지 않을 때가 있다
std::lock_guard를 “문에 단 자물쇠”로 보면, 열쇠 없는 자물쇠와 같습니다: 문을 닫으면 방을 나갈 때까지 열 수 없습니다. 대부분의 경우에는 이것이 이상적입니다: 잠금을 “가지고 놀” 수 있는 여지가 적을수록 실수를 저지를 가능성도 줄어듭니다.
하지만 전형적인 상황이 두 가지 있습니다.
첫째: 함수 내부에 안전하게 데이터를 가져와야 하는 작은 구간이 있고, 그 이후로는 긴 작업이 이어집니다 — 계산, 문자열 포맷, 정렬, 리포트 준비 등. 이런 모든 시간 동안 뮤텍스를 잡아두면 다른 스레드들을 인위적으로 지연시킵니다. 원하는 동작은 이렇습니다: “빠르게 잡고 → 빠르게 놓고 → 락 없이 오래 처리하고 → 빠르게 결과를 반환”.
둘째: 연산이 두 개의 보호된 객체를 건드립니다. 예를 들어 두 계좌 간 이체: 중간 상태가 노출되지 않도록 양쪽 잔액을 모두 변경해야 합니다. 이 경우에는 “두 개의 뮤텍스를 논리적 하나의 연산으로 획득”해야 하는 문제가 생깁니다.
이 두 상황을 위해 std::unique_lock과 std::scoped_lock가 고안되었습니다.
std::unique_lock: 해제와 재획득이 가능한 RAII 락
std::unique_lock<Mutex>는 lock_guard와 마찬가지의 RAII 래퍼이지만 더 “똑똑”하고 유연합니다. 기본 아이디어는 동일합니다: 객체가 존재하는 동안 락을 보유하고, 객체가 파괴되면 락을 해제합니다. 하지만 unique_lock은 보유 시간 제어를 제공합니다: 중간에 unlock()을 하고 필요하면 다시 lock()을 할 수 있습니다.
중요한 철학은 다음과 같습니다: unique_lock는 “더 낫다”가 아니라 “더 위험하지만 필요한 경우가 있다”입니다. 더 많은 권한을 주므로 — 권한은 초보자에게도 문제를 일으킬 수 있습니다.
최소 골격은 다음과 같습니다:
#include <mutex>
std::mutex m;
void f() {
std::unique_lock<std::mutex> lock(m); // 잠금 획득
// 임계 구역
lock.unlock(); // scope 끝보다 일찍 잠금 해제
// 임계 구역이 아닌 작업
} // 여전히 잠금이 유지되어 있다면 소멸자에서 해제됨
여기서 주목할 점은 unique_lock이 “아름다움” 때문이 아니라, 임계 구역의 경계가 블록 경계({ ... })와 일치하지 않을 때 유용하다는 것입니다.
2. std::unique_lock에서의 패턴 “snapshot → compute → commit”
이제 작고 현실적인 예를 하나 보겠습니다: 여러 스레드가 공용 리스트에 숫자를 추가(비유하자면 “메트릭”)하고, 주기적으로 리포트를 생성합니다. 리포트 생성은 오래 걸릴 수 있으므로(예: 합 계산, 포맷) 전체를 뮤텍스로 보호하는 것은 좋지 않습니다.
간단한 애플리케이션 스텁부터 시작하겠습니다: 공용 Metrics 컨테이너와 값을 추가하는 워커 스레드. 여기서는 의도적으로 간단화했습니다: 작업 큐나 이벤트 대기는 생략합니다 — 그건 다른 시간에 다룹니다.
공용 데이터와 워커 스레드
#include <mutex>
#include <vector>
std::mutex metrics_m;
std::vector<int> metrics;
void push_metric(int x) {
std::lock_guard<std::mutex> lock(metrics_m);
metrics.push_back(x);
}
여기서는 lock_guard가 완벽합니다: “획득 → 추가 → 빠져나옴”이 딱 맞습니다. 여타 트릭은 불필요합니다.
리포트 구성: 락 아래에서 복사하고, 락 없이 계산
이제 리포트 함수를 봅시다. 먼저 보호하에 “스냅샷”(snapshot)을 찍고, 뮤텍스를 해제한 뒤 얼마든지 계산합니다.
#include <mutex>
#include <numeric>
#include <vector>
std::vector<int> snapshot_metrics() {
std::unique_lock<std::mutex> lock(metrics_m);
std::vector<int> copy = metrics; // 빠르게 복사
lock.unlock(); // 뮤텍스 해제
return copy;
}
주의: 우리는 metrics를 “보호 없이 읽는다”고 시도하지 않습니다. 정직하게 복사한 뒤 로컬 데이터로 작업합니다. 이것이 멀티스레드의 핵심 트릭입니다: 공용 데이터를 로컬 데이터로 바꿀 수 있다면 문제의 수가 급격히 줄어듭니다.
이제 복사본으로 합과 평균을 계산합니다:
#include <iostream>
#include <numeric>
#include <vector>
void print_report(const std::vector<int>& data) {
const int sum = std::accumulate(data.begin(), data.end(), 0);
std::cout << "count=" << data.size() << " sum=" << sum << '\n';
// 예: count=10 sum=42
}
전체 흐름은 다음과 같습니다:
flowchart TD
A[lock] --> B[copy metrics]
B --> C[unlock]
C --> D[compute on local copy]
D --> E[print / format]
여기서 unique_lock의 의미는 락의 경계가 “의미상 필요한 위치”에 있도록 하는 것입니다. 함수 끝이 아닌 곳에 경계를 둘 수 있게 해줍니다.
왜 같은 일을 블록 { ... }로 하지 않나?
좋은 질문입니다: 때로는 그렇게 할 수 있습니다.
std::vector<int> copy;
{
std::lock_guard<std::mutex> lock(metrics_m);
copy = metrics;
} // lock_guard가 파괴되어 뮤텍스가 해제됨
이 방식은 작동하며 종종 더 간단해서 선호됩니다. 하지만 unique_lock은 논리가 더 복잡할 때 유용합니다: 조기 리턴, 여러 단계, 조건부로 락 유지, 함수 끝 쪽에서 다시 락을 얻어야 하는 경우 등.
4. 지연 획득: std::defer_lock
함수에서 미리 “락 객체”를 만들지만 실제로는 해당 지점까지 도달했을 때만 뮤텍스를 잡고 싶을 때가 있습니다. 예를 들어 빠른 종료 경로가 있어 “데이터가 없으면 바로 반환”하는 경우, 불필요한 락은 피하고 싶습니다.
이를 위해 unique_lock에는 “deferred locking” 모드가 있습니다 — 객체는 만들되 즉시 뮤텍스를 획득하지 않는 것입니다.
#include <mutex>
extern std::mutex metrics_m;
void maybe_touch_metrics(bool need) {
std::unique_lock<std::mutex> lock(metrics_m, std::defer_lock); // 아직 잠금하지 않음
if (!need) {
return; // 괜찮음: 우리는 락을 전혀 획득하지 않음
}
lock.lock(); // 이제야 잠금 획득
// 공용 데이터 작업
}
여기서 스스로에게 정직해야 합니다: 유연성이 유용하지만 바로 이런 지점에서 “이미 잠겨 있다고 생각했다”와 같은 실수가 발생합니다. 따라서 defer_lock은 매일 쓰는 도구는 아니며, 코드 중복을 피하거나 락을 너무 일찍 잡는 것을 막아야 할 때만 고려하세요.
5. std::scoped_lock: 여러 뮤텍스를 위한 하나의 RAII 객체
만약 unique_lock이 “락 보유 시간” 문제를 해결한다면, std::scoped_lock은 “여러 뮤텍스를 한 번에 획득해야 한다”는 문제를 해결합니다.
아이디어는 단순합니다: 하나의 scoped_lock을 생성하고 여러 뮤텍스를 전달하면, 그 객체가 존재하는 동안 모두 보유됩니다. 두 번의 lock_guard를 연속으로 쓰는 것보다 낫습니다. 연속으로 쓰면 코드의 다른 부분에서 뮤텍스를 서로 다른 순서로 잡아 데드락 위험을 만들 수 있기 때문입니다.
(데드락에 대한 자세한 내용은 다음 강의에서 다룹니다 — 오늘은 “두 개의 자물쇠를 동시에 올바르게 잡는 방법”을 배우는 것이 목적입니다.)
미니 모델: 두 계좌와 그 사이의 이체
간단한 계좌 구조를 만듭니다: 잔액 + 자체 뮤텍스.
#include <mutex>
struct Account {
int balance = 0;
std::mutex m;
};
이체 연산은 두 개의 계좌에 대한 작업입니다. 따라서 동시에 두 개의 뮤텍스가 필요합니다:
#include <mutex>
void transfer(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.m, to.m);
from.balance -= amount;
to.balance += amount;
}
코드는 짧고 읽기 쉽습니다: “이체를 위해 두 객체를 동시에 보호해야 한다”가 명확합니다. 초심자에게 중요한 점은 임계 구역의 경계가 다시 lock 변수의 유효 범위와 일치한다는 것입니다.
뮤텍스가 두 개 이상인 경우
문법적으로는 문제 없습니다: scoped_lock은 여러 뮤텍스를 받을 수 있습니다.
std::scoped_lock lock(m1, m2, m3);
설계 측면에서는 “내 연산이 한 번에 너무 많은 일을 하는 것은 아닌가?”를 고민해야 할 신호입니다. 하지만 도구 자체는 이를 지원합니다.
6. 선택 미니 표: lock_guard vs unique_lock vs scoped_lock
도구가 많아지면 머리가 혼란스러워지기 쉽습니다(그건 정상입니다). 간단한 의사결정 표가 도움이 됩니다 — 규칙이 아니라 치트시트로 보세요.
| 도구 | 언제를 선택할까 | 무엇을 할 수 있나 | 실수의 비용 |
|---|---|---|---|
|
“스코프 전체를 잠그고 잊기” | 단순한 RAII 보유 | 최소: unlock()을 잊기 어려움 |
|
함수 내부에서 unlock()/lock()이 필요하거나 지연 획득이 필요할 때 | 조기 unlock(), 재잠금 lock(), defer_lock | 더 높음: 지금 잠겨 있는지 헷갈릴 수 있음 |
|
하나의 연산으로 여러 뮤텍스를 잡아야 할 때 | 여러 뮤텍스에 대한 RAII | 높음: 서로 다른 순서로 직접 잠그면 문제 발생 |
기본 원칙은 그대로입니다: 가능한 한 단순한 것(lock_guard)으로 시작하고, 실제로 필요할 때만 더 유연한 도구로 전환하세요. 스레드 환경에서 “너무 똑똑한 코드”는 보통 “곧 터질 버그”를 의미합니다.
체크 질문
- 어떤 시나리오에서 std::unique_lock이 블록 { ... }와 std::lock_guard보다 더 유용한가?
- 왜 패턴 “snapshot → compute → commit”이 최적화가 없어도 멀티스레드 코드를 자주 빠르게 만드는가?
- 왜 두 객체 간의 돈 이체는 동시에 두 뮤텍스를 획득해야 하는가?
- std::scoped_lock은 “연속된 두 개의 lock_guard”와 비교해 어떤 의미의 문제를 해결하는가?
- 왜 std::defer_lock이 코드를 더 깔끔하게도 하고 더 위험하게도 만들 수 있는가?
7. std::unique_lock 및 std::scoped_lock 사용 시 전형적인 실수
실수 №1: 만약을 대비해 모든 곳에 std::unique_lock을 사용하는 것.
이것은 흔한 반사 반응입니다: unique_lock이 더 많은 기능을 제공하니 “범용적”이라고 생각하는 것. 실제로는 반대가 됩니다: 위험한 도구를 코드 전반에 펴 바르면, 어디에서 뮤텍스가 보유되고 어디에서 해제되는지 눈으로 확인하기 어려워집니다. 올바른 습관은 기본적으로 lock_guard를 사용하고, 정말 필요할 때만 unique_lock을 선택하는 것입니다.
실수 №2: 인바리언트를 복원하지 않고 unlock()을 호출하는 것.
가장 교활한 논리적 실수 중 하나는 보호된 상태의 일부만 변경하고 뮤텍스를 해제한 뒤 나머지를 완료하는 것입니다. 멀티스레드 환경에서는 “나중에”가 오지 않거나 너무 늦게 올 수 있습니다: 다른 스레드가 중간 상태를 보게 됩니다. 뮤텍스를 중간에 해제한다면, 보호된 데이터가 다시 일관된 상태가 될 때까지 해제하지 마세요.
실수 №3: unlock() 이후에도 습관적으로 공용 데이터를 읽거나 쓰는 것.
예: unique_lock을 얻어 sharedValue를 읽고 unlock()을 호출한 뒤, 아래에서 마치 값이 여전히 보호되고 있는 것처럼 다시 사용하는 경우입니다. 컴파일 오류는 아니지만 사고의 오류입니다. 규율: unlock() 이후에는 로컬 복사본으로만 작업하거나, 다시 공용 리소스가 필요하면 명시적으로 lock()을 호출하세요.
실수 №4: 여러 뮤텍스를 서로 다른 함수에서 서로 다른 순서로 수동으로 획득하는 것.
오늘은 데드락을 자세히 다루지 않았지만, 한 위치에서 mA를 먼저 잡고 mB를 나중에 잡는 반면, 다른 위치에서는 그 반대라면 “영원한 대기” 조건을 스스로 만듭니다. std::scoped_lock은 “모두 한꺼번에 잡는다”는 의도를 명시적으로 표현해주어 이런 실수의 위험을 줄여줍니다.
실수 №5: std::scoped_lock을 너무 오래 유지하며 그 안에서 “무거운” 작업을 수행하는 것.
scoped_lock은 임계 구역을 확장하기 쉽습니다: 두 뮤텍스가 동시에 잡혀 있으므로 더 많은 스레드와 더 많은 코드가 차단됩니다. 그 안에 무거운 문자열 포맷이나 입출력 같은 작업이 들어가면 병목이 생깁니다. 원칙은 동일합니다: 락 안에서는 정말로 상호 배제가 필요한 작업만 하세요.
GO TO FULL VERSION