volatile 이해하기 위한 여정 1편

Chu Sang Yoon·2026년 3월 18일

volatile

목록 보기
1/3

volatile 이해하기 위한 여정 1편: 캐시 일관성과 False Sharing

volatile 키워드 하나를 제대로 이해하려면 CPU가 메모리를 어떻게 다루는지부터 알아야 한다. 그래서 시작점은 캐시다.

volatile을 공부하다 보면 "가시성을 보장한다"는 설명을 만난다. 가시성이 뭔지, 왜 보장이 안 될 수 있는지를 이해하려면 멀티코어 CPU의 캐시 구조를 먼저 알아야 한다. 1편에서는 캐시가 어떻게 동작하고, 멀티코어 환경에서 어떤 문제가 생기는지를 다룬다.


캐시 메모리가 필요한 이유

CPU는 빠르고 RAM은 느리다. 이 속도 차이가 어마어마하다. CPU가 연산을 마치고 다음 데이터를 기다리는 동안 계속 놀게 되는 문제를 메모리 성능 격차(Memory Wall) 라고 한다.

해결책이 캐시다. CPU와 RAM 사이에 고속 메모리를 끼워 넣어서 자주 쓰는 데이터를 가까이 둔다. 현대 CPU는 L1, L2, L3 캐시의 3계층 구조를 쓴다.

속도와 용량은 반비례한다:

  • L1: 가장 빠름, 수십KB, 코어별 독립
  • L2: 중간, 수백KB, 코어별 독립
  • L3: 가장 느림, 수MB~수십MB, 여러 코어 공유

이 구조가 단일 코어에서는 아무 문제가 없다. 문제는 멀티코어에서 시작된다.


캐시 일관성 문제

멀티코어 시스템에서는 각 코어가 독립적인 L1, L2 캐시를 가진다. 그러면 동일한 메모리 주소의 데이터가 여러 캐시에 동시에 존재할 수 있다.

Core 0         Core 1
┌────────┐     ┌────────┐
│ L1 캐시│     │ L1 캐시│
│ x = 1 │     │ x = 1  │  ← 같은 x가 두 곳에!
└────────┘     └────────┘
      └──────────────┘
             │
        ┌────────┐
        │  RAM   │
        │ x = 1  │
        └────────┘

Core 0이 x를 2로 바꾸면 어떻게 될까? Core 1의 캐시에는 아직 x = 1이 있다. RAM도 아직 1이다. 이게 캐시 일관성 문제(Cache Coherency Problem) 다.

한 곳에서 값을 바꾸면, 그 사실을 다른 코어들에게 전달하고 기존 데이터를 무효화해야 한다.

Coherence vs Consistency

헷갈리기 쉬운 두 개념을 구분해두자.

  • Cache Coherence: 같은 주소의 값이 모든 코어에서 동일하게 보이느냐
  • Memory Consistency: 다른 주소들 간의 연산 순서가 보장되느냐

뒤에 나오는 MESI는 Coherence 문제를 해결하고, volatile이 실제로 다루는 Memory Model은 Consistency에 관련된 내용이다.


캐시 쓰기 정책

캐시에 데이터를 쓸 때 RAM을 언제 업데이트할지에 대한 정책이 있다.

Write-Through

캐시에 쓸 때 RAM에도 즉시 쓴다. 일관성은 쉽게 유지되지만 느리다. RAM은 캐시보다 훨씬 느려서 매번 기다려야 하기 때문이다. 이를 완화하기 위해 Write Buffer를 추가해서 CPU가 직접 기다리지 않도록 한다.

Write-Back

캐시에만 우선 쓰고, 해당 캐시 블록이 교체될 때 RAM에 반영한다. Write-Through보다 훨씬 빠르지만 구현이 복잡하다. 변경된 데이터를 추적하기 위해 Dirty Bit를 사용한다. Dirty Bit가 1이면 "이 캐시라인은 RAM과 다르다"는 의미다. 느리게 쓴다는 의미에서 Lazy Write라고도 부른다.

Write Miss

