Skip to content
Published on

Lock-Free 동시성 프로그래밍 완전 가이드 2025: Atomics, 메모리 모델, CAS, 무잠금 자료구조

Authors

TL;DR

  • Lock-Free의 핵심: 락 없이 동시성 보장. CAS(Compare-And-Swap) 같은 원자 명령 활용
  • 메모리 모델: x86은 강한 순서 보장, ARM/RISC-V는 약함. Acquire/Release semantics 이해 필수
  • 장점: 데드락 없음, 우선순위 역전 없음, 일부 시나리오에서 락보다 빠름
  • 단점: 매우 어려움, 디버깅 악몽, 미세한 실수가 데이터 손상
  • 언제 사용: 카운터, 시퀀스 ID, 로그 버퍼 등 단순 작업. 복잡한 자료구조는 검증된 라이브러리 사용

1. Lock-Free란 무엇인가?

1.1 정의

Lock-Free 알고리즘: 여러 스레드가 동시에 작업할 때, 적어도 하나의 스레드는 항상 진전(progress)을 보장하는 알고리즘.

계층 구조:

수준보장
Wait-Free모든 스레드가 유한 시간 내 완료 (가장 강력)
Lock-Free적어도 하나의 스레드는 유한 시간 내 진전
Obstruction-Free다른 스레드가 멈추면 진전 가능
Blocking스레드가 무한 대기 가능 (락 사용)

1.2 왜 Lock-Free인가?

락의 문제점:

  1. 데드락 — 두 스레드가 서로의 락을 기다림
  2. 우선순위 역전 — 낮은 우선순위 스레드가 락 보유 → 높은 우선순위 스레드가 대기
  3. 컨텐션 — 많은 스레드가 같은 락 경쟁 → 직렬화
  4. 블로킹 — 락 보유자가 페이지 폴트, GC, 스케줄링 → 다른 스레드 모두 멈춤

Lock-Free의 약속:

  • 데드락 불가능 (락이 없음)
  • 시스템 호출 회피 (사용자 공간만)
  • 캐시 친화적 (CPU atomic 명령)

1.3 현실적 트레이드오프

Lock-Free는 만능이 아닙니다:

  • 구현 복잡도: 락 기반의 10-100배
  • 검증 어려움: 인종 조건이 매우 미묘
  • 메모리 회수: 안전한 객체 삭제가 어려움 (Hazard Pointer, Epoch)
  • 경합 시 성능: 무거운 컨텐션에서 락보다 느릴 수 있음

조언: 단순한 작업에만 Lock-Free 사용. 복잡한 자료구조는 검증된 라이브러리(crossbeam, concurrent_hash_map).


2. CPU 메모리 모델

2.1 왜 메모리 모델이 중요한가?

// Thread 1
x = 1;
y = 2;

// Thread 2
print(y);
print(x);

직관적으로는 print(y)2이면 print(x)1이어야 합니다. 하지만 CPU와 컴파일러가 명령을 재배치할 수 있어 항상 그렇지 않습니다.

2.2 x86 vs ARM 메모리 모델

x86ARMRISC-V
Store-Store 재배치
Load-Load 재배치
Load-Store 재배치
Store-Load 재배치
모델TSO (Total Store Order)WeakWeak

의미: x86에서 잘 동작하던 코드가 ARM에서 실패할 수 있습니다. iPhone, M1/M2 Mac, AWS Graviton의 등장으로 ARM 메모리 모델 이해는 필수.

2.3 메모리 순서 (Memory Ordering)

C++ / Rust의 std::memory_order:

#include <atomic>

std::atomic<int> x{0};

// 가장 강함 (Sequential Consistency, 기본값)
x.store(1, std::memory_order_seq_cst);

// Release: 이 store 이전의 모든 메모리 작업이 완료된 후 수행
x.store(1, std::memory_order_release);

// Acquire: 이 load 이후의 모든 메모리 작업이 이 load 이후에 수행
int v = x.load(std::memory_order_acquire);

// Relaxed: 원자성만 보장, 순서 X (가장 빠름)
x.store(1, std::memory_order_relaxed);

Release-Acquire 패턴:

std::atomic<bool> ready{false};
int data = 0;

// Thread 1 (Producer)
data = 42;
ready.store(true, std::memory_order_release);

// Thread 2 (Consumer)
while (!ready.load(std::memory_order_acquire)) {}
assert(data == 42);  // 보장됨!

release 이전의 모든 쓰기는 acquire 이후에 보입니다.


3. CAS — Compare-And-Swap

3.1 원리

bool compare_and_swap(int* ptr, int expected, int new_value) {
    if (*ptr == expected) {
        *ptr = new_value;
        return true;
    }
    return false;
}

