← 문제로

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에서 반드시 보이도록 언어/하드웨어 메모리 모델이 보장하는 순서 관계다. 두 가지로 성립:

  1. 같은 스레드 내 프로그램 순서(sequenced-before).
  2. 스레드 간 동기화: 한 스레드의 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. (1) sequenced-before (2) — 같은 스레드, 게다가 release가 (1)을 자기 뒤로 못 넘기게 막음.
  2. (2) release-store 를 (3) acquire-load 가 true관측 → (2) synchronizes-with (3).
  3. (3) sequenced-before (4) — acquire가 (4)를 자기 앞으로 못 올림.
  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만으로는 사슬이 안 이어진다.

꼬리질문 대비

  1. Q: memory_order_acq_relseq_cst는 언제 쓰나? A: acq_rel은 RMW(fetch_add, compare_exchange)가 읽으면서 동시에 쓰는 경우, 양쪽 의미를 다 원할 때. seq_cst는 모든 seq_cst 연산에 대해 단일 전역 순서까지 필요할 때(예: 두 플래그를 교차로 보는 Dekker 류). 기본값이라 안전하지만 가장 비싸다.

  2. Q: store buffer가 가시성 지연을 만드는 메커니즘은? A: CPU는 store를 즉시 캐시/메모리에 반영하지 않고 자기 코어의 store buffer에 잠시 둔다. 그 사이 다른 코어는 옛 값을 본다. release-store/펜스는 이 buffer를 적절히 드레인하거나 순서를 강제해 가시성을 보장한다.

  3. Q: C#에서 일반 bool 플래그 폴링 루프가 무한루프가 되는 이유와 해결은? A: JIT가 루프 불변(loop-invariant)으로 판단해 필드를 레지스터에 호이스팅하면 메모리 재읽기를 안 한다. volatile/Volatile.Read로 매번 메모리에서 읽게 하면 해결.