쓰려는 데이터가 캐시에 없는 경우다.

  • Write-Allocate: 미스가 난 위치에 데이터를 캐시에 먼저 로드한 후 쓴다. Write-Back 정책과 함께 쓴다.
  • No Write-Allocate(Write-Around): 캐시에 로드하지 않고 RAM에 직접 쓴다. Write-Through 정책과 함께 쓴다.

Bus Snooping — 일관성 해결의 기본 아이디어

캐시 일관성을 유지하는 가장 기본적인 방법이 Bus Snooping이다.

모든 캐시가 버스를 감시(snoop)하고 있다가, 자신이 가진 데이터에 대한 읽기/쓰기가 발생하면 반응한다. 다른 캐시가 데이터를 변경했다는 신호를 받으면 두 가지 방법으로 처리할 수 있다.

Update(변경): 변경된 값을 자신의 캐시에도 반영한다. 데이터가 자주 바뀌지 않으면 효율적이지만, 변경이 있을 때마다 버스에 신호를 보내야 해서 버스가 병목이 될 수 있다. Write-Through 정책에 해당한다.

Invalidate(무효화): 자신의 캐시라인을 무효 상태로 표시한다. 변경이 있을 때 한 번만 신호를 보내면 되어서 버스 부담이 적다. Write-Back 정책에 해당한다.


MESI 프로토콜

Bus Snooping을 구체적으로 구현한 것이 MESI 프로토콜이다. Intel, AMD, ARM 모든 주요 프로세서에서 변형된 형태로 사용한다.

각 캐시라인이 4가지 상태 중 하나를 가진다.

Modified (M): 이 캐시에서만 데이터가 변경된 상태. RAM과 다르다(Dirty). 이 코어가 독점 소유하고 있고, 다른 코어의 읽기 요청이 오면 지연이 발생한다.

Exclusive (E): 이 캐시에만 데이터가 있고 RAM과 일치한다(Clean). 수정 시 브로드캐스트 없이 즉시 Modified로 전환할 수 있다.

Shared (S): 여러 캐시에 동일한 데이터가 존재한다. 읽기는 자유롭지만 쓰기 시 다른 모든 캐시를 Invalid 상태로 만들어야 한다.

Invalid (I): 유효하지 않은 상태. 데이터 접근 시 RAM이나 다른 캐시에서 가져와야 한다.

E → M 전환: 브로드캐스트 없이 조용하게 전환 가능
S → M 전환: 다른 캐시들에게 Invalidate 신호 필요
M → S 전환: RAM에 데이터를 써야 하는가? ← 여기서 비용 발생

이 마지막 문제를 해결하기 위해 MOESI 프로토콜에서는 Owned(O) 상태를 추가했다. M 상태의 캐시라인을 다른 코어가 읽으려 할 때, RAM에 쓰는 대신 해당 캐시가 직접 데이터를 제공한다. 캐시끼리 데이터를 교환하는 게 RAM을 거치는 것보다 빠르기 때문이다.

💡 : MSI 프로토콜은 MESI에서 Exclusive 상태가 없는 버전이다. Exclusive 없이 단독으로 캐시에 있는 경우도 Shared 상태로 처리하는데, 그러면 S → M 전환 시 불필요한 Invalidate 신호를 보내야 하는 낭비가 생긴다. MESI에서 Exclusive가 이 낭비를 없애는 역할을 한다.


Cacheable vs Non-Cacheable

캐시 일관성이 문제가 되는 또 다른 상황이 있다. DMA(Direct Memory Access) 다.

DMA는 CPU를 거치지 않고 I/O 장치가 메인 메모리에 직접 데이터를 전송하는 메커니즘이다. CPU는 캐시를 보고, DMA는 RAM을 직접 건드리니까 당연히 불일치가 생긴다.

이를 해결하기 위해 메모리 영역을 두 가지로 구분한다.

  • Cacheable: 캐시를 사용하는 영역. CPU 전용.
  • Non-Cacheable: 캐시를 사용하지 않는 영역. CPU와 DMA처럼 여러 하드웨어가 같은 메모리를 공유할 때 사용. 접근 속도는 느리지만 일관성이 보장된다.