핵심: 이 전체가 원자적으로 실행됩니다. CPU 명령 cmpxchg(x86) 또는 ldrex/strex(ARM)가 직접 지원.

3.2 CAS 기반 카운터

std::atomic<int> counter{0};

void increment() {
    int old_value, new_value;
    do {
        old_value = counter.load();
        new_value = old_value + 1;
    } while (!counter.compare_exchange_weak(old_value, new_value));
}

weak?: 일부 아키텍처(ARM)에서 spurious failure 가능. 루프 안에서는 weak가 더 빠름.

3.3 std::atomic의 fetch_add

위 코드는 표준 라이브러리로 더 단순:

counter.fetch_add(1, std::memory_order_relaxed);

내부적으로 LOCK XADD (x86) 또는 LDADDX (ARM v8.1+) 사용.

3.4 Rust의 atomics

use std::sync::atomic::{AtomicUsize, Ordering};

let counter = AtomicUsize::new(0);

// 원자적 증가
counter.fetch_add(1, Ordering::Relaxed);

// CAS
let result = counter.compare_exchange(
    0,                     // expected
    1,                     // new
    Ordering::Acquire,     // success ordering
    Ordering::Relaxed      // failure ordering
);

Rust는 컴파일 타임에 데이터 레이스를 검출 — atomics를 잘못 사용하면 컴파일 에러.


4. Lock-Free 자료구조

4.1 Treiber Stack — 가장 단순한 Lock-Free

template<typename T>
class LockFreeStack {
    struct Node {
        T value;
        Node* next;
    };
    std::atomic<Node*> head{nullptr};

public:
    void push(T value) {
        Node* new_node = new Node{value, nullptr};
        Node* old_head;
        do {
            old_head = head.load();
            new_node->next = old_head;
        } while (!head.compare_exchange_weak(old_head, new_node));
    }

    bool pop(T& value) {
        Node* old_head;
        Node* new_head;
        do {
            old_head = head.load();
            if (!old_head) return false;
            new_head = old_head->next;
        } while (!head.compare_exchange_weak(old_head, new_head));
        value = old_head->value;
        delete old_head;  // 위험! 다른 스레드가 읽고 있을 수 있음
        return true;
    }
};

문제: delete old_head가 안전하지 않음! 다른 스레드가 같은 노드를 읽는 중일 수 있습니다.

4.2 ABA 문제

초기: head → ABC

Thread 1:                    Thread 2:
old_head = A
new_head = B
                              pop Adelete A
                              pop Bdelete B
                              push A (재할당된 메모리)
                              head → AC

CAS(old_head=A, new_head=B)  ← 성공! 하지만 잘못됨
head → B (이미 free된 메모리)

해결책 1: Tagged Pointers (버전 번호 추가)

struct TaggedPtr {
    Node* ptr;
    uint64_t tag;  // 매번 증가
};
std::atomic<TaggedPtr> head;

CAS는 (ptr, tag) 쌍을 비교 → 같은 ptr이라도 tag가 다르면 실패.

해결책 2: Hazard Pointers

각 스레드가 "내가 지금 사용 중인 포인터"를 발표(announce). 다른 스레드는 그 포인터를 free하지 않음.

해결책 3: Epoch-Based Reclamation

전역 "에폭(epoch)" 카운터. 모든 스레드가 같은 에폭을 통과할 때까지 free 지연. crossbeam-epoch (Rust)가 표준 구현.

4.3 Michael-Scott Queue

가장 유명한 Lock-Free 큐.

struct Node {
    T value;
    std::atomic<Node*> next{nullptr};
};

std::atomic<Node*> head;
std::atomic<Node*> tail;

void enqueue(T value) {
    Node* new_node = new Node{value};
    Node* old_tail;
    Node* old_next;
    while (true) {
        old_tail = tail.load();
        old_next = old_tail->next.load();
        if (old_tail == tail.load()) {  // 일관성 체크
            if (old_next == nullptr) {
                // CAS로 next 설정 시도
                if (old_tail->next.compare_exchange_weak(old_next, new_node)) {
                    break;
                }
            } else {
                // tail이 뒤처짐, 도와주기 (helping)
                tail.compare_exchange_weak(old_tail, old_next);
            }
        }
    }
    // tail 갱신 (실패해도 OK, 다른 스레드가 도와줌)
    tail.compare_exchange_weak(old_tail, new_node);
}

핵심 트릭 — Helping: tail이 뒤처져 있으면 다른 스레드가 자기 작업 전에 도와줌. 이게 "Lock-Free" — 한 스레드가 멈춰도 다른 스레드가 진전 가능.


5. RCU — Read-Copy-Update

