- Published on
NUMA & Cache Coherence 완전 가이드 2025: MESI 프로토콜, False Sharing, Memory Ordering — 멀티코어 성능의 진짜 벽
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며: 16코어인데 왜 4배만 빠른가?
수상한 벤치마크
당신의 코드를 단일 코어에서 측정했다: 1 million ops/sec. 16코어 서버로 옮기면?
기대: 16 million ops/sec. 실제: 4 million ops/sec.
멀티스레드로 병렬화했는데도 4배만 빠르다. 코어 사용률은 100%. 락도 없다. 뭐가 문제인가?
답: Cache coherence. 아마도 false sharing.
숨은 하드웨어의 진실
현대 CPU는 다음과 같이 복잡하다:
- 코어마다 L1, L2 캐시 (전용).
- 소켓 내 L3 캐시 (공유).
- 여러 NUMA 노드 (DRAM 접근 비대칭).
- Cache coherence protocol (MESI).
- Memory ordering (out-of-order execution).
- Store buffer, invalidation queue.
이 하드웨어 계층은 보통 숨겨져 있다. 그러나 극한 성능에서는 이를 이해해야 한다.
이 글에서 다룰 것
- Memory hierarchy: L1부터 메모리까지.
- NUMA: 소켓 간 비대칭 메모리.
- Cache line: 64바이트의 지배자.
- MESI protocol: 일관성의 수학.
- False sharing: 숨은 성능 킬러.
- Memory ordering: 순서의 미묘함.
- atomic operations: 동시성의 원시 도구.
- 실전 튜닝: NUMA pinning, padding, prefetch.
왜 이걸 알아야 하는가?
- 고성능 서버: 데이터베이스, 게임 엔진, HFT.
- Lock-free 프로그래밍: 이 지식 없이 불가능.
- 대형 메모리 시스템: 수백 GB ~ TB.
- 디버깅: "코어를 늘려도 안 빨라져"의 답.
평범한 엔지니어와 성능 엔지니어의 차이는 이 지식에서 나온다.
1. Memory Hierarchy 재확인
속도와 크기의 피라미드
┌──────────┐
│ Register │ ~1 cycle, ~1 KB
├──────────┤
│ L1 Cache │ ~4 cycles, 32~64 KB
├──────────┤
│ L2 Cache │ ~12 cycles, 256 KB~1 MB
├──────────┤
│ L3 Cache │ ~40 cycles, 8~64 MB (shared)
├──────────┤
│ DRAM │ ~200 cycles, GBs
├──────────┤
│ SSD/NVMe │ ~100,000 cycles
└──────────┘
핵심 비율:
- L1 → DRAM: 50배 느림.
- DRAM → SSD: 500배 느림.
왜 캐시 계층이 있는가
CPU 클럭은 GHz인데 메모리 접근은 수 백 cycles. bottleneck.
해결: 캐시. 자주 쓰는 데이터를 가까이. locality 원리:
- Temporal locality: 최근 쓴 것은 곧 다시 쓸 것.
- Spatial locality: 인접한 주소가 다음에 쓰일 것.
Cache Line: 모든 것의 단위
CPU는 바이트 단위가 아닌 cache line 단위로 메모리를 다룬다:
- 기본 크기: 64 바이트 (x86-64, ARM 일반).
- 일부 아키텍처: 128 바이트.
메모리 접근 시:
- 요청:
addr 0x1000의 1 바이트. - 캐시 miss → DRAM에서 64 바이트 전체 로드.
- 다음 63 바이트 접근은 캐시 hit.
이것이 spatial locality의 기반이다.
Cache miss의 비용
int x = array[0]; // Cache miss: ~200 cycles
int y = array[1]; // Cache hit: ~4 cycles
int z = array[16]; // 64 / 4 = 16. 같은 line. Hit.
int w = array[17]; // 같은 line. Hit.
int u = array[16384]; // 다른 line. Miss.
Stride access (16, 32, 48...) 또는 large skip은 캐시를 활용 못 한다.
2. NUMA: 비대칭 메모리의 세계
UMA vs NUMA
UMA (Uniform Memory Access):
- 모든 CPU가 동일한 시간에 모든 메모리 접근.
- 단일 소켓 또는 작은 시스템.
NUMA (Non-Uniform Memory Access):
- 여러 소켓이 각자의 메모리.
- 로컬 메모리는 빠름, 원격 메모리는 느림.
- 2 소켓 이상 서버에서 표준.
┌──────────────────┐ ┌──────────────────┐
│ Socket 0 │ │ Socket 1 │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Cores 0-15 │ │ │ │ Cores 16-31 │ │
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ L3 Cache │ │ │ │ L3 Cache │ │
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Local RAM │ │ │ │ Local RAM │ │
│ │ 128 GB │ │ │ │ 128 GB │ │
│ └──────────────┘ │ │ └──────────────┘ │
└────────┬─────────┘ └─────────┬────────┘
│ UPI / Infinity │
└────────────────────────┘
NUMA의 접근 비용
- Local access: ~100 cycles (DRAM).
- Remote access: ~300 cycles (UPI bus 경유).
- 1.5~2배 느림.
Big workload에선 이 차이가 CPU의 30%+ 성능 영향.
NUMA node 확인
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30
# node 0 size: 128911 MB
# node 0 free: 11234 MB
# node 1 cpus: 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31
# node 1 size: 129012 MB
# node 1 free: 10234 MB
# node distances:
# node 0 1
# 0: 10 21
# 1: 21 10
node distances: 로컬=10, 원격=21 (상대값, 20이면 2배 느림).
NUMA policy
Linux가 제공하는 memory allocation 정책:
- default: 현재 CPU의 노드에서 우선 할당.
- bind: 특정 노드만 사용.
- preferred: 특정 노드 선호.
- interleave: 라운드 로빈으로 모든 노드에 분산.
# 특정 노드에서 실행
numactl --cpunodebind=0 --membind=0 ./my_app
# Interleave (큰 메모리 앱)
numactl --interleave=all ./my_app
NUMA의 Thread Migration 문제
Linux 스케줄러는 load balancing을 위해 스레드를 다른 CPU로 이동시킨다. NUMA에선 위험:
스레드가 Node 0에서 실행 중.
모든 메모리가 Node 0에 할당됨.
스레드가 Node 1로 이동.
→ 모든 메모리 접근이 원격 (2배 느림).
해결:
- CPU affinity:
taskset으로 고정. - numactl bind: 메모리도 함께.
- AutoNUMA (커널 기능): 자동 이동.
실측: JVM에서의 NUMA 효과
Java HotSpot의 -XX:+UseNUMA 옵션:
비활성:
- Young generation이 임의 노드에 할당.
- GC thread가 remote memory 많이 접근.
- 처리량 100%.
활성:
- Young generation을 각 NUMA node로 분할.
- 각 thread가 로컬 메모리 우선.
- 처리량 120~130%.
30% 향상은 공짜가 아니다. JVM 엔지니어들의 수년간 노력의 결과.
3. Cache Coherence: 동기화의 하드웨어
문제
각 코어가 자기 L1 캐시를 가진다. 두 코어가 같은 메모리를 읽고 쓰면?
Core 0의 L1: variable x = 10
Core 1의 L1: variable x = 10
Core 0: x = 20
Core 0의 L1: x = 20
Core 1의 L1: x = 10 (stale!)
Core 1: y = x + 1
→ y = 11 (잘못된 값)
이 문제가 cache coherence. 현대 CPU는 하드웨어 수준에서 해결한다.
Coherence 보장
Cache coherence protocol이 다음을 보장:
- Write propagation: Core 0의 쓰기가 Core 1에게 결국 보임.
- Write serialization: 모든 코어가 같은 순서로 쓰기 관찰.
- Consistency: 캐시 상태가 일관성 유지.
이를 위한 가장 유명한 프로토콜이 MESI.
4. MESI Protocol
네 가지 상태
각 cache line은 4가지 상태 중 하나를 가진다 (MESI):
M (Modified):
- 이 코어만 최신 버전 소유.
- 메모리와 다름 (dirty).
- 쓰기 권한 있음.
E (Exclusive):
- 이 코어만 이 line 소유.
- 메모리와 같음 (clean).
- 쓰기 가능 (쓰면 M으로).
S (Shared):
- 여러 코어가 이 line 소유.
- 모두 메모리와 같음.
- 쓰려면 다른 코어 invalidate 필요.
I (Invalid):
- 유효하지 않음.
- 사용 전 메모리 또는 다른 캐시에서 가져와야.
상태 전이
┌────────┐
│ I │ (초기)
└───┬────┘
│ read miss (다른 캐시 O)
▼
┌────────┐ other write ┌────────┐
│ S │ ────────────→ │ I │
└───┬────┘ └────────┘
│ write (BusUpgr)
▼
┌────────┐
│ M │
└───┬────┘
│ other read (Flush)
▼
┌────────┐
│ S │
└────────┘
Read 시나리오
Core 0가 address X를 처음 읽기:
- Core 0 L1: miss (I).
- 버스에 BusRd 요청.
- 아무 캐시도 X 없음 → 메모리에서 로드.
- 상태: E (Exclusive). Core 0만 소유.
Core 1이 같은 X를 읽기:
- Core 1 L1: miss.
- BusRd 요청.
- Core 0가 응답 (스누핑).
- Core 0: E → S (share 상태로).
- Core 1: E가 아닌 S로 로드.
- 결과: 둘 다 S.
Write 시나리오
Core 0가 X를 쓰기 (현재 S):
- BusUpgr 브로드캐스트: "내가 X 쓸 거야".
- 다른 코어들이 X 캐시 있으면 I로 invalidate.
- Core 0: S → M.
- Core 0가 X 수정.
Core 1이 X를 다시 읽기:
- Core 1 L1: I (방금 invalidate됨).
- BusRd 요청.
- Core 0가 최신 값으로 응답 (writeback).
- Core 0: M → S.
- Core 1: S로 로드.
Memory Barrier
MESI는 일관성을 보장하지만 모든 것을 해결하지 않는다:
- Store buffer: Core가 쓰기를 버퍼링 후 나중에 cache에 반영.
- Invalidate queue: 다른 코어의 invalidation을 큐잉.
이 때문에 메모리 순서가 예상과 다를 수 있다 (다음 섹션).
MOESI, MESIF의 확장
현대 CPU는 MESI를 확장한 프로토콜 사용:
- MOESI (AMD): O (Owned) 상태 추가. Shared이면서 Modified.
- MESIF (Intel): F (Forward) 상태 추가. 여러 Shared 중 하나만 응답.
기본 원리는 같음.
5. False Sharing: 숨은 킬러
문제의 시나리오
struct Counter {
int count;
};
Counter counters[16]; // 16 스레드용
void thread_func(int id) {
for (int i = 0; i < 1000000; i++) {
counters[id].count++;
}
}
각 스레드가 자기 것만 수정. 락 없음. 완벽한 병렬화?
아니다. 이 코드는 매우 느리다.
이유: False Sharing
int는 4 바이트. Counter 구조체도 4 바이트. counters[16] = 64 바이트.
64 바이트는 정확히 하나의 cache line. 모든 16개 counter가 같은 line.
결과:
- Thread 0가
counters[0]수정 → cache line M. - Thread 1이
counters[1]수정 → Thread 0의 cache invalidate. - Thread 0가 다시 수정 → Thread 1 invalidate.
- ... 끝없는 ping-pong.
같은 변수를 수정하는 것이 아닌데도 cache coherence 때문에 마치 공유 변수처럼 동작. 이것이 false sharing.
성능 영향
벤치마크:
False sharing 있음:
- 1 thread: 10M ops/sec.
- 16 threads: 15M ops/sec. (1.5배만!)
False sharing 없음:
- 1 thread: 10M ops/sec.
- 16 threads: 150M ops/sec. (15배!)
10배 차이. 멀티코어의 효과가 거의 사라진다.
해결: Padding
struct alignas(64) PaddedCounter {
int count;
char padding[60]; // 64 바이트 채우기
};
PaddedCounter counters[16]; // 각 64 바이트
각 counter가 다른 cache line. False sharing 없음.
C++:
#include <new>
struct alignas(std::hardware_destructive_interference_size) Counter {
int count;
};
hardware_destructive_interference_size는 C++17의 표준 방법 (보통 64).
Rust의 해결
use crossbeam_utils::CachePadded;
struct Counter {
count: CachePadded<AtomicU64>,
}
crossbeam_utils::CachePadded가 자동으로 적절한 padding.
Java의 해결
JDK 8+:
import sun.misc.Contended;
@Contended
static class Counter {
volatile long value;
}
-XX:-RestrictContended JVM 옵션 필요 (혹은 애플리케이션 코드).
False Sharing 탐지
perf c2c (cache to cache):
sudo perf c2c record ./my_app
sudo perf c2c report
Cache line 단위로 동시 접근 패턴 분석. False sharing 후보 식별.
실전 사례
Linux kernel: per-CPU variables + padding.
JVM: ConcurrentHashMap의 counter 배열에 padding.
Disruptor (LMAX): Padding이 핵심 최적화.
Redis: 특정 구조체에 padding.
Disruptor의 성능:
- Padding 없음: ~5M ops/sec.
- Padding 추가: ~50M ops/sec.
10배 차이. Padding 한 줄로.
6. Memory Ordering: 순서의 환상
왜 순서가 문제인가
멀티코어에서 다음 코드를 생각해 보자:
// Thread 0:
x = 1; // (1)
flag = true; // (2)
// Thread 1:
while (!flag); // (3)
print(x); // (4)
기대: Thread 1이 x = 1을 출력.
실제: 경우에 따라 0을 출력할 수 있다.
이유: CPU와 컴파일러가 순서를 재배치. (1)과 (2)의 순서가 바뀌면 Thread 1은 flag만 보고 x는 아직 안 봄.
Out-of-Order Execution
현대 CPU는 명령어를 순서대로 실행하지 않는다:
- Instruction-level parallelism.
- 이전 명령이 메모리 대기 중이면 다음 명령 먼저.
- 결과는 원래 순서로 retire.
단일 스레드에선 상관없지만 멀티스레드에선 다른 스레드가 이 재배치를 볼 수 있다.
Store Buffer
각 코어는 store buffer를 가진다:
Core 0:
store x = 1 → store buffer
store flag = true → store buffer
Cache (아직 안 나감):
x = 0 (old)
flag = false (old)
Store가 눈에 띄기 전에 다른 store가 cache에 도달할 수 있다.
Memory Barriers
Memory barrier (또는 fence)는 CPU에게 순서를 강제:
x = 1;
memory_barrier(); // 모든 이전 store가 완료되어야
flag = true;
Barrier는 파이프라인을 flush. 성능 비용 있지만 순서 보장.
x86 vs ARM
x86-64: 상대적으로 strong memory model. 대부분의 reordering이 제한됨:
- Loads이 loads와 재배치 안 됨.
- Stores가 stores와 재배치 안 됨.
- Store-load 재배치는 있음 (store buffer).
ARM, POWER: weak memory model. 거의 모든 재배치 가능.
결과:
- x86에서만 개발하면 "운 좋은" 버그가 ARM에서 터짐.
- ARM 서버 시대에 더 중요해진 이슈.
Memory Ordering Types
C++11, Rust, Java 등은 memory ordering 레벨 제공:
- Relaxed: 순서 보장 없음. 가장 빠름.
- Acquire: 이후의 load/store가 이 연산 이후에 발생.
- Release: 이전의 load/store가 이 연산 이전에 발생.
- AcqRel: Acquire + Release.
- SeqCst: Sequentially consistent. 가장 강함, 가장 느림.
std::atomic<bool> flag{false};
std::atomic<int> data{0};
// Producer
data.store(42, std::memory_order_release);
flag.store(true, std::memory_order_release);
// Consumer
while (!flag.load(std::memory_order_acquire)) {}
int value = data.load(std::memory_order_acquire);
// value == 42가 보장됨
release와 acquire의 쌍이 happens-before 관계를 형성.
SeqCst는 왜 느린가
Sequentially consistent는 모든 코어가 같은 순서로 관찰을 보장한다:
- Store buffer flush.
- 모든 코어에 보이는 시점 동기화.
- MESI의 엄격한 적용.
비용: 수십~수백 cycles 추가. 가벼운 연산에선 큰 차이.
최적화 원칙: 필요한 최소 순서만 사용. 대부분 acquire/release로 충분.
7. Atomic Operations
Atomic 연산의 의미
원자적(atomic): 연산이 인터럽트되지 않고 완료됨. 중간 상태가 보이지 않음.
counter++ // non-atomic: load, add, store의 3단계
이 세 단계 사이에 다른 스레드가 끼어들면 race condition.
atomic_fetch_add(&counter, 1) // atomic: 단일 연산
하드웨어가 락 없이 이를 보장.
x86의 Atomic 명령어
LOCK ADD: atomic 덧셈.LOCK XADD: atomic fetch-and-add.LOCK CMPXCHG: compare-and-swap.LOCK CMPXCHG16B: 128비트 CAS.
LOCK prefix는 명령 실행 동안 메모리 버스를 독점. 비용: ~20-100 cycles (일반 store의 수 배).
Compare-and-Swap (CAS)
lock-free 알고리즘의 기본 원시 연산:
bool cas(int *ptr, int expected, int desired) {
atomically {
if (*ptr == expected) {
*ptr = desired;
return true;
} else {
return false;
}
}
}
사용 패턴:
int old_val;
do {
old_val = *ptr;
int new_val = compute(old_val);
} while (!cas(ptr, old_val, new_val));
다른 스레드가 먼저 수정하면 재시도.
ABA 문제
CAS의 유명한 함정:
Thread 0: 값 읽기 A
Thread 1: A → B → A로 두 번 수정
Thread 0: CAS(A, C) → 성공 (여전히 A)
값은 같지만 의미는 다를 수 있다. 예: stack pop 중 node가 재사용되어 문제.
해결:
- Tagged pointer: 카운터와 포인터를 함께.
- Hazard pointer: 사용 중인 포인터 추적.
- Epoch-based reclamation: 세대 기반 GC.
LL/SC (Load-Linked / Store-Conditional)
RISC 아키텍처(ARM, POWER, RISC-V)의 CAS 대안:
LL addr, reg1 # 값 읽고 "예약"
(수정 로직)
SC reg2, addr # 예약이 유효하면 쓰기, 아니면 실패
ABA 문제 원천 해결 (포인터 값이 아닌 주소 예약).
x86의 fence 명령어
- LFENCE: load fence.
- SFENCE: store fence.
- MFENCE: full barrier.
대부분의 atomic 연산이 자체적으로 fence 포함.
성능
- 일반 load/store: ~1 cycle.
- Atomic load/store: ~1-2 cycles.
- CAS (uncontended): ~20 cycles.
- CAS (contended): 수백 cycles (cache line 경합).
핵심: Contention이 있으면 atomic도 느려진다. Data layout이 중요.
8. Lock-free vs Lock-based
Lock의 본질
Mutex, semaphore는 내부적으로 atomic operations + wait queue:
void lock() {
while (cas(&locked, 0, 1) == false) {
wait_in_queue();
}
}
- Uncontended: ~20 cycles (CAS 한 번).
- Contended: 수천 cycles (system call, context switch).
Lock-free의 약속
Lock-free: 어떤 스레드도 block되지 않음. 진행 보장.
장점:
- Deadlock 없음.
- Priority inversion 없음.
- Interrupt-safe.
- 일부 경우 더 빠름.
단점:
- 구현 복잡.
- ABA 문제.
- Memory reclamation 어려움.
- Contention이 심하면 오히려 느릴 수 있음.
Wait-free, Lock-free, Obstruction-free
세 가지 보장 수준:
- Wait-free: 모든 스레드가 유한 시간 내 완료. 가장 강함.
- Lock-free: 최소 하나의 스레드가 항상 진행. 대부분 시도 만족.
- Obstruction-free: 한 스레드만 실행되면 완료. 가장 약함.
대부분의 "lock-free" 자료구조는 lock-free (wait-free는 드묾).
실전: Queue 구현
Mutex-based queue: 간단. push/pop 시 락.
Lock-free queue (Michael-Scott):
push(x):
node = new Node(x)
loop:
tail = load(tail_ptr)
next = load(tail->next)
if (next == null):
if (cas(tail->next, null, node)):
cas(tail_ptr, tail, node)
return
else:
cas(tail_ptr, tail, next) // help others
복잡하지만 락 없음. 실제로는 구현 더 복잡.
성능 비교
벤치마크: 4 스레드 queue push/pop.
- Mutex: 10M ops/sec.
- Lock-free: 30M ops/sec.
- Single thread: 50M ops/sec.
Lock-free가 3배 빠름. 하지만 single thread보다는 느림 (조율 오버헤드).
높은 contention에선 락이 더 빠를 수 있다. Lock-free가 항상 정답은 아니다.
9. 실전 튜닝 기법
1. False Sharing 제거
가장 쉬운 개선:
// Before
struct Stats {
atomic<long> reads;
atomic<long> writes;
};
// After
struct alignas(64) Stats {
atomic<long> reads;
char pad[64 - sizeof(atomic<long>)];
atomic<long> writes;
};
2. NUMA-aware Allocation
#include <numa.h>
void* ptr = numa_alloc_onnode(size, numa_node_of_cpu(current_cpu));
또는 cgroup에서 NUMA 노드 지정.
3. Thread Pinning
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu_id, &cpuset);
pthread_setaffinity_np(thread, sizeof(cpuset), &cpuset);
특정 코어에 고정. Cache 유지.
4. Prefetch
컴파일러나 명시적:
__builtin_prefetch(&array[i + 16]); // GCC
// 또는
_mm_prefetch(&array[i + 16], _MM_HINT_T0); // Intel intrinsics
데이터 접근 직전에 캐시로 미리 로드. Sequential access에는 자동 (prefetcher).
5. Data Layout 최적화
Array of Structures (AoS):
struct Point { float x, y, z; };
Point points[1000];
Structure of Arrays (SoA):
struct Points {
float x[1000], y[1000], z[1000];
};
x만 순회할 때 SoA가 캐시 효율 3배 높음. SIMD 친화.
6. Memory Ordering 최적화
// Strong (느림)
counter.fetch_add(1, memory_order_seq_cst);
// Weak (빠름)
counter.fetch_add(1, memory_order_relaxed);
단순 카운터엔 relaxed 충분. 동기화 지점만 acquire/release.
7. Huge Pages
echo 1024 > /proc/sys/vm/nr_hugepages # 1024 × 2MB
TLB miss 감소. DB buffer pool에 유용.
8. Avoid Contention
예: 공유 카운터 대신 per-thread counter + 주기적 집계.
thread_local long local_count = 0;
void increment() {
local_count++;
if (local_count % 1000 == 0) {
global_counter.fetch_add(1000);
local_count = 0;
}
}
Contention 1000배 감소.
10. 디버깅 도구
perf stat
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./my_app
캐시 효율 측정.
perf c2c (cache to cache)
sudo perf c2c record ./my_app
sudo perf c2c report
False sharing 탐지의 표준 도구.
Intel VTune / AMD uProf
상세한 마이크로아키텍처 분석. False sharing, NUMA traffic, cache miss 등.
Valgrind Cachegrind
valgrind --tool=cachegrind ./my_app
느리지만 상세한 캐시 miss 분석.
bpftrace
# L3 cache miss 추적
sudo bpftrace -e 'hardware:cache-misses:1000 { @[comm] = count(); }'
Thread Sanitizer
gcc -fsanitize=thread -g -o my_app my_app.c
Data race 탐지. Atomic 순서 버그 발견에 유용.
11. 실전 사례
사례 1: Redis의 Single-threaded 선택
Redis는 single-threaded다. 왜?
- 모든 데이터가 한 코어의 캐시.
- False sharing 걱정 없음.
- Lock 오버헤드 없음.
- 단순한 코드.
멀티스레드의 "이득"이 오버헤드 때문에 사라질 수 있다. Redis는 극도의 효율을 위해 단일 스레드를 선택했다.
사례 2: LinkedIn의 Per-CPU Counter
LinkedIn의 Java 시스템에서:
Before (전통 atomic counter):
- 4 스레드에서 극심한 contention.
- 성능 1/4로.
After (per-CPU counter + periodic aggregation):
- 거의 선형 확장.
- 12배 성능 향상.
사례 3: Netflix의 NUMA 최적화
Netflix의 Open Connect Appliance:
- Dual-socket 서버 (NUMA).
- CPU pinning: 각 NIC의 interrupt를 로컬 코어에 고정.
- Memory allocation: NIC과 같은 NUMA 노드에서.
- 결과: 단일 서버 200 Gbps 달성.
NUMA 무시 → 100 Gbps 한계.
사례 4: JVM의 Biased Locking (deprecated)
JDK 6~14: Biased locking. 락이 동일 스레드에 반복 획득되면 낙관적으로 빠른 경로.
- 단일 스레드: 매우 빠름.
- 다중 스레드: 오버헤드.
JDK 15+에서 제거: 멀티코어 환경에선 이득이 적고 복잡성만 남김.
사례 5: Cassandra의 Token Ring과 NUMA
Cassandra 초기:
- 토큰 ring 데이터가 random NUMA 노드에.
- Hot path에서 remote access.
개선:
- NUMA-aware 배치.
- 하나의 토큰 범위를 하나의 NUMA 노드에.
- 처리량 15% 향상.
12. 흔한 함정과 교훈
함정 1: "Atomic이 빠르다"
오해: Atomic은 락보다 빠르다.
진실: Atomic은 uncontended 상황에서만 빠르다. High contention에선 cache line ping-pong으로 더 느릴 수 있다.
해결: 측정하라. 단순 benchmark로 atomic과 mutex를 비교.
함정 2: False Sharing 무시
증상: 멀티스레드 성능이 기대보다 낮음.
해결: perf c2c로 확인. Padding 추가.
함정 3: NUMA 무시
증상: 2 소켓 서버에서 1 소켓보다 성능이 안 나옴.
원인: Remote memory access.
해결: NUMA pinning, interleave.
함정 4: x86에서만 테스트
증상: ARM 서버에서 이상한 동작.
원인: x86의 strong memory model에 의존.
해결: ARM에서도 테스트. 명시적 memory barrier 사용.
함정 5: Prefetch 남용
증상: Prefetch 추가했는데 더 느림.
원인: Prefetcher 방해, cache pollution.
해결: 측정. 자동 prefetcher가 대부분 충분.
함정 6: Lock-free 함정
증상: Lock-free 큐 구현이 버그 투성이.
원인: ABA, memory reclamation, memory ordering.
해결: 검증된 라이브러리 사용 (boost::lockfree, folly, crossbeam).
퀴즈로 복습하기
Q1. False sharing이 왜 "같은 변수를 공유하지 않는데도" 성능을 파괴하는가?
A.
원인: Cache line 단위의 coherence.
CPU는 바이트가 아닌 64바이트 cache line 단위로 메모리를 다룬다. Cache coherence protocol (MESI)도 cache line 단위로 동작.
시나리오:
struct Counters {
int a; // offset 0
int b; // offset 4
};
Counters c;
// Thread 0: c.a만 수정
// Thread 1: c.b만 수정
c.a와 c.b는 다른 변수지만 같은 cache line에 있다 (4 + 4 = 8 bytes < 64).
MESI의 동작:
-
Thread 0이
c.a = 1실행:- Core 0 cache line: M (Modified),
a=1, b=0. - Core 1 cache line: I (Invalidated).
- Core 0 cache line: M (Modified),
-
Thread 1이
c.b = 2실행:- Core 1: cache miss (I).
- BusRd 요청.
- Core 0: cache line flush (writeback 또는 forward).
- Core 0: S → I.
- Core 1:
a=1, b=2로드, 상태 M.
-
Thread 0이
c.a = 3실행:- Core 0: I (방금 invalidate).
- 다시 로드해야.
- Core 1: M → I.
- Core 0:
a=3, b=2로드.
결과: 매 수정마다 cache line이 두 코어 사이를 왕복. 이것이 cache line ping-pong.
비용:
- 정상 L1 접근: ~4 cycles.
- Cache line invalidation: ~30-100 cycles.
- Remote L1 load (cross-socket): ~200+ cycles.
Thread가 "자기 것만" 수정하는 것처럼 보여도 마치 공유 변수처럼 동기화 오버헤드 발생.
성능 영향:
실제 측정:
- False sharing 없음: 16 threads = 16배 성능.
- False sharing 있음: 16 threads = 1.5배 성능 (또는 심지어 더 느림).
10배 이상 손실. 멀티코어의 효과가 거의 사라진다.
해결:
Padding:
struct alignas(64) PaddedCounter {
int count;
char pad[60];
};
각 counter를 별도 cache line에 배치. Ping-pong 완전히 제거.
C++17:
struct alignas(std::hardware_destructive_interference_size) Counter {
int count;
};
Rust:
use crossbeam_utils::CachePadded;
let counters: Vec<CachePadded<AtomicU64>> = vec![...];
Java:
@sun.misc.Contended
static long value;
탐지:
sudo perf c2c record ./my_app
sudo perf c2c report
HitM (Hit in Modified state) 이벤트가 많은 cache line = false sharing 후보.
교훈:
False sharing은 "존재하지 않는 문제"를 찾아내는 것이 핵심이다. 코드를 봐선 전혀 안 보인다. 하드웨어 지식 없이는 이해 불가.
"내 코드가 왜 멀티코어에서 안 빨라지지?" 라는 질문의 답이 70% 이상 false sharing이다. 현대 멀티코어 서버에서 이는 가장 흔한 숨은 성능 킬러다.
이것이 평범한 엔지니어와 성능 엔지니어의 차이다. 후자는 cache line을 보고, 전자는 보지 못한다.
Q2. MESI protocol의 네 가지 상태와 각 상태가 존재하는 이유는?
A.
네 가지 상태:
M (Modified):
- 이 cache에만 존재하며 (exclusive).
- 메모리와 다름 (dirty, writeback 필요).
- 자유롭게 읽기/쓰기 가능.
E (Exclusive):
- 이 cache에만 존재하며.
- 메모리와 같음 (clean).
- 자유롭게 읽기 가능. 쓰려면 M으로 전환.
S (Shared):
- 여러 cache에 존재 가능.
- 모두 메모리와 같음.
- 읽기만 가능. 쓰려면 다른 cache를 invalidate 해야.
I (Invalid):
- 유효하지 않음.
- 사용 전 메모리나 다른 cache에서 가져와야.
왜 네 가지가 필요한가:
언뜻 Modified와 Invalid만 있으면 되지 않을까? 더 효율적으로 만들려면 세 개? 네 개는 필수인가?
Shared의 필요성:
여러 코어가 같은 데이터를 읽기만 한다면? Modified로 두면 한 코어만 소유 가능 → 다른 코어는 읽을 때마다 invalidate + 재로드. 비효율.
Shared 상태로 두면 모든 코어가 동시에 캐시 보유 가능. 읽기는 로컬 캐시에서 즉시.
Modified의 필요성:
쓰기가 있으면 "누가 최신 버전을 가졌는가" 추적 필요. Modified = "나만 최신" = writeback 책임.
Exclusive의 필요성:
Shared와 Modified의 중간 상태. "나만 가졌지만 아직 안 바꿨다".
왜 필요한가? 최적화. 만약 E 없이 S만 있다면:
- 한 코어가 처음 로드: S.
- 쓰기: S → M (BusUpgr로 invalidate 브로드캐스트).
- 다른 코어에 이 line이 없는데도 불필요한 브로드캐스트.
E가 있으면:
- 첫 로드: E (나만 가졌음을 알고).
- 쓰기: E → M (브로드캐스트 불필요, 조용히 전환).
- 버스 트래픽 절약.
Invalid의 필요성:
명시적으로 "이 cache line은 stale"이라고 표시. 사용 시 무효함을 인식.
상태 전이 예시:
Read 시나리오:
[초기 상태: 모두 I]
Core 0 reads X:
- I → check bus → 아무 캐시도 없음 → memory에서 로드 → E
Core 1 reads X:
- I → BusRd → Core 0 responds → Core 0: E→S, Core 1: S
Core 2 reads X:
- I → BusRd → Core 0 or Core 1 responds → all: S
Write 시나리오:
[Core 0, 1이 S]
Core 0 writes X:
- S → BusUpgr → Core 1 invalidates → Core 0: M, Core 1: I
Core 1 reads X again:
- I → BusRd → Core 0 responds (writeback) → Core 0: S, Core 1: S
MESI의 확장:
MOESI (AMD의 선택):
- O (Owned) 상태 추가.
- O = Shared + Modified. 메모리와 다르지만 다른 캐시도 가질 수 있음.
- 이득: Modified 상태에서 다른 코어가 읽으려 하면, M → O → 다른 코어도 S (또는 O). writeback 지연 가능.
MESIF (Intel의 선택):
- F (Forward) 상태 추가.
- F = Shared 중 "응답할 대표".
- 여러 코어가 S로 가졌을 때, 한 코어만 F. 다른 코어가 read 시 F 상태의 코어가 응답 (메모리보다 빠름).
프로토콜 복잡성의 이유:
모든 최적화가 성능을 위한 것. 각 상태 추가는 특정 시나리오에서 몇 cycle 절약.
평범한 소프트웨어 엔지니어에겐 세부사항 무관. 그러나 lock-free 프로그래머, OS 개발자, 컴파일러 엔지니어는 이를 깊이 이해해야 한다. 메모리 모델의 정확한 이해가 correctness의 기반이다.
실전 시각화:
Core 0 read: I → E (메모리에서)
Core 1 read: I → S (Core 0: E → S)
Core 0 write: S → M (Core 1: S → I)
Core 1 read: I → S (Core 0: M → S, writeback)
Core 0 write: S → M (Core 1: S → I)
...
두 코어가 번갈아 쓰기 → ping-pong. 이것이 false sharing의 근본 원리.
교훈:
MESI는 분산 시스템의 consistency를 하드웨어 수준에서 구현한 것이다. 소프트웨어 distributed system이 해결하는 문제 (consistency, ordering)를 CPU가 수 GHz 속도로 해결한다.
이 우아함이 현대 멀티코어의 기반이다. 모든 프로그래머가 이를 활용하지만 대부분 인식 못한다. 하드웨어가 해주는 마법을 인식하는 것이 엔지니어링의 깊이다.
Q3. NUMA에서 "메모리 locality가 무너지면" 왜 성능이 급락하는가?
A.
NUMA의 물리적 구조:
Socket 0 Socket 1
┌──────────────┐ ┌──────────────┐
│ CPUs 0-15 │ │ CPUs 16-31 │
│ L1/L2/L3 │ │ L1/L2/L3 │
└──────┬───────┘ └──────┬───────┘
│ Memory Channel │ Memory Channel
│ (100 GB/s) │ (100 GB/s)
▼ ▼
┌──────────────┐ ┌──────────────┐
│ DRAM 128GB │ │ DRAM 128GB │
│ (local to 0) │ │ (local to 1) │
└──────────────┘ └──────────────┘
▲ ▲
│ │
└─── UPI / Infinity Fabric ─┘
(slower, ~40 GB/s)
Local access:
- Socket 0 CPU → Socket 0 DRAM.
- 경로: CPU → Memory Controller → DRAM.
- 지연: ~100 cycles (~30 ns).
- 대역폭: 100 GB/s.
Remote access:
- Socket 0 CPU → Socket 1 DRAM.
- 경로: CPU → UPI → Remote Memory Controller → DRAM.
- 지연: ~300 cycles (~90 ns).
- 대역폭: 40 GB/s (UPI link 제한).
숫자의 의미:
- 3배 느린 지연.
- 2.5배 낮은 대역폭.
- 대규모 워크로드에선 전체 성능 30-50% 손실.
Locality가 무너지는 상황:
1. Thread migration:
Linux 스케줄러가 load balancing으로 thread를 이동:
Thread A가 Socket 0에서 시작.
Thread A의 working set (10 GB)이 Socket 0 memory에 할당.
시간이 지나 Load balance → Thread A가 Socket 1로.
이제 모든 memory access가 REMOTE.
결과: 성능이 30% 이상 급락.
2. 잘못된 allocation:
// Main thread (Socket 0)에서 allocation
void *buffer = malloc(1GB);
// First-touch policy: 접근 전까지는 physical page 할당 안 됨
// Worker threads (여러 socket) 생성
// 각 worker가 buffer의 일부를 처리
Linux의 first-touch policy는 처음 접근하는 CPU의 노드에 물리 페이지 할당. Main thread에서 메모리를 초기화하면 모든 페이지가 Socket 0에 할당됨. Socket 1의 worker는 전부 remote access.
3. 거대 공유 자료구조:
huge_hashmap map; // 100 GB
// 모든 thread가 접근
Map이 여러 노드에 분산되어 있으면 반반 확률로 remote. 평균 지연 증가.
4. Buffer pool 문제:
DB의 shared buffer pool이 한 노드에 모두 할당되면 다른 노드의 thread가 모두 remote access.
측정: 실제 영향
PostgreSQL 벤치마크 (2 socket, 각 16 cores):
- NUMA aware (thread와 memory 고정): 100K TPS.
- NUMA unaware (기본): 70K TPS.
- 30% 손실.
JVM 벤치마크 (Spark 워크로드):
-XX:+UseNUMA: 100%.- 비활성: 80-85%.
- 15-20% 손실.
Redis: Single-threaded이라 NUMA 영향 적음. 단, CPU pinning 없으면 스케줄링으로 간헐적 영향.
해결책:
1. CPU Affinity:
# 특정 노드에 바인드
numactl --cpunodebind=0 --membind=0 ./app
# 또는 taskset
taskset -c 0-15 ./app
2. 애플리케이션 레벨 NUMA awareness:
#include <numa.h>
void* alloc_on_node(size_t size, int node) {
return numa_alloc_onnode(size, node);
}
// 각 worker가 자기 노드에 할당
for (int i = 0; i < num_workers; i++) {
int node = i % num_numa_nodes;
worker_buffers[i] = alloc_on_node(BUFFER_SIZE, node);
// worker를 해당 node의 CPU에 pin
pin_thread_to_node(worker_thread[i], node);
}
3. AutoNUMA (Linux 커널):
echo 1 > /proc/sys/kernel/numa_balancing
커널이 자동으로 page와 thread를 가까이 이동. 완벽하진 않지만 어느 정도 도움.
4. Interleave policy (큰 공유 데이터):
numactl --interleave=all ./app
모든 노드에 고르게 분산. 평균적 접근. 최적은 아니지만 worst case 방지.
5. JVM tuning:
java -XX:+UseNUMA \
-XX:+UseG1GC \
-XX:ParallelGCThreads=16 \
MyApp
JVM이 young generation을 NUMA node별로 분할.
실전 고려사항:
Container 환경:
Kubernetes, Docker에서 NUMA를 인식하려면:
- CPU Manager: static policy로 exclusive CPU 할당.
- Topology Manager: CPU와 memory의 NUMA 정렬.
# Kubernetes Node
kubelet 옵션:
--cpu-manager-policy=static
--topology-manager-policy=single-numa-node
언제 NUMA가 중요한가:
- 2+ socket 서버: 주로 여기서.
- Memory-bound 워크로드: DB, 빅데이터, 캐시.
- High throughput: 처리량이 핵심.
언제 덜 중요한가:
- Single socket: NUMA 없음.
- Compute-bound: CPU가 bottleneck (ML 학습).
- Small dataset: L3 cache에 들어가면 memory 접근 적음.
교훈:
NUMA는 "눈에 안 보이는 세금" 이다. 설정이 틀리면 20-30% 성능 손실이 발생하지만 어디에서 손실이 일어나는지 명확하지 않다.
현대 서버의 표준 아키텍처이므로 고성능 애플리케이션은 반드시 NUMA를 고려해야 한다. 그렇지 않으면 하드웨어의 잠재력을 활용하지 못한다.
"16 core로 확장했는데 왜 8 core 때만큼 빠르지 않지?" 의 답이 종종 NUMA다. 이것을 이해하는 것이 진짜 시스템 엔지니어링이다.
Q4. Memory barrier (fence)가 왜 필요하며 언제 사용해야 하는가?
A.
문제의 근원: 현대 CPU는 out-of-order execution을 한다. 명령어를 순서대로 실행하지 않고 의존성이 없는 것부터 병렬로 처리. 단일 스레드에선 결과가 올바르지만 멀티스레드에선 다른 스레드가 "잘못된 순서"로 관찰할 수 있다.
예시:
// Shared variables
int data = 0;
bool ready = false;
// Thread 0 (Producer):
data = 42; // (1)
ready = true; // (2)
// Thread 1 (Consumer):
while (!ready); // (3)
print(data); // (4)
기대: Thread 1이 42를 출력.
실제로 일어날 수 있는 일:
Store buffer의 영향:
- Thread 0이
data = 42실행 → store buffer로. - Thread 0이
ready = true실행 → store buffer로. - Store buffer가 순서를 바꿔서 memory에 flush 가능.
- 가능한 관찰 순서:
ready=true가data=42보다 먼저.
Out-of-order의 영향:
- CPU가
ready = true를data = 42보다 먼저 retire. - Thread 1이
ready=true를 보지만data는 여전히 0.
결과: Thread 1이 0을 출력할 수 있다. 매우 드물지만 발생.
Memory Barrier의 역할:
CPU에게 "이 지점 이전의 모든 memory 연산이 완료되어야 이후 연산 시작" 이라고 명령:
// Thread 0:
data = 42;
memory_barrier(); // 모든 이전 store가 visible
ready = true;
이제 ready=true가 보이면 반드시 data=42도 보임. Thread 1이 42를 본다.
Barrier의 종류:
Full barrier (MFENCE in x86):
- 이 지점 이전의 모든 load/store가 완료.
- 이후 load/store는 아직 시작 안 함.
- 가장 강한 보장, 가장 비쌈.
Load barrier (LFENCE):
- 이전 load 완료.
- Store는 무관.
Store barrier (SFENCE):
- 이전 store 완료.
- Load는 무관.
CPU 아키텍처 차이:
x86/x86-64: Strong memory model
- Store-store reordering 없음.
- Load-load reordering 없음.
- Store-load reordering 있음 (store buffer 때문).
- 대부분의 코드가 barrier 없이도 작동.
ARM, POWER, RISC-V: Weak memory model
- 거의 모든 reordering 가능.
- 반드시 명시적 barrier.
x86에서만 테스트한 코드가 ARM에서 버그나는 전형적 이유.
고수준 언어의 지원:
C++11 std::atomic:
std::atomic<int> data{0};
std::atomic<bool> ready{false};
// Thread 0
data.store(42, std::memory_order_release);
ready.store(true, std::memory_order_release);
// Thread 1
while (!ready.load(std::memory_order_acquire));
int value = data.load(std::memory_order_acquire);
// value == 42 보장
Acquire-Release pattern:
releasestore는 이전 memory 연산이 먼저 끝나도록.acquireload는 이후 memory 연산이 나중에 시작하도록.- 두 쌍이 맞으면 happens-before 관계 형성.
Memory ordering levels (C++ 기준):
- relaxed: 순서 없음. Atomic만 보장.
- acquire: 이후 load/store가 이 load 이후.
- release: 이전 load/store가 이 store 이전.
- acq_rel: 둘 다.
- seq_cst: 전 코어가 같은 순서로 관찰. 가장 강함, 기본값.
실전 사용:
단순 카운터:
counter.fetch_add(1, std::memory_order_relaxed);
// 순서 무관. 단지 atomic 증가만.
Publish-Subscribe:
// Publisher
config_ptr.store(new_config, std::memory_order_release);
// Subscriber
Config* cfg = config_ptr.load(std::memory_order_acquire);
Lock 구현:
// Lock acquire
while (locked.exchange(true, std::memory_order_acquire)) { /* spin */ }
// Lock release
locked.store(false, std::memory_order_release);
성능 영향:
- Relaxed: 1-2 cycles.
- Acquire/Release: 1-5 cycles (x86에선 거의 공짜).
- SeqCst: 20-100 cycles (full barrier, store buffer flush).
권장: 필요한 최소 순서만 사용. 대부분 acquire/release 충분. seq_cst는 정말 필요할 때만.
언제 barrier가 필요한가:
- Lock-free 자료구조 구현.
- Producer-consumer 패턴.
- Double-checked locking.
- Signal handler.
- Memory-mapped I/O (드라이버).
언제 필요 없는가:
- Mutex 사용 시: Mutex가 내부적으로 barrier 포함.
- 단일 스레드: Out-of-order 투명.
- High-level 언어의 atomic: 자동 처리.
Volatile의 한계:
C/C++의 volatile은 barrier 아님:
- 컴파일러 최적화 방지만.
- Memory ordering 보장 없음.
- Java/C#의
volatile과 다름.
// BAD
volatile int ready = 0;
data = 42;
ready = 1; // reordering 가능!
올바른 방법:
std::atomic<int> ready{0};
data = 42;
ready.store(1, std::memory_order_release);
교훈:
Memory barrier는 "cost without visible benefit" 의 전형. 코드에 없으면 정상 동작하는 것처럼 보이지만 특정 조건에서 예측 못한 버그. 멀티코어, 여러 아키텍처를 타겟팅할수록 중요.
"나는 lock-free 코드를 안 쓰니까 괜찮아"? 틀렸다. 다음 경우에도 필요:
- 간단한 flag 기반 통신.
- Initialization order.
- Lock 구현.
규칙: 멀티스레드 코드를 작성하면 memory ordering을 이해하거나, mutex/condition variable 같은 고수준 primitive만 사용하라. 중간은 위험하다.
Q5. 왜 lock-free 알고리즘이 항상 mutex 기반보다 빠르지는 않은가?
A.
일반적 오해: "Lock-free = 빠름, Mutex = 느림".
현실: Depends. 워크로드, 경합 수준, 구현에 따라 다름.
Lock-free의 약속:
- Deadlock 없음: 락 없으니 dead할 수 없음.
- Priority inversion 없음: 우선순위 낮은 스레드가 높은 것을 block 안 함.
- Signal-safe: 인터럽트에서 사용 가능.
- Progress guarantee: 최소 하나는 진행.
Lock-free의 숨은 비용:
1. Atomic operation 자체의 비용:
일반 increment: 1 cycle
Atomic increment: 5-20 cycles (uncontended)
Atomic increment (contended): 50-500 cycles (cache miss + MESI traffic)
Uncontended lock-free는 빠르지만, contended면 cache line ping-pong으로 극도로 느려진다.
2. Retry loops:
Lock-free 알고리즘은 보통 CAS + 재시도:
do {
old = load(ptr);
new = compute(old);
} while (!cas(ptr, old, new));
경합이 심하면 CAS가 계속 실패. 많은 재시도. CPU 낭비.
3. Memory reclamation 복잡성:
누가 언제 메모리를 해제할까? 여러 스레드가 참조 중일 수 있음. 해결책들:
- Hazard pointer: 복잡.
- Epoch-based reclamation: 주기적 cleanup.
- RCU: 특수 패턴.
모두 추가 오버헤드와 복잡도.
4. False sharing 위험:
Atomic 변수들이 같은 cache line에 있으면 성능 파괴. Padding 필요.
Mutex의 숨은 이점:
1. Queue-based fairness:
좋은 mutex 구현 (FIFO mutex, ticket lock)은:
- 공정한 순서.
- 경합 시 효율적 대기 (futex: kernel queue).
- CPU 낭비 없음 (잠들어 있음).
2. OS 통합:
Linux futex: 경합이 있으면 kernel sleep, 풀리면 wake up. CPU 시간 낭비 없음.
3. 단순성:
Mutex 기반 코드는 읽기 쉽고 검증하기 쉽다. 버그가 적음.
벤치마크로 본 실제:
Scenario 1: Low contention (1~2 threads)
Counter increment (1M times):
- Mutex: 30 ms
- std::atomic<int>: 10 ms
- Custom lock-free: 8 ms
Lock-free가 3배 빠름. Atomic primitive만 사용.
Scenario 2: Medium contention (8 threads)
Concurrent queue (1M ops):
- Mutex-based queue: 100 ms
- Lock-free queue (Michael-Scott): 60 ms
Lock-free가 40% 빠름.
Scenario 3: High contention (32 threads)
Increment shared counter (1M × 32 threads):
- Mutex: 800 ms
- std::atomic: 1200 ms ← 더 느림!
- Per-thread counter + aggregate: 50 ms ← 압도적
왜 atomic이 더 느린가:
- 32 스레드가 같은 cache line을 동시에 수정.
- 매 atomic 연산이 cache line invalidation.
- Cache line이 코어 사이를 계속 이동.
- 실제로는 직렬화되는데 overhead가 더 큼.
Mutex는 경합 시 잠들기 때문에 오히려 나을 수 있다.
교훈: 알고리즘 설계가 primitive 선택보다 중요. Per-thread counter는 단순 atomic보다 10배 빠르다.
Scenario 4: Very high contention + short critical section
Increment with tiny critical section:
- Spin lock: 가장 빠름
- Mutex: 보통
- Lock-free: 느림 (재시도 많음)
Critical section이 수 nanosecond면 spin lock이 이김.
Scenario 5: Long critical section
- Mutex: 빠름 (잠들기)
- Spin lock: 느림 (CPU 낭비)
- Lock-free: 중간 (복잡)
Critical section이 수 μs+면 mutex가 낫다 (CPU 절약).
결론: When to use what:
Lock-free 선호:
- Uncontended 또는 low contention 워크로드.
- Priority inversion 걱정.
- Signal handler에서 공유.
- Low latency (꼬리 지연 중요).
- 성능이 증명되면.
Mutex 선호:
- Simple and correct 원하면.
- Long critical section.
- High contention with sleeping 허용.
- 표준 동시성 패턴.
Alternative approaches:
종종 더 나은 선택:
- Immutable data structures: 수정 대신 복사.
- Per-thread data + aggregation: 접촉 최소화.
- Message passing (Actor model): 공유 자체 회피.
- Read-write locks: 많은 reader + 적은 writer.
- Optimistic concurrency: 버전 기반.
실전 권장:
- 측정하라: 추측하지 말고 벤치마크.
- Profile부터: 정말 이 부분이 병목인가?
- 가장 단순한 것부터: Mutex부터 시작.
- 검증된 라이브러리: 자체 lock-free 구현 금물.
- Data layout 우선: False sharing 제거가 알고리즘 선택보다 중요할 수 있음.
검증된 lock-free 라이브러리:
- C++: boost::lockfree, folly.
- Rust: crossbeam.
- Java: java.util.concurrent.
- Go: sync package (goroutine 기반 alternative).
교훈:
"Lock-free is faster"는 마케팅이다. 현실은 더 복잡:
- Uncontended: lock-free 유리.
- Low contention: lock-free 약간 유리.
- Medium contention: 비슷.
- High contention: 알고리즘 설계가 중요 (둘 다 느릴 수 있음).
- Very high contention: 공유 자체를 피하라.
정답: 측정 + 문제에 맞는 선택. 독단적 주장 (lock-free 만세, mutex 나빠)은 경험 부족의 표시다.
Martin Thompson의 금언: "Mechanical sympathy". 하드웨어를 이해하면 어떤 알고리즘을 선택할지 명확해진다. Lock-free는 도구이지 만능이 아니다.
마치며: 하드웨어의 숨은 언어
핵심 정리
- Cache line 64 바이트: 모든 것의 단위.
- NUMA: 멀티 소켓의 비대칭. 무시하면 30% 손실.
- MESI: 하드웨어 동기화의 수학.
- False sharing: 10배 느려질 수 있는 숨은 함정.
- Memory ordering: 순서는 환상, barrier가 필요.
- Atomic: 빠르지만 contention 시 폭발.
- Lock-free: 항상 답은 아님.
언제 이 지식이 필요한가
필수:
- Lock-free 자료구조 구현.
- 데이터베이스 엔진.
- 고성능 네트워크 스택.
- 게임 엔진.
- HFT (고빈도 거래).
- OS 커널.
유용:
- 대규모 서버 튜닝.
- JVM 성능 최적화.
- Rust/C++ 멀티스레드 코드.
- NUMA 환경.
불필요:
- 단일 스레드 앱.
- 일반 CRUD 백엔드.
- 스크립트 언어 앱.
마지막 교훈
하드웨어를 아는 프로그래머와 모르는 프로그래머의 차이는 극단적 성능 차이로 나타난다. 10배, 100배가 드물지 않다.
그러나 이 지식은 독이 될 수 있다. 조기 최적화, 불필요한 복잡성, 검증 안 된 lock-free 코드. 규칙:
- 먼저 측정하라. 병목이 정말 여기인가?
- 알고리즘이 먼저. O(N^2) → O(N log N)이 하드웨어 trick보다 낫다.
- 단순함이 기본. Mutex로 시작, lock-free는 증명된 필요 시만.
- False sharing 체크. 가장 흔한 숨은 이슈.
- 측정 반복. 환경, 코드 변경 시마다.
당신이 진짜 성능 엔지니어가 되고 싶다면, 이 지식은 선택이 아니라 필수다. Cache line, MESI, memory barrier — 이들이 당신의 도구가 될 때, 하드웨어가 당신과 말하기 시작한다.
그리고 그 순간 "왜 이 코드가 느리지?" 의 답이 보인다. 보이지 않던 것이 보이게 되는 경험, 그것이 엔지니어링의 가장 큰 보상 중 하나다.
참고 자료
- What Every Programmer Should Know About Memory (Ulrich Drepper, 2007) - 이 분야의 바이블
- Intel 64 and IA-32 Architectures Software Developer's Manual Vol 3A
- ARM Architecture Reference Manual
- Memory Barriers: a Hardware View for Software Hackers (Paul McKenney)
- C++ Memory Model (cppreference)
- Martin Thompson: Mechanical Sympathy
- Herb Sutter: Atomic Weapons (talks)
- Linux NUMA documentation
- LMAX Disruptor Technical Paper
- Intel VTune Profiler
- perf c2c: False sharing detection