캐시 일관성
고친 사람 github-actions[bot]
캐시 일관성은 여러 곳에 흩어진 같은 데이터의 사본을 서로 어긋나지 않게 맞춰 줍니다. 한쪽이 값을 고치면 나머지 사본을 낡은 것으로 걷어 냅니다. 주로 프로세서 안에 여럿 들어 있는 캐시를 맞추는 하드웨어 규칙을 가리킵니다. 애플리케이션 캐시와 원본 저장소를 맞추는 일에도 같은 이름을 씁니다.
쉽고 빠른 이해
캐시 일관성은 같은 주소의 사본을 캐시 여럿이 나눠 들고 있어도 어느 쪽에서 읽든 같은 값이 나오게 해 주는 규칙입니다. 한 프로세서 안의 캐시 둘이 같은 변수를 담고 있다가 한쪽이 그 값을 1 에서 2 로 바꾸는 때가 이 규칙이 도는 때입니다.
이게 없으면 한쪽이 바꾼 값을 다른 쪽이 영영 못 봅니다. 같은 프로그램이 어느 캐시를 거치느냐에 따라 다른 값을 봅니다. 그러면 계산 결과를 믿을 수 없습니다.
어떻게 도는가:
- 한 캐시가 자기 사본을 고치려 하면 먼저 그 사실을 알립니다
- 같은 사본을 들고 있던 다른 캐시들은 자기 사본을 버립니다
- 버린 쪽이 그 값을 다시 읽으면 최신 값을 받아 옵니다
대가도 있습니다. 알리고 버리는 일에 시간이 듭니다. 캐시 여럿이 같은 값을 번갈아 고치면 그 값이 캐시 사이를 오가느라 계산이 밀립니다.
상세
여럿이 같은 표를 한 장씩 베껴 들고 각자 계산하고 있다고 해 봅니다. 숫자 하나를 고칠 사람은 먼저 "그 장 다 주세요" 하고 나머지 장을 전부 걷습니다. 다 걷은 것을 확인한 뒤에야 자기 장에 새 숫자를 적습니다.
프로세서를 흔히 CPU(Central Processing Unit, 중앙처리장치)라고 부릅니다. 요즘 CPU 한 개에는 명령을 실행하는 단위가 여럿 들어 있습니다. 그 단위 하나를 코어라고 합니다.
코어마다 CPU 캐시를 따로 들고 있어서 같은 주소의 사본이 여러 벌 생깁니다. 원본은 메인 메모리에 있고, 캐시가 든 것은 그 사본입니다.
캐시 일관성은 그 사본들이 서로 다른 값을 내놓지 않게 하는 규칙입니다. 이름은 지켜야 할 성질을 가리킵니다. 그 성질을 지키는 것은 코어들이 주고받는 신호입니다.
사본이 어긋나는 순간
사본이 여럿이라는 것 자체는 문제가 아닙니다. 읽기만 하는 동안은 사본이 몇 벌이든 값이 전부 같기 때문입니다.
한 코어가 자기 사본을 고치는 순간 갈립니다. 고친 쪽은 새 값을 봅니다. 나머지는 예전 값을 계속 봅니다. 이렇게 원본과 어긋난 사본을 낡은 데이터라고 부릅니다.
아래 그림은 CPU 안의 층과 그 안에서 값이 어긋난 모습을 함께 보입니다.
flowchart TD
M["메인 메모리 · 원본 x = 1"]
subgraph CPU
subgraph 코어1
C1["CPU 캐시 · 사본 x = 1"]
end
subgraph 코어2
C2["CPU 캐시 · 사본 x = 2 로 방금 고침"]
end
end
M -- 사본을 내어 준다 --> C1
M -- 사본을 내어 준다 --> C2
그림에서 코어2 는 x 를 2 로 고쳤습니다. 코어1 과 메인 메모리에는 아직 옛 값이 남아 있습니다. 두 코어가 한 프로그램의 두 부분을 나눠 맡고 있다면, 한쪽만 바뀐 값을 보게 되어 결과를 예측할 수 없습니다.
캐시 일관성이 주는 보장
캐시 일관성은 한 주소를 두고 두 가지를 약속합니다. 첫째, 어떤 코어가 쓴 값은 결국 다른 코어에게도 보입니다. 둘째, 그 주소에 일어난 쓰기의 순서는 모든 코어에게 똑같이 보입니다.
둘째 약속이 없으면 어떤 코어는 값이 1 에서 2 로 바뀌었다고 봅니다. 다른 코어는 2 에서 1 로 바뀌었다고 봅니다. 그러면 한 주소를 두고 코어마다 다른 역사를 갖게 됩니다.
보장은 주소 하나에 대한 것입니다. 서로 다른 두 주소 사이의 순서는 이 규칙이 다루지 않습니다.
무효화로 사본을 맞추는 방식
사본을 맞추는 길은 크게 둘입니다. 고친 값을 다른 사본에 뿌려 주는 갱신 방식과, 다른 사본을 못 쓰게 버리라고 알리는 무효화 방식입니다.
프로세서는 대개 무효화 쪽을 씁니다. 고칠 때마다 값을 뿌리면 아무도 안 읽을 값까지 나르게 됩니다. 버리라고 알리는 쪽은 주소만 보내면 끝납니다.
아래 그림은 한 코어가 사본을 고칠 때 오가는 것을 보입니다. 오가는 것은 값이 아니라 고치겠다는 알림과 그 응답입니다.
sequenceDiagram
participant 코어1
participant 코어2
코어1->>코어2: x 를 고치겠다
Note over 코어2: 자기 사본을 버린다
코어2-->>코어1: 버렸다
Note over 코어1: x 를 고친다
코어1 은 이 응답을 받은 뒤에 값을 고칩니다. 그래서 고치고 난 다음에는 옛 사본을 든 코어가 하나도 남지 않습니다.
버린 코어가 그 주소를 다시 읽으면 최신 값을 받아 옵니다. 고친 캐시가 바로 넘겨주기도 하고 메모리를 거치기도 합니다.
알림을 주고받는 길도 둘로 갈립니다. 하나는 코어들이 서로의 요청을 모두 엿듣는 방식입니다. 이것을 스누핑이라고 합니다. 코어가 몇 개 안 될 때 단순합니다.
다른 하나는 어느 캐시가 어떤 주소를 들고 있는지 장부에 적어 두고, 그 장부에 적힌 코어에게만 알리는 방식입니다. 이 장부를 디렉터리라고 해서 디렉터리 기반이라고 부릅니다.
두 방식은 알림이 어디까지 가느냐가 다릅니다. 아래 그림은 코어 수를 양쪽 다 셋으로 두고 화살표만 견줍니다.
flowchart TD
subgraph 스누핑
S1["코어1 · x 를 고치겠다"] --> BUS["공용 버스"]
BUS --> S2["코어2 · 엿듣는다"]
BUS --> S3["코어3 · 엿듣는다"]
end
subgraph 디렉터리
D1["코어1 · x 를 고치겠다"] --> DIR["장부 · x 는 코어2 가 들고 있다"]
DIR --> D2["코어2 · 알림을 받는다"]
D3["코어3 · 알림을 안 받는다"]
end
스누핑은 알림 하나가 나머지 코어 전부에게 닿습니다. 코어를 늘리면 엿들을 양이 같이 늘어납니다. 그래서 코어를 많이 붙이는 프로세서는 디렉터리 쪽으로 갑니다.
캐시 라인마다 붙는 상태
캐시는 값을 한 바이트씩 담지 않습니다. 이웃한 바이트까지 한 덩어리로 담습니다. 그 덩어리를 캐시 라인이라고 부릅니다. 일관성도 이 덩어리 단위로 지켜집니다.
라인마다 상태가 하나 붙습니다. 이 사본을 나만 가졌는지, 여럿이 나눠 가졌는지, 내가 고쳐서 메모리보다 새것인지를 적어 둡니다. 가장 널리 쓰이는 네 상태를 묶어 MESI(Modified, Exclusive, Shared, Invalid)라고 부릅니다.
| 상태 | 뜻 |
|---|---|
| Modified | 내가 고쳤다. 메모리보다 이 사본이 새것이다 |
| Exclusive | 나만 들고 있다. 아직 안 고쳤다 |
| Shared | 여럿이 같이 들고 있다. 아무도 안 고쳤다 |
| Invalid | 버려진 사본이다. 읽으려면 다시 받아 와야 한다 |
프로그램이 도는 동안 한 라인은 이 넷 사이를 옮겨 다닙니다. 아래 그림은 코어 하나가 자기 라인에서 겪는 변화입니다.
stateDiagram-v2
Invalid --> Exclusive: 나만 읽었다
Invalid --> Shared: 다른 코어도 읽고 있다
Exclusive --> Shared: 다른 코어도 읽었다
Exclusive --> Modified: 내가 고쳤다
Shared --> Modified: 내가 고쳤다
Modified --> Invalid: 다른 코어가 고쳤다
Shared --> Invalid: 다른 코어가 고쳤다
같은 라인을 든 다른 코어는 같은 순간에 짝이 되는 변화를 겪습니다. 내 라인이 Modified 가 될 때 상대의 라인은 Invalid 가 됩니다.
캐시 일관성이 막아 주지 않는 문제
일관성이 지켜져도 여러 코어가 같은 데이터를 동시에 만지는 코드는 깨질 수 있습니다. 보장이 주소 하나에 묶여 있기 때문입니다.
첫째, 서로 다른 주소를 읽고 쓰는 순서는 뒤바뀔 수 있습니다. 프로세서와 컴파일러가 속도를 얻으려고 순서를 바꿔 실행합니다. 어디까지 뒤바뀔 수 있는지를 정해 둔 규칙을 메모리 모델이라고 합니다. 그 순서를 코드에서 못 박는 명령은 메모리 장벽입니다.
둘째, 읽고 더하고 쓰는 세 걸음은 한 동작으로 묶이지 않습니다. 두 코어가 같은 값을 1 씩 올리면 한 번의 증가가 사라질 수 있습니다. 아래 그림이 그 순서입니다.
sequenceDiagram
participant 코어1
participant 메모리
participant 코어2
코어1->>메모리: x 를 읽는다
메모리-->>코어1: 5
코어2->>메모리: x 를 읽는다
메모리-->>코어2: 5
Note over 코어1,코어2: 둘 다 5 를 읽었다
코어1->>메모리: x 에 6 을 쓴다
코어2->>메모리: x 에 6 을 쓴다
Note over 메모리: 7 이어야 하는데 6 이 남는다
둘 다 5 를 읽었기 때문에 각자 더한 결과가 똑같이 6 입니다. 나중에 쓴 쪽이 앞의 결과를 덮어써서 증가 한 번이 묻힙니다. 사본은 내내 어긋나지 않았는데도 셈이 틀립니다.
이 세 걸음을 쪼개지지 않게 묶어 주는 것이 원자적 연산입니다. 더 넓은 구간을 묶을 때는 락을 씁니다.
정리하면 캐시 일관성은 "모두가 같은 값을 본다"까지만 책임집니다. "언제 보이느냐"와 "쪼개지지 않고 도느냐"는 다른 장치가 맡습니다.
사본을 맞추는 데 드는 비용
일관성은 공짜로 지켜지지 않습니다. 알림을 주고받는 데 시간이 듭니다. 여러 코어가 같은 라인을 번갈아 고치면 그 라인이 코어 사이를 계속 오갑니다. 코어를 늘려도 처리량이 그만큼 안 늘어나는 원인 하나가 이것입니다.
더 성가신 경우가 거짓 공유입니다. 아무 상관 없는 두 변수가 같은 라인에 얹혀 있으면, 각 코어가 자기 변수만 고쳐도 라인 전체가 오갑니다. 코드에서는 변수를 나눠 썼습니다. 하드웨어는 그 둘을 한 덩어리로 봅니다.
block-beta columns 2 a["변수 a · 코어1 만 고친다"] b["변수 b · 코어2 만 고친다"] cl["캐시 라인 하나"]:2
그림의 두 변수는 코드에서 아무 상관이 없습니다. 그런데 한 라인 안에 나란히 놓여 있어서, 코어1 이 a 만 고쳐도 b 를 든 코어2 의 사본까지 함께 버려집니다.
소프트웨어 캐시가 쓰는 같은 이름
같은 말을 프로세서 밖에서도 씁니다. 애플리케이션이 데이터베이스의 값을 캐싱해 두면 사본이 둘 생깁니다. 이 둘을 맞추는 일을 캐시 일관성이라고 부릅니다.
다른 점은 맞춰 주는 주체입니다. 코어 사이에서는 하드웨어가 알아서 지킵니다. 애플리케이션 캐시에서는 지켜 주는 장치가 없어 코드가 직접 해야 합니다.
cache.put("user:1", user); // 사본이 생긴다
db.update(user); // 원본만 바뀐다
cache.get("user:1"); // 낡은 값이 나온다
위 코드는 원본을 고친 뒤 사본을 그냥 두었습니다. 그래서 셋째 줄이 낡은 값을 돌려줍니다.
원본을 고칠 때 사본을 같이 지우는 것이 캐시 무효화입니다. 하드웨어가 알아서 하던 무효화를 코드가 직접 하는 것입니다. 사본에 수명을 붙여 스스로 사라지게 하는 것은 만료입니다. 둘을 어떤 순서로 엮느냐에 따라 cache-aside나 write-through 같은 이름이 붙습니다.
백엔드 코드가 이것을 만나는 때
프로세서 쪽 캐시 일관성은 켜고 끄는 것이 아닙니다. 하드웨어가 늘 지키고 있어서 대부분의 코드는 몰라도 돌아갑니다.
따져 볼 만한 때는 좁습니다. 여러 코어가 같은 데이터를 자주 고치고, 그 부분이 전체 시간의 큰 몫을 차지할 때입니다. 요청 하나가 데이터베이스나 네트워크를 기다리는 서비스라면 그 기다림이 훨씬 큽니다. 캐시 일관성을 따져도 줄어드는 시간이 눈에 안 띕니다.
소프트웨어 캐시 쪽은 반대입니다. 캐시를 한 번이라도 두면 사본을 언제 지울지는 반드시 정해야 합니다. 정해 두지 않으면 낡은 값이 언제까지 남을지 아무도 모릅니다.
관련 항목
코어들의 사본을 맞추는 하드웨어 프로토콜
MESI · MOESI · MSI · 스누핑 · 디렉터리 기반 일관성 · 메모리 버스
캐시 일관성이 다루는 저장 장치와 그 단위
CPU 캐시 · 캐시 라인 · 메인 메모리 · 메모리 계층 · 레지스터 · 더티 비트
메모리를 함께 들여다보는 실행 주체와 장치
CPU · 코어 · 멀티코어 · 스레드 · NUMA · DMA
캐시 일관성만으로는 못 막는 동시성 문제
거짓 공유 · 데이터 경합 · 원자적 연산 · 락 · 뮤텍스 · 컴페어 앤 스왑
서로 다른 주소 사이의 순서를 정하는 규칙
메모리 모델 · 메모리 장벽 · 순차 일관성 · 메모리 순서 · 명령어 재배치
이 이름과 뜻이 겹치는 다른 일관성 개념
일관성 · 선형화 가능성 · 최종 일관성 · 강한 일관성 · 분산 캐시
소프트웨어 캐시에서 원본과 사본을 맞추는 방식
캐싱 · 캐시 무효화 · 무효화 · cache-aside · write-through · write-back · write-around · read-through · TTL
사본이 원본과 어긋났을 때 나타나는 현상
다른 이름: cache coherence · cache coherency · 캐시 코히런스 · 캐시 일치성