5.1 Linux 커널의 비밀 무기

RCU는 읽기를 락 없이 만드는 기법:

  1. Read: 락 없이, 그냥 포인터 읽기
  2. Update: 데이터를 복사 → 수정 → 원자적 포인터 교체
  3. Reclaim: 모든 기존 readers가 끝나기를 기다린 후 옛 데이터 free
// 의사 코드
struct config* config = rcu_dereference(global_config);
int value = config->some_value;  // 락 없이 읽기

// 업데이트
struct config* new_config = malloc(sizeof(*new_config));
memcpy(new_config, old_config, sizeof(*new_config));
new_config->some_value = 42;
rcu_assign_pointer(global_config, new_config);
synchronize_rcu();  // 모든 readers 완료 대기
free(old_config);

왜 빠른가?: 읽기에 atomic 명령조차 없습니다 — 그냥 일반 메모리 읽기. 캐시 친화적.

언제 사용: 읽기가 쓰기보다 1000:1 이상 많을 때. Linux 커널의 라우팅 테이블, 설정 등.

5.2 사용자 공간 RCU

  • liburcu (사용자 공간 RCU 라이브러리)
  • crossbeam-epoch (Rust)
  • folly::rcu (Facebook C++)

6. 언어별 비교

6.1 Java

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

// 단순 카운터 (낮은 컨텐션)
AtomicLong counter = new AtomicLong(0);
counter.incrementAndGet();

// 높은 컨텐션 카운터 (Java 8+)
LongAdder adder = new LongAdder();
adder.increment();  // 분할 셀로 컨텐션 분산
long value = adder.sum();  // 집계

LongAdder의 비밀: 여러 카운터 셀로 분산하여 컨텐션 회피. 읽기는 모든 셀 합산. 카운터에는 거의 항상 LongAdder가 더 빠름.

java.util.concurrent.atomic 패키지:

  • AtomicInteger, AtomicLong, AtomicReference
  • AtomicIntegerArray, AtomicLongArray
  • LongAdder, LongAccumulator (고성능)
  • ConcurrentHashMap (Lock-Free 읽기)

6.2 C++

#include <atomic>

std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);

// 메모리 순서를 정확히 지정해야 함
std::atomic<bool> flag{false};
flag.store(true, std::memory_order_release);
bool v = flag.load(std::memory_order_acquire);

C++20 추가:

  • std::atomic_ref — 비-atomic 값에 atomic 작업
  • std::atomic_wait/notify — 효율적 spinning 대안

6.3 Rust

use std::sync::atomic::{AtomicUsize, AtomicBool, Ordering};

static COUNTER: AtomicUsize = AtomicUsize::new(0);
COUNTER.fetch_add(1, Ordering::Relaxed);

Rust의 강점: 컴파일 타임에 데이터 레이스 검출. atomics를 잘못 사용하면 컴파일 에러.

crossbeam 라이브러리:

  • crossbeam-channel — Lock-Free MPMC 채널
  • crossbeam-epoch — Epoch-based reclamation
  • crossbeam-queue — Lock-Free 큐
  • crossbeam-skiplist — 동시성 skip list

6.4 Go

import "sync/atomic"

var counter int64
atomic.AddInt64(&counter, 1)
value := atomic.LoadInt64(&counter)

Go의 메모리 모델은 비교적 단순 — 대부분 sync.Mutex나 channel을 권장. atomics는 성능이 정말 중요한 경우만.


7. 실전 예시

7.1 Lock-Free Logger

class LockFreeLogger {
    static constexpr size_t SIZE = 1024;
    std::atomic<size_t> head{0};
    std::array<std::string, SIZE> buffer;

public:
    void log(const std::string& msg) {
        size_t idx = head.fetch_add(1, std::memory_order_relaxed) % SIZE;
        buffer[idx] = msg;  // 주의: 동시 쓰기 가능, 실제로는 더 정교하게
    }
};

이는 단순화된 예시 — 실제로는 ring buffer + 더 정교한 동기화 필요.

7.2 Sequence ID Generator

use std::sync::atomic::{AtomicU64, Ordering};

struct IdGenerator {
    next_id: AtomicU64,
}

impl IdGenerator {
    fn next(&self) -> u64 {
        self.next_id.fetch_add(1, Ordering::Relaxed)
    }
}

수백만 ID/초 처리 가능. 락보다 100배 빠름.

7.3 Spin Lock (학습용)

class SpinLock {
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 바쁘게 대기 (busy wait)
        }
    }
    void unlock() {
        flag.clear(std::memory_order_release);
    }
};

언제 사용: 락을 보유하는 시간이 매우 짧을 때 (수십 ns). 그보다 길면 OS mutex가 더 효율적.

