1편에서 캐시 일관성과 MESI 프로토콜을 다뤘다. 이번엔 한 단계 더 들어간다. 캐시 일관성이 보장된다고 해도 실행 순서 자체가 바뀌면 여전히 문제가 생긴다.
1편에서 멀티코어 환경에서 캐시 복사본 간 일관성 문제를 봤다. 그런데 사실 그것보다 더 근본적인 문제가 있다. 코드가 작성한 순서대로 실행되지 않을 수 있다는 것이다. 컴파일러도, CPU도, 심지어 메모리 시스템도 순서를 바꾼다.
[소스 코드]
↓ 컴파일러 최적화 (재정렬 1차)
[기계어]
↓ CPU 실행 (재정렬 2차)
[실행 순서]
↓ 메모리 시스템 (재정렬 3차)
[메모리에 반영되는 순서]
각 레벨에서 왜 재정렬이 일어나는지 하나씩 살펴본다.
컴파일러는 싱글 스레드 관점에서 동일한 결과를 보장하면서 더 빠른 코드를 생성한다. 레지스터 재사용, 캐시 지역성 향상 등을 위해 명령어 순서를 바꾼다.
// 원본 코드
int a = 1;
int b = 2;
int c = a + 1;
// 컴파일러 최적화 후 (가능한 재정렬)
int a = 1;
int c = a + 1; // a를 바로 사용해서 레지스터 재사용
int b = 2;
싱글 스레드에서는 결과가 같으니 문제없다. 하지만 멀티스레드에서는 치명적이다.
// Thread 1
void init() {
data = 42; // (1) 데이터 설정
initialized = 1; // (2) 플래그 설정
}
// Thread 2
void use() {
while (!initialized); // 플래그 대기
printf("%d", data); // 데이터 사용
}
컴파일러가 Thread 1을 이렇게 바꿔버리면:
// 컴파일러 최적화 후 (위험!)
initialized = 1; // (2) 먼저 실행
data = 42; // (1) 나중에 실행
Thread 2가 initialized = 1을 보고 data를 읽으러 갔는데 아직 42가 쓰이지 않은 상태일 수 있다. 싱글 스레드에서는 의미 없는 순서 변경이 멀티스레드에서는 버그가 된다.
컴파일러에게 "이 지점에서 재정렬하지 마라"고 강제하는 방법들이다.
asm volatile("" ::: "memory");atomic_signal_fence(memory_order_seq_cst);volatile 키워드가 컴파일러 배리어 역할현대 CPU는 파이프라인 구조를 쓴다. 앞 명령어가 느리면(캐시 미스 등) 뒤 명령어를 먼저 실행해버린다.
┌─────────────────────────────────────────────────────────────────┐
│ CPU Core │
│ ┌─────────┐ ┌─────────────────────────────────────────┐ │
│ │ Fetch │──▶│ Reorder Buffer (ROB) │ │
│ │ Unit │ │ [ I1 ][ I2 ][ I3 ][ I4 ][ I5 ][ ... ] │ │
│ └─────────┘ └────┬────┬────┬────┬────┬─────────────────┘ │
│ ┌─────────┐ ▼ ▼ ▼ ▼ ▼ │
│ │ Decode │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ Unit │ │ ALU │ │ ALU ││Load │ │Store│ │ FPU │ │
│ └─────────┘ │ 1 │ │ 2 ││Unit │ │Unit │ │ │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ └───────┴───────┴───┬───┴───────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Commit (순서대로) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
실제 OoO 실행 예시:
원본 명령어:
1. LOAD R1, [A] ← 캐시 미스, 느림
2. ADD R2, R1, 1 ← R1 의존 → 1번 완료까지 대기
3. LOAD R3, [B] ← A와 무관 → 대기할 이유 없음
4. ADD R4, R3, 2 ← R3 의존
OoO 실행 순서:
Cycle 1: LOAD R1,[A] 시작, LOAD R3,[B] 동시 시작
Cycle 2: R3 준비됨 → ADD R4,R3,2 실행
...
Cycle N: R1 준비됨 → ADD R2,R1,1 실행
CPU가 지키는 핵심 원칙이 있다. 같은 주소에 대한 의존성은 지켜준다. R1에 의존하는 명령은 R1이 준비될 때까지 기다린다. 하지만 다른 주소 간의 순서는 보장하지 않는다.
가장 미묘하고 이해하기 어려운 부분이다. Store Buffer와 Invalidation Queue 때문에 발생한다.
┌────────────────────────────────────────────────────────────────────┐
│ CPU Core 0 │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ CPU │────▶│ Store Buffer │───▶│ │ │
│ │ Pipeline │ │ [ A = 1 ] │ │ L1 Cache │ │
│ │ │ │ [ B = 2 ] │ │ │ │
│ │ │◀─────│ [ ... ] │ │ │ │
│ └──────────┘ 읽기 └──────────────┘ └──────────────────────┘ │
│ Store-to-Load Forwarding │ │
└─────────────────────────────────────────────────────┼──────────────┘
▼
┌──────────────────────┐
│ Shared L3 / Memory│
└──────────────────────┘
STORE [A], 1을 실행하려면 MESI 프로토콜로 다른 코어에 Invalidate 요청을 보내고 응답을 기다려야 한다. 그 사이 수백 사이클 동안 CPU가 멈춘다. Store Buffer에 넣고 다음 명령을 실행하는 방식으로 이 문제를 해결한다.
Store Buffer가 만드는 재정렬 문제를 보자:
초기값: x = 0, y = 0
Core 0 Core 1
x = 1; y = 1;
r1 = y; r2 = x;
직관적으로는 r1 = 0, r2 = 0이 불가능해 보인다. 하지만 실제로 가능하다.
Core 0 실행 순서:
[Store Buffer에 x = 1 저장]
[y 읽기 → 캐시에서 0 반환] ← x = 1이 아직 캐시에 반영 안 됨
[Store Buffer → 캐시로 x = 1 반영]
Core 1 실행 순서:
[Store Buffer에 y = 1 저장]
[x 읽기 → 캐시에서 0 반환] ← y = 1이 아직 캐시에 반영 안 됨
[Store Buffer → 캐시로 y = 1 반영]
둘 다 상대방의 Store가 캐시에 반영되기 전에 Load를 수행한 것이다. 결과적으로 r1 = 0, r2 = 0이 가능하다.
Store Buffer와 유사한 개념이다. 읽으려는 데이터가 캐시에 없으면 즉시 CPU를 멈추지 않고 Load Buffer에 넣어서 대기시킨다. 이후 데이터가 준비되면 CPU로 전달한다.
MESI 프로토콜에서 Invalidate 신호를 받으면 해당 캐시라인을 무효화해야 한다. 그런데 무효화 처리를 즉시 하면 그 동안 CPU가 멈춘다.
Invalidation Queue 없이 처리하면:
Core 0 Core 1
│ │
│──── "A 무효화해!" ────────────────▶ │
│ (Invalidate) │ A를 Invalid로 변경
│◀──── "무효화 했어!" ────────────── │
│ (Invalidate Ack) │
│ │
│ 이제야 A에 쓸 수 있음 (느림!) │
▼
STORE A = 1
Invalidation Queue 도입 후:
Core 0 Core 1
│ │
│──── "A 무효화해!" ────────────────▶ │
│ │
│ ┌────────┴────────┐
│ │ Invalidation │
│ │ Queue에 등록 │
│ │ [A 무효화 예정] │
│ └────────┬────────┘
│ │
│◀──── "알겠어!" (바로 응답) ──────── │ ← 아직 실제 무효화 안 했음!
│ (Invalidate Ack) │
│ │
│ Core 0은 바로 진행! │ A는 캐시에 아직 유효한 상태로 남아있음
▼ │
STORE A = 1 │
(나중에 Queue 처리)
▼
이제야 A를 Invalid로 변경
성능은 높아졌지만 즉시 무효화를 반영하지 않기 때문에 그 사이에 Core 1이 A의 구값을 읽을 수 있다. 데이터 무결성이 깨지는 지점이다.
┌─────────────────────────────────────────────────────────────────┐
│ CPU Core │
│ │
│ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Store Buffer │ │ Invalidation Queue │ │
│ │ │ │ [ "A 무효화해라" ] │ │
│ │ 쓰기 지연 │ │ [ "B 무효화해라" ] │ │
│ └──────────────┘ │ 나중에 처리... │ │
│ │ └─────────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ L1 Cache │◀── 아직 유효한 줄 알고 읽음 (stale data!) │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
재정렬을 막는 명시적인 명령어가 메모리 배리어(Memory Barrier, Fence) 다.
Full Fence (mfence): 모든 재정렬 금지. 가장 강력하지만 비싸다.
[모든 이전 연산] ─── FENCE ─── [모든 이후 연산]
Store Fence (sfence): Store 순서만 보장.
[이전 Store들] ─── SFENCE ─── [이후 Store들]
Load Fence (lfence): Load 순서만 보장.
[이전 Load들] ─── LFENCE ─── [이후 Load들]
Acquire: 이후 연산이 이 지점 앞으로 올라가지 못한다.
─── ACQUIRE ─── [이후 모든 연산]
Release: 이전 연산이 이 지점 뒤로 내려가지 못한다.
[이전 모든 연산] ─── RELEASE ───
Producer-Consumer 구조에서 가장 많이 쓰이는 패턴이다.
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Thread 1 (Producer) Thread 2 (Consumer) │
│ │
│ data = 42; ─┐ │
│ metadata = ...; │ 모두 Release 전에 완료 보장 │
│ prepare(); ─┘ │
│ │ │
│ ▼ │
│ ╔═══════════════╗ │
│ ║ RELEASE ║ ─────────────────────▶ │
│ ╚═══════════════╝ 전파 ╔═══════════════╗ │
│ flag = 1; ║ ACQUIRE ║ │
│ ╚═══════════════╝ │
│ │ │
│ ┌─────────────┘ │
│ ▼ │
│ use(data); ─┐ │
│ process(); │ Acquire 이후 실행 │
│ validate(); ─┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
flag = 1 이전의 모든 연산이 뒤로 내려가지 않음을 보장한다. data = 42가 flag = 1보다 반드시 먼저 메모리에 반영된다.flag 읽은 이후의 연산이 앞으로 올라가지 않음을 보장한다. data를 읽을 때 Thread 1이 써둔 42가 반드시 보인다.둘을 쌍으로 쓰면 Producer에서 쓴 모든 데이터가 Consumer에서 정확하게 보인다.
같은 Java 코드가 CPU 아키텍처마다 다르게 동작할 수 있다.
x86 (TSO, Total Store Order): 비교적 강한 메모리 모델이다. Store → Load 재정렬만 가능하고 나머지는 하드웨어가 알아서 막아준다. volatile read/write가 대부분 lock prefix 하나로 해결된다.
ARM/POWER: 훨씬 약한 메모리 모델이다. 대부분의 재정렬이 허용된다. dmb ish, dsb 같은 명시적인 fence 명령을 자주 삽입해야 한다.
x86에서 volatile write:
MOV [addr], value
LOCK prefix (Store Buffer flush)
ARM에서 volatile write:
STR r0, [addr]
DMB ISH ← 명시적 fence 필수!
JVM이 플랫폼별로 fence를 다르게 삽입하는 이유가 여기 있다. x86은 하드웨어가 강하게 보장해주니 fence가 적고, ARM은 소프트웨어가 명시적으로 더 많은 fence를 삽입해야 한다.
💡 팁: "x86에서는 volatile 없이도 잘 동작하는 코드가 ARM에서는 깨진다"는 버그가 실제로 존재한다. x86이 하드웨어 레벨에서 많은 것을 보장해주기 때문에 로컬 개발 환경(Intel Mac, x86 PC)에서 테스트하면 문제가 안 보이다가 ARM 서버 배포 후 발생하는 케이스다. Java에서는
volatile을 올바르게 쓰면 JVM이 플랫폼에 맞는 fence를 삽입해주므로 아키텍처에 무관하게 동작한다.
재정렬이 일어나는 세 가지 레벨을 정리하면:
이 세 가지가 합쳐지면 멀티스레드에서 코드가 작성한 순서대로 실행되지 않는다. 메모리 배리어는 이 재정렬을 막는 명시적인 도구다.
이제 volatile을 이해할 준비가 됐다. 3편에서는 Java의 volatile이 정확히 무엇을 보장하고, 내부적으로 어떤 배리어를 삽입하는지, 그리고 어떤 상황에서는 충분하지 않은지를 다룬다.