15. .NET 메모리 모델: volatile·Interlocked·가시성/원자성/순서
난이도 최상내 리뷰 · C#
해설 · C#
해설 — .NET 메모리 모델: volatile·Interlocked·가시성/원자성/순서
난이도: 최상
요약
- (1) 평범한 필드로 플래그/데이터를 주고받으면 소비자가 42가 아닌 0을 보거나,
_ready변경을 영영 못 봐 무한 루프에 빠질 수 있다 — 항상 42가 아니다. - (2)
_counter++는 읽기-증가-쓰기의 비원자 복합 연산이라 갱신이 유실되어 최종값이 2,000,000보다 작다. - (3)
volatile은 가시성/순서(획득-해제)를 주지 원자성을 주지 않는다 → (2)를 못 고친다. (2)는Interlocked.Increment(원자 RMW + 풀 펜스)가 필요. (1)은volatile(또는Volatile.Read/Write)로 가시성·순서를 보장하면 해결.
핵심 개념: 세 가지를 구분하라
- 원자성(atomicity): 연산이 쪼개지지 않음.
_counter++는 비원자(3단계). 32비트/참조 대입은 원자지만++/+=는 아니다. - 가시성(visibility): 한 스레드의 쓰기가 다른 스레드에 보이는가. JIT가 변수를 레지스터에 캐싱하면 루프에서 갱신을 영영 못 볼 수 있다.
- 순서(ordering): 쓰기/읽기의 재배치 가능성. 컴파일러(JIT)와 CPU 모두 재배치한다.
단계별 내부 동작
(1) Publish/Consume — 가능한 잘못된 결과
- 레지스터 호이스팅(가시성):
while(!_ready)에서 JIT가_ready를 레지스터에 한 번 읽고 재사용하면, 생산자가_ready=true로 바꿔도 소비자는 영영 못 본다 → 무한 루프. - 재배치(순서): 설령
_ready=true를 봤더라도,_data=42와_ready=true의 쓰기가 재배치되거나, 소비자 쪽 읽기가 재배치되면 소비자가_ready==true인데_data==0(기본값)을 읽을 수 있다.- x86/x64는 강한 메모리 모델(TSO)이라 하드웨어 재배치는 드물지만, JIT의 재배치/캐싱은 여전히 가능하다. ARM 등 약한 모델에선 하드웨어 재배치까지 일어나 더 잘 깨진다.
- 해결:
_ready를volatile로(또는Volatile.Write(ref _ready, true)/Volatile.Read). 그러면_ready쓰기는 release(그 앞의_data=42가 먼저 보임), 읽기는 acquire(그 뒤_data읽기가 뒤로 못 감)라 "ready를 봤으면 data=42도 보인다"가 보장된다. 호이스팅도 막혀 루프가 종료된다.
(2) _counter++ — 갱신 유실
_counter++는 ① 읽기 ② +1 ③ 쓰기. 두 스레드가 같은 값을 읽고 각자 +1 후 쓰면 한 증가가 사라진다.- 100만 × 2 = 200만이 목표지만 실제로는 그보다 작게 끝난다(경쟁 강도에 따라 다름).
- 해결:
Interlocked.Increment(ref _counter)— 하드웨어 원자 명령(lock add/xadd)으로 읽기-증가-쓰기를 불가분하게, 게다가 풀 메모리 펜스를 동반한다. 그러면 정확히 2,000,000.
(3) volatile vs Interlocked
- volatile은 원자성을 주지 않는다.
volatile int _counter; _counter++도 여전히 비원자(읽기·쓰기 각각은 volatile이지만 그 사이가 갈라진다) → (2)를 못 고친다. 이게 가장 흔한 오해. - volatile = 순서/가시성(acquire/release, 호이스팅 방지). Interlocked = 원자 RMW + 풀 펜스. 단순 플래그 발행/소비는 volatile, 카운터/CAS 같은 복합 갱신은 Interlocked.
흔한 오해·함정
- "x86은 강한 모델이니 volatile 필요 없다" — 틀림. JIT의 레지스터 캐싱/재배치는 하드웨어와 무관하게 일어난다. 그리고 코드가 ARM(모바일/일부 서버)에서 돌면 하드웨어 재배치까지 노출된다.
volatile로 카운터를 고치려는 시도. 원자성이 필요하면Interlocked또는lock.lock(Monitor) 진입/이탈도 메모리 펜스를 포함하므로, lock 안에서 접근하는 공유 변수는 추가 volatile이 필요 없다.- 64비트 값을 32비트 플랫폼에서 비원자로 읽고 쓰면 찢긴 읽기(torn read) 가 가능 →
Interlocked.Read/Volatile또는 lock.
면접 포인트
- 원자성·가시성·순서 셋을 분리해서 설명:
++는 원자성 결여, 플래그 폴링은 가시성/순서 결여. - volatile의 정확한 의미(쓰기=release, 읽기=acquire)와 한계(원자성 없음). Interlocked = 원자 + 풀 펜스. 언제 무엇을 쓰는지 기준을 댈 수 있어야 한다.
- .NET 메모리 모델이 ECMA보다 강하다는 점(특히 x86 구현)과, 그럼에도 이식성·JIT 재배치 때문에 명시적 동기화가 필요하다는 점.