동시성과 메모리 + 종합
목표 학습 시간: 105분 + 복습 15분 · 전공 수준 정독용 · 캡스톤
이 챕터를 마치면: (1) 캐시 일관성(MESI)과 false sharing을 설명하고, (2) 컴파일러·CPU의 메모리 재배치와 메모리 모델(SC/TSO/약한 모델)을 구분하고, (3) 배리어·atomic·acquire/release로 순서를 통제하고, (4) 데이터 레이스를 정의·탐지하고, (5) 세션 1~10 전체를 하나의 인과 사슬로 꿸 수 있다.
0. 학습 지도 (105분)
| 구간 | 분 | 내용 |
|---|---|---|
| 1 | 18 | 멀티코어와 캐시 일관성(MESI) |
| 2 | 15 | false sharing |
| 3 | 22 | 메모리 재배치와 메모리 모델 |
| 4 | 20 | 배리어·atomic·acquire/release |
| 5 | 10 | 데이터 레이스와 TSan |
| 6 | 20 | 종합 — 20시간을 하나의 사슬로 |
| 복습 | 15 | 통합 인출 |
1. 멀티코어와 캐시 일관성
1.1 문제: 코어마다 사적인 캐시
멀티코어에선 각 코어가 자기만의 L1/L2 캐시를 가진다(세션 4). 그런데 같은 메모리 주소를 두 코어가 각자 캐시에 올려두면, 한 코어가 값을 바꿔도 다른 코어의 캐시엔 옛값이 남는다. "메모리는 모두에게 똑같이 보인다"는 단일 코어 시절의 가정이 깨지는 것이다.
1.2 MESI — 일관성 프로토콜
하드웨어는 캐시 일관성(cache coherence) 프로토콜로 이 문제를 푼다. 대표 MESI는 각 캐시 라인에 네 상태 중 하나를 부여한다.
| 상태 | 의미 |
|---|---|
| Modified | 내가 고쳐서 나만 최신본을 가짐(메모리는 옛값). 쫓겨날 때 메모리에 써야 함. |
| Exclusive | 나만 가졌고 아직 안 고침(메모리와 일치). |
| Shared | 여러 코어가 같은 값을 공유(읽기). |
| Invalid | 무효(못 씀). |
핵심 규칙: 한 코어가 라인을 쓰려면 다른 코어들의 같은 라인을 Invalid로 만들어야 한다(소유권 요청, RFO). 그러면 그 라인의 최신본은 한 코어에만 존재하고, 다른 코어가 다시 읽으면 최신본을 가져온다. 이로써 "쓴 값이 결국 모두에게 보인다" 는 일관성이 유지된다.
비용 통찰: 한 라인을 여러 코어가 번갈아 쓰면, 매 쓰기마다 다른 코어의 사본을 무효화하고 최신본을 주고받는 코히어런스 트래픽이 발생한다. 이 라인이 코어 사이를 핑퐁하면 메모리만큼 느려진다. (MOESI/MESIF는 이를 더 최적화한 변종.)
1.3 NUMA — 메모리에도 "거리"가 있다
지금까진 "메모리는 하나, 모든 코어가 같은 비용으로 접근"이라 가정했다. 하지만 소켓(CPU 패키지)이 여럿인 서버에선 메모리가 소켓마다 따로 붙어 있다. 이를 NUMA(Non-Uniform Memory Access) 라 한다.
- 코어가 자기 소켓에 붙은 로컬 메모리를 접근하면 빠르고, 다른 소켓의 원격 메모리를 접근하면 인터커넥트(UPI·Infinity Fabric 등)를 건너야 해 느리다(지연 1.5~2배, 대역폭도 낮음).
- 그래서 "데이터가 어느 노드에 있고, 어느 코어가 그걸 쓰느냐"가 성능을 가른다.
실무 규칙:
- first-touch 정책: 리눅스는 페이지를 처음 건드린 스레드의 로컬 노드에 할당한다. "할당만 한 스레드"와 "실제로 쓰는 스레드"가 다른 노드면 내내 원격 접근이 된다 → 쓸 스레드가 직접 초기화(touch) 하게 하라.
numactl로 프로세스를 특정 노드에 고정(bind)하거나 인터리브할 수 있다.- false sharing(2절)이 라인(64B) 단위 지역성 함정이라면, NUMA는 노드 단위의 더 큰 지역성 문제다 — 둘 다 "데이터를 쓰는 주체 근처에 둬라"는 같은 교훈.
2. false sharing — 일관성이 만든 함정
캐시 일관성은 라인(64B) 단위로 작동한다(세션 4). 그래서 논리적으로 무관한 두 변수라도 같은 캐시 라인에 있으면, 두 코어가 각자 다른 변수를 써도 라인 전체가 서로 무효화되어 핑퐁한다. 변수는 안 겹치는데 라인이 겹쳐서 생기는 거짓 공유 — 이게 false sharing이다.
struct { long a; long b; } s; // a, b가 같은 64B 라인일 수 있다
// 코어1이 s.a만, 코어2가 s.b만 갱신해도
// → 매 쓰기가 상대 코어의 라인을 무효화 → 코히어런스 트래픽 폭증 → 급격히 느려짐
증상: 멀티스레드로 만들었는데 코어를 늘릴수록 오히려 느려지는 비직관적 현상. 원인을 모르면 디버깅이 지옥이다.
해결: 각 코어가 만지는 변수를 캐시 라인 크기로 패딩/정렬해 서로 다른 라인에 둔다(세션 2의 alignas).
struct {
_Alignas(64) long a; // a를 라인 경계에
_Alignas(64) long b; // b를 다른 라인에
} s;
이것이 세션 2(정렬)·4(라인)·10(일관성)이 한 점에서 만나는 대표 사례다.
3. 메모리 재배치와 메모리 모델
3.1 놀라운 사실: 순서가 바뀐다
컴파일러와 CPU는 성능을 위해 메모리 연산의 순서를 바꿀 수 있다. 컴파일러는 최적화로 명령을 재배열하고, CPU는 스토어 버퍼·비순차 실행으로 쓰기/읽기를 미루거나 앞당긴다. 규칙은 단 하나:
단일 스레드의 관찰 가능한 동작을 바꾸지 마라.
그 안에서라면 재배치는 합법이고, 단일 스레드 프로그램은 차이를 못 느낀다. 문제는 멀티스레드에서 이 재배치가 드러난다는 것이다.
3.2 고전 예제 — publish 패턴
// 스레드 1 // 스레드 2
data = 42; if (ready)
ready = 1; use(data); // data가 42라고 보장될까?
스레드 1에서 data=42와 ready=1의 저장 순서가 (컴파일러나 CPU에 의해) 뒤바뀌면, 스레드 2는 ready==1을 보고도 data는 아직 옛값일 수 있다. 단일 스레드 논리로는 멀쩡한 코드가 멀티스레드에서 깨진다.
또 다른 고전(스토어 버퍼 효과): 두 스레드가 각각 한 변수를 쓰고 다른 변수를 읽을 때, 둘 다 "옛값(0)"을 읽는 결과가 가능하다 — 각자의 쓰기가 스토어 버퍼에 머물러 상대에게 아직 안 보였기 때문.
3.3 메모리 모델 — 얼마나 재배치를 허용하나
아키텍처마다 "허용하는 재배치"가 다르다. 이를 메모리 모델이라 한다.
- 순차 일관성(SC): 모든 스레드가 하나의 일관된 연산 순서를 본다(재배치 없음). 가장 직관적이지만 비용이 크다 — 이론·언어 기본 모델의 기준점.
- TSO(Total Store Order, x86): 비교적 강하다. store→load 재배치만 허용(스토어 버퍼 때문)하고 나머지는 막는다. x86이 동시성 버그가 "덜 드러나" 보이는 이유 — 그래서 x86에서 우연히 동작하던 코드가 ARM에서 깨지기도 한다.
- 약한 모델(ARM, POWER): 대부분의 재배치를 허용한다. 명시적 배리어 없이는 순서가 거의 보장 안 된다. 그만큼 하드웨어는 자유롭고 빠르지만, 프로그래머가 동기화를 정확히 해야 한다.
4. 순서를 통제하는 도구 — 배리어 · atomic · acquire/release
4.1 메모리 배리어(fence)
"이 지점을 가로질러 메모리 연산을 재배치하지 마라"는 하드웨어 명령. 종류(full/acquire/release)에 따라 막는 방향이 다르다. 저수준이고 직접 쓰기 까다로워, 보통 atomic의 의미론으로 감싸 쓴다.
4.2 atomic과 acquire/release 의미론
C11/C++11의 atomic 타입은 "쪼개지지 않는" 연산을 제공할 뿐 아니라, 순서 보장(memory order) 도 함께 준다.
- release 저장: 이 저장 이전의 모든 쓰기가 다른 스레드에 먼저 보이도록 보장한다.
- acquire 로드: 이 로드 이후의 모든 읽기가 이 로드 뒤에 오도록 보장한다.
이 둘을 짝지으면 3.2의 publish 패턴이 안전해진다.
// 스레드 1 // 스레드 2
data = 42; if (ready.load(acquire))
ready.store(1, release); // ← release use(data); // data == 42 보장!
release 저장이 "data=42를 먼저 보이게" 묶어주고, acquire 로드가 "ready를 본 뒤에 data를 읽도록" 묶어준다. 이렇게 형성되는 happens-before 관계가 "data 쓰기는 data 읽기보다 먼저 일어난다"를 보장한다.
- 순차 일관성(seq_cst): C++/Java atomic의 기본. 가장 강하고 직관적이지만 약간의 성능 비용. 잘 모르겠으면 이걸 쓰는 게 안전하다.
4.3 락은 이 위에 선다
뮤텍스의 lock/unlock도 내부적으로 acquire/release 의미론을 갖는다 — 그래서 임계 구역 안의 메모리 연산이 밖으로 새지 않는다. 락 없이 atomic만으로 동기화하는 게 lock-free 프로그래밍이고, CAS(compare-and-swap) 루프 같은 기법을 쓴다(ABA 문제 등 함정이 많아 고난도).
5. 데이터 레이스
정의: 두 스레드가 동기화 없이 같은 메모리에 접근하고, 그중 최소 하나가 쓰기면 데이터 레이스다. C/C++에서 데이터 레이스는 미정의 동작(UB) 이고(세션 9), 증상은 타이밍 의존적이라 비결정적이다.
- 해결: 공유 데이터 접근을 락이나 atomic으로 동기화한다. "동시에 읽기만" 하는 건 레이스가 아니다(쓰기가 없으니).
- 탐지: ThreadSanitizer(TSan) 가 실행 중 happens-before를 추적해 레이스를 잡는다(세션 9의 새니타이저 계열).
6. 종합 — 20시간을 하나의 인과 사슬로
이제 전체를 하나로 꿰자. 각 세션은 독립 주제가 아니라 한 줄기 사슬의 마디였다.
[1] 빠르고 큰 메모리는 불가능하다(SRAM↔DRAM)
│ 그래서 계층을 쌓고, 지역성에 베팅한다
▼
[2] 메모리를 바이트 배열로 보고 주소·포인터로 가리킨다(정렬·레이아웃)
▼
[3] 값을 자동 수명의 스택과 명시적 수명의 힙으로 나눈다
▼
[4] 계층은 지역성으로 작동하고, 캐시는 라인 단위로 그것을 현금화한다
│ (행/열 순회·SoA·블로킹으로 미스를 줄인다)
▼
[5] 프로세스마다 자기 주소 공간이라는 착시를 주려 가상 메모리가 페이지 단위로 번역한다
▼
[6] 비싼 번역은 TLB로 캐싱하고, 폴트로 지연 로딩하며, mmap·COW로 공유한다
▼
[7] 힙 안에서 할당자가 free list로 빈 공간을 잘라 주고 합친다
▼
[8] 그 회수를 수동·참조 카운팅·추적 GC가 저마다의 트레이드오프로 떠맡는다
▼
[9] 어긋나면 누수·UAF·오버플로 버그가 나고, 도구가 그 순간을 잡는다
▼
[10] 코어가 여럿이면 캐시 일관성·메모리 순서라는 새 진실이 드러나고,
배리어·atomic으로 그것을 다스린다
이 사슬을 관통하는 단 하나의 질문이 있다:
"이 코드는 메모리 계층의 어디까지 내려가고, 누가 그 메모리를 번역(5·6)·할당(7)·회수(8)·동기화(10)하는가?"
이 질문에 막힘없이 답할 수 있게 되는 것 — 그것이 이 20시간의 목표였다. 80/20의 진짜 효과는 "전부를 안다"가 아니라, 모르는 것을 만났을 때 어디에 끼워 넣을지 아는 지도가 생기는 것이다. 이 10세션이 그 지도다.
7. 흔한 오해 바로잡기
- ❌ "캐시 일관성이 있으니 멀티스레드 메모리는 알아서 맞는다." → 일관성은 "쓴 값이 결국 보인다"만 보장한다. 순서는 보장 안 한다 — 그래서 메모리 모델·배리어가 별도로 필요하다.
- ❌ "변수가 다르면 캐시 문제도 없다." → 같은 라인에 있으면 false sharing으로 핑퐁한다. 정렬/패딩으로 분리하라.
- ❌ "x86에서 잘 돌면 안전하다." → x86은 TSO로 재배치가 덜 드러날 뿐. ARM 같은 약한 모델에서 깨질 수 있다.
- ❌ "volatile이 스레드 동기화를 해준다(C/C++)." → 아니다. volatile은 최적화 제거일 뿐 원자성·순서를 보장 안 한다. atomic을 써야 한다.
- ❌ "데이터 레이스는 그냥 가끔 틀린 값." → C/C++에선 UB라 무슨 일이든 가능하고 비결정적이다.
8. 한 장 정리
- 멀티코어는 코어마다 사적 캐시 → 캐시 일관성(MESI) 으로 "쓴 값이 결국 보이게" 한다(쓰기는 다른 사본을 무효화).
- false sharing: 무관한 변수가 같은 라인에 있어 핑퐁 → 라인 크기 정렬/패딩으로 분리.
- 컴파일러·CPU는 메모리 순서를 재배치한다(단일 스레드만 보존). 허용 범위가 메모리 모델(SC/TSO=x86/약한=ARM).
- 배리어·atomic·acquire-release 로 순서를 통제(publish 패턴 → happens-before). 락은 그 위에 선다.
- 데이터 레이스 = 비동기화 공유 접근(≥1 쓰기) = UB. TSan으로 탐지.
- 종합: 메모리 시스템은 "어디까지 내려가고, 누가 번역·할당·회수·동기화하는가"라는 한 질문으로 통합된다.
9. 복습 (15분) — 통합 인출
Q1. MESI 네 상태와, 쓰기 시 무슨 일이 일어나나?
Modified(나만 최신·dirty), Exclusive(나만·clean), Shared(여럿 공유·읽기), Invalid(무효). 한 코어가 라인을 쓰려면 다른 코어들의 같은 라인을 Invalid로 만든다(소유권 획득).
Q2. false sharing을 한 문장으로 설명하고 회피법을 말하라.
논리적으로 무관한 변수들이 같은 캐시 라인에 있어, 다른 코어가 각자 다른 변수를 써도 라인 전체가 서로 무효화되어 핑퐁(코히어런스 트래픽 폭증)하는 현상. 변수를 캐시 라인 크기(64B)로 정렬/패딩해 다른 라인에 두면 피한다.
Q3. publish 패턴(data; ready)이 깨지는 이유와, acquire/release로 어떻게 고치나?
스레드 1의 data 쓰기와 ready 쓰기 순서가 재배치되면, 스레드 2는 ready=1을 보고도 data가 옛값일 수 있다. ready를 release로 저장하면 그 이전 쓰기(data)가 먼저 보이고, ready를 acquire로 로드하면 그 뒤 읽기(data)가 뒤에 와, happens-before로 data==42가 보장된다.
Q4. x86(TSO)과 ARM(약한 모델)의 차이가 실무에 주는 함의는?
x86은 store→load만 재배치해 동시성 버그가 덜 드러나, x86에서 우연히 동작하던 코드가 재배치를 폭넓게 허용하는 ARM에서 깨질 수 있다. 따라서 메모리 모델에 의존하지 말고 atomic/배리어로 명시적으로 동기화해야 한다.
Q5. 데이터 레이스의 정의와 탐지 도구는?
두 스레드가 동기화 없이 같은 메모리에 접근하고 그중 하나 이상이 쓰기인 것. C/C++에선 UB. ThreadSanitizer(TSan)로 탐지.
Q6. (통합) 세션 1의 메모리 계층부터 세션 10까지를 인과 사슬로 5문장 안에 요약하라.
모범답안 핵심 키워드 순서: ① 빠르고 큰 메모리 불가→계층·지역성 ② 주소·포인터로 바이트를 가리킴 ③ 스택(자동)·힙(명시) 분리 ④ 캐시가 라인 단위로 지역성을 현금화 ⑤ 가상 메모리가 페이지로 번역(격리/추상화) ⑥ TLB 캐싱·폴트 지연 로딩·mmap/COW ⑦ 할당자가 free list로 힙 관리 ⑧ 수동/RC/GC가 회수 ⑨ 버그와 도구 ⑩ 멀티코어 일관성·메모리 순서. 이 흐름이 한 사슬로 이어지면 정답.
Q7. (메타) 이 20시간 전체를 관통하는 단 하나의 질문은?
"이 코드는 메모리 계층의 어디까지 내려가고, 누가 그 메모리를 번역·할당·회수·동기화하는가?"
학습을 마치며
이 10챕터는 메모리 시스템의 뼈대다. 살은 각 세션의 원자료(OSTEP·CSAPP·Crafting Interpreters·Preshing·Drepper)와 직접 손으로 한 실험으로 붙인다. 특히 세 가지는 꼭 손으로 해보라.
- 세션 4: 행/열 순회 속도와 작업 집합 크기별 "원소당 시간"을 직접 측정(
perf). - 세션 7: 50줄짜리 free-list 할당자를 직접 구현.
- 세션 9: 버그를 일부러 심고 ASan으로 잡아 리포트의 삼각형 읽기.
세 번만 손으로 만지면, 이 문서의 모든 문장이 "읽은 지식"에서 "아는 것"으로 바뀐다.