Lock-Free 자료구조 성능 — CAS·ABA·Hazard Pointer·Epoch Reclamation
#한 줄 요약
“Lock-free는 시스템 전체에서 한 thread는 반드시 진행을 보장하며, wait-free는 모든 thread의 진행을 보장합니다.”
#어떤 문제를 푸는가
Lock 기반 동기화는 thread가 lock을 잡은 채 죽거나 무한 loop에 빠지면 다른 thread도 모두 멈춥니다. Lock-free 자료구조는 어떤 thread가 어디서 멈추더라도 다른 thread는 진행할 수 있도록 설계됩니다.
Lock 자체를 제거하므로 priority inversion이나 deadlock도 없습니다. 단, 구현이 까다롭고 ABA 같은 미묘한 버그가 발생하기 쉽습니다. 잘못 만들면 데이터가 깨지는 것은 물론, lock보다 느려지기도 합니다.
이 글에서는 CAS라는 기본 atomic 명령에서 출발해 lock-free stack을 만들고, ABA 문제와 그 해결책인 hazard pointer, epoch reclamation을 살펴봅니다.
#Progress Property — 진행 보장의 등급
Obstruction-free: thread 혼자 실행되면 진행 보장Lock-free : 시스템 전체에서 적어도 한 thread는 진행Wait-free : 모든 thread가 n cycle 안에 진행 보장대부분의 실용 구현은 lock-free 수준에 머뭅니다. Wait-free는 이론적으로는 강력하지만 구현이 매우 복잡해 일반 사용에는 부담스럽습니다.
#CAS — Compare-And-Swap
bool cas(int *ptr, int *expected, int desired) { if (*ptr == *expected) { *ptr = desired; return true; } *expected = *ptr; return false;}ARM에서는 LDREX/STREX 쌍 또는 v8.1의 CASAL로 구현합니다.
loop: ldrex r1, [r0] cmp r1, expected bne fail strex r2, desired, [r0] cmp r2, #0 bne loopx86에서는 LOCK CMPXCHG 한 명령으로 처리됩니다. CAS는 거의 모든 lock-free 알고리즘의 기본 building block입니다.
#Lock-Free Counter
atomic_int counter;
void inc(void) { int old; do { old = atomic_load(&counter); } while (!atomic_compare_exchange_weak(&counter, &old, old + 1));}
/* 또는 atomic_fetch_add — 한 명령 */atomic_fetch_add(&counter, 1);ARMv8.1 이후에는 LDADD 명령으로 fetch-and-add가 single instruction이 됩니다. CAS retry loop보다 훨씬 효율적이며 contention이 높을 때 차이가 큽니다.
#Lock-Free Stack
struct node { int value; struct node *next; };
struct node *top;
void push(int v) { struct node *new_node = malloc(sizeof(*new_node)); new_node->value = v; struct node *old_top; do { old_top = atomic_load(&top); new_node->next = old_top; } while (!atomic_compare_exchange(&top, &old_top, new_node));}
bool pop(int *out) { struct node *old_top; do { old_top = atomic_load(&top); if (!old_top) return false; } while (!atomic_compare_exchange(&top, &old_top, old_top->next)); *out = old_top->value; free(old_top); /* ABA·use-after-free 위험 */ return true;}언뜻 보면 동작할 것 같지만 두 가지 위험이 있습니다. 다른 thread가 동시에 pop 중이면 old_top->next 접근이 use-after-free가 될 수 있고, ABA 문제로 잘못된 다음 노드를 가리킬 수 있습니다.
#ABA 문제
Thread 1 starts pop: old_top = X (X->next = Y) preemptThread 2: pop X, pop Y, push X (재사용) top = X (그러나 X->next는 이제 Z) resume Thread 1Thread 1: CAS(top, X, Y) — 성공 → top = Y (그런데 Y는 이미 free됨)CAS는 값이 같은지만 비교하므로 같은 pointer라도 의미가 달라졌는지 알 수 없습니다. 이를 ABA 문제라고 부르며, 1980년대 IBM 360에서 처음 보고되었습니다.
#해결 1 — Tagged Pointer
struct { void *ptr; uint64_t tag; } top; /* 128-bit */
push: loop: old = top; new->next = old.ptr; new_pair = { new, old.tag + 1 }; CAS(top, old, new_pair);매 push마다 tag를 증가시키면 같은 pointer라도 다른 값이 되어 CAS가 실패합니다. x86의 CMPXCHG16B, ARM의 CASP로 128-bit double-word CAS를 사용합니다.
단점은 64-bit pointer + 64-bit tag로 메모리 footprint가 두 배가 되며, 일부 ARM 코어는 128-bit atomic을 지원하지 않습니다.
#해결 2 — Hazard Pointer
각 thread가 현재 접근 중인 pointer를 hazard array에 등록해 두면, free하려는 thread가 다른 thread의 hazard pointer를 검사해 안전한지 확인합니다.
__thread void *hp;struct node *hp_list[MAX_THREADS];
void pop(...) { struct node *top_; do { top_ = atomic_load(&top); hp = top_; /* 보호 등록 */ if (top_ != atomic_load(&top)) continue; } while (!CAS(&top, top_, top_->next));
retire(top_); /* 즉시 free하지 않고 retired list로 */}
void scan_and_free(void) { for (each retired r) { if (no_hp_points_to(r)) free(r); }}Maged Michael의 2002년 논문이 표준입니다. Folly, DPDK, LMAX Disruptor 같은 고성능 라이브러리가 이 방식을 채택했습니다.
#해결 3 — Epoch Reclamation
atomic_int global_epoch;__thread int local_epoch;
void read(void) { local_epoch = atomic_load(&global_epoch); /* access shared ptr */ local_epoch = -1;}
void writer(void) { /* modify, retire old */ atomic_fetch_add(&global_epoch, 1); wait_until_all_threads_pass_epoch(); free(retired);}모든 thread가 같은 epoch에 머무는 동안에는 retire한 노드를 free하지 않고, 모두가 다음 epoch으로 넘어가면 안전하게 free합니다. Linux kernel의 RCU가 이 아이디어 위에 만들어져 있습니다.
Hazard pointer가 per-pointer 보호라면 epoch은 per-thread 보호입니다. 일반적으로 epoch이 read overhead가 더 낮지만, retire한 객체가 free될 때까지 메모리를 더 오래 점유할 수 있습니다.
#Lock-Free SPSC Queue
struct spsc { alignas(64) atomic_size_t head; /* producer */ alignas(64) atomic_size_t tail; /* consumer */ alignas(64) T buf[CAPACITY];};
bool push(struct spsc *q, T v) { size_t h = atomic_load_explicit(&q->head, memory_order_relaxed); size_t t = atomic_load_explicit(&q->tail, memory_order_acquire); if (h - t == CAPACITY) return false; q->buf[h % CAPACITY] = v; atomic_store_explicit(&q->head, h + 1, memory_order_release); return true;}
bool pop(struct spsc *q, T *out) { size_t t = atomic_load_explicit(&q->tail, memory_order_relaxed); size_t h = atomic_load_explicit(&q->head, memory_order_acquire); if (h == t) return false; *out = q->buf[t % CAPACITY]; atomic_store_explicit(&q->tail, t + 1, memory_order_release); return true;}Single producer와 single consumer가 보장된 경우에는 CAS조차 필요 없습니다. Release-acquire memory order만으로 충분합니다. ISR에서 task로 데이터를 넘기는 logging pipe 같은 곳에서 표준 패턴입니다.
#MPMC — Multi-Producer Multi-Consumer
여러 producer와 consumer가 동시에 접근하려면 더 복잡한 알고리즘이 필요합니다. Vyukov의 bounded MPMC queue가 대표적입니다.
struct cell { atomic_size_t sequence; T data; };struct mpmc { alignas(64) atomic_size_t enqueue_pos; alignas(64) atomic_size_t dequeue_pos; struct cell buf[CAPACITY];};각 cell에 sequence number를 두어 enqueue와 dequeue가 CAS로 자기 cell을 claim 합니다. Lock-free이며 Folly의 MPMCQueue가 같은 구조입니다.
#Lock-Free List와 Tree
- Harris-Michael linked list — CAS로 insert와 delete, marked pointer로 logically deleted를 표시합니다
- Lock-free skip list — 복잡하지만 효과가 큽니다
- Lock-free B-tree — 거의 연구 영역에 가깝습니다
복잡도가 빠르게 올라가며 코드 검증이 어렵습니다. 실 시스템에서는 fine-grained lock에 striping을 결합한 방식이 더 흔합니다.
#자주 보는 함정과 안티패턴
⚠️ ABA를 무시한 lock-free stack
위의 단순한 lock-free stack은 ABA 위험이 있습니다. Tagged pointer나 hazard pointer 같은 보호 mechanism이 반드시 필요합니다.
⚠️ Memory order 누락
atomic_store(&flag, 1, memory_order_relaxed); /* producer *//* consumer가 data 변경을 못 봄 */Release-acquire pair가 없으면 다른 thread가 데이터 변경을 관찰하지 못합니다. 4-08 편에서 자세히 다룹니다.
⚠️ Critical path의 malloc/free
push: malloc(node); /* lock-free가 아니라 lock 안에 있음 */glibc malloc은 내부적으로 lock을 사용하므로 lock-free 알고리즘에서 호출하면 의미가 없어집니다. Object pool과 lock-free free list로 미리 할당해 두는 것이 필요합니다.
⚠️ Default seq_cst 가정
C++에서 atomic 연산의 기본 memory order는 seq_cst로 가장 비쌉니다. 의도적으로 relaxed나 acq_rel을 명시해야 lock-free의 성능 이점이 살아납니다.
#측정 — 실측 결과
Cortex-A72 4-core에서 4 thread가 counter를 1M번씩 증가시킨 결과입니다.
Latency ThroughputMutex (uncontended) 30 ns 13 M/sMutex (contended) 2 µs 2 M/sSpinlock (contended) 200 ns 20 M/sCAS retry loop 100 ns 40 M/satomic_fetch_add (LDADD) 50 ns 320 M/sSPSC queue 25 ns 400 M/sLDADD처럼 single instruction atomic이 가능한 경우가 lock-free의 최고 성능 구간입니다. CAS retry loop는 contention이 높아지면 retry rate가 올라가 throughput이 떨어지므로 측정이 필요합니다.
#정리
- Progress 보장은 obstruction-free, lock-free, wait-free 순으로 강해집니다.
- CAS가 거의 모든 lock-free 알고리즘의 기본 명령입니다.
- ABA는 pointer 재사용으로 발생하는 false positive이며 tagged pointer나 hazard pointer로 해결합니다.
- Epoch reclamation은 hazard pointer보다 read overhead가 낮으며 Linux RCU의 기반입니다.
- SPSC queue는 CAS 없이 release-acquire만으로 구현 가능합니다.
- Retry rate가 lock-free 성능의 핵심 측정 지표입니다.
다음 편은 Memory Ordering — acquire-release semantics를 살펴봅니다.
#관련 항목
Embedded Performance Engineering · 37 of 57
- 1Embedded Performance Engineering — 임베디드 성능 엔지니어링 시리즈 소개
- 2임베디드 성능 분석 방법론 — Measure → Analyze → Optimize 사이클
- 3성능 지표 정의 — Latency·Throughput·Utilization 분석
- 4성능 측정의 기본 — Wall-Clock·CPU Cycle·Instruction Count
- 5성능 데이터 통계적 분석 — Percentile·Histogram·평균의 함정
- 6실시간 성능 분석 — WCET·Jitter·Deadline Miss 측정
- 7임베디드 벤치마킹 기초 — 재현성·Warmup·노이즈 제거
- 8성능 모델링 — Amdahl·Gustafson·Roofline Model 적용
- 9프로파일링 기법 개요 — Sampling vs Instrumentation·PGO·LTO
- 10CPU 파이프라인 분석 — 5-stage·Cortex-M·Cortex-A 비교
- 11Pipeline Stall 분석 — Data·Structural·Control Hazard·Forwarding
- 12Branch Prediction 분석 — Static·2-bit·BTB·BHT·Mispredict 비용
- 13Speculative Execution 분석 — OoO·Reorder Buffer·Register Renaming
- 14CPU Cache 기초 — L1·L2·L3·Set Associative·Replacement Policy
- 15Cache Miss 3C Model 분석 — Compulsory·Capacity·Conflict
- 16Cache Line 최적화 — Alignment·Prefetch·False Sharing 처리
- 17메모리 대역폭 분석 — STREAM·Roofline·Bus Saturation 측정
- 18SIMD·NEON 활용 — 128-bit Vector·Auto-Vectorization·SVE/SVE2
- 19PMU·HPM 하드웨어 카운터 분석 — 정밀 성능 진단
- 20임베디드 Bus Architecture — AHB·AXI·CHI 진화와 5-Channel
- 21Bus Contention 진단 — Arbitration·QoS·Starvation 측정
- 22DMA 성능 최적화 — Burst·Scatter-Gather·Chain·Cache 일관성
- 23DMA vs CPU Copy 성능 비교 — Break-even·Setup Overhead 실측
- 24Interrupt Latency 분석 — 진입·종료·Tail-Chaining·Late Arrival
- 25Interrupt Storm 처리 — NAPI·Rate-Limit·Polling 전환
- 26MMIO 접근 성능 — Cache Policy·Write-Combining·Volatile·Barrier
- 27Peripheral Clock 분석 — PLL·Divider·Gating·DVFS
- 28Power vs Performance 트레이드오프 — DVFS·Race-to-Idle·Big.LITTLE
- 29Thermal Throttling 분석 — Junction Temp·Trip Point·냉각
- 30CXL Interconnect 분석 — AI 시대 메모리 대역폭 확장
- 31Concurrency 기초 — Concurrency vs Parallelism·Race·Memory Model
- 32False Sharing 진단 — Cache Line Ping-Pong·Padding·측정
- 33Lock Contention 분석 — Wait·Hold·Convoy·측정 기법
- 34Spinlock 성능 분석 — Spin-Wait vs Context Switch·Ticket·MCS
- 35Mutex 성능 분석 — Futex·Adaptive·Priority Inheritance
- 36Reader-Writer Lock 성능 — Reader/Writer Priority·RCU·Seqlock
- 37Lock-Free 자료구조 성능 — CAS·ABA·Hazard Pointer·Epoch Reclamation
- 38Memory Ordering 분석 — Acquire·Release·Seq-Cst·ARM Relaxed Model
- 39Cache Coherency 프로토콜 — MESI·MOESI·Snoop·Directory
- 40SMP 성능 분석 — Per-Core·Affinity·Load Balance·Scalability
- 41Linux perf 기초 — stat·record·report 활용
- 42Linux perf 고급 — Raw Event·Tracepoint·perf script
- 43ftrace 활용 — function·function_graph·latency tracer
- 44eBPF·bpftrace 동적 트레이싱 — 커널 무수정 관측
- 45Flamegraph 분석 — On-CPU·Off-CPU·Differential
- 46ARM DS·Lauterbach 분석 — Hardware Trace 전문 도구
- 47Bare-metal 프로파일링 — GPIO·DWT·SysTick·ITM 활용
- 48NVIDIA Nsight Systems — GPU·NPU 포함 시스템 분석
- 49모던 프로파일러 비교 — Tracy·Hotspot·uftrace·Coz
- 50연속 프로파일링 — Parca·Pixie·Pyroscope·Tetragon
- 51실전 사례 — ISR Latency 100µs Deadline Miss 추적
- 52실전 사례 — Matrix Multiply가 예상의 10배 느린 이유
- 53실전 사례 — 8-core가 4-core를 넘으면 throughput이 떨어지는 이유
- 54실전 사례 — 카메라 1080p 60fps가 30fps로 떨어지는 이유
- 55CXL.mem 지연·대역폭 실측 — Direct·Switch·Pooled 토폴로지 비교
- 56CXL 성능 프로파일링 도구 — cxl-cli·DAMON·perf-mem 활용
- 57실전 사례 — CXL.mem 추가로 LLM inference KV cache 처리량 회복
관련 글
실전 사례 — CXL.mem 추가로 LLM inference KV cache 처리량 회복
70B 모델 KV cache가 HBM 한계를 넘어 throughput이 무너졌을 때, CXL.mem 256 GB pool 추가로 회복한 실전 케이스.
CXL 성능 프로파일링 도구 — cxl-cli·DAMON·perf-mem 활용
CXL.mem 환경 성능 도구 — cxl-cli 토폴로지·DAMON page activity·perf-mem로 보는 CXL 트래픽·numastat 통계.
CXL.mem 지연·대역폭 실측 — Direct·Switch·Pooled 토폴로지 비교
CXL.mem 토폴로지별 실측 — Direct attach·Single switch·Multi-host pool의 지연·대역폭 비용 측정.
이 글을 참조하는 글 (5)
- Stream Buffer와 Message Buffer — FreeRTOS 10의 Lock-Free SPSC— Practical RTOS Internals
- Memory Ordering 분석 — Acquire·Release·Seq-Cst·ARM Relaxed Model— Embedded Performance Engineering
- Reader-Writer Lock 성능 — Reader/Writer Priority·RCU·Seqlock— Embedded Performance Engineering
- MPMC Queue 구현 — Multi-producer Multi-consumer Lock-Free— Modern Embedded Recipes
- RCU (Read-Copy-Update) 기초 — Quiescent State·Grace Period— Modern Embedded Recipes