C++ Data Race | "Mutex 대신 Atomic을 써야 하는 상황은?" 면접 단골 질문 정리
이 글의 핵심
C++ Data Race에 대한 실전 가이드입니다.
들어가며: “Mutex 대신 Atomic을 써야 하는 상황은 언제인가요?“
7번 시리즈를 면접용으로 압축
C++ 실전 가이드 #7-2: mutex와 동기화, #7-4: atomic과 메모리 모델에서 동기화 기초를 다뤘습니다. 이 글은 면접에서 꼬리물기로 나오는 질문에 대비해, Data Race 정의, Mutex vs Atomic 선택 기준, Deadlock 해결, CAS까지 한 번에 정리합니다.
이 글에서 다루는 것:
- Data Race (C++ 표준 정의), 동기화가 필요한 이유
- Mutex: 크리티컬 섹션, 한 번에 한 스레드만
- Atomic: 단일 변수 원자 연산, 락 없이
- Mutex vs Atomic — 언제 무엇을 쓸지
- Deadlock과 해결(순서 잠금, std::lock)
- CAS(Compare-And-Swap—메모리 값이 예상과 같을 때만 새 값으로 바꾸는 원자 연산. 락 없이 동기화할 때 사용) 개념
이 글을 읽으면:
- Data race의 정확한 정의와 실제 버그 사례를 이해할 수 있습니다.
- Mutex와 Atomic의 차이를 구분하고, 상황에 맞게 선택할 수 있습니다.
- Deadlock을 피하는 실전 패턴을 적용할 수 있습니다.
- CAS의 개념과
compare_exchange사용법을 알 수 있습니다.
개념을 잡는 비유
Mutex는 은행 창구 한 줄 서기처럼, 한 번에 한 사람만 공유 자원 앞에 서게 합니다. Atomic은 카운터 한 칸만 원자적으로 바꾸는 창구에 가깝고, 짧은 연산에는 줄을 서지 않아도 됩니다.
목차
- Data Race란 무엇인가
- 문제 시나리오: 실제로 발생하는 Data Race
- Mutex의 역할과 한계
- Atomic의 역할과 한계
- Mutex vs Atomic: 언제 무엇을 쓸까
- Deadlock과 해결법
- CAS(Compare-And-Swap) 개요
- 자주 발생하는 실수와 해결법
- 성능 비교: Mutex vs Atomic
- 프로덕션 패턴
- 면접 Q&A
1. Data Race란 무엇인가
C++ 표준에서의 정의
- 한 메모리 위치에 대해, 한 스레드는 쓰기하고 다른 스레드는 읽기 또는 쓰기를 하며, 그 접근들이 동기화되지 않은 경우를 data race라고 합니다.
- Data race가 있으면 C++ 표준상 undefined behavior입니다. “가끔만 틀린다”는 현상의 많은 원인이 data race입니다.
동기화 수단
- Mutex: 락을 잡은 스레드만 보호된 구간에 들어갈 수 있게 해서, 공유 메모리 접근이 겹치지 않게 함.
- Atomic: 해당 변수에 대한 읽기·쓰기·읽기-수정-쓰기를 원자적으로 만들어, 그 변수에 대한 data race를 제거함.
- Condition variable 등: 대기/알림으로 순서를 맞출 때 사용.
면접에서는 “data race는 동기화 없이 한 스레드는 쓰기, 다른 스레드는 읽기/쓰기를 할 때 발생하고, UB다. Mutex나 Atomic 등으로 제거한다”라고 말할 수 있으면 됩니다.
2. 문제 시나리오: 실제로 발생하는 Data Race
시나리오 1: 카운터가 깨지는 경우
이벤트 당일 주문 처리 서버에서 실시간 주문 수를 여러 워커 스레드가 하나의 카운터에 더하는 구조였습니다. 배치가 끝나고 집계를 보니 실제 DB 건수와 수만 건 차이가 났습니다.
// ❌ 문제 코드: counter++가 원자적이지 않음
#include <thread>
#include <iostream>
int counter = 0; // 공유 변수
void increment() {
for (int i = 0; i < 100000; ++i) {
counter++; // 읽기-수정-쓰기가 분리됨 → data race
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n"; // 200000이 아님!
return 0;
}
왜 깨지는가? counter++는 실제로 세 단계로 나뉩니다:
- 메모리에서
counter값을 읽기 - 그 값에 1을 더하기
- 결과를 메모리에 쓰기
두 스레드가 동시에 같은 값을 읽으면, 한 스레드의 증가가 다른 스레드에 의해 덮어써집니다.
sequenceDiagram
participant T1 as 스레드 1
participant Mem as 메모리
participant T2 as 스레드 2
Mem->>T1: 읽기: 0
Mem->>T2: 읽기: 0
T1->>T1: +1 → 1
T2->>T2: +1 → 1
T1->>Mem: 쓰기: 1
T2->>Mem: 쓰기: 1
Note over Mem: 결과 1 (기대값 2)
시나리오 2: 재고 감소 버그 (check-then-act)
온라인 쇼핑몰에서 여러 사용자가 동시에 같은 상품을 구매할 때, 재고가 음수가 되는 버그가 발생한 사례입니다.
// ❌ 문제 코드: 재고가 음수로 떨어질 수 있음
int stock = 100;
void purchase(int quantity) {
if (stock >= quantity) { // 스레드 A: 100 >= 5 → true
// 여기서 스레드 B가 끼어들어 stock을 읽고 10개 구매
stock -= quantity; // 스레드 A: stock = 95, 스레드 B: stock = 85
} // 결과: 15개 팔렸는데 stock은 85? 또는 음수?
}
원인: if 조건 검사와 stock -= quantity 사이에 다른 스레드가 끼어들 수 있어, “검사 시점”과 “수정 시점”이 분리됩니다. check-then-act 패턴의 전형적인 race condition입니다. 이 경우 Mutex로 조건 검사와 수정을 한 덩어리로 보호해야 합니다.
시나리오 3: 플래그와 데이터의 불일치
한 스레드가 데이터를 쓰고 플래그를 설정하는데, 다른 스레드는 플래그만 보고 데이터를 읽습니다. 컴파일러나 CPU가 명령 순서를 바꿀 수 있어 플래그가 먼저 설정되고 데이터는 나중에 쓰일 수 있습니다.
// ❌ 문제 코드: 플래그와 데이터의 순서 보장 없음
bool ready = false;
int data = 0;
void producer() {
data = 42; // 1. 데이터 쓰기
ready = true; // 2. 플래그 설정 (순서가 바뀔 수 있음!)
}
void consumer() {
while (!ready) // 3. 플래그 확인
;
std::cout << data << "\n"; // 4. data가 아직 0일 수 있음
}
해결: std::atomic<bool> ready와 std::atomic<int> data를 쓰거나, mutex로 두 접근을 묶어야 합니다.
시나리오 4: Double-Checked Locking 실패
싱글톤이나 지연 초기화에서 “락 없이 빠르게 읽기”를 위해 Double-Checked Locking을 쓰다가 잘못 구현한 사례입니다.
// ❌ 문제 코드: DCLP(Double-Checked Locking Problem) - C++11 이전
Singleton* Singleton::instance = nullptr;
std::mutex mtx;
Singleton* Singleton::getInstance() {
if (instance == nullptr) { // 1차 검사: 락 없이 (data race!)
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) { // 2차 검사
instance = new Singleton(); // 쓰기와 생성자 완료 순서가 보장되지 않음
}
}
return instance; // 다른 스레드가 아직 초기화 중인 객체를 읽을 수 있음
}
원인: instance에 대한 쓰기와 생성자 완료 사이에 메모리 순서가 보장되지 않아, 다른 스레드가 “쓰여진 포인터”를 읽었지만 객체가 아직 완전히 초기화되지 않은 상태를 볼 수 있습니다. C++11 이후에는 std::call_once나 static 지역 변수로 안전하게 해결합니다.
// ✅ C++11 이후: static 지역 변수 (스레드 안전, 지연 초기화)
Singleton& Singleton::getInstance() {
static Singleton instance; // C++11: 스레드 안전하게 한 번만 초기화
return instance;
}
시나리오 5: 캐시 무효화와 가시성
멀티코어 환경에서 스레드 A가 변수를 수정했는데, 스레드 B가 오래된 캐시 값을 계속 읽는 문제입니다. 동기화 없이는 한 코어의 쓰기가 다른 코어에 즉시 보이지 않을 수 있습니다.
// ❌ 문제 코드: 캐시 일관성 미보장
int shared_value = 0; // CPU0 캐시에 0, CPU1 캐시에도 0
// 스레드 A (CPU0)
void writer() {
shared_value = 42; // CPU0 캐시에만 42
}
// 스레드 B (CPU1)
void reader() {
std::cout << shared_value << "\n"; // CPU1 캐시의 0을 읽을 수 있음!
}
해결: std::atomic 또는 mutex를 사용하면 적절한 메모리 배리어가 삽입되어, 한 스레드의 쓰기가 다른 스레드에 가시적으로 됩니다.
3. Mutex의 역할과 한계
역할
- 한 번에 한 스레드만 지정한 구간(크리티컬 섹션)을 실행하게 합니다.
- 락을 잡은 스레드만 그 구간에 들어가고, 나머지는 락이 풀릴 때까지 대기합니다.
- 여러 변수를 한꺼번에 수정하거나, 복잡한 조건으로 읽고 쓸 때 적합합니다. (예: 큐에 넣고 빼기, 맵 수정)
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: 크리티컬 섹션 실행
Mutex로 카운터 보호하기
// ✅ Mutex로 data race 제거
#include <mutex>
#include <thread>
#include <iostream>
int counter = 0;
std::mutex mtx;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx); // RAII: 자동 unlock
counter++;
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n"; // 항상 200000
return 0;
}
코드 설명:
std::lock_guard: 생성 시mtx.lock(), 소멸 시mtx.unlock()을 자동 호출하는 RAII 래퍼.- 크리티컬 섹션은
counter++한 줄뿐이므로, 락 범위를 최소화한 좋은 예입니다. - 예외가 발생해도
lock_guard소멸자에서 락이 풀리므로 안전합니다.
Mutex로 여러 변수 일관되게 보호하기
// ✅ 여러 변수를 한 번에 수정할 때는 Mutex가 적합
#include <mutex>
#include <map>
#include <string>
std::map<std::string, int> inventory;
std::mutex inv_mtx;
void addStock(const std::string& item, int quantity) {
std::lock_guard<std::mutex> lock(inv_mtx);
inventory[item] += quantity; // 맵 조회와 수정이 원자적으로
}
bool purchaseIfAvailable(const std::string& item, int quantity) {
std::lock_guard<std::mutex> lock(inv_mtx);
auto it = inventory.find(item);
if (it != inventory.end() && it->second >= quantity) {
it->second -= quantity;
return true;
}
return false;
}
왜 Atomic이 아닌가? inventory는 std::map으로, 여러 노드가 연결된 복잡한 자료구조입니다. “재고 확인”과 “재고 감소”를 한 덩어리로 보호해야 하므로 Mutex가 맞습니다.
unique_lock과 try_lock: 조건부 대기
lock_guard는 생성 시 무조건 락을 잡고, 소멸 시 풉니다. unique_lock은 락을 나중에 풀었다가 다시 잡거나, try_lock으로 “바로 잡을 수 있으면 잡고, 아니면 포기”하는 패턴을 구현할 수 있습니다.
// ✅ unique_lock + try_lock: 조건부 락
#include <mutex>
#include <chrono>
#include <queue>
std::mutex mtx;
std::queue<int> task_queue;
// try_lock: 즉시 반환, 대기 없음 (std::mutex)
bool tryProcessOne(int& task) {
std::unique_lock<std::mutex> lock(mtx, std::try_to_lock);
if (!lock.owns_lock())
return false; // 락 획득 실패
if (!task_queue.empty()) {
task = task_queue.front();
task_queue.pop();
return true;
}
return false;
}
// timed_mutex: 타임아웃 대기 (try_lock_for)
std::timed_mutex timed_mtx;
std::queue<int> timed_queue;
bool tryGetTaskWithTimeout(int& task, int timeout_ms) {
std::unique_lock<std::timed_mutex> lock(
timed_mtx, std::chrono::milliseconds(timeout_ms));
if (lock.owns_lock() && !timed_queue.empty()) {
task = timed_queue.front();
timed_queue.pop();
return true;
}
return false; // 타임아웃 또는 큐 비어있음
}
lock_guard vs unique_lock:
lock_guard: 가볍고 단순. 생성 시 lock, 소멸 시 unlock만 지원.unique_lock:lock(),unlock(),try_lock(),try_lock_for()등 유연한 제어. condition_variable과 함께 쓸 때 필수.
Mutex 종류 요약
| 뮤텍스 | 특징 | 사용 상황 |
|---|---|---|
std::mutex | 기본 상호 배제 | 일반적인 크리티컬 섹션 |
std::recursive_mutex | 같은 스레드가 여러 번 lock 가능 | 재귀 호출에서 같은 락 필요할 때 |
std::timed_mutex | try_lock_for, try_lock_until 지원 | 타임아웃이 필요한 대기 |
std::shared_mutex (C++17) | 읽기(shared) / 쓰기(exclusive) 분리 | 읽기 다수, 쓰기 소수일 때 |
한계/주의
- 락을 오래 잡으면 다른 스레드가 대기하는 시간이 길어져 병목이 됩니다. 보호가 필요한 구간만 최소한으로 잡는 것이 좋습니다.
- 여러 락을 잡을 때 순서가 엉키면 데드락이 발생할 수 있습니다.
4. Atomic의 역할과 한계
역할
- 단일 변수에 대한 읽기·쓰기·읽기-수정-쓰기(예:
fetch_add)를 원자적으로 만듭니다. - 락을 걸지 않고 CPU 수준의 원자 연산(또는 락 프리 구조)으로 구현되므로, 단순 카운터·플래그처럼 변수 하나만 보호할 때 Mutex보다 가볍고 경합이 적습니다.
한계
- 한 변수 단위만 보호합니다. “A와 B를 동시에 바꿔야 한다” 같은 여러 변수의 일관성은 Atomic만으로는 보장하기 어렵고, 그런 경우 Mutex가 적합합니다.
- 복잡한 조건(if로 읽고 수정하고 쓰기)은 여러 atomic 연산으로 나누면 여전히 race가 생길 수 있어, 그런 구간은 Mutex로 한 번에 감싸는 편이 안전합니다.
Atomic으로 카운터 보호하기
// ✅ Atomic: 락 없이 단일 변수 보호
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1); // 원자적 증가
// 또는 counter++; // atomic은 ++도 원자적
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter.load() << "\n"; // 항상 200000
return 0;
}
코드 설명:
std::atomic<int>: 해당 변수의 읽기·쓰기·fetch_add 등이 한 번에 수행됩니다.counter++도atomic에서는 원자적으로 동작하므로fetch_add(1)과 동일합니다.- Mutex에 비해 락 대기가 없어 경합이 심한 카운터에서 성능이 좋습니다.
Atomic 플래그로 동기화하기
// ✅ Atomic 플래그: 데이터 준비 완료 신호
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<bool> ready{false};
std::atomic<int> data{0};
void producer() {
data.store(42); // 1. 데이터 쓰기
ready.store(true); // 2. 플래그 설정 (seq_cst로 순서 보장)
}
void consumer() {
while (!ready.load()) // 3. 플래그 확인
;
std::cout << data.load() << "\n"; // 4. 항상 42
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
주의: ready와 data가 모두 atomic이어야 합니다. 하나만 atomic이면 여전히 data race입니다.
Atomic 연산 전체 목록
| 연산 | 설명 | 예시 |
|---|---|---|
load() | 현재 값 읽기 | counter.load() |
store(val) | 값 쓰기 | flag.store(true) |
fetch_add(n) | n 더하고 이전 값 반환 | counter.fetch_add(1) |
fetch_sub(n) | n 빼고 이전 값 반환 | stock.fetch_sub(qty) |
fetch_and(val) | 비트 AND 후 이전 값 반환 | mask.fetch_and(0xFF) |
fetch_or(val) | 비트 OR 후 이전 값 반환 | flags.fetch_or(FLAG_X) |
exchange(val) | val로 바꾸고 이전 값 반환 | old = ptr.exchange(new_ptr) |
compare_exchange_strong/weak | CAS 연산 | 조건부 업데이트 |
// ✅ Atomic fetch_add/fetch_sub: 단순 카운터
#include <atomic>
std::atomic<int> total_sold{0};
void recordSale(int quantity) {
total_sold.fetch_add(quantity, std::memory_order_relaxed);
}
// CAS로 "재고 충분할 때만 감소" (조건부 업데이트)
std::atomic<int> stock{100};
bool purchaseIfAvailable(int quantity) {
int expected = stock.load(std::memory_order_relaxed);
do {
if (expected < quantity) return false;
} while (!stock.compare_exchange_weak(expected, expected - quantity));
return true;
}
// exchange: 포인터 스왑 (lock-free)
std::atomic<Node*> head{nullptr};
void push(Node* new_node) {
new_node->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(new_node->next, new_node))
;
}
memory_order 요약 (면접 참고)
std::atomic 연산에 memory_order를 지정하면, “다른 변수에 대한 가시성·순서”를 조절할 수 있습니다. 기본값은 seq_cst (순차 일관성)이고, 성능이 중요할 때만 완화합니다.
| memory_order | 용도 |
|---|---|
| seq_cst | 기본값. 읽기/쓰기 순서가 단일 전체 순서처럼 보임. 가장 안전, 상대적으로 비용 큼. |
| acquire | load에 사용. “이 로드 이후”의 메모리 접근은 “이 로드보다 앞서” 다른 스레드에서 store된 값이 보임. (락 획득) |
| release | store에 사용. “이 store 이전”의 메모리 접근이 “이 store 이후” 다른 스레드의 acquire load에 보임. (락 해제) |
| relaxed | 동기화 없음. 해당 변수만 원자적. 다른 변수와의 순서 보장 없음. 카운터 등 단순 연산에만. |
// relaxed: 카운터 등 단순 연산에만 사용
counter.fetch_add(1, std::memory_order_relaxed);
// acquire-release: 동기화가 필요한 플래그
ready.store(true, std::memory_order_release);
// 다른 스레드
while (!ready.load(std::memory_order_acquire))
;
면접에서 “atomic에 memory_order를 쓰는 이유”를 물으면: “기본은 seq_cst인데, acquire/release로 동기화 범위를 줄이거나 relaxed로 순서 보장을 포기해 성능을 낼 수 있다. 다만 relaxed는 논리적 오류가 나기 쉬우므로 단순 카운터 등에만 쓴다” 정도로 답할 수 있습니다.
5. Mutex vs Atomic: 언제 무엇을 쓸까
Mutex를 써야 하는 상황
- 여러 변수를 한 번에 수정해야 할 때 (예:
std::map에 넣고 빼기). - 읽기-조건 확인-쓰기가 한 덩어리로 보호돼야 할 때 (예: “값이 0이면 1로 바꾸기”를 원자적으로).
- 복잡한 자료구조(큐, 리스트, 트리)를 여러 스레드가 같이 수정할 때.
Atomic을 써야 하는 상황
- 단일 변수(카운터, 플래그, 포인터 하나)만 스레드 안전하게 다룰 때.
- 락 오버헤드와 경합을 줄이고 싶을 때. (예: 수많은 스레드가 하나의 카운터만 올리는 경우)
flowchart TD
subgraph 선택["Mutex vs Atomic 선택"]
A[보호할 대상이?] --> B{단일 변수?}
B -->|Yes| C{단순 연산?}
C -->|Yes| D[Atomic]
C -->|No| E[복잡한 조건?]
E -->|Yes| F[Mutex]
B -->|No| F
D --> G[카운터, 플래그]
F --> H[맵, 큐, 여러 변수]
end
한 줄 요약
- 한 변수만 보호하면 되고, 단순 연산(증가, 플래그 설정)이면 Atomic.
- 여러 변수·복잡한 로직이면 Mutex.
면접에서 “Mutex 대신 Atomic을 써야 하는 상황은?” → “보호 대상이 단일 변수이고, 단순한 읽기-수정-쓰기(증가, 플래그)만 할 때입니다. 여러 변수를 일관되게 바꿔야 하거나 복잡한 조건이 있으면 Mutex를 써야 합니다.”
6. Deadlock과 해결법
Deadlock이란
- 두 스레드가 서로가 가진 락을 기다리며 영원히 대기하는 상황입니다.
- 예: 스레드 1은 락 A를 잡고 락 B를 기다리고, 스레드 2는 락 B를 잡고 락 A를 기다림.
sequenceDiagram
participant T1 as 스레드 1
participant A as 락 A
participant B as 락 B
participant T2 as 스레드 2
T1->>A: lock() ✓
T2->>B: lock() ✓
T1->>B: lock() ... 대기
T2->>A: lock() ... 대기
Note over T1,T2: 데드락! 영원히 대기
해결법 1: 순서 잠금
모든 스레드가 같은 순서로 락을 잡습니다. (예: 항상 A → B) 그러면 “A 잡고 B 기다리기”와 “B 잡고 A 기다리기”가 동시에 발생하지 않아 데드락이 생기지 않습니다.
// ✅ 순서 잠금: 항상 A → B
void thread1() {
std::lock_guard<std::mutex> lockA(mutexA);
std::lock_guard<std::mutex> lockB(mutexB);
// ...
}
void thread2() {
std::lock_guard<std::mutex> lockA(mutexA); // 같은 순서!
std::lock_guard<std::mutex> lockB(mutexB);
// ...
}
해결법 2: std::lock
두 개 이상의 락을 한꺼번에 잡고 싶을 때 std::lock(mutexA, mutexB) 를 쓰면, 데드락을 피하는 방식으로 두 락을 모두 잡아 줍니다. 그 다음 std::lock_guard에 adopt_lock을 넘겨 소유권만 넘기면 됩니다.
std::lock(mutexA, mutexB) 는 두 뮤텍스를 동시에 잡되, 다른 스레드가 A→B 또는 B→A 순서로 잡는 상황과 엇갈려 데드락이 나지 않도록 내부적으로 순서를 조정합니다. 이미 잡은 뒤이므로 lock_guard 생성 시 std::adopt_lock 을 넘겨 “이미 잠긴 뮤텍스의 소유권만 가져간다”고 알려 주어, 소멸 시에만 unlock 이 호출되게 합니다.
// 컴파일: g++ -std=c++17 -pthread -o deadlock_avoid deadlock_avoid.cpp && ./deadlock_avoid
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mutexA, mutexB;
void useBothLocks() {
std::lock(mutexA, mutexB); // 데드락 없이 둘 다 획득
std::lock_guard<std::mutex> lockA(mutexA, std::adopt_lock);
std::lock_guard<std::mutex> lockB(mutexB, std::adopt_lock);
std::cout << "락 획득 완료\n";
}
int main() {
std::thread t1(useBothLocks);
std::thread t2(useBothLocks);
t1.join();
t2.join();
std::cout << "정상 종료\n";
return 0;
}
실행 결과: 데드락 없이 두 락을 순서대로 획득한 뒤 정상 종료됩니다.
7. CAS(Compare-And-Swap) 개요
개념
- Compare-And-Swap은 “이 메모리 위치의 값이 예상 값이면 새 값으로 바꾸고, 아니면 바꾸지 않는다”는 원자적 연산입니다. 반환값으로 “바꿨는지 여부” 또는 “바꾸기 전 값”을 알 수 있습니다.
- Lock-free 자료구조에서 자주 쓰입니다. “값이 A일 때만 B로 바꾼다”를 한 번에 수행해, 락 없이 조건부 업데이트를 할 수 있습니다.
C++에서
std::atomic의 compare_exchange_strong / compare_exchange_weak 가 CAS에 해당합니다.expected값을 넣어 두고, 현재 값이expected와 같으면desired로 바꾸고 true를 반환하고, 다르면 현재 값을expected에 써 넣고 false를 반환합니다. (실패 시 expected를 업데이트해 루프에서 다시 시도하는 패턴이 많습니다.)
// CAS로 락 없는 스핀락 구현
#include <atomic>
std::atomic<bool> lock_flag{false};
void lock() {
bool expected = false;
while (!lock_flag.compare_exchange_strong(expected, true)) {
expected = false; // 실패 시 expected가 현재 값으로 바뀌므로 리셋
}
}
void unlock() {
lock_flag.store(false);
}
compare_exchange_strong vs weak:
- strong: 실패 시 “spurious failure”(가짜 실패)가 없음. 루프에서 한 번만 시도할 때 적합.
- weak: 일부 플랫폼(특히 ARM)에서 spurious failure가 발생할 수 있음. 루프에서 재시도할 때는 weak가 더 효율적일 수 있어, lock-free 자료구조에서 자주 사용합니다.
// Lock-free 스택의 push (개념 예시)
void push(Node* new_node) {
new_node->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(new_node->next, new_node,
std::memory_order_release,
std::memory_order_relaxed))
; // CAS 실패 시 new_node->next가 현재 head로 갱신됨
}
면접에서는 “CAS는 ‘같으면 바꾸고, 아니면 바꾸지 않는’ 원자 연산이고, lock-free 구조에서 조건부 업데이트에 쓴다. C++에서는 atomic의 compare_exchange_strong/weak가 그에 해당한다” 정도로 말할 수 있으면 됩니다.
8. 프로덕션 패턴
실무에서 자주 쓰는 스레드 안전 패턴을 정리합니다.
패턴 1: 스레드 안전 싱글톤 (Meyers’ Singleton)
// ✅ C++11: static 지역 변수 - 스레드 안전, 지연 초기화
class Config {
public:
static Config& instance() {
static Config cfg; // 한 번만 초기화, 스레드 안전
return cfg;
}
// 복사/이동 금지
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
private:
Config() = default;
};
패턴 2: std::call_once로 무거운 초기화
// ✅ 한 번만 실행되는 초기화 (DB 연결, 캐시 워밍 등)
#include <mutex>
std::once_flag init_flag;
Database* db = nullptr;
void initDatabase() {
std::call_once(init_flag, {
db = new Database("connection_string");
db->connect();
});
}
패턴 3: 스레드 안전 큐 (Mutex + Condition Variable)
// ✅ 생산자-소비자 패턴: 조건 변수로 대기
#include <mutex>
#include <condition_variable>
#include <queue>
template<typename T>
class ThreadSafeQueue {
std::queue<T> queue_;
std::mutex mtx_;
std::condition_variable cv_;
public:
void push(T value) {
{
std::lock_guard<std::mutex> lock(mtx_);
queue_.push(std::move(value));
}
cv_.notify_one(); // 락 밖에서 알림 (성능)
}
bool pop(T& value, int timeout_ms = -1) {
std::unique_lock<std::mutex> lock(mtx_);
if (timeout_ms < 0) {
cv_.wait(lock, [this]{ return !queue_.empty(); });
} else if (!cv_.wait_for(lock, std::chrono::milliseconds(timeout_ms),
[this]{ return !queue_.empty(); })) {
return false; // 타임아웃
}
value = std::move(queue_.front());
queue_.pop();
return true;
}
};
패턴 4: 읽기 다수 / 쓰기 소수 — shared_mutex (C++17)
// ✅ 읽기는 동시에, 쓰기는 배타적으로
#include <shared_mutex>
#include <map>
#include <string>
class Cache {
std::map<std::string, std::string> data_;
mutable std::shared_mutex mtx_; // mutable: const 메서드에서 lock
public:
std::string get(const std::string& key) const {
std::shared_lock lock(mtx_); // 읽기 락 (여러 스레드 동시 허용)
auto it = data_.find(key);
return it != data_.end() ? it->second : "";
}
void set(const std::string& key, const std::string& value) {
std::unique_lock lock(mtx_); // 쓰기 락 (배타적)
data_[key] = value;
}
};
패턴 5: Per-Thread 카운터 (락 경합 회피)
// ✅ 스레드별 로컬 카운터 + 주기적 합산 (경합 최소화)
#include <atomic>
#include <thread>
#include <vector>
thread_local int64_t local_count = 0; // 스레드마다 별도
std::atomic<int64_t> global_total{0};
void worker() {
for (int i = 0; i < 1000000; ++i)
++local_count; // 락 없음
global_total.fetch_add(local_count, std::memory_order_relaxed);
}
int64_t getTotal() {
return global_total.load(std::memory_order_relaxed);
}
패턴 6: Graceful Shutdown 플래그
// ✅ 종료 요청 플래그 (atomic)
std::atomic<bool> shutdown_requested{false};
void workerThread() {
while (!shutdown_requested.load(std::memory_order_acquire)) {
doWork();
}
}
void requestShutdown() {
shutdown_requested.store(true, std::memory_order_release);
}
9. 자주 발생하는 실수와 해결법
실수 1: Atomic 하나만 쓰고 나머지는 일반 변수
// ❌ 잘못된 예: data는 일반 int → data race
std::atomic<bool> ready{false};
int data = 0; // 일반 변수!
void producer() {
data = 42;
ready.store(true);
}
void consumer() {
while (!ready.load())
;
std::cout << data << "\n"; // data race! 42가 아닐 수 있음
}
해결: data도 std::atomic<int>로 바꾸거나, mutex로 두 접근을 묶습니다.
실수 2: “단순해 보이는” 연산을 Atomic으로
// ❌ 잘못된 예: "값이 0이면 1로"를 여러 atomic으로 나누면 race
std::atomic<int> flag{0};
void setIfZero() {
if (flag.load() == 0) { // 1. 읽기
flag.store(1); // 2. 쓰기 (사이에 다른 스레드가 끼어들 수 있음!)
}
}
해결: compare_exchange_strong으로 한 번에 처리하거나, mutex로 감쌉니다.
// ✅ compare_exchange로 원자적 "0이면 1로"
void setIfZero() {
int expected = 0;
flag.compare_exchange_strong(expected, 1);
}
실수 3: Mutex를 잡은 채로 오래 두기
// ❌ 잘못된 예: I/O를 락 안에서 수행
void processRequest() {
std::lock_guard<std::mutex> lock(mtx);
auto data = shared_queue.front();
shared_queue.pop();
sendToNetwork(data); // 네트워크 대기 동안 다른 스레드 전부 블록!
}
해결: 락 범위를 최소화합니다.
// ✅ 락 범위 최소화
void processRequest() {
Data data;
{
std::lock_guard<std::mutex> lock(mtx);
data = shared_queue.front();
shared_queue.pop();
} // 락 해제
sendToNetwork(data); // 락 밖에서 I/O
}
실수 4: 락 순서 불일치
// ❌ 스레드마다 락 순서가 다름 → 데드락
void thread1() {
std::lock_guard<std::mutex> a(mutexA);
std::lock_guard<std::mutex> b(mutexB);
}
void thread2() {
std::lock_guard<std::mutex> b(mutexB); // B 먼저!
std::lock_guard<std::mutex> a(mutexA);
}
해결: 모든 스레드에서 동일한 순서(예: 항상 A → B)로 락을 잡습니다.
실수 5: ThreadSanitizer 없이 디버깅
Data race는 재현이 어렵고, “가끔만” 잘못된 결과가 나올 수 있습니다. ThreadSanitizer(TSan)를 사용하면 실행 시 data race를 감지할 수 있습니다.
# ThreadSanitizer로 컴파일 및 실행
g++ -std=c++17 -fsanitize=thread -g -o race_test race_test.cpp
./race_test
TSan이 보고하는 경고를 따라가면 동기화가 누락된 위치를 찾을 수 있습니다. CI 파이프라인에 TSan 빌드를 추가하면 회귀를 방지할 수 있습니다.
실수 6: 재귀 락 없이 같은 뮤텍스를 두 번 잡기
// ❌ 같은 스레드가 이미 잡은 뮤텍스를 다시 lock → 데드락 (표준 mutex)
std::mutex mtx;
void recursiveWork(int n) {
std::lock_guard<std::mutex> lock(mtx);
if (n > 0) recursiveWork(n - 1); // 데드락! 자기 자신이 락을 기다림
}
해결: 재귀 호출에서 같은 락이 필요하면 std::recursive_mutex를 사용합니다.
// ✅ recursive_mutex: 같은 스레드가 여러 번 lock 가능
std::recursive_mutex rec_mtx;
void recursiveWork(int n) {
std::lock_guard<std::recursive_mutex> lock(rec_mtx);
if (n > 0) recursiveWork(n - 1); // OK
}
실수 7: 락 해제 없이 반환
// ❌ lock() 후 early return 시 unlock 누락
void process() {
mtx.lock();
if (error_condition) return; // unlock 안 함 → 데드락
// ...
mtx.unlock();
}
해결: lock_guard나 unique_lock을 사용해 RAII로 자동 해제를 보장합니다.
10. 성능 비교: Mutex vs Atomic
카운터 증가 벤치마크 (개념)
| 방식 | 8스레드, 100만 회 증가 | 특징 |
|---|---|---|
| Mutex | ~50–100ms | 락 경합 시 대기 발생 |
| Atomic | ~10–20ms | 락 없이 CPU 원자 명령 |
| relaxed Atomic | ~8–15ms | memory_order 완화로 추가 최적화 |
요약: 단일 변수에 대한 단순 연산은 Atomic이 Mutex보다 수 배 빠른 경우가 많습니다. 여러 변수나 복잡한 로직은 Mutex가 맞고, 그때는 락 범위를 최소화하는 것이 성능의 핵심입니다.
주의: Atomic이 “항상” Mutex보다 빠른 것은 아닙니다. 락 경합이 거의 없는 경우(예: 스레드 수가 적고 크리티컬 섹션이 짧을 때) Mutex의 오버헤드가 작을 수 있습니다. 반대로 수십 개 스레드가 하나의 카운터를 동시에 올릴 때는 Atomic이 압도적으로 유리합니다. 실제 부하로 벤치마크한 뒤 선택하는 것이 좋습니다.
성능 최적화 팁
| 팁 | 설명 |
|---|---|
| 락 범위 최소화 | lock_guard 범위를 크리티컬 섹션만 감싸도록. I/O, 계산은 락 밖에서. |
| 세분화된 락 | 하나의 큰 락 대신 여러 작은 락으로 나누면 경합 감소. (데드락 주의) |
| 읽기 다수 | shared_mutex로 읽기는 동시에, 쓰기만 배타적으로. |
| 로컬 버퍼 | 스레드별로 로컬에 모았다가 주기적으로 전역에 합산 (Per-Thread 패턴). |
| memory_order | 카운터는 relaxed, 동기화 플래그는 acquire/release로 완화. |
| 캐시 라인 분리 | alignas(64)로 false sharing 방지 (다음 글 #34-2 참고). |
// ❌ 나쁜 예: 락 안에서 무거운 연산
void process() {
std::lock_guard<std::mutex> lock(mtx);
auto data = fetchFromQueue(); // 큐 접근만 보호
auto result = heavyComputation(data); // 락 안에서 계산 → 병목!
saveResult(result);
}
// ✅ 좋은 예: 락 범위 최소화
void process() {
Data data;
{
std::lock_guard<std::mutex> lock(mtx);
data = fetchFromQueue();
}
auto result = heavyComputation(data); // 락 밖에서
{
std::lock_guard<std::mutex> lock(mtx);
saveResult(result);
}
}
언제 무엇을 쓸지 요약표
| 상황 | 권장 | 이유 |
|---|---|---|
| 카운터, 참조 카운트 | Atomic | 단일 변수, 단순 연산 |
| 플래그 (준비 완료 등) | Atomic | 단일 변수 |
| 맵/큐/리스트 수정 | Mutex | 여러 변수, 복잡한 구조 |
| ”0이면 1로” 같은 조건부 | CAS 또는 Mutex | 원자적 조건 검사 필요 |
| 여러 락 필요 | std::lock 또는 순서 통일 | 데드락 방지 |
실전 구현 체크리스트
멀티스레드 코드를 작성할 때 아래 항목을 확인하면 data race와 deadlock을 줄일 수 있습니다.
- 공유 변수 식별: 여러 스레드가 접근하는 메모리 위치를 모두 파악했는가?
- 쓰기 여부 확인: 최소 한 스레드가 쓰기하면 동기화가 필요하다.
- 단일 변수 vs 복합: 카운터·플래그는 Atomic, 맵·큐·여러 변수는 Mutex.
- 락 범위 최소화: I/O, 네트워크 대기는 락 밖에서 수행하는가?
- 락 순서 통일: 여러 락을 쓸 때 모든 스레드에서 같은 순서로 잡는가?
- RAII 사용:
lock_guard·unique_lock으로 예외 시에도 락 해제가 보장되는가? - Atomic 쌍 확인: 플래그와 데이터를 함께 쓸 때 둘 다 atomic이거나 mutex로 묶었는가?
- 프로덕션 패턴 적용: 싱글톤은
static지역 변수, 초기화는call_once, 읽기 다수는shared_mutex. - ThreadSanitizer: CI에서
-fsanitize=thread빌드로 data race 회귀 검사.
11. 면접 Q&A
Q: Data race가 뭔가요?
- “한 메모리 위치에 한 스레드는 쓰기, 다른 스레드는 읽기 또는 쓰기를 하는데, 그 접근이 동기화되지 않은 경우입니다. C++에서는 undefined behavior이고, Mutex나 Atomic 등으로 동기화해 제거합니다.”
Q: Mutex 대신 Atomic을 써야 하는 상황은?
- “보호할 대상이 단일 변수이고, 단순한 연산(증가, 플래그 설정)만 할 때입니다. 여러 변수를 일관되게 바꿔야 하거나 복잡한 조건이 있으면 Mutex가 맞습니다.”
Q: Deadlock을 어떻게 피하나요?
- “락을 잡는 순서를 모든 스레드에서 동일하게 하거나,
std::lock으로 여러 락을 한 번에 잡아 데드락을 피하는 방식을 씁니다.”
Q: CAS가 뭔가요?
- “Compare-And-Swap의 약자로, ‘현재 값이 예상 값이면 새 값으로 바꾸고, 아니면 바꾸지 않는’ 원자 연산입니다. Lock-free 구조에서 조건부 업데이트에 쓰고, C++에서는
atomic의compare_exchange_strong/weak가 해당합니다.”
Q: memory_order는 언제 바꾸나요?
- “기본 seq_cst로 두고, 성능 프로파일링에서 atomic이 병목일 때만 acquire/release나 relaxed를 검토합니다. relaxed는 카운터 등 단순 연산에만 쓰고, 동기화가 필요한 플래그에는 쓰지 않습니다.”
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Lock-Free 프로그래밍 실전 | CAS·ABA·메모리 순서·고성능 큐 [#34-3]
- C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
- C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
이 글에서 다루는 키워드 (관련 검색어)
data race, mutex atomic, C++ 동기화, 데드락 해결, CAS compare_exchange 등으로 검색하시면 이 글이 도움이 됩니다.
정리
- Data race: 동기화 없이 한 스레드는 쓰기, 다른 스레드는 읽기/쓰기 → UB. Mutex 또는 Atomic 등으로 제거.
- Mutex: 여러 변수·복잡한 구간을 한 번에 한 스레드만 실행하게 보호. Atomic: 단일 변수의 원자 연산, 락 없이.
- Deadlock: 락 순서 통일 또는 std::lock으로 회피.
- CAS: “같으면 바꾼다” 원자 연산.
atomic::compare_exchange_*로 사용. - 실수 방지: Atomic은 단일 변수에만, 복잡한 조건은 Mutex 또는 CAS, 락 범위는 최소화.
- 프로덕션 패턴: 싱글톤(static), call_once, ThreadSafeQueue, shared_mutex, Per-Thread 카운터.
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ 동기화 면접: Data Race, Mutex vs Atomic 언제 쓸지, Deadlock 해결, CAS(Compare-And-Swap). 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다. 카운터·플래그는 Atomic, 맵·큐·여러 변수는 Mutex를 기본으로 두고, 성능 프로파일링 후에 최적화합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다. mutex와 동기화, atomic과 메모리 모델을 먼저 읽으면 이해가 빠릅니다.
Q. 더 깊이 공부하려면?
A. cppreference의 std::atomic, std::mutex 문서와 해당 라이브러리 공식 문서를 참고하세요. 메모리 모델과 lock-free 자료구조는 고급 주제이므로, 기본기를 다진 뒤 단계적으로 공부하는 것을 권합니다.
한 줄 요약: Data race는 mutex·atomic으로 막고, 데드락은 lock 순서 통일로 피할 수 있습니다. 다음으로 캐시·메모리 정렬(#34-2)를 읽어보면 좋습니다.
다음 글: [C++ 면접 심화 #34-2] 캐시 히트(Cache Hit)를 높이는 C++ 메모리 정렬과 패딩
이전 글: C++ shared_ptr 순환 참조 완전 정복 (#33-4)
관련 글
- C++ 캐시 히트(Cache Hit)를 높이는 메모리 정렬과 패딩 | False Sharing 해결
- C++ Lock-Free 프로그래밍 실전 | CAS·ABA·메모리 순서·고성능 큐 [#34-3]
- C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
- C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]
- C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]