3. 메모리 모델과 가시성: volatile, Interlocked, memory_order, happens-before
난이도 상모범답안 — 메모리 모델과 가시성: volatile, Interlocked, memory_order, happens-before
난이도: 상
핵심 답변
- 가시성 문제: 한 스레드가 쓴 값이 다른 스레드에서 언제, 어떤 순서로 보이는지 보장되지 않는 문제. 원인은 (a) 컴파일러의 재배열/레지스터 캐싱(메모리 접근을 생략하거나 순서를 바꿈), (b) CPU의 비순차 실행과 store buffer/캐시 일관성 지연으로 인한 재배열이다. 동기화 도구는 단순 잠금뿐 아니라 이 순서와 가시성을 정의하는 역할을 한다.
- C#
volatile: 해당 필드 읽기는 acquire, 쓰기는 release 의미를 가져 재배열·가시성을 제어한다. 하지만 원자성을 주지 않는다 →count++(RMW)는 여전히 유실 가능. 원자 RMW는Interlocked(Add/Increment/CompareExchange 등)를 써야 한다. - C++ memory_order:
relaxed: 원자성만 보장(찢김 없음). 순서/가시성 보장 없음.release(쓰기): 이 store 이전의 모든 메모리 연산이 이 store 뒤로 넘어가지 않게 막고, 그 결과를 짝이 되는 acquire에 게시(publish)한다.acquire(읽기): 이 load 이후의 모든 메모리 연산이 이 load 앞으로 올라오지 않게 막고, 짝 release가 게시한 값들을 본다.- release-store를 acquire-load가 관측하면 둘 사이에 happens-before가 성립해, release 이전 쓰기가 acquire 이후 코드에 보인다.
깊이 있는 설명 (메커니즘, 왜)
happens-before란
"A happens-before B"는 단순 시간 순서가 아니라, A의 메모리 효과가 B에서 반드시 보이도록 언어/하드웨어 메모리 모델이 보장하는 순서 관계다. 두 가지로 성립:
- 같은 스레드 내 프로그램 순서(sequenced-before).
- 스레드 간 동기화: 한 스레드의 release 연산을 다른 스레드의 acquire 연산이 관측하면, release-이전이 acquire-이후로 happens-before. 이게 스레드를 가로지르는 "다리"다.
happens-before가 없으면 두 접근은 data race이고, C++에서는 정의되지 않은 동작(UB)이다.
시나리오에서 relaxed가 깨지는 이유
두 ???에 relaxed를 쓰면:
- (1)
g_payload = 42와 (2)g_ready.store(true, relaxed)는 둘 다 relaxed라 서로 재배열 가능. 컴파일러/CPU가 (2)를 (1)보다 먼저 가시화할 수 있다. - 소비자가 (3) relaxed load로
true를 봤어도, 그건 (1)의 결과가 보인다는 보장을 전혀 주지 않는다. relaxed-store와 relaxed-load 사이에는 happens-before가 생기지 않기 때문. - 결과: (4)에서
g_payload가 아직 0일 수 있다.g_ready만 원자적이고 데이터 게시는 깨진다.
올바른 조합: release / acquire
// 생산자
g_payload = 42; // (1)
g_ready.store(true, std::memory_order_release); // (2) release
// 소비자
while (!g_ready.load(std::memory_order_acquire)) {} // (3) acquire
use(g_payload); // (4) 여기서 42 보장
보장 사슬:
- (1) sequenced-before (2) — 같은 스레드, 게다가 release가 (1)을 자기 뒤로 못 넘기게 막음.
- (2) release-store 를 (3) acquire-load 가
true로 관측 → (2) synchronizes-with (3). - (3) sequenced-before (4) — acquire가 (4)를 자기 앞으로 못 올림.
- 위를 이으면 (1) happens-before (4) → (4)에서
g_payload == 42확정.
relaxed는 짝 동기화가 없어 사슬이 끊긴다. 가장 강한 seq_cst(기본값)는 항상 옳지만, 이 패턴엔 acquire/release면 충분하고 더 싸다(특히 약한 메모리 모델인 ARM에서 펜스 비용 차이가 크다. x86은 acquire/release가 거의 공짜지만 코드 의도는 명시해야 이식성이 있다).
relaxed가 유용한 경우
순서가 필요 없는 순수 카운터(예: 통계 누적, 참조 카운트 증가). 단, 참조 카운트 감소 후 해제 판단에는 acquire 펜스가 필요(해제 전 다른 스레드의 사용이 끝났음을 봐야 함) — fetch_sub(1, acq_rel) 또는 감소는 release, 0 확인 후 acquire fence가 전형 패턴.
C# 매핑
static int g_payload;
static volatile bool g_ready; // 읽기=acquire, 쓰기=release
// 생산자
g_payload = 42; // 일반 쓰기
g_ready = true; // volatile write = release: 위 쓰기를 이 뒤로 못 넘김
// 소비자
while (!g_ready) { } // volatile read = acquire
use(g_payload); // 42 보장
C#의 volatile은 정확히 acquire/release 의미를 가지므로 이 publish 패턴에 맞는다. RMW가 필요하면 Interlocked, 명시적 펜스가 필요하면 Thread.MemoryBarrier() / Volatile.Read/Write.
응용/실무 연결 (게임서버에서)
- 잡큐/SPSC 큐의 게시 패턴: 생산자가 노드 내용을 쓰고 tail 인덱스를 release-store, 소비자가 tail을 acquire-load. 이 한 쌍이 "데이터 다 썼다"를 안전하게 전달한다. memory_order를 틀리면 소비자가 반쯤 쓰인 노드를 읽는다.
- "준비 플래그" 안티패턴 주의: 게임서버에서
bool initialized같은 플래그를 그냥bool로 두고 다른 스레드가 폴링하면, 컴파일러가 루프에서 레지스터로 캐싱해 영원히 갱신을 못 보는 무한루프가 난다. 반드시 atomic/volatile. - 틱 루프 간 데이터 전달: 네트워크 스레드 → 로직 스레드로 패킷을 넘길 때, 큐의 인덱스/포인터에 acquire/release를 정확히 걸어야 페이로드 가시성이 보장된다.
흔한 오답·함정
- "
volatile이면 멀티스레드 안전" → 가시성/순서만. 원자성 없음.count++, check-then-act는 여전히 깨진다. - "C++
volatile= C#volatile" → 완전히 다르다. C++volatile은 MMIO용이고 스레드 동기화 의미가 전혀 없다(메모리 모델과 무관). 스레드 동기화는std::atomic을 써야 한다. - "x86은 강한 메모리 모델이니 memory_order 아무거나 써도 된다" → 하드웨어는 봐줘도 컴파일러 재배열은 그대로 일어난다. 또 ARM 등에 이식하면 깨진다. 의미를 정확히 명시해야 한다.
- "happens-before는 시간 순서다" → 아니다. 동기화로 맺어진 가시성 보장 관계다. 시간상 먼저 일어나도 동기화가 없으면 happens-before가 아니다.
- "release/acquire를 한쪽만 걸면 된다" → 짝이 맞아야 synchronizes-with가 성립한다. release만, acquire만으로는 사슬이 안 이어진다.
꼬리질문 대비
-
Q:
memory_order_acq_rel과seq_cst는 언제 쓰나? A:acq_rel은 RMW(fetch_add,compare_exchange)가 읽으면서 동시에 쓰는 경우, 양쪽 의미를 다 원할 때.seq_cst는 모든 seq_cst 연산에 대해 단일 전역 순서까지 필요할 때(예: 두 플래그를 교차로 보는 Dekker 류). 기본값이라 안전하지만 가장 비싸다. -
Q: store buffer가 가시성 지연을 만드는 메커니즘은? A: CPU는 store를 즉시 캐시/메모리에 반영하지 않고 자기 코어의 store buffer에 잠시 둔다. 그 사이 다른 코어는 옛 값을 본다. release-store/펜스는 이 buffer를 적절히 드레인하거나 순서를 강제해 가시성을 보장한다.
-
Q: C#에서 일반
bool플래그 폴링 루프가 무한루프가 되는 이유와 해결은? A: JIT가 루프 불변(loop-invariant)으로 판단해 필드를 레지스터에 호이스팅하면 메모리 재읽기를 안 한다.volatile/Volatile.Read로 매번 메모리에서 읽게 하면 해결.