캐시 일관성(MESI)
멀티코어 CPU에서는 코어마다 자기 캐시를 가지고 있어서, 같은 메모리 주소의 복사본이 여러 캐시에 동시에 들어 있을 수 있습니다. 한 코어가 그 값을 바꿨는데 다른 코어의 캐시에는 예전 값이 남아 있다면, 코어마다 같은 변수를 다른 값으로 읽게 됩니다. 이 문제를 막는 하드웨어 규칙이 캐시 일관성(cache coherence) 프로토콜입니다. 프로그래머가 따로 코드를 쓰지 않아도 하드웨어가 알아서 유지하지만, 공짜는 아닙니다. 여러 코어가 같은 데이터를 자주 쓰면 캐시 라인이 코어 사이를 오가며 지연이 생깁니다.
대표적인 프로토콜이 MESI입니다. 캐시 라인을 Modified(수정됨), Exclusive(단독), Shared(공유), Invalid(무효) 네 상태 중 하나로 두고, 읽기와 쓰기가 일어날 때마다 상태를 바꾸며 다른 캐시와 신호를 주고받습니다. 한 코어가 쓰기 전에 다른 코어의 복사본을 무효화하는 것이 핵심입니다. 코어 수가 많아지면 모든 캐시가 버스를 지켜보는 방식(스누핑) 대신 라인마다 누가 가지고 있는지 기록해 두는 디렉터리 방식이 쓰이기도 합니다. 이 구조 때문에 서로 다른 변수가 우연히 같은 캐시 라인에 놓이면 성능이 크게 떨어지는 거짓 공유(false sharing)가 생깁니다.
핵심 개념
캐시 일관성
같은 주소에 대해 (1) 한 코어가 쓴 값은 그 코어가 이어서 읽으면 그대로 읽히고, (2) 다른 코어가 쓴 값은 충분한 시간이 지난 뒤 읽히며, (3) 같은 주소에 대한 쓰기는 모든 코어에서 같은 순서로 보이는 성질입니다(Hennessy & Patterson). 코어마다 복사본이 있어도 하나의 메모리처럼 보이게 합니다.
핵심 개념
MESI 네 가지 상태
M(수정됨)은 이 캐시만 가지고 있고 메모리보다 최신인 상태, E(단독)는 이 캐시만 가지고 있고 메모리와 같은 상태, S(공유)는 여러 캐시가 가지고 있고 메모리와 같은 상태, I(무효)는 쓸 수 없는 상태입니다.
핵심 개념
스누핑과 디렉터리
스누핑은 모든 캐시가 공용 버스 같은 브로드캐스트 매체에서 다른 코어의 요청을 엿보고 자기 상태를 바꾸는 방식입니다. 디렉터리 방식은 캐시 라인마다 어느 캐시가 복사본을 가졌는지 기록해 두고 필요한 캐시에만 신호를 보내므로 코어 수가 많은 시스템에 알맞습니다.
핵심 개념
무효화와 갱신
한 코어가 쓸 때 다른 복사본을 무효로 만드는 방식(write-invalidate)과 새 값으로 갱신하는 방식(write-update)이 있습니다. 교재는 최근 멀티프로세서가 무효화 방식을 택했다고 설명하며, 이 시각화도 무효화 방식입니다.
핵심 개념
캐시 라인 단위 관리
일관성은 변수 하나가 아니라 캐시 라인(x86에서 흔히 64바이트) 단위로 관리됩니다. 라인 안의 어느 바이트를 쓰든 그 라인 전체의 상태가 바뀝니다.
핵심 개념
거짓 공유
코어들이 서로 다른 변수를 쓰는데 그 변수들이 같은 캐시 라인에 있으면, 한 코어의 쓰기가 다른 코어의 라인을 무효화해 라인이 계속 오갑니다. 공유하는 데이터가 없는데도 공유한 것처럼 느려집니다.
캐시 일관성의 배경
캐시 일관성 문제는 프로세서마다 개인 캐시를 두는 멀티프로세서에서 생겼습니다. 코어가 하나일 때는 값을 쓰고 읽는 주체가 한 곳이라 하드웨어가 캐시 계층을 하나의 일관된 메모리처럼 보이게 하기 쉽지만(DMA 같은 장치가 메모리에 직접 쓰는 경우는 별도의 문제입니다), 코어마다 개인 캐시가 있으면 같은 주소의 복사본들이 서로 어긋날 수 있기 때문입니다. MESI 같은 상태 기반 프로토콜은 이런 복사본을 개별 라인의 상태로 추적해 어긋남을 막습니다. 제품마다 이름이 조금씩 다른 변형(예: Owned 상태를 더한 MOESI, Forward 상태를 더한 MESIF)이 쓰이므로, 이 토픽의 MESI는 기본 형태로 이해하면 됩니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
캐시 일관성 문제란 무엇인가요?
코어마다 캐시가 따로 있어 같은 주소의 복사본이 여러 개일 때, 한 코어가 값을 바꾸면 다른 코어의 복사본이 낡은 값이 되는 문제입니다. 프로토콜이 쓰기를 다른 캐시에 알려 복사본을 무효화하거나 갱신합니다.
면접 질문
MESI에서 공유(S) 라인에 쓰면 어떻게 되나요?
먼저 다른 캐시에 무효화 신호를 보내 그쪽 복사본을 무효(I)로 만들고, 자신의 라인을 수정됨(M)으로 바꾼 뒤 씁니다. 그래서 수정됨(M) 라인은 한 시점에 한 캐시에만 있습니다.
면접 질문
거짓 공유란 무엇이고 어떻게 피하나요?
서로 다른 변수가 같은 캐시 라인에 있어 한 코어의 쓰기가 다른 코어의 라인을 무효화하는 현상입니다. 변수를 라인 단위로 띄워 놓거나(패딩, 정렬), 스레드별 지역 값을 모았다가 합쳐서 피합니다.
면접 질문
캐시 일관성과 메모리 일관성 모델은 어떻게 다른가요?
캐시 일관성은 같은 주소에 대한 접근 규칙이고, 메모리 일관성 모델은 서로 다른 주소에 대한 접근이 다른 코어에 어떤 순서로 보이는지를 정합니다. 둘은 별개이며 동기화 코드는 후자를 다룹니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
거짓 공유 — 이웃한 원소를 서로 다른 스레드가 씀
// 두 스레드가 서로 다른 원소를 쓰지만 이웃해서 같은 캐시 라인에 놓일 수 있음 long[] counters = new long[2]; Thread t0 = new Thread(() -> { for (int i = 0; i < N; i++) counters[0]++; }); Thread t1 = new Thread(() -> { for (int i = 0; i < N; i++) counters[1]++; }); // 인덱스 간격을 띄움: long 8개 = 64바이트 떨어져 서로 다른 라인에 놓임(라인이 64바이트일 때) long[] padded = new long[16]; // t0은 padded[0]을, t1은 padded[8]을 증가
첫 번째 배열에서 두 스레드는 서로 다른 변수를 쓰지만, 배열의 시작 주소는 JVM이 정하므로 항상 같은 라인이라고 보장되지는 않지만, 이웃한 8바이트 값은 대개 같은 64바이트 라인에 들어갑니다. 한쪽이 쓸 때마다 상대 코어의 라인이 무효화되어 라인이 코어 사이를 오갑니다. 두 번째 배열처럼 쓰는 위치를 64바이트 이상 띄우면 각 스레드가 자기 라인을 수정됨(M) 상태로 유지해 라인 이동이 사라집니다. 캐시 라인 크기는 CPU마다 다를 수 있으므로 64바이트는 흔한 값을 가정한 예입니다. 실제 속도 차이는 하드웨어와 JVM에 따라 다르므로 직접 측정해 확인해야 합니다.
경합을 줄이는 카운터 — AtomicLong과 LongAdder
// 모든 스레드가 같은 변수 하나를 갱신: 그 변수가 든 라인을 코어들이 서로 빼앗음 AtomicLong shared = new AtomicLong(); shared.incrementAndGet(); // 갱신이 경합하면 내부 변수를 여러 개로 나눠 가짐: 합계는 sum()으로 읽음 LongAdder adder = new LongAdder(); adder.increment(); long total = adder.sum();
여러 스레드가 동시에 같은 AtomicLong을 갱신하면 쓰기마다 그 변수가 든 캐시 라인의 독점 소유권이 코어 사이를 옮겨 다닙니다. 경합이 낮으면 이 비용은 작습니다. LongAdder는 경합이 생기면 합계를 여러 변수로 나눠 가져서 스레드들이 서로 다른 변수를 갱신하게 하고, 읽을 때 sum()으로 합칩니다. 대신 sum()을 호출하는 순간의 값은 갱신이 한창일 때 정확한 스냅샷이 아닐 수 있어, 통계처럼 자주 쓰고 가끔 읽는 값에 알맞습니다.
C++ — 카운터를 캐시 라인 단위로 정렬
// C++17: 거짓 공유를 피하기 위한 최소 간격(구현이 제공하는 상수) #include <new> #include <atomic> struct alignas(std::hardware_destructive_interference_size) Counter { std::atomic<long> value{0}; }; Counter counters[2]; // 스레드 0은 counters[0], 스레드 1은 counters[1]만 사용
alignas로 구조체를 해당 간격에 정렬하면 배열의 각 원소가 서로 다른 캐시 라인에 놓이므로 이웃한 카운터를 서로 다른 스레드가 써도 거짓 공유가 생기지 않습니다. hardware_destructive_interference_size는 거짓 공유를 피하려고 서로 떼어 놓아야 할 최소 간격의 힌트입니다. 이름은 표준이 정하고 값은 구현이 정하며, 컴파일러 옵션과 타깃에 따라 달라질 수 있습니다. 구조체 크기가 라인 크기만큼 커지므로, 원소 수가 많은 배열에 이런 패딩을 쓰면 메모리를 많이 쓴다는 점도 함께 따져야 합니다.
Q1
캐시 일관성(Cache Coherence) 문제가 생기는 상황은?
Q2
MESI 프로토콜에서 캐시 라인이 가질 수 있는 네 가지 상태는?
Q3
MESI에서 한 코어가 Shared 상태의 캐시 라인에 쓰기를 하려 할 때 일어나는 일은?