🧠 메모리 교재 · 10_동시성과종합

동시성과 메모리 + 종합

목표 학습 시간: 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). 그러면 그 라인의 최신본은 한 코어에만 존재하고, 다른 코어가 다시 읽으면 최신본을 가져온다. 이로써 "쓴 값이 결국 모두에게 보인다" 는 일관성이 유지된다.

MModified EExclusive SShared IInvalid 읽기·공유자 없음 읽기·공유자 있음 내가 쓰기 쓰기 → 남 무효화(RFO) 타 코어 쓰기 타 코어 읽기
MESI — 한 코어가 라인을 쓰려면 다른 코어 사본을 Invalid로 만든다(RFO). 번갈아 쓰면 라인이 코어 사이를 핑퐁한다.

비용 통찰: 한 라인을 여러 코어가 번갈아 쓰면, 매 쓰기마다 다른 코어의 사본을 무효화하고 최신본을 주고받는 코히어런스 트래픽이 발생한다. 이 라인이 코어 사이를 핑퐁하면 메모리만큼 느려진다. (MOESI/MESIF는 이를 더 최적화한 변종.)

1.3 NUMA — 메모리에도 "거리"가 있다

지금까진 "메모리는 하나, 모든 코어가 같은 비용으로 접근"이라 가정했다. 하지만 소켓(CPU 패키지)이 여럿인 서버에선 메모리가 소켓마다 따로 붙어 있다. 이를 NUMA(Non-Uniform Memory Access) 라 한다.

실무 규칙:


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;
코어 1s.a 쓰기 코어 2s.b 쓰기 하나의 캐시 라인 (64B) a b ↔ 매 쓰기마다 라인 전체 무효화 → 핑퐁 해결: 각 변수를 _Alignas(64)로 다른 라인에 분리
false sharing — a·b는 논리적으로 무관하지만 같은 64B 라인에 있어, 두 코어가 각자 다른 변수를 써도 라인이 코어 사이를 핑퐁한다.

이것이 세션 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=42ready=1저장 순서가 (컴파일러나 CPU에 의해) 뒤바뀌면, 스레드 2는 ready==1을 보고도 data는 아직 옛값일 수 있다. 단일 스레드 논리로는 멀쩡한 코드가 멀티스레드에서 깨진다.

또 다른 고전(스토어 버퍼 효과): 두 스레드가 각각 한 변수를 쓰고 다른 변수를 읽을 때, 둘 다 "옛값(0)"을 읽는 결과가 가능하다 — 각자의 쓰기가 스토어 버퍼에 머물러 상대에게 아직 안 보였기 때문.

3.3 메모리 모델 — 얼마나 재배치를 허용하나

아키텍처마다 "허용하는 재배치"가 다르다. 이를 메모리 모델이라 한다.


4. 순서를 통제하는 도구 — 배리어 · atomic · acquire/release

4.1 메모리 배리어(fence)

"이 지점을 가로질러 메모리 연산을 재배치하지 마라"는 하드웨어 명령. 종류(full/acquire/release)에 따라 막는 방향이 다르다. 저수준이고 직접 쓰기 까다로워, 보통 atomic의 의미론으로 감싸 쓴다.

4.2 atomic과 acquire/release 의미론

C11/C++11의 atomic 타입은 "쪼개지지 않는" 연산을 제공할 뿐 아니라, 순서 보장(memory order) 도 함께 준다.

이 둘을 짝지으면 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 읽기보다 먼저 일어난다"를 보장한다.

4.3 락은 이 위에 선다

뮤텍스의 lock/unlock도 내부적으로 acquire/release 의미론을 갖는다 — 그래서 임계 구역 안의 메모리 연산이 밖으로 새지 않는다. 락 없이 atomic만으로 동기화하는 게 lock-free 프로그래밍이고, CAS(compare-and-swap) 루프 같은 기법을 쓴다(ABA 문제 등 함정이 많아 고난도).


5. 데이터 레이스

정의: 두 스레드가 동기화 없이 같은 메모리에 접근하고, 그중 최소 하나가 쓰기면 데이터 레이스다. C/C++에서 데이터 레이스는 미정의 동작(UB) 이고(세션 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. 흔한 오해 바로잡기


8. 한 장 정리


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)와 직접 손으로 한 실험으로 붙인다. 특히 세 가지는 꼭 손으로 해보라.

  1. 세션 4: 행/열 순회 속도와 작업 집합 크기별 "원소당 시간"을 직접 측정(perf).
  2. 세션 7: 50줄짜리 free-list 할당자를 직접 구현.
  3. 세션 9: 버그를 일부러 심고 ASan으로 잡아 리포트의 삼각형 읽기.

세 번만 손으로 만지면, 이 문서의 모든 문장이 "읽은 지식"에서 "아는 것"으로 바뀐다.