4. atomic 메모리 순서(memory_order)와 happens-before
난이도 상내 리뷰 · C++
해설 · C++
해설 — atomic 메모리 순서(memory_order)와 happens-before
난이도: 상
요약
memory_order_relaxed는 원자성만 보장하고 순서/가시성(happens-before)은 보장하지 않는다. 따라서 위 코드는 (4)에서 0이 보일 수 있고, 더 나아가 payload를 한 스레드가 쓰고 다른 스레드가 읽는데 둘 사이에 happens-before가 없으므로 데이터 경합 = 미정의 동작(UB) 이다. (2)를 release, (3)를 acquire로 바꿔야 "synchronizes-with"가 성립해 (1)이 (4)에서 보이고 경합도 사라진다.
핵심 개념
- happens-before: 한 평가의 효과가 다른 평가에서 보이도록 정의하는 순서 관계. 같은 스레드의 sequenced-before와, 스레드 간 release/acquire의 synchronizes-with로 만들어진다.
- release 저장 ↔ acquire 로드: 어떤 acquire 로드가 그에 짝하는 release 저장이 쓴 값을 읽으면, 그 저장은 그 로드와 synchronizes-with 한다. 그 결과 release 이전의 모든 쓰기가 acquire 이후의 모든 읽기에서 보인다.
- relaxed: 단일 원자 변수에 대한 수정 순서(modification order)의 일관성과 원자성만 보장. 다른 메모리와의 순서 보장은 없다.
- seq_cst: release/acquire의 보장에 더해, 모든 seq_cst 연산에 대한 단일 전역 전순서(total order) 를 부여.
단계별 내부 동작
-
relaxed 버전(현재 코드)
- (2)의 relaxed store는 (1)의
payload=42를 (3)/(4)에 대해 순서지어 주지 않는다. 컴파일러/CPU가 (1)과 (2)를 재배치하거나, (4)가 캐시에 남은 옛payload(0)를 읽을 수 있다. - (3)이 true를 읽어도 (1)과의 happens-before가 없으므로 (4)는 0을 볼 수 있다.
- 결정적으로,
payload는 비원자인데 producer 쓰기와 consumer 읽기 사이에 happens-before가 없다 → 데이터 경합 → UB. "운 좋게 42가 나오는" 것은 보장이 아니다.
- (2)의 relaxed store는 (1)의
-
release/acquire 버전
ready.store(true, std::memory_order_release); // (2) while (!ready.load(std::memory_order_acquire)) {} // (3)- (3)이 (2)가 쓴 true를 읽는 순간 (2) synchronizes-with (3).
- 따라서 (1)(release 이전 쓰기) happens-before (4)(acquire 이후 읽기) → (4)는 항상 42, 그리고
payload접근은 더 이상 경합이 아니다(정의된 동작).
-
seq_cst가 더 보장하는 것
- release/acquire는 "짝지어진 변수"를 통한 단방향 순서만 준다. 여러 원자 변수에 걸친 전역 순서가 필요한 패턴(예: 두 플래그를 X→읽기Y, Y→읽기X 하는 store-buffering/Dekker)에서는 release/acquire로는 "둘 다 상대의 옛값을 본다"가 허용된다.
- 모든 연산을
seq_cst로 하면 단일 전순서가 생겨 그런 모순이 배제된다. 대신 비용(특히 store에 풀 배리어)이 가장 크다.
-
relaxed가 정당한 경우
- 순서가 필요 없고 원자적 카운팅만 필요한 경우:
counter.fetch_add(1, std::memory_order_relaxed). 통계/참조계수 증가처럼 다른 메모리와의 가시성 순서가 필요 없을 때. (단, 참조계수 감소 후 0 검사→삭제는 acquire/release가 필요하다 — 삭제가 마지막 사용 이후로 순서져야 하므로.)
- 순서가 필요 없고 원자적 카운팅만 필요한 경우:
흔한 오해·함정
- "atomic이면 알아서 안전하다" → 아니다. atomic은 그 변수 한 칸의 원자성일 뿐, 주변 일반 메모리의 순서는 memory_order가 정한다.
- "relaxed는 그냥 빠른 atomic" → 빠르지만 happens-before를 안 만든다. 메시지 패싱/락 구현엔 부적합.
volatile은 C++에서 스레드 동기화 도구가 아니다(가시성/순서 보장 X). 동기화는std::atomic+memory_order.- x86은 강한 메모리 모델이라 release/acquire가 대개 추가 명령 없이 되지만, 컴파일러 재배치는 여전히 막아야 하고 ARM/PowerPC에선 하드웨어 재배치도 실재한다 — 코드는 아키텍처가 아니라 메모리 모델에 맞춰야 한다.
면접 포인트
- release/acquire의 정의: "acquire 로드가 release 저장의 값을 읽으면 synchronizes-with → 그 이전 쓰기들이 이후 읽기에서 보인다."
- relaxed는 순서 보장이 없어 메시지 패싱에 쓰면 데이터 경합(UB). 카운터처럼 순서 불필요할 때만.
- seq_cst가 필요한 지점은 "여러 원자에 걸친 전역 순서가 불변식인" 경우(store-buffering) — 그 외엔 release/acquire가 더 싸고 충분.