레이스 컨디션을 정확히 이해하기
재현 예제
#include <iostream>
#include <thread>
int sum = 0;
void Inc() {
for (int i = 0; i < 1'000'000; ++i) {
++sum;
}
}
void Dec() {
for (int i = 0; i < 1'000'000; ++i) {
--sum;
}
}
int main() {
std::thread t1(Inc), t2(Dec);
t1.join();
t2.join();
std::cout << sum << '\n';
}
왜 깨지는가
++sum/--sum은 읽기-수정-쓰기(Read-Modify-Write) 복합 연산입니다.
- 두 스레드가 같은 값을 읽고 각자 저장하면 업데이트가 유실됩니다.
- 비원자 공유 변수의 동시 읽기/쓰기는 C++에서 데이터 레이스 -> UB(정의되지 않은 동작) 입니다.
핵심 결론
- "결과가 이상할 수 있다" 수준이 아니라, 프로그램 의미 자체가 깨질 수 있습니다.
std::atomic 핵심
기본 사용
#include <atomic>
std::atomic<int> sum{0};
void Inc() {
for (int i = 0; i < 1'000'000; ++i) {
sum.fetch_add(1, std::memory_order_relaxed);
}
}
atomic이 보장하는 것
- 단일 원자 변수에 대한 연산을 끊기지 않게 수행(원자성)
- 같은 원자 변수에 대해 일관된 수정 순서를 형성
- 적절한 memory order와 함께 쓰면 스레드 간 가시성/순서 동기화 가능
atomic이 보장하지 않는 것
- 여러 변수에 걸친 불변식(예: HP와 상태를 동시에 맞추기)
- 컨테이너 내부 구조의 동시 수정 안전성
- 자동으로 "항상 lock-free, 항상 빠름"을 보장하지 않음
자주 쓰는 atomic 연산
| 연산 | 의미 | 사용 예 |
|---|
load() | 원자적 읽기 | 플래그/카운터 조회 |
store(v) | 원자적 쓰기 | 상태 갱신 |
fetch_add(n) | 이전 값 반환 후 더함 | 카운터 증가 |
exchange(v) | 값 교체 + 이전 값 반환 | 토글/소유권 넘김 |
compare_exchange_* | 예상값 비교 후 조건부 교체(CAS) | 락프리 자료구조 핵심 |
is_lock_free() | 해당 타입이 락프리인지 힌트 | 성능 조사 참고 |
compare_exchange 주의점
- 실패 시
expected 인자가 현재 값으로 덮어써집니다.
- 그래서 CAS 루프에서는
expected를 매 반복마다 올바르게 유지해야 합니다.
- 일반적으로 반복 루프에서는
weak, 단발 비교에서는 strong을 자주 사용합니다.
memory_order 빠른 가이드
| 오더 | 의미 | 권장 사용 |
|---|
relaxed | 원자성만 보장, 순서 보장 약함 | 단순 통계 카운터 |
acquire | 이후 읽기/쓰기 재배치 방지 | 소비자 측 load |
release | 이전 읽기/쓰기 재배치 방지 | 생산자 측 store |
acq_rel | acquire + release | read-modify-write |
seq_cst | 가장 직관적 전역 순서 | 기본값으로 시작할 때 |
실무 원칙
- 처음에는
seq_cst로 정확성을 확보합니다.
- 병목이 확인될 때만
acquire/release나 relaxed로 낮추고, 근거를 문서화합니다.
CAS 패턴 예시
최대값 갱신 (lock-free 스타일)
void UpdateMax(std::atomic<int>& mx, int v) {
int cur = mx.load(std::memory_order_relaxed);
while (cur < v &&
!mx.compare_exchange_weak(cur, v,
std::memory_order_release,
std::memory_order_relaxed)) {
}
}
배울 포인트
- CAS는 "현재 값이 내가 예상한 값일 때만" 갱신합니다.
- 락 없이 경쟁을 제어할 수 있지만, 루프/재시도 비용이 생길 수 있습니다.
atomic으로 해결되지 않는 문제
vector::push_back 동시 호출
- 여러 스레드가 같은
vector에 push_back하면 내부 size/capacity/포인터 갱신이 충돌합니다.
reserve()는 재할당 횟수만 줄일 뿐, 동시 수정 안전성을 보장하지 않습니다.
안전한 대안
| 상황 | 권장 방법 |
|---|
| 공용 컨테이너에 쓰기 | std::mutex로 임계영역 보호 |
| 고성능 수집 | 스레드 로컬 버퍼에 모은 뒤 단일 스레드 merge |
| 생산자-소비자 구조 | 동시성 큐 또는 락+큐 |
std::mutex m;
std::vector<int> v;
void SafePush(int x) {
std::lock_guard<std::mutex> lock(m);
v.push_back(x);
}
강의 시 유의사항
강조 포인트
atomic은 강력하지만 "단일 상태"에 특히 강합니다.
- 복합 상태/컨테이너 불변식은
mutex가 더 적합한 경우가 많습니다.
memory_order를 모르면 "가끔만 재현되는 버그"를 설명하기 어렵습니다.
자주 하는 오해
| 오해 | 바로잡기 |
|---|
atomic이면 모든 동기화 문제 해결 | 여러 변수의 일관성은 별도 설계 필요 |
reserve()하면 vector 동시 push가 안전 | 동시 수정 자체가 여전히 위험 |
relaxed가 항상 더 좋다 | 순서 보장이 필요하면 오동작 가능 |
체크 질문 (스스로 답해보기)
- 왜 비원자 공유 변수의 동시 접근은 UB인가?
compare_exchange_weak에서 expected가 왜 바뀌는가?
atomic과 mutex 중 무엇을 선택할지 어떤 기준으로 판단할 것인가?