DMA가 사용하는 메모리를 Non-Cacheable로 설정하면 불일치 문제가 사라진다.


False Sharing

캐시 일관성 메커니즘이 잘 동작하더라도, 구조적으로 성능을 갉아먹는 문제가 있다. 바로 False Sharing이다.

CPU는 메모리를 1바이트씩 읽지 않는다. 64바이트 덩어리(캐시라인) 단위로 읽는다.

┌────────────────────────────────────────────────────────────────┐
│0x1000                                              0x103F │
│  [  변수 a  ][  변수 b  ][  변수 c  ][    ...     ]         │
│   8 bytes     8 bytes     8 bytes                         │
│                                                           │
│  이 전체가 하나의 단위로 캐시에 올라감 (64 bytes)             │
└────────────────────────────────────────────────────────────────┘

서로 다른 변수인데 같은 캐시라인에 있으면 문제가 생긴다.

Core 0 캐시                         Core 1 캐시
┌──────────────────────┐            ┌──────────────────────┐
│ 캐시 라인 #42       │            │ 캐시 라인 #42       │
│ [a=1][b=0][...  ]  │            │ [a=1][b=0][...]    │ 
│  ↑                 │            │       ↑            │
│ Thread 0이 a만 씀   │            │ Thread 1이 b만 씀   │
└──────────────────────┘            └──────────────────────┘

Thread 0이 a를 수정하면:

1. Core 0: 캐시 라인 전체를 Modified로 변경
2. Core 0 → Core 1: "캐시 라인 #42 무효화해!"
3. Core 1: 캐시 라인 전체가 Invalid됨

Thread 1이 b를 읽으려면:
4. Core 1: "캐시 라인 #42가 Invalid네..."
5. Core 1 → Core 0: "캐시 라인 #42 줘!"
6. 버스 통해서 64바이트 전체 전송
7. 이제야 b를 읽을 수 있음

→ b는 전혀 안 바뀌었는데, a 때문에 캐시 미스 발생!

락도 없고 데이터도 공유하지 않는데 성능이 나쁜 이유가 여기 있다.

배열에서도 똑같이 발생한다:

long[] counts = new long[4];  // 연속된 32 bytes

// 캐시 라인 #42
// [counts[0]][counts[1]][counts[2]][counts[3]][...]
//  Thread 0    Thread 1    Thread 2    Thread 3
//  전부 같은 캐시 라인 안에 있음!

Thread 0이 counts[0]을 수정하면 Thread 1, 2, 3의 캐시가 전부 무효화된다. 4개 스레드가 서로의 캐시를 계속 무효화하는 상황이 벌어진다.


False Sharing 해결 방법

수동 패딩

변수들을 서로 다른 캐시라인에 배치한다.

// Before: 같은 캐시 라인
class BadCounter {
    volatile long count1;  // 0x1000 ~ 0x1007
    volatile long count2;  // 0x1008 ~ 0x100F
    // 둘 다 캐시 라인 #42에 있음
}

// After: 다른 캐시 라인
class GoodCounter {
    volatile long count1;                      // 0x1000 ~ 0x1007
    long p1, p2, p3, p4, p5, p6, p7;           // 56 bytes 패딩
    // ─────────────── 캐시 라인 경계 ───────────────
    volatile long count2;                      // 0x1040 ~ (다음 캐시 라인!)
}

Thread 0이 count1 수정 → 캐시 라인 #42만 무효화. Thread 1의 count2는 캐시 라인 #43에 있으니 영향 없다.

배열이라면 간격을 벌려준다:

long[] counts = new long[4 * 8];  // 256 bytes

// Thread 0: counts[0]   → 캐시 라인 #42
// Thread 1: counts[8]   → 캐시 라인 #43 (64 bytes 뒤)
// Thread 2: counts[16]  → 캐시 라인 #44
// Thread 3: counts[24]  → 캐시 라인 #45