개선: x86의 PAUSE 명령으로 spinning 효율 향상:

while (flag.test_and_set(std::memory_order_acquire)) {
    _mm_pause();  // CPU 힌트
}

8. 성능 측정과 함정

8.1 false sharing

struct Counters {
    std::atomic<int> a;
    std::atomic<int> b;  // 같은 캐시 라인!
};

ab가 같은 64바이트 캐시 라인에 있으면, 한 코어가 a를 수정할 때 다른 코어의 b 캐시가 무효화됩니다. 성능 10배+ 저하 가능.

해결: 패딩으로 분리

struct Counters {
    alignas(64) std::atomic<int> a;
    alignas(64) std::atomic<int> b;
};

8.2 컨텐션 vs 처리량

벤치마크 결과 (간단한 카운터, 4 코어):

방법1 스레드4 스레드16 스레드
Mutex30M ops/s8M ops/s2M ops/s
atomic CAS100M ops/s25M ops/s6M ops/s
LongAdder80M ops/s200M ops/s800M ops/s

교훈: 컨텐션이 높을 때 단순 atomic도 한계. LongAdder 같은 분할 카운터가 압도적으로 빠릅니다.

8.3 측정 도구

  • Linux: perf stat -e cache-misses, perf c2c (false sharing 감지)
  • Intel VTune — 마이크로아키텍처 분석
  • Java: JMH (Java Microbenchmark Harness)
  • Rust: criterion
  • C++: Google Benchmark

9. 검증된 라이브러리

거의 항상 직접 작성하지 마세요. 검증된 라이브러리 사용:

언어라이브러리제공
RustcrossbeamLock-Free 큐, 채널, atomic, epoch
RustdashmapLock-Free HashMap
Rustparking_lot빠른 mutex (대안)
C++folly (Facebook)Lock-Free 다양한 자료구조
C++boost::lockfree큐, 스택, spsc_queue
C++concurrencpp코루틴 + 동시성
Javaj.u.c.atomic, j.u.c.ConcurrentHashMap표준
JavaJCTools고성능 큐
Gosync/atomic, sync.Map표준

퀴즈

1. Lock-Free와 Wait-Free의 차이는?

: Lock-Free는 여러 스레드 중 적어도 하나가 항상 진전(progress)을 보장합니다. 다른 스레드는 starvation 가능. Wait-Free모든 스레드가 유한 시간 내 완료를 보장합니다. Wait-Free가 더 강하지만 구현이 훨씬 어렵습니다. 대부분의 실용적 알고리즘은 Lock-Free입니다.

2. ABA 문제는 무엇이고 어떻게 해결하나요?

: 값이 A→B→A로 변했을 때, CAS는 현재 값이 기대값(A)과 같으므로 성공한다고 판단합니다. 하지만 그 사이에 변화가 있었을 수 있어 잘못된 결정. 해결: (1) Tagged Pointers — 버전 번호 추가, (2) Hazard Pointers — 사용 중인 포인터 발표, (3) Epoch-Based Reclamation — 안전한 시점에만 free.

3. x86과 ARM의 메모리 모델 차이는?

: x86은 Total Store Order (TSO) — Load-Load, Store-Store, Load-Store 재배치 안 됨 (Store-Load만 가능). ARM은 Weak Memory Model — 거의 모든 재배치 가능. 결과: x86에서 잘 동작하던 코드가 ARM에서 실패 가능. iPhone, M1/M2 Mac, AWS Graviton 시대에는 Acquire/Release 의미를 정확히 이해해야 합니다.

4. False Sharing이 무엇이고 어떻게 방지하나요?

: 두 atomic 변수가 같은 64바이트 캐시 라인에 있으면, 한 코어가 한 변수를 수정할 때 다른 코어의 캐시가 무효화됩니다. 성능이 10배+ 저하될 수 있습니다. 해결: alignas(64) (C++) 또는 #[repr(align(64))] (Rust)로 캐시 라인 경계에 정렬하거나 패딩 추가. perf c2c로 감지 가능.

5. 언제 Lock-Free를 사용하면 안 되나요?

: (1) 단순한 락으로 충분할 때 — 락 컨텐션이 낮으면 mutex가 더 빠를 수 있습니다, (2) 복잡한 자료구조 — 직접 구현하지 말고 crossbeam, folly 같은 검증된 라이브러리 사용, (3) 유지보수 비용 — 팀이 메모리 모델을 이해하지 못하면 미묘한 버그 위험, (4) 디버깅 가능성 — Lock-Free 버그는 재현이 어렵고 디버깅이 매우 어렵습니다. 단순 카운터, 시퀀스 ID, 플래그에만 직접 사용하세요.


참고 자료