뮤텍스와 락
공유 상태의 경쟁 조건을 재현하고 mutex와 RAII 락으로 임계 영역을 보호하며 데드락을 예방합니다.
스레드 생성과 관리의 예제는 두 스레드가 서로 다른 변수에만 썼기 때문에 문제가 없었습니다. 여러 스레드가 같은 변수를 동시에 바꾸면, 한 스레드가 바꾸는 도중에 다른 스레드가 끼어들어 값이 꼬일 수 있습니다. 이런 접근을 차례로 하나씩만 일어나게 만드는 것을 동기화(synchronization)라고 하며, 가장 기본적인 도구가 뮤텍스(mutex)입니다.
이 글은 동시에 같은 데이터를 바꿀 때 생기는 데이터 경쟁, 뮤텍스와 그것을 안전하게 쓰는 락, 간단한 값에 쓰는 원자적 변수, 읽기와 쓰기를 나누는 공유 뮤텍스, 그리고 뮤텍스를 여러 개 쓸 때 생기는 교착 상태를 다룹니다.
데이터 경쟁
++counter는 한 문장이지만, 실제로는 값을 읽고, 1을 더하고, 다시 쓰는 여러 단계로 실행됩니다. 두 스레드가 같은 counter에 동시에 ++를 하면, 둘이 같은 값을 읽어 각자 1을 더해 쓰는 바람에 하나가 사라질 수 있습니다.
#include <iostream>
#include <thread>
int counter = 0;
void work() {
for (int i = 0; i < 100000; ++i) {
++counter; // 동기화 없음: 데이터 경쟁
}
}
int main() {
std::thread t1(work);
std::thread t2(work);
t1.join();
t2.join();
std::cout << counter << '\n';
}두 스레드가 10만 번씩 더하므로 200000을 기대하지만, 이 프로그램이 무엇을 출력할지는 말할 수 없습니다. 적어도 한 스레드가 쓰는 같은 메모리를 여러 스레드가 동기화 없이 접근하는 것을 데이터 경쟁(data race)이라고 하며, C++에서는 정의되지 않은 동작입니다. 실행할 때마다 다른 값이 나오거나, 우연히 200000이 나와 문제가 숨을 수도 있어서, 실행해 보는 것으로는 올바른지 확인할 수 없습니다.
이렇게 실행 순서에 따라 결과가 달라지는 상황을 경쟁 조건(race condition)이라고 부르며, 여러 스레드가 함께 쓰는 데이터를 다루는 코드 구간을 임계 영역(critical section)이라고 합니다. 임계 영역은 한 번에 한 스레드만 실행하도록 보호해야 하며, 임계 영역 문제가 만족해야 할 조건은 운영체제 교재의 동기화 문제의 본질에서 자세히 다룹니다.
뮤텍스와 락
<mutex>의 std::mutex는 한 번에 한 스레드만 잠글 수 있는 객체입니다. 임계 영역에 들어가기 전에 lock()으로 잠그고 나올 때 unlock()으로 풀면, 다른 스레드는 잠금이 풀릴 때까지 lock()에서 기다립니다. 하지만 직접 unlock()을 부르면, 조기 반환이나 예외로 그 줄을 건너뛰어 잠금이 영원히 남을 수 있습니다.
그래서 뮤텍스는 동적 메모리 할당에서 본 RAII 방식의 락 객체로 다룹니다. std::lock_guard는 만들 때 뮤텍스를 잠그고, 블록을 벗어나 사라질 때 자동으로 풉니다.
#include <iostream>
#include <mutex>
#include <thread>
int counter = 0;
std::mutex counterMutex;
void work() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(counterMutex);
++counter;
}
}
int main() {
std::thread t1(work);
std::thread t2(work);
t1.join();
t2.join();
std::cout << counter << '\n';
}200000아래 그림처럼 두 스레드가 같은 뮤텍스를 쓰므로, 한 스레드가 counter를 바꾸는 동안 다른 스레드는 기다렸다가 잠금이 풀린 뒤에 들어갑니다. 그래서 모든 ++가 하나씩 차례로 일어나 결과가 언제나 200000입니다. 어느 스레드가 먼저 잠그는지는 실행할 때마다 다를 수 있지만, 결과는 그 순서와 상관이 없습니다.
동일한 mutex가 공유 invariant의 모든 접근을 직렬화한다
A가 먼저 획득하는 한 가지 가능한 lock→불변식 갱신→unlock→B 획득 순서를 선택해 소유자·공유 상태 접근을 따라갈 수 있다.
C++ · MUTEX CONTRACT
같은 mutex로 공유 상태의 접근을 직렬화한다
data race는 결과가 가끔 틀리는 수준이 아니라 undefined behavior다. 읽기와 쓰기를 같은 mutex 계약 아래 두고 스코프를 벗어나거나 스택 풀기가 진행될 때 RAII lock이 잠금을 해제하게 한다.
- 공유 invariant 정의
서로 함께 참이어야 하는 필드와 연산 범위를 먼저 정합니다.
- 같은 mutex 사용
모든 읽기·쓰기가 동일한 잠금 규칙을 따라야 합니다.
- RAII lock
lock_guard또는scoped_lock이 범위를 벗어날 때 자동으로 해제합니다. - 짧은 임계 영역
sleep, I/O, 긴 계산은 가능하면 잠금 밖으로 옮깁니다.
volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 한 가능한 순서이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | A가 먼저 lock한 가능한 순서 하나이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 참여자를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다. |
| 노드·연결선 라벨 | 1 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 2 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 3 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 4 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 5 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 6 · 가능한 실행 순서 하나 |
| 본문·주석 | volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 가능한 순서 하나이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A의 lock 가능한 실행 하나에서 A가 먼저 mutex를 얻는다. |
|
|
| A의 갱신과 unlock A가 보호한 상태를 갱신한 뒤 잠금을 푼다. |
|
|
| B의 lock B가 이후 잠금을 얻어 상태를 다룬다. |
|
|
동일한 mutex가 공유 invariant의 모든 접근을 직렬화한다
A가 먼저 획득하는 한 가지 가능한 lock→불변식 갱신→unlock→B 획득 순서를 선택해 소유자·공유 상태 접근을 따라갈 수 있다.
C++ · MUTEX CONTRACT
같은 mutex로 공유 상태의 접근을 직렬화한다
data race는 결과가 가끔 틀리는 수준이 아니라 undefined behavior다. 읽기와 쓰기를 같은 mutex 계약 아래 두고 스코프를 벗어나거나 스택 풀기가 진행될 때 RAII lock이 잠금을 해제하게 한다.
- 공유 invariant 정의
서로 함께 참이어야 하는 필드와 연산 범위를 먼저 정합니다.
- 같은 mutex 사용
모든 읽기·쓰기가 동일한 잠금 규칙을 따라야 합니다.
- RAII lock
lock_guard또는scoped_lock이 범위를 벗어날 때 자동으로 해제합니다. - 짧은 임계 영역
sleep, I/O, 긴 계산은 가능하면 잠금 밖으로 옮깁니다.
volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 한 가능한 순서이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | A가 먼저 lock한 가능한 순서 하나이다. A가 공유 불변식을 갱신하고 unlock한 다음 B가 같은 mutex를 lock한다. B도 공유 불변식을 갱신하고 unlock한다. 모바일은 여섯 교환마다 같은 참여자를 다시 표기해 여섯 개의 방향·연산을 유지하며, 구간 길이는 실행 시간의 비례 축이 아니다. |
| 노드·연결선 라벨 | 1 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 2 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 3 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 4 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 5 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 6 · 가능한 실행 순서 하나 |
| 본문·주석 | volatile은 스레드 간 동기화를 제공하지 않는다. 그림은 A가 먼저 획득한 가능한 순서 하나이며 스레드 순서를 보장하지 않는다. 단일 독립 값에는 atomic을 검토한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A의 lock 가능한 실행 하나에서 A가 먼저 mutex를 얻는다. |
|
|
| A의 갱신과 unlock A가 보호한 상태를 갱신한 뒤 잠금을 푼다. |
|
|
| B의 lock B가 이후 잠금을 얻어 상태를 다룬다. |
|
|
보호하려는 데이터에 접근하는 모든 곳이 같은 뮤텍스를 잠가야 합니다. 쓰는 쪽만 잠그고 읽는 쪽이 잠그지 않으면 여전히 데이터 경쟁입니다. 또 잠근 채로 오래 걸리는 일을 하면 다른 스레드가 그만큼 기다리므로, 임계 영역은 꼭 필요한 부분만 감싸도록 짧게 만듭니다.
std::unique_lock은 lock_guard보다 유연한 락으로, 도중에 unlock()했다가 다시 lock()할 수 있고, 나중에 잠그도록 만들거나 다른 곳으로 옮길 수도 있습니다. 다음 글의 조건 변수는 기다리는 동안 잠금을 풀어야 하므로 unique_lock이 필요합니다.
원자적 변수
카운터 하나처럼 단순한 값이라면 <atomic>의 std::atomic을 쓸 수 있습니다. std::atomic<int>의 ++는 읽고 더하고 쓰는 단계가 다른 스레드에 쪼개지지 않는 원자적 연산(atomic operation)이어서, 뮤텍스 없이도 데이터 경쟁이 생기지 않습니다.
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> counter{0};
void work() {
for (int i = 0; i < 100000; ++i) {
++counter;
}
}
int main() {
std::thread t1(work);
std::thread t2(work);
t1.join();
t2.join();
std::cout << counter << '\n';
}200000원자적 변수는 값 하나를 안전하게 바꾸는 데는 알맞지만, 여러 변수를 함께 일관되게 바꿔야 하는 경우에는 쓸 수 없습니다. 예를 들어 두 계좌의 잔액을 함께 옮기는 일은 두 값을 한꺼번에 보호해야 하므로 뮤텍스가 필요합니다.
더 깊이: 원자적 연산의 메모리 순서와 잠금 없는 구현
std::atomic의 연산은 기본으로 가장 강한 순서인 순차적 일관성(std::memory_order_seq_cst)을 따릅니다. 모든 스레드가 원자적 연산들을 하나의 같은 순서로 본다는 뜻이어서 따져 보기 쉽지만, 플랫폼에 따라 그만큼 비용이 들 수 있습니다. 위의 counter처럼 다른 데이터의 공개 여부와 상관없이 횟수만 정확하면 되는 경우에는 counter.fetch_add(1, std::memory_order_relaxed)처럼 느슨한 순서를 줄 수 있습니다. cppreference의 메모리 순서 설명은 느슨한 순서가 원자성과 그 변수 하나의 수정 순서만 지킬 뿐 다른 메모리 접근의 순서는 정하지 않으며, std::shared_ptr의 참조 횟수를 늘리는 일이 그 대표적인 쓰임이라고 적고 있습니다.
느슨한 순서를 플래그로 쓰면 문제가 생깁니다. 한 스레드가 데이터를 쓴 뒤 ready를 느슨하게 참으로 바꾸어도, 다른 스레드가 ready가 참인 것을 보았을 때 데이터까지 보인다는 보장이 없습니다. 이런 공개에는 쓰는 쪽의 memory_order_release와 읽는 쪽의 memory_order_acquire를 짝지어, 읽는 쪽이 쓴 값을 읽었을 때 그 앞의 쓰기가 모두 보이게 합니다.
또 std::atomic<T>가 언제나 CPU의 잠금 없는 명령으로 구현되는 것은 아닙니다. std::atomic_flag를 뺀 모든 원자 타입은 뮤텍스 같은 잠금으로 구현될 수도 있어서, 큰 구조체를 담은 std::atomic은 잠금 없는 구현이 아닐 수 있습니다. C++17의 std::atomic<T>::is_always_lock_free로 그 타입이 언제나 잠금 없이 동작하는지를 컴파일할 때 확인할 수 있습니다.
공유 뮤텍스
데이터를 읽기만 하는 스레드가 많고 쓰는 스레드는 가끔뿐이라면, 읽기끼리는 동시에 해도 됩니다. C++17의 <shared_mutex>에 있는 std::shared_mutex는 std::shared_lock으로 여러 스레드가 함께 잠그는 공유 잠금과, std::unique_lock으로 한 스레드만 잠그는 배타적 잠금을 제공합니다.
#include <iostream>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <vector>
int sharedData = 0;
std::shared_mutex dataMutex;
int read() {
std::shared_lock<std::shared_mutex> lock(dataMutex);
return sharedData;
}
void write() {
std::unique_lock<std::shared_mutex> lock(dataMutex);
++sharedData;
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 3; ++i) {
threads.emplace_back([] { for (int j = 0; j < 5; ++j) { read(); } });
}
for (int i = 0; i < 2; ++i) {
threads.emplace_back([] { for (int j = 0; j < 2; ++j) { write(); } });
}
for (auto& t : threads) {
t.join();
}
std::cout << sharedData << '\n';
}4읽는 스레드 셋은 공유 잠금으로 동시에 읽을 수 있고, 쓰는 스레드가 배타적 잠금을 잡는 동안에는 다른 읽기와 쓰기가 모두 기다립니다. 쓰는 스레드 둘이 두 번씩 더하므로 마지막 값은 언제나 4이며, 각 읽기가 몇을 보게 될지는 실행 순서에 따라 다릅니다. 공유 뮤텍스는 관리 비용이 일반 뮤텍스보다 크므로, 읽기가 훨씬 많을 때만 이득이 있습니다.
교착 상태
뮤텍스를 둘 이상 쓰면, 스레드들이 서로 상대가 가진 뮤텍스를 기다리며 영원히 멈출 수 있습니다. 이를 교착 상태(deadlock)라고 합니다. 아래 그림처럼 스레드 A가 M1을 잡은 채 M2를 기다리고, 스레드 B가 M2를 잡은 채 M1을 기다리면 둘 다 진행할 수 없습니다.
C++ · DEADLOCK PREVENTION
순환 대기를 식별하고 획득 전에 예방한다
보유한 mutex와 기다리는 mutex의 관계를 따로 읽는다. 아래 예방 방법은 잠금 획득을 시작하기 전에 적용한다.
순환 대기 · A → M2 → B → M1 → A
A는 M1을 보유하고 M2를 기다린다. M2를 가진 B는 M1을 기다리며 M1은 A가 보유한다. 둘 다 진행하지 못한다.
예방 · C++17 scoped_lock
필요한 mutex를 획득하기 전에 한 객체에 함께 전달한다.
std::scoped_lock lock(mtx1, mtx2);교착 회피 획득과 스코프 종료 시 RAII 해제를 묶는다. 이미 막힌 스레드를 풀어 주는 연산은 아니다.
본문 대안 · defer_lock + std::lock
락 객체를 잠그지 않고 만든 뒤 교착 회피 알고리즘으로 획득한다.
std::unique_lock<std::mutex> a(
mtx1, std::defer_lock);
std::unique_lock<std::mutex> b(
mtx2, std::defer_lock);
std::lock(a, b);다른 방법은 모든 코드 경로가 같은 전역 잠금 순서를 지키는 것이다.
복수 mutex 획득은 원자적인 동시 획득이 아니다. std::lock은 일부 획득·해제와 재시도를 포함할 수 있다. timeout만으로 livelock·starvation까지 예방하지는 못한다.
운영체제 교재의 교착 상태의 개념에서 네 가지 조건으로 정리하듯, 교착 상태는 자원을 한 번에 한 스레드만 쓰고, 자원을 잡은 채 다른 자원을 기다리며, 남이 가진 자원을 빼앗을 수 없고, 기다림이 원을 이룰 때 생깁니다. 이 가운데 하나라도 막으면 생기지 않으며, 가장 쉬운 방법은 모든 스레드가 뮤텍스를 같은 순서로 잠그게 하거나, 여러 뮤텍스를 한꺼번에 잠그는 C++17의 std::scoped_lock을 쓰는 것입니다.
#include <iostream>
#include <mutex>
#include <thread>
struct Account {
std::mutex m;
int balance = 100;
};
void transfer(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.m, to.m);
from.balance -= amount;
to.balance += amount;
}
int main() {
Account a;
Account b;
std::thread t1([&] { for (int i = 0; i < 1000; ++i) { transfer(a, b, 1); } });
std::thread t2([&] { for (int i = 0; i < 1000; ++i) { transfer(b, a, 1); } });
t1.join();
t2.join();
std::cout << a.balance << ' ' << b.balance << '\n';
}100 100t1은 a에서 b로, t2는 b에서 a로 옮기므로, 각자 from.m을 먼저 잠그고 to.m을 나중에 잠그도록 직접 쓰면 두 스레드가 서로 반대 순서로 잠가 교착 상태가 생길 수 있습니다. std::scoped_lock은 넘긴 뮤텍스들을 교착 상태가 생기지 않는 방법으로 모두 잠그고, 블록을 벗어날 때 모두 풉니다. 두 스레드가 1000번씩 서로 옮기므로 잔액은 처음과 같은 100씩입니다. C++17 이전에는 std::lock(m1, m2)로 잠근 뒤 std::adopt_lock을 준 lock_guard로 넘기는 방식을 썼습니다.
정리
- 여러 스레드가 동기화 없이 같은 메모리에 접근하고 그중 하나라도 쓰면 데이터 경쟁이며, 정의되지 않은 동작입니다.
- 함께 쓰는 데이터를 다루는 임계 영역은
std::mutex로 한 번에 한 스레드만 실행하게 하고, 접근하는 모든 곳이 같은 뮤텍스를 씁니다. - 뮤텍스는 직접
unlock()하지 말고std::lock_guard나std::unique_lock같은 RAII 락으로 다룹니다. - 값 하나를 안전하게 바꿀 때는
std::atomic을 쓸 수 있지만, 여러 값을 함께 바꿀 때는 뮤텍스가 필요합니다. - 읽기가 훨씬 많다면
std::shared_mutex로 읽기끼리는 동시에 하게 할 수 있습니다. - 여러 뮤텍스는 같은 순서로 잠그거나
std::scoped_lock으로 한꺼번에 잠가 교착 상태를 막습니다.
연습 문제
1. 동기화하지 않은 카운터
data_race.cpp를 실행했더니 200000이 나왔습니다. 이 프로그램은 올바르다고 할 수 있나요?
해설 보기
올바르지 않습니다. 두 스레드가 같은 counter를 동기화 없이 바꾸므로 데이터 경쟁이고, 정의되지 않은 동작이어서 이번 실행에서 200000이 나온 것은 우연일 뿐입니다. 실행해 보는 것으로는 올바른지 확인할 수 없으므로, std::mutex나 std::atomic으로 접근을 동기화해야 합니다.
2. 락의 위치 옮기기
mutex_counter.cpp에서 lock_guard를 for 문 안이 아니라 for 문 바로 앞으로 옮기면 결과와 동작은 어떻게 달라지나요?
해설 보기
결과는 여전히 200000입니다. 다만 한 스레드가 10만 번의 덧셈을 모두 마칠 때까지 잠금을 쥐고 있으므로, 다른 스레드는 그동안 기다리기만 해서 두 스레드가 사실상 하나씩 차례로 실행됩니다. 임계 영역을 넓히면 안전하지만 동시에 실행하는 이점이 사라지므로, 꼭 보호해야 하는 부분만 감쌉니다.
3. 순서대로 잠그는 이체
scoped_lock_transfer.cpp의 transfer를 std::lock_guard<std::mutex> l1(from.m); std::lock_guard<std::mutex> l2(to.m);으로 바꾸면 어떤 일이 생길 수 있나요?
해설 보기
t1은 a.m을 먼저, t2는 b.m을 먼저 잠그므로, t1이 a.m을 잡고 t2가 b.m을 잡은 순간 서로 상대의 뮤텍스를 기다리며 교착 상태가 생길 수 있습니다. 실행할 때마다 생기지는 않아 찾기 어려우며, std::scoped_lock으로 두 뮤텍스를 한꺼번에 잠그거나, 계좌마다 번호를 매겨 언제나 번호가 작은 쪽부터 잠그면 막을 수 있습니다.
다음 글 조건 변수에서는 어떤 조건이 만족될 때까지 스레드를 잠재웠다가 깨우는 조건 변수와, 생산자와 소비자가 큐를 함께 쓰는 방법을 다룹니다.