@Contended

JVM이 자동으로 패딩을 삽입해준다. 수동 패딩과 원리는 같다.

@Contended
volatile long count1;

@Contended
volatile long count2;

128바이트씩 패딩하는 이유는 캐시 라인 경계가 어디인지 확실히 모르니까 넉넉하게 잡는 것이다.

클래스 분리

같은 객체 안에 있으면 JVM이 필드를 메모리에 연속으로 배치하기 때문에 붙어있을 가능성이 높다. 별도 객체로 분리하면 힙의 다른 위치에 할당돼서 캐시 라인이 달라질 가능성이 높아진다.

// 같은 객체 → 연속 배치
class Counter {
    long count1;  // 붙어있음
    long count2;
}

// 별도 객체 → 힙의 다른 위치
class Counter1 { long count; }
class Counter2 { long count; }

LongAdder

단순 패딩이 아니라 Striping + 패딩 방식이다.

AtomicLong: 하나의 변수에 모든 스레드가 경합
LongAdder:  스레드별로 다른 셀(Cell)에 분산
┌─────────────────────────────────────────────────────┐
│                      LongAdder                 │
│                                                │
│  base 값 (경합 없을 때)                          │
│                                                │
│  Cell 배열 (@Contended 적용):                   │
│  ┌───────────┐ ┌───────────┐ ┌───────────┐        │
│  │ Cell[0]  │ │ Cell[1]  │ │ Cell[2]  │         │
│  │ value=10 │ │ value=7  │ │ value=12 │         │
│  │ (패딩)    │ │ (패딩)   │ │ (패딩)    │         │
│  └───────────┘ └───────────┘ └───────────┘         │
│   Thread 0용    Thread 1용    Thread 2용         │
│                                                 │
│  sum() = base + Cell[0] + Cell[1] + Cell[2] = 29│
└─────────────────────────────────────────────────────┘

각 스레드가 자기 해시값으로 Cell을 선택해서 독립적으로 증가시킨다. 다른 스레드와 캐시 라인이 겹치지 않으니 경합이 없다. sum() 호출 시에만 전체 합산을 한다.


False Sharing 탐지 방법

perf c2c (Linux)

perf c2c record ./my_program
perf c2c report

Rmt Hitm 값이 높으면 False Sharing을 의심할 수 있다. 다른 코어 캐시에서 Modified 상태로 데이터를 가져온 횟수다.

JMH 벤치마크

// 간단하게 성능 차이로 확인하는 방법
// Bad 버전 vs Good 버전(패딩 있는)을 같은 조건으로 측정

Benchmark               thrpt
FalseSharingTest.bad    50,123 ops/ms  ← 느림
FalseSharingTest.good  512,456 ops/ms  ← 10배 빠름

다음 증상이 보이면 False Sharing을 의심해보자:

  • 락이 없는데 멀티스레드가 싱글스레드보다 느림
  • 코어를 늘려도 성능이 오르지 않음
  • CPU 사용량은 높은데 처리량이 낮음

마치며

캐시 구조와 MESI 프로토콜을 이해하면 두 가지가 명확해진다.

첫 번째, 멀티코어에서 메모리 값은 언제든지 캐시에 여러 복사본이 존재할 수 있다. 한 코어에서 값을 바꾼다고 다른 코어에 즉시 반영되는 게 아니다. 이게 volatile이 "가시성을 보장한다"는 말의 배경이다.

두 번째, 캐시 무효화는 캐시라인 단위로 일어난다. 내가 수정한 변수와 다른 스레드가 읽는 변수가 같은 캐시라인에 있으면, 수정하지 않은 변수도 무효화된다. 이게 False Sharing이다.

2편에서는 메모리 모델과 명령어 재정렬(Instruction Reordering)을 다룬다. CPU와 컴파일러가 왜 코드 순서를 바꾸는지, 그게 멀티스레드에서 어떤 문제를 일으키는지, 그리고 volatile이 정확히 무엇을 막는지를 살펴본다.

0개의 댓글