Atomic 연산 (Atomic Operations)

Linux 커널의 atomic_t/atomic64_t/refcount_t, bitops, cmpxchg/try_cmpxchg 기반 lock-free 갱신 패턴을 중심으로 원자성 보장 범위를 설명합니다. 더불어 relaxed/acquire/release 메모리 순서 의미론, publication, bit-lock, hybrid fast/slow path, per-CPU 및 락과의 경계 설정, 오버플로와 use-after-free를 줄이는 실전 패턴까지 매우 상세히 다룹니다.

전제 조건: 커널 아키텍처인터럽트 문서를 먼저 읽으세요. 동기화 선택은 실행 문맥(프로세스/IRQ(Interrupt Request, 인터럽트 요청)/softirq)에 직접 좌우되므로, 먼저 컨텍스트 경계를 정확히 잡아야 합니다.
일상 비유: 이 주제는 교차로 신호 제어와 비슷합니다. 동시에 진입하는 흐름을 규칙 없이 처리하면 충돌이 나듯이, 락과 대기 규칙이 없으면 레이스와 데드락이 발생합니다.

핵심 요약

  • 타입 선택 — 일반 카운터는 atomic_t, 참조 카운트는 refcount_t가 기본입니다.
  • 핵심 연산cmpxchg/try_cmpxchg 루프가 lock-free 갱신의 중심입니다.
  • 순서 의미론_relaxed, _acquire, _release의 차이를 구분해야 합니다.
  • 보조 도구READ_ONCE/WRITE_ONCE, per-CPU 변수와 함께 사용 시 효과가 큽니다.
  • 경계 조건 — 원자성 보장과 메모리 순서 보장은 별개라는 점을 항상 확인합니다.

단계별 이해

  1. 공유 상태 범위 확정
    단일 변수만 보호하면 되는지, 구조체 전체 일관성이 필요한지 먼저 판별합니다.
  2. 연산자 선택
    단순 증감은 atomic API, 조건부 갱신은 cmpxchg 계열로 분리합니다.
  3. 메모리 순서 추가
    가시성 요구가 있으면 acquire/release 또는 배리어를 함께 배치합니다.
  4. 실측 검증
    고경합 경로에서 lock 대비 성능과 실패 재시도 비용을 함께 측정합니다.
관련 표준: C11/C++11 Memory Model (원자적 연산, acquire/release 시맨틱), LKMM (Linux Kernel Memory Model) — 커널 atomic 연산과 메모리 배리어의 이론적 기반입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

Atomic 연산 개요

Atomic 연산은 다른 CPU나 인터럽트에 의해 중간에 끼어들 수 없는 불가분(indivisible) 연산입니다. 단순한 카운터나 플래그에 대해 무거운 잠금 없이 안전한 동시 접근을 제공합니다.

Race Condition 발생 원리

멀티코어 환경에서 counter++는 Read–Modify–Write 세 단계로 실행됩니다. 두 CPU가 동시에 실행하면 갱신이 유실됩니다.

Without Atomic (Data Corruption) vs With Atomic (정상) Without Atomic — 데이터 손상 CPU A CPU B READ counter=0 READ counter=0 COMPUTE 0+1=1 COMPUTE 0+1=1 WRITE counter=1 WRITE counter=1 결과: counter=1 (예상 2) — 갱신 유실! With Atomic — 정상 동작 CPU A CPU B LOCK atomic_inc Read-Modify-Write (불가분, 방해 불가) 대기 중... (버스 잠금) LOCK atomic_inc Read-Modify-Write (순차 실행 보장) 결과: counter=2 (정확)

atomic_t / atomic64_t

#include <linux/atomic.h>

atomic_t counter = ATOMIC_INIT(0);

atomic_set(&counter, 5);           /* counter = 5 */
int val = atomic_read(&counter);    /* val = 5 */

atomic_add(3, &counter);            /* counter += 3 */
atomic_sub(1, &counter);            /* counter -= 1 */
atomic_inc(&counter);               /* counter++ */
atomic_dec(&counter);               /* counter-- */

/* 반환값 있는 변형 */
int old = atomic_fetch_add(3, &counter);  /* old = counter; counter += 3 */
bool zero = atomic_dec_and_test(&counter); /* --counter == 0 ? */
bool neg = atomic_add_negative(-5, &counter);

/* Compare-and-swap */
int old = atomic_cmpxchg(&counter, expected, new_val);
int old = atomic_xchg(&counter, new_val);

/* --- 연산 후 새 값 반환 (add_return / inc_return) --- */
int new_val = atomic_add_return(3, &counter);    /* counter += 3; return counter */
int new_val = atomic_sub_return(1, &counter);    /* counter -= 1; return counter */
int new_val = atomic_inc_return(&counter);       /* counter++; return counter */
int new_val = atomic_dec_return(&counter);       /* counter--; return counter */

/* --- 연산 전 이전 값 반환 (fetch_*, modify-then-return과 구분) --- */
int old = atomic_fetch_inc(&counter);        /* old = counter; counter++ */
int old = atomic_fetch_dec(&counter);        /* old = counter; counter-- */
int old = atomic_fetch_sub(2, &counter);     /* old = counter; counter -= 2 */

/* --- 비트와이즈 원자 연산 --- */
atomic_and(0xFF, &flags);                    /* flags &= 0xFF (반환값 없음) */
atomic_or(BIT(3), &flags);                   /* flags |= BIT(3) */
atomic_xor(BIT(7), &flags);                  /* flags ^= BIT(7) */
int old = atomic_fetch_and(0xFF, &flags);    /* 이전 값 반환 */
int old = atomic_fetch_or(BIT(3), &flags);
int old = atomic_fetch_xor(BIT(7), &flags);

/* --- 조건부 연산 --- */
/* v가 u가 아닐 때만 a 추가 → 성공 여부 반환 */
bool ok = atomic_add_unless(&counter, 1, 0);     /* 0이 아니면 +1 */
/* 0이 아닐 때만 증가 (RCU 패턴 필수) */
bool ok = atomic_inc_not_zero(&counter);
/* 양수일 때만 감소: 성공이면 감소 후 값(≥0), 실패면 음수 */
int ret = atomic_dec_if_positive(&counter);
/* 빼기 후 0인지 반환 */
bool zero = atomic_sub_and_test(3, &counter);

/* acquire 의미론으로 읽기 */
int val = atomic_read_acquire(&counter);
add_return vs fetch_add 차이:
  • add_return → 연산 후 새 값 반환 (post-modify)
  • fetch_add → 연산 전 이전 값 반환 (pre-modify)
둘 다 원자적이며 _relaxed/_acquire/_release 변형을 지원합니다.

비트 연산 (Bit Operations)

#include <linux/bitops.h>

unsigned long flags = 0;

set_bit(3, &flags);        /* 비트 3 설정 (atomic) */
clear_bit(3, &flags);      /* 비트 3 해제 (atomic) */
change_bit(3, &flags);     /* 비트 3 토글 (atomic) */

bool was_set = test_and_set_bit(3, &flags);
bool was_set = test_and_clear_bit(3, &flags);

/* Non-atomic 버전 (락 보호 하에 사용 시 더 빠름) */
__set_bit(3, &flags);
__clear_bit(3, &flags);

/* 읽기 전용 테스트 (변경 없이 확인) */
bool is_set = test_bit(3, &flags);

/* 토글 + 이전 값 반환 (atomic) */
bool was_set = test_and_change_bit(3, &flags);

/* Non-atomic 읽기 */
bool is_set = __test_bit(3, &flags);         /* 락 보호 하에서 사용 */
비트맵 검색/배치 연산: find_first_bit(), find_next_bit(), bitmap_weight(), for_each_set_bit(), bitmap_and(), bitmap_zero() 등 비트맵 전용 API는 Bitmap — 원자적 비트 연산비트맵 검색 API에서 상세히 다룹니다.

메모리 배리어 (Memory Barriers)

현대 CPU와 컴파일러는 성능 최적화를 위해 명령어 순서를 변경합니다. 메모리 배리어는 특정 지점에서 순서를 강제합니다.

메모리 배리어 유형 Full Barrier mb() / smp_mb() Read Barrier rmb() / smp_rmb() Write Barrier wmb() / smp_wmb() 이전 모든 읽기/쓰기 완료 후 이후 실행 이전 모든 읽기 완료 후 이후 읽기 이전 모든 쓰기 완료 후 이후 쓰기
리눅스 커널 메모리 배리어: smp_ 접두사는 SMP(Symmetric Multi-Processing, 대칭형 다중 처리) 시스템에서만 동작 (UP(Uniprocessor, 단일 프로세서)에서는 no-op)
/* 컴파일러 배리어 (CPU 재정렬은 방지하지 않음) */
barrier();

/* 메모리 배리어 (CPU + 컴파일러) */
smp_mb();    /* full memory barrier */
smp_rmb();   /* read memory barrier */
smp_wmb();   /* write memory barrier */

/* Acquire / Release semantics */
smp_load_acquire(&var);    /* 읽기 + acquire barrier */
smp_store_release(&var, val); /* 쓰기 + release barrier */

/* 예: producer-consumer pattern */
/* Producer */
data->field = value;
smp_store_release(&data_ready, 1);

/* Consumer */
if (smp_load_acquire(&data_ready))
    use(data->field);  /* guaranteed to see producer's write */

Per-CPU 변수

Per-CPU 변수는 각 CPU마다 독립적인 복사본을 가지므로, 동기화 없이 안전하게 접근할 수 있습니다 (선점만 비활성화하면 됩니다).

Per-CPU 변수 레이아웃 (캐시라인 바운싱 없음) CPU 0 my_counter [CPU0] 캐시라인 전용 락 불필요 CPU 1 my_counter [CPU1] 캐시라인 전용 락 불필요 CPU 2 my_counter [CPU2] 캐시라인 전용 락 불필요 글로벌 atomic_t counter (공유) 모든 CPU 경합 캐시라인 바운싱 LOCK 명령어 필요 for_each_possible_cpu → 합산 total = per_cpu(my_counter, 0) + per_cpu(my_counter, 1) + per_cpu(my_counter, 2) vs.
#include <linux/percpu.h>

/* 정적 per-CPU 변수 */
DEFINE_PER_CPU(int, my_counter);

/* 접근 */
get_cpu();  /* 선점 비활성화 + 현재 CPU ID */
this_cpu_inc(my_counter);
put_cpu();  /* 선점 재활성화 */

/* 또는 */
this_cpu_add(my_counter, 5);
int val = this_cpu_read(my_counter);

/* 전체 CPU 합산 */
int total = 0;
int cpu;
for_each_possible_cpu(cpu)
    total += per_cpu(my_counter, cpu);
💡

Per-CPU 변수는 캐시라인 바운싱을 완전히 제거하므로 카운터에 매우 효율적입니다. 네트워크 패킷 카운터, 통계 수집 등에 널리 사용됩니다.

참조 카운팅 (refcount_t)

refcount_t 객체 생명주기 객체 생성 ref = 1 참조 획득 ref++ (≥2) 참조 반납 ref-- ref = 0 → kfree() 0에서 inc → WARN use-after-free 방지 refcount_inc refcount_inc dec_and_test 복수 참조 가능
#include <linux/refcount.h>

refcount_t ref = REFCOUNT_INIT(1);

refcount_inc(&ref);                  /* 증가 (0→1 변환 시 WARN) */
bool last = refcount_dec_and_test(&ref); /* 0이면 true */
bool ok = refcount_inc_not_zero(&ref);   /* 0이 아닐 때만 증가 */

unsigned int val = refcount_read(&ref);  /* 현재 값 읽기 */
refcount_dec(&ref);                      /* 단순 감소 (반환값 없음, 드물게 사용) */

/* atomic_t 대신 refcount_t를 사용하면 use-after-free, */
/* overflow/underflow 등의 버그를 조기에 탐지합니다. */
refcount_dec() 주의: refcount_dec()는 마지막 참조 여부를 알 수 없어 use-after-free 위험이 있습니다. 대부분의 경우 refcount_dec_and_test()를 사용하세요.

atomic_long_t와 atomic64_t

아키텍처 독립적인 타입으로, 32비트와 64비트 시스템에서 일관된 동작을 보장합니다:

/* atomic_long_t: sizeof(long)에 따라 32/64비트 */
atomic_long_t counter = ATOMIC_LONG_INIT(0);
atomic_long_inc(&counter);

/* atomic64_t: 항상 64비트 (32비트 시스템에서도) */
atomic64_t big_counter = ATOMIC64_INIT(0);
atomic64_add(1000000, &big_counter);
s64 val = atomic64_read(&big_counter);

Lock-free 패턴 (cmpxchg)

cmpxchg는 compare-and-swap 연산으로, lock-free 알고리즘의 핵심 프리미티브입니다:

CAS (Compare-And-Swap) 루프 흐름 시작 old = atomic_read(&counter) new = old + 1 cmpxchg(&counter, old, new) == old? 성공 (Yes) 완료 counter=new 실패 (No) 재시도 (경합 발생) 경합이 없으면 1회 시도로 성공 | 경합이 많을수록 재시도 횟수 증가
/* cmpxchg(ptr, old, new) → 실제 이전 값 반환 */
/* 반환 값 == old 이면 교환 성공 */

/* Lock-free 카운터 증가 패턴 */
int old, new;
do {
    old = atomic_read(&counter);
    new = old + 1;
} while (atomic_cmpxchg(&counter, old, new) != old);

/* Lock-free 연결 리스트 삽입 (스택) */
struct node *new_node = kmalloc(sizeof(*new_node), GFP_ATOMIC);
do {
    new_node->next = READ_ONCE(stack_top);
} while (cmpxchg(&stack_top, new_node->next, new_node) != new_node->next);

/* try_cmpxchg: 더 효율적인 변형 (x86에서 1 instruction) */
int old = atomic_read(&counter);
while (!atomic_try_cmpxchg(&counter, &old, old + 1))
    ;  /* old가 자동으로 업데이트됨 */

ABA (A→B→A) 문제

CAS(Compare-And-Swap)는 값이 동일하면 성공으로 판단합니다. 그러나 A→B→A 변경 후에도 CAS가 성공하여 중간 변경을 놓치는 ABA 문제가 발생할 수 있습니다.

ABA 문제 타임라인 Thread 1 Thread 2 old = READ(ptr) 값 = A 읽음 Thread 1 선점 중 (Thread 2 실행) CAS A → B ptr = B 처리 / 변경 ptr = B 상태 CAS B → A ptr = A (복원!) CAS(ptr, A, new) 성공! (B 거침 모름) 재개 ABA 위험: 값은 A로 동일하지만 중간에 B를 거쳤음 — 버전 태그(시퀀스 번호) 또는 hazard pointer로 해결
ABA 해결책: 포인터에 시퀀스 번호를 태그로 붙이거나(u64 상위 비트 활용), 커널의 llist(lock-free list) 또는 hazard pointer 패턴을 사용합니다.

ABA 문제 해결 패턴

ABA 문제를 해결하는 두 가지 주요 패턴입니다:

/* 패턴 1: 버전 태그(버전 카운터) — 포인터와 버전을 함께 관리 */
struct tagged_ptr {
    uintptr_t   ptr : 48;   /* 포인터 (x86-64 : 48비트 주소) */
    uintptr_t   tag : 16;   /* 버전 카운터 */
};

/* 128비트 cmpxchg로 포인터+버전을 원자적으로 교체 */
union {
    struct { void *ptr; unsigned long tag; } s;
    __uint128_t val;
} old_val, new_val;

old_val.s.ptr = current_ptr;
old_val.s.tag = current_tag;
new_val.s.ptr = next_ptr;
new_val.s.tag = current_tag + 1;  /* 항상 버전 증가 */

/* A→B→A 가 발생해도 tag가 달라지므로 cmpxchg 실패 */
cmpxchg128(&head.val, old_val.val, new_val.val);

/* 패턴 2: 커널 llist (lock-free singly-linked list) */
/* kernel/llist.c — ABA 안전한 lock-free 리스트 구현 */
#include <linux/llist.h>

struct llist_head my_list = LLIST_HEAD_INIT(my_list);
struct my_node {
    struct llist_node   node;
    int                 data;
};

/* 삽입: 여러 생산자가 동시에 호출해도 안전 */
llist_add(&item->node, &my_list);

/* 전체 리스트 원자적 교체 후 순회 */
struct llist_node *list = llist_del_all(&my_list);
struct my_node *entry;
llist_for_each_entry(entry, list, node) {
    process(entry->data);
}
해결책방법장점단점
버전 태그 (Tagged Pointer)포인터 + 버전 카운터를 128비트 원자적 교체일반적, ABA 완벽 방지128비트 cmpxchg 필요, 아키텍처 의존
커널 llist단방향 lock-free 리스트 (llist_add/del_all)커널 API 직접 사용, 검증됨단방향 삽입/일괄 삭제만 지원
RCU + refcount포인터 교체 후 grace period 대기read-side 무비용, 안전지연 해제, grace period 필요
Hazard Pointer현재 접근 중인 포인터를 전역에 등록즉시 해제 가능구현 복잡, 커널에 표준 API 없음

메모리 순서 의미론 (Ordering Semantics)

atomic 연산에는 메모리 순서 변형이 있습니다:

접미사순서 보장설명
(없음)Fully ordered완전한 순서 보장 (가장 무거움)
_relaxedNone원자성만 보장, 순서 없음 (가장 가벼움)
_acquireAcquire이후 읽기/쓰기가 이 연산 전으로 이동 불가
_releaseRelease이전 읽기/쓰기가 이 연산 후로 이동 불가
Acquire / Release 의미론 — happens-before 관계 CPU 0 (Producer) data->field = value; /* 데이터 쓰기 */ data->field2 = value2; smp_store_release(&data_ready, 1) RELEASE 배리어: 이전 쓰기가 이 뒤로 이동 불가 이후 코드 실행 계속... CPU 1 (Consumer) smp_load_acquire(&data_ready) ACQUIRE 배리어: 이후 읽기가 이 앞으로 이동 불가 use(data->field); /* Producer 쓰기 보장 */ use(data->field2); /* 최신 값 보장 */ 이후 코드 실행 계속... happens-before Release store 이전의 모든 쓰기는 Acquire load 이후의 모든 읽기에서 반드시 가시(visible)
/* 성능이 중요한 경우: relaxed 사용 */
atomic_inc_return_relaxed(&counter);  /* 순서 보장 불필요 시 */

/* 잠금 획득 패턴: acquire */
while (atomic_cmpxchg_acquire(&lock, 0, 1) != 0)
    cpu_relax();

/* 잠금 해제 패턴: release */
atomic_set_release(&lock, 0);

/* x86에서는 TSO(Total Store Order) 때문에 */
/* _acquire/_release가 거의 무비용 */
/* ARM/RISC-V에서는 barrier 명령어 필요 → 성능 차이 큼 */

local_t (Per-CPU atomic)

local_t는 Per-CPU 변수에 대한 최적화된 atomic 연산입니다. 다른 CPU의 동시 접근은 없지만 인터럽트로부터 보호가 필요할 때 사용합니다:

#include <asm/local.h>

DEFINE_PER_CPU(local_t, my_counter);

/* 같은 CPU 내에서만 수정 → SMP 동기화 불필요 */
local_inc(this_cpu_ptr(&my_counter));

/* x86에서 local_inc는 lock 접두사 없는 inc → 매우 빠름 */
/* atomic_inc는 lock inc (캐시라인 잠금) → 느림 */

WRITE_ONCE / READ_ONCE

컴파일러의 최적화(load/store 분할, 중복 접근, 재정렬)를 방지합니다:

/* 다른 CPU/인터럽트와 공유하는 변수 접근 시 */
WRITE_ONCE(shared_flag, 1);   /* 단일 store 보장 */
int v = READ_ONCE(shared_flag); /* 단일 load 보장 */

/* 없으면 컴파일러가 이렇게 변환할 수 있음: */
/* shared_flag = 0; shared_flag = 1; (2회 store) */
/* 또는 register에 캐싱하여 재로드 안 함 */

atomic_t 심화 — 내부 구현과 아키텍처 차이

atomic_t 연산은 아키텍처마다 구현이 다릅니다. x86은 LOCK 접두사로 버스 락/캐시 락을 사용하고, ARM은 LDREX/STREX(ARMv7) 또는 LDXR/STXR(ARMv8) LL/SC(Load-Linked/Store-Conditional)를 사용합니다.

/* x86 atomic_add 구현 (arch/x86/include/asm/atomic.h) */
static __always_inline void arch_atomic_add(int i, atomic_t *v)
{
    asm volatile(LOCK_PREFIX "addl %1,%0"
                 : "+m"(v->counter) : "ir"(i) : "memory");
}

/* ARM64 atomic_add 구현 (LL/SC 방식) */
static inline void arch_atomic_add(int i, atomic_t *v)
{
    unsigned long tmp;
    int result;
    asm volatile(
    "1: ldxr   %w0, %2\\n"
    "   add    %w0, %w0, %w3\\n"
    "   stxr   %w1, %w0, %2\\n"
    "   cbnz   %w1, 1b"          /* stxr 실패 시 재시도 */
    : "=&r"(result), "=&r"(tmp), "+Q"(v->counter)
    : "Ir"(i));
}

/* ARM64 LSE (Large System Extensions) — 하드웨어 atomic */
/* ARMv8.1+: stadd, ldadd 등 하드웨어 atomic 명령어 */
static inline void arch_atomic_add(int i, atomic_t *v)
{
    asm volatile(
    "   stadd  %w[i], %[v]\\n"
    : [v] "+Q"(v->counter)
    : [i] "r"(i)
    : "memory");
}

atomic 연산의 메모리 순서 보장

연산 변형순서 보장예시사용 시나리오
기본 (fully ordered)전후 배리어 포함atomic_add_return()동기화 지점, 락 구현
_relaxed순서 보장 없음atomic_add_return_relaxed()통계 카운터, 성능 우선
_acquire이후 접근이 재배열 안 됨atomic_add_return_acquire()락 획득, 데이터 읽기 전
_release이전 접근이 재배열 안 됨atomic_add_return_release()락 해제, 데이터 쓰기 후
/* 성능이 중요한 카운터: relaxed 사용 */
atomic_inc(&stats->packets);  /* fully ordered — 불필요하게 느림 */
atomic_add_return_relaxed(1, &stats->packets);  /* 순서 불필요 → 빠름 */

/* 락 구현 패턴: acquire/release */
/* 락 획득: 이후 코드가 앞으로 재배열되면 안 됨 */
while (atomic_cmpxchg_acquire(&lock, 0, 1) != 0)
    cpu_relax();

/* 크리티컬 섹션 */
shared_data = new_value;

/* 락 해제: 이전 코드가 뒤로 재배열되면 안 됨 */
atomic_set_release(&lock, 0);

refcount_t 심화 — atomic_t와의 차이

/* refcount_t는 참조 카운팅 전용으로 atomic_t보다 안전 */
/* 차이점: */
/* 1. 0 → 1 전환 방지 (use-after-free 방지) */
/* 2. 오버플로/언더플로 감지 (REFCOUNT_FULL 시) */
/* 3. 포화(saturation) 동작으로 corruption 방지 */

struct my_obj {
    refcount_t ref;
    struct kref kref;  /* 또는 kref 사용 */
};

/* refcount_t API */
refcount_set(&obj->ref, 1);             /* 초기값 설정 */
refcount_inc(&obj->ref);                /* 증가 (0에서 증가 시 WARN) */
bool last = refcount_dec_and_test(&obj->ref);  /* 감소 후 0이면 true */
if (last) kfree(obj);

/* 안전한 증가: 0이 아닐 때만 */
if (!refcount_inc_not_zero(&obj->ref))
    return NULL;  /* 이미 해제된 객체 */

/* kref: refcount_t의 고수준 래퍼 */
kref_init(&obj->kref);
kref_get(&obj->kref);
kref_put(&obj->kref, my_obj_release);  /* 0 도달 시 release 콜백 */

static void my_obj_release(struct kref *kref)
{
    struct my_obj *obj = container_of(kref, struct my_obj, kref);
    kfree(obj);
}
⚠️

refcount_t 주의사항:

  • atomic_t를 참조 카운팅에 사용하지 마십시오 — refcount_t가 use-after-free와 오버플로를 방지합니다
  • refcount_inc()은 0에서 호출하면 WARN 출력. 반드시 refcount_inc_not_zero() 사용
  • RCU와 결합 시: rcu_read_lock() 내에서 refcount_inc_not_zero()로 안전하게 참조 획득
  • 커널/배포판 설정에 따라 refcount 검사 강도가 다를 수 있으므로, 대상 환경의 CONFIG_DEBUG_REFCOUNT 및 관련 디버그 옵션을 확인

READ_ONCE / WRITE_ONCE 심화

/* include/asm-generic/rwonce.h */
/* 목적: 컴파일러가 메모리 접근을 최적화하지 못하도록 방지 */
/* 1. 접근 분할(tearing) 방지: 단일 load/store 보장 */
/* 2. 접근 병합(merging) 방지: 여러 접근을 하나로 합치지 않음 */
/* 3. 접근 삽입(invention) 방지: 없는 접근을 만들지 않음 */
/* 4. volatile 의미론을 정확한 지점에만 적용 */

/* BAD: 컴파일러가 최적화할 수 있음 */
while (flag == 0)  /* 컴파일러: "flag는 안 변해" → 무한 루프 */
    cpu_relax();

/* GOOD: 매번 메모리에서 읽기 강제 */
while (READ_ONCE(flag) == 0)
    cpu_relax();

/* BAD: 컴파일러가 쓰기를 재배열하거나 제거할 수 있음 */
flag = 1;   /* 다른 CPU에서 관찰 불가능할 수 있음 */

/* GOOD: 단일 store 보장 */
WRITE_ONCE(flag, 1);

/* 주의: READ_ONCE/WRITE_ONCE는 CPU 간 순서를 보장하지 않음 */
/* CPU 간 가시성이 필요하면 메모리 배리어도 함께 사용 */
WRITE_ONCE(data, new_value);
smp_wmb();                    /* 쓰기 배리어 */
WRITE_ONCE(data_ready, 1);

/* 읽는 쪽 */
if (READ_ONCE(data_ready)) {
    smp_rmb();                /* 읽기 배리어 */
    val = READ_ONCE(data);     /* 최신 data 보장 */
}

메모리 배리어 심화

배리어범위목적비용
mb()모든 CPU + 디바이스전체 순서 보장매우 높음
rmb()모든 CPU + 디바이스읽기 순서 보장높음
wmb()모든 CPU + 디바이스쓰기 순서 보장높음
smp_mb()CPU 간전체 순서 (SMP만)중간 (UP(Uniprocessor, 단일 프로세서)에서는 barrier())
smp_rmb()CPU 간읽기 순서 (SMP만)낮음~중간
smp_wmb()CPU 간쓰기 순서 (SMP만)낮음
smp_store_release()CPU 간 (단방향)이전 접근 완료 후 store낮음
smp_load_acquire()CPU 간 (단방향)load 후에만 이후 접근낮음
barrier()컴파일러만컴파일러 재배열 방지없음 (런타임 비용 0)
dma_wmb()DMA(Direct Memory Access, 직접 메모리 접근) 디바이스DMA 디스크립터 쓰기 순서아키텍처 의존
dma_rmb()DMA 디바이스DMA 디스크립터 읽기 순서아키텍처 의존
/* 권장 패턴: smp_store_release / smp_load_acquire 쌍 */
/* smp_wmb/smp_rmb보다 의도가 명확하고 실수 가능성 낮음 */

/* 생산자 (CPU 0) */
buffer[idx] = data;
smp_store_release(&producer_idx, idx + 1);
/* buffer 쓰기가 producer_idx 갱신보다 먼저 보장 */

/* 소비자 (CPU 1) */
unsigned int idx = smp_load_acquire(&producer_idx);
/* idx 읽은 후의 buffer 접근이 재배열 안 됨 */
val = buffer[idx - 1];  /* 올바른 데이터 보장 */
💡

배리어 선택 가이드:

  • CPU 간 데이터 공유: smp_store_release() / smp_load_acquire() 쌍 우선 사용
  • DMA 디스크립터: dma_wmb() / dma_rmb()
  • MMIO(Memory-Mapped I/O, 메모리 매핑 입출력) 레지스터: mb() / rmb() / wmb() (디바이스 포함 필요)
  • 컴파일러 최적화 방지만: barrier() (가장 가벼움)
  • x86에서 smp_rmb()barrier()와 동일 (x86은 읽기 순서 자동 보장). ARM에서는 실제 배리어 명령어 발행

타입 선택 가이드

상황에 맞는 atomic 타입을 선택하는 결정 트리입니다. 잘못된 타입 선택은 성능 저하나 보안 취약점으로 이어집니다. 아래 다이어그램에서 UAF(Use-After-Free, 해제된 메모리 재접근)는 refcount_t가 방지하는 대표적인 버그 유형입니다.

무엇을 써야 할까? — Atomic 타입 선택 트리 어떤 용도인가? (Use case) 객체 참조 카운팅 refcount_t UAF·오버플로 방지 고빈도 통계/카운터 Per-CPU 변수 캐시라인 바운싱 0 인터럽트 경합만 local_t LOCK 없는 per-CPU 단순 공유 카운터 atomic_t 일반 목적 원자 카운터 ⚠ 조건부 갱신이 필요하면 cmpxchg 패턴 사용 | 범위 제한이 필요하면 atomic_add_unless / dec_if_positive 고려

실전 패턴 (Practical Patterns)

패턴 1 — 네트워크 드라이버 패킷 카운터 (relaxed vs per-CPU)

/* 수신 경로: relaxed로 캐시 미스 최소화 */
atomic_long_fetch_add_relaxed(1, &stats->rx_packets);

/* 더 고빈도라면 per-CPU 카운터 권장 */
DEFINE_PER_CPU(long, rx_packets_pcpu);
this_cpu_inc(rx_packets_pcpu);              /* 선점 비활성 불필요 (softirq 컨텍스트) */

/* 합산 */
long total = 0;
int cpu;
for_each_possible_cpu(cpu)
    total += per_cpu(rx_packets_pcpu, cpu);
상세 해설:
  • atomic_long_fetch_add_relaxed()는 카운터 값의 정확한 증가만 보장하고, 그 이전이나 이후 메모리 접근의 순서는 보장하지 않습니다. 통계 카운터처럼 "숫자만 맞으면 되는" 경로에는 이 특성이 가장 적합합니다.
  • 패킷 수신 경로는 보통 softirq 문맥에서 매우 자주 실행되므로, 모든 패킷마다 전역 캐시라인 하나를 두드리면 캐시라인 바운싱이 심해집니다. 그래서 더 뜨거운 경로는 this_cpu_inc()로 나누고, 느린 경로에서 합산하는 편이 일반적으로 더 낫습니다.
  • 중요한 점은 정확한 합산 시점입니다. per-CPU 통계는 순간적으로 CPU별 값이 어긋나도 괜찮을 때만 적합합니다. 즉시 일관된 총합이 필요한 제어 값이라면 per-CPU가 아니라 atomic 또는 락 기반 집계를 써야 합니다.

패턴 2 — RCU + refcount_inc_not_zero 안전한 참조 획득

struct my_obj *obj_get(int id)
{
    struct my_obj *obj;
    rcu_read_lock();
    obj = rcu_dereference(obj_table[id]);
    if (obj && !refcount_inc_not_zero(&obj->ref))
        obj = NULL;  /* grace period 내 해제 중 */
    rcu_read_unlock();
    return obj;
}

void obj_put(struct my_obj *obj)
{
    if (refcount_dec_and_test(&obj->ref))
        kfree_rcu(obj, rcu);
}
상세 해설:
  • 이 패턴의 핵심은 rcu_dereference()로 포인터를 읽은 직후, 객체가 아직 살아 있는지 refcount_inc_not_zero()승격하는 데 있습니다. 포인터를 봤다고 해서 곧바로 안전한 것이 아니라, 참조 카운트를 실제로 올려야 안전한 소유권이 생깁니다.
  • refcount_inc()를 쓰면 0인 객체를 되살리는 문제가 생길 수 있습니다. 반면 refcount_inc_not_zero()는 이미 해제 절차에 들어간 객체를 다시 잡지 못하게 막습니다.
  • 해제 측이 kfree_rcu()를 쓰는 이유는 읽는 쪽이 아직 RCU 읽기 구간 안에 있을 수 있기 때문입니다. 즉, refcount는 소유권을, RCU는 포인터 생존 기간을 담당합니다. 둘은 역할이 다르므로 하나만으로는 부족한 경우가 많습니다.

패턴 3 — atomic_add_unless / atomic_dec_if_positive 세마포어 패턴

atomic_t conn_count = ATOMIC_INIT(0);
#define MAX_CONN 100

bool try_connect(void)
{
    /* MAX_CONN 미만일 때만 +1 */
    return atomic_add_unless(&conn_count, 1, MAX_CONN);
}

void disconnect(void) { atomic_dec(&conn_count); }

/* 토큰 풀 패턴 */
atomic_t tokens = ATOMIC_INIT(5);

bool acquire_token(void)
{
    return atomic_dec_if_positive(&tokens) >= 0;
}

void release_token(void) { atomic_inc(&tokens); }
상세 해설:
  • atomic_add_unless()는 "현재 값이 특정 금지 값과 같지 않을 때만 갱신"이라는 단일 규칙을 원자적으로 수행합니다. 따라서 단일 상한선이나 특정 상태 값 차단에는 매우 편리합니다.
  • atomic_dec_if_positive()는 토큰이 없을 때 음수로 내려가지 않도록 막아 줍니다. 별도의 atomic_read()atomic_dec()를 쓰면, 읽은 직후 다른 CPU가 값을 바꿔 버려 음수 토큰이 생길 수 있습니다.
  • 다만 이 패턴은 어디까지나 단일 정수 자원에만 잘 맞습니다. 예를 들어 한 번에 N개 슬롯을 예약하거나, 카운터와 별도의 버퍼 상태를 함께 일치시켜야 하는 경우에는 CAS 루프 또는 락이 더 적합합니다.

구현 예시 확장 (Implementation Cookbook)

아래 예시들은 "atomic API를 안다"에서 끝나지 않고, 실제 설계에서 어떤 문제를 어떤 방식으로 잘라내는지를 보여 주기 위해 의도적으로 성격이 다른 패턴을 모아 둔 것입니다. 일부는 순수 atomic fast path이고, 일부는 atomic과 락, 배리어, 대기 큐를 함께 섞은 하이브리드 설계입니다. 실무에서는 이 조합 능력이 더 중요합니다.

패턴 4 — 가변 크기 자원 예약 (CAS 루프)

struct submit_ring {
    atomic_t free_slots;
};

bool reserve_slots(struct submit_ring *ring, int nr)
{
    int old, new;

    old = atomic_read(&ring->free_slots);
    for (;;) {
        if (old < nr)
            return false;

        new = old - nr;
        if (atomic_try_cmpxchg(&ring->free_slots, &old, new))
            return true;

        /* 실패 시 old에는 최신 값이 자동 반영됨 */
        cpu_relax();
    }
}

void release_slots(struct submit_ring *ring, int nr)
{
    atomic_add(nr, &ring->free_slots);
}

이 예시는 한 번에 여러 개의 자원을 잡아야 하는 경우를 보여 줍니다. atomic_add_unless()는 금지 값 하나만 검사하는 데는 좋지만, "현재 값이 7이면 4개를 빼도 되고 3이면 실패"처럼 비교식이 들어가는 순간 부족합니다. 그때는 현재 값을 읽고, 조건을 검사하고, 새 값을 계산하고, 그 사이에 누군가 끼어들었는지 CAS로 확인하는 루프가 필요합니다.

이 패턴이 막는 레이스:
  • if (atomic_read(&free_slots) >= nr) atomic_sub(nr, ...)처럼 나누어 쓰면, 읽기와 빼기 사이에 다른 CPU가 동일 슬롯을 먼저 가져가 중복 예약이 생길 수 있습니다.
  • atomic_try_cmpxchg()는 비교와 갱신을 한 번에 묶어 주므로, 실패 시 최신 값을 받아 다시 계산할 수 있습니다.
  • 이 카운터가 단순 수량을 넘어서 "슬롯 내부 데이터가 완전히 준비되었는지"까지 의미한다면, 숫자 갱신만으로는 부족하고 별도의 release/acquire 공개 규칙을 추가해야 합니다.

패턴 5 — 완전 초기화 후 공개 (publication)

struct shared_cfg {
    int mode;
    int depth;
    void *table;
};

static struct shared_cfg *global_cfg;

int install_cfg(int mode, int depth, void *table)
{
    struct shared_cfg *cfg;

    cfg = kmalloc(sizeof(*cfg), GFP_KERNEL);
    if (!cfg)
        return -ENOMEM;

    cfg->mode = mode;
    cfg->depth = depth;
    cfg->table = table;

    /* 구조체 필드 초기화를 모두 끝낸 뒤 포인터 공개 */
    smp_store_release(&global_cfg, cfg);
    return 0;
}

struct shared_cfg *lookup_cfg(void)
{
    struct shared_cfg *cfg;

    cfg = smp_load_acquire(&global_cfg);
    if (!cfg)
        return NULL;

    /* acquire 이후에는 mode/depth/table 읽기가 안전 */
    return cfg;
}

이 예시는 atomic 자체보다는 atomic 설계가 메모리 순서와 만나야 비로소 완성된다는 점을 보여 줍니다. 공유 포인터를 공개하는 상황에서 실수하기 쉬운 코드는 WRITE_ONCE(global_cfg, cfg)처럼 포인터만 던지는 코드입니다. 이렇게 쓰면 다른 CPU가 포인터는 보지만 내부 필드 일부는 아직 예전 값처럼 보일 수 있습니다.

중요한 구분:
  • WRITE_ONCE()는 컴파일러 최적화 억제와 tearing 방지에는 유용하지만, 이전 필드 초기화가 먼저 보인다는 뜻은 아닙니다.
  • smp_store_release()는 공개 이전의 모든 쓰기를 포인터 공개보다 앞에 배치하고, 읽는 쪽의 smp_load_acquire()는 포인터를 본 뒤의 읽기가 그 공개 이전으로 넘어가지 않게 막습니다.
  • 즉, atomic한 포인터 저장과 메모리 순서 보장은 별개입니다. publication 문제는 거의 항상 이 둘을 함께 봐야 합니다.

패턴 6 — 비트 잠금과 대기 큐 결합

#include <linux/bitops.h>
#include <linux/wait_bit.h>

#define MYOBJ_LOCKED 0

struct my_obj {
    unsigned long flags;
};

bool my_obj_try_lock(struct my_obj *obj)
{
    return !test_and_set_bit_lock(MYOBJ_LOCKED, &obj->flags);
}

int my_obj_lock(struct my_obj *obj)
{
    /* 비트가 비워질 때까지 sleep, 비워지면 다시 set까지 수행 */
    return wait_on_bit_lock(&obj->flags, MYOBJ_LOCKED, TASK_UNINTERRUPTIBLE);
}

void my_obj_unlock(struct my_obj *obj)
{
    clear_bit_unlock(MYOBJ_LOCKED, &obj->flags);
    wake_up_bit(&obj->flags, MYOBJ_LOCKED);
}

비트 잠금은 구조체 하나에 작은 상태 비트 집합이 있고, 그중 한 비트를 잠금처럼 쓰고 싶을 때 유용합니다. 페이지 플래그, 버퍼 헤드 상태, I/O 진행 상태처럼 "플래그와 잠금이 같은 워드에 있어야 캐시 친화적"인 상황에서 자주 보입니다.

상세 해설:
  • test_and_set_bit_lock()는 이미 누군가 비트를 잡고 있었는지 즉시 알려 주는 trylock 성격의 API입니다. 짧은 fast path에서는 이것만으로도 충분합니다.
  • 대기 가능한 경로에서는 wait_on_bit_lock()가 더 적합합니다. 이 함수는 비트가 풀릴 때까지 해시된 waitqueue에서 sleep하고, 깨어난 뒤 다시 비트를 set하는 동작까지 책임집니다.
  • 해제 시에는 clear_bit_unlock()만으로 끝내지 않고 wake_up_bit()까지 호출해야, 그 비트를 기다리던 태스크들이 실제로 깨어납니다. 많은 초보자 실수가 바로 이 깨우기 누락입니다.
  • 이 패턴은 "플래그 + 잠금" 통합에는 좋지만, 긴 크리티컬 섹션이나 공정성이 중요한 경로에는 맞지 않습니다. 그런 경우는 mutex나 spinlock이 더 적합합니다.

패턴 7 — atomic fast path + spinlock slow path

struct credit_pool {
    atomic_t credits;
    spinlock_t refill_lock;
    int refill_batch;
};

bool consume_credit(struct credit_pool *pool)
{
    if (likely(atomic_add_unless(&pool->credits, -1, 0)))
        return true;

    spin_lock(&pool->refill_lock);
    if (atomic_read(&pool->credits) == 0 && backend_can_refill(pool))
        atomic_add(pool->refill_batch, &pool->credits);
    spin_unlock(&pool->refill_lock);

    return atomic_add_unless(&pool->credits, -1, 0);
}

실무에서 가장 자주 보이는 형태는 "전부 lock-free"도 "전부 락"도 아닌 이 하이브리드 패턴입니다. 보통 경로는 atomic으로 끝내고, 고갈·예외·리필처럼 드문 경로만 락으로 직렬화합니다. 이렇게 하면 흔한 경로의 지연은 낮추면서도, 복잡한 상태 전환은 안정적으로 관리할 수 있습니다.

왜 이 조합이 좋은가:
  • 크레딧이 남아 있는 동안은 전역 락을 잡지 않으므로 대부분 호출이 저렴합니다.
  • 동시에 여러 CPU가 "고갈됨"을 감지하더라도, slow path의 refill_lock이 리필 절차를 한 번으로 직렬화합니다.
  • 이 패턴은 네트워크 TX budget, 배치 할당기, freelist refill, 메모리 풀 등에서 자주 응용됩니다.
  • 반대로 slow path가 자주 발생하면 atomic 재시도와 락 경쟁이 둘 다 늘어나므로, 결국 설계 자체를 다시 봐야 합니다. 즉, atomic 도입만으로 병목이 해결된다고 보면 안 됩니다.

패턴 8 — 단조 증가 시퀀스 번호 발급

static atomic_t issue_seq = ATOMIC_INIT(0);

u32 next_seq(void)
{
    return (u32)atomic_fetch_inc_relaxed(&issue_seq);
}

bool seq_before(u32 a, u32 b)
{
    return (s32)(a - b) < 0;
}

이 패턴은 락 없이 고유 번호를 발급해야 할 때 매우 흔합니다. trace 이벤트 ID, 요청 번호, 세대 번호, 배치 티켓, 버전 카운터처럼 "중복만 없어도 충분"한 값은 순서 보장이 아니라 원자적 증가만 필요하므로 _relaxed가 잘 맞습니다.

상세 해설:
  • atomic_fetch_inc_relaxed()는 증가 전 값을 돌려주므로, 발급된 시퀀스가 곧 고유 티켓이 됩니다. 새 값을 쓰고 싶으면 atomic_inc_return_relaxed() 계열을 고르면 됩니다.
  • 이런 번호는 언젠가 반드시 wraparound가 발생합니다. 그래서 단순한 a < b 비교가 아니라, 커널에서 자주 쓰는 signed 차이 비교 패턴으로 앞뒤를 판단합니다.
  • 중요한 점은 시퀀스 번호 그 자체가 데이터를 공개하지 않는다는 사실입니다. 번호를 올렸다는 이유만으로 관련 버퍼나 객체 필드가 다른 CPU에 보인다고 가정하면 안 됩니다. 번호와 데이터 공개는 별도 규칙으로 설계해야 합니다.

패턴 9 — atomic만으로는 부족한 다중 필드 불변식

/* BAD: head/tail/depth가 서로 독립적으로 보일 수 있음 */
struct bad_queue {
    atomic_t head;
    atomic_t tail;
    atomic_t depth;
    void *ring[256];
};

void enqueue_bad(struct bad_queue *q, void *item)
{
    int head = atomic_read(&q->head);
    q->ring[head] = item;
    atomic_set(&q->head, (head + 1) & 255);
    atomic_inc(&q->depth);
}

/* GOOD: 다중 필드를 하나의 임계 구역으로 묶음 */
struct good_queue {
    spinlock_t lock;
    unsigned int head;
    unsigned int tail;
    unsigned int depth;
    void *ring[256];
};

void enqueue_good(struct good_queue *q, void *item)
{
    spin_lock(&q->lock);
    q->ring[q->head] = item;
    q->head = (q->head + 1) & 255;
    q->depth++;
    spin_unlock(&q->lock);
}

원자 연산은 한 위치의 갱신을 안전하게 만들 뿐, 여러 필드 사이의 관계까지 자동으로 보존하지는 않습니다. 큐의 head, tail, depth, 그리고 실제 슬롯 데이터는 하나의 불변식을 이루므로, 각각을 atomic으로 나눠 두면 관측자는 중간 상태를 볼 수 있습니다. "원자적 필드 여러 개"와 "원자적 자료구조"는 전혀 다른 말입니다.

요구사항권장 도구이유atomic 단독 사용 위험
단일 카운터 증감atomic_t한 변수만 정확하면 충분낮음
조건부 단일 상태 전이cmpxchg비교와 갱신을 한 번에 수행중간
포인터 공개와 가시성release/acquire원자성보다 순서 보장이 핵심높음
여러 필드의 일관성spinlock / mutex / seqlock자료구조 전체를 한 단위로 보호매우 높음
실무 판단 기준: "이 값을 바꾸는 순간 다른 필드도 동시에 같은 세계선에 있어야 하는가?"라는 질문에 예라고 답하면, atomic 하나로 끝나지 않을 가능성이 높습니다. 그때는 락, seqlock, RCU, 혹은 자료구조 전용 API를 먼저 검토하는 편이 맞습니다.

패턴 10 — atomic 카운터와 디바이스 가시성은 별개

struct tx_desc {
    dma_addr_t addr;
    u32 len;
    u32 flags;
};

struct tx_ring {
    struct tx_desc *desc;
    atomic_t prod;
    void __iomem *doorbell;
};

void post_desc(struct tx_ring *ring, dma_addr_t dma, u32 len)
{
    int idx = atomic_fetch_inc_relaxed(&ring->prod) & (RING_SIZE - 1);

    ring->desc[idx].addr = dma;
    ring->desc[idx].len = len;

    /* payload 필드가 ownership 플래그보다 먼저 보이도록 보장 */
    dma_wmb();
    WRITE_ONCE(ring->desc[idx].flags, DESC_OWNED_BY_HW);

    /* writel()은 일관성 있는 메모리 쓰기 이후 MMIO 통지를 보장 */
    writel(idx, ring->doorbell);
}

이 예시는 atomic을 과대평가하면 어디서 무너지는지 잘 보여 줍니다. 생산자 인덱스를 atomic으로 올렸다고 해서 디바이스가 디스크립터 내용을 같은 순서로 본다는 보장은 없습니다. CPU 간 메모리 모델과 디바이스 DMA 가시성은 서로 다른 문제입니다.

핵심 경계:
  • atomic_fetch_inc_relaxed()는 인덱스 충돌만 막습니다. 디스크립터 필드와 MMIO doorbell 사이 순서는 보장하지 않습니다.
  • 디바이스가 DMA로 읽는 메모리는 dma_wmb() 같은 DMA 배리어로 정렬해야 합니다. 일반 smp_wmb()만으로 충분하지 않은 플랫폼이 있습니다.
  • writel_relaxed()는 이런 순서를 자동으로 보강하지 않으므로, 이런 ownership handoff 예시에서는 보통 writel()이 더 안전한 선택입니다.
  • 즉, atomic은 공유 정수의 동시 접근 문제를 풀고, DMA 배리어와 MMIO 순서는 디바이스 가시성 문제를 풉니다. 둘을 혼동하면 드라이버가 간헐적으로 망가집니다.

디버깅 CONFIG 옵션

Atomic 연산과 참조 카운팅의 버그를 탐지하는 데 유용한 커널 설정 옵션입니다:

CONFIG 옵션기능오버헤드권장 환경
CONFIG_REFCOUNT_FULL refcount_t 오버플로/언더플로 시 WARN_ONCE 발생. 기본값은 플랫폼별 최적화된 구현 사용 소폭 (카운터 증감 시 검사 추가) 개발/스테이징
CONFIG_KCSAN Kernel Concurrency Sanitizer — atomic 없는 동시 접근(data race) 탐지 높음 (접근마다 watchpoint 검사) 동시성 버그 탐지 시
CONFIG_DEBUG_ATOMIC_SLEEP atomic context(spinlock 보유 중 등)에서 sleep 시도 시 경고 출력 낮음 개발 환경 상시 권장
CONFIG_PROVE_LOCKING lockdep — atomic 연산과 잠금 순서 위반 탐지 중간 (잠금 그래프 추적) 개발/스테이징
CONFIG_KASAN Use-After-Free, out-of-bounds 탐지 (refcount_dec 후 접근 포함) 높음 (메모리 영역 표시) 메모리 버그 탐지 시
# 현재 커널의 atomic 관련 CONFIG 확인
grep -E "CONFIG_(REFCOUNT|KCSAN|DEBUG_ATOMIC|PROVE_LOCK)" /boot/config-$(uname -r)

# refcount_t 오버플로 버그 예시 — CONFIG_REFCOUNT_FULL로 탐지
# WARNING: refcount_t: underflow; use-after-free.
# Call Trace:
#   refcount_sub_and_test_checked+0x...
#   kobject_put+0x...
💡

개발 환경 권장 조합: CONFIG_DEBUG_ATOMIC_SLEEP=y + CONFIG_PROVE_LOCKING=y + CONFIG_REFCOUNT_FULL=y를 함께 활성화하면 대부분의 atomic 관련 버그를 조기에 탐지할 수 있습니다. 성능에 민감한 경우 CONFIG_KCSANCONFIG_KASAN은 선별적으로 사용하세요.

Linux Kernel Memory Model (LKMM)

LKMM은 커널 코드에서 메모리 접근의 순서와 가시성을 형식적으로 정의하는 모델입니다. C11 메모리 모델을 기반으로 하되, 커널 고유의 smp_store_release(), smp_load_acquire(), RCU 등을 포함합니다. LKMM의 핵심은 happens-before 관계와 이를 구성하는 여러 순서 관계입니다.

LKMM 순서 관계 (Ordering Relations) sequenced-before (sb) 같은 스레드 내 프로그램 순서 컴파일러 + CPU 모두 존중 reads-from (rf) 읽기가 특정 쓰기의 값을 관찰하는 관계 coherence-order (co) 같은 주소 쓰기의 전역 일관성 순서 from-reads (fr) 읽기 이후 해당 주소에 덮어쓰는 관계 happens-before (hb) sb ∪ release→acquire ∪ rcu-fence ∪ ... 의 전이적 폐포 커널 프리미티브 → LKMM 매핑 smp_store_release() → release 순서 (W→W, R→W) 이전 접근 완료 후 쓰기 smp_load_acquire() → acquire 순서 (R→R, R→W) 읽기 후에만 이후 접근 smp_mb() → full barrier (모든 순서) 이전↔이후 모든 접근 분리 synchronize_rcu() → rcu-fence 순서 grace period 경계 LKMM 핵심 규칙 1. 같은 주소 접근: coherence order(co)로 전역 순서 존재 | 서로 다른 주소: 명시적 순서 지정 필요 2. release→acquire 쌍은 happens-before 관계를 생성 | relaxed 연산은 순서 관계 없음 3. 데이터 레이스(data race) = 두 접근이 hb로 순서 없고, 최소 하나가 쓰기 → UNDEFINED BEHAVIOR 4. tools/memory-model/: herd7 기반 litmus 테스트로 형식 검증 가능 (klitmus7, herd7)

Litmus 테스트

LKMM은 tools/memory-model/ 디렉토리에 포함된 herd7 도구로 형식 검증합니다. Litmus 테스트는 작은 병렬 프로그램으로, 특정 실행 결과가 가능한지 검증합니다.

(* Message Passing (MP) litmus test *)
(* tools/memory-model/litmus-tests/MP+pooncerelease+poacquireonce.litmus *)
C MP+pooncerelease+poacquireonce

(* 초기값 *)
{}

(* Producer 스레드 *)
P0(int *x, int *y) {
  WRITE_ONCE(*x, 1);            /* 데이터 쓰기 */
  smp_store_release(y, 1);       /* 플래그 release */
}

(* Consumer 스레드 *)
P1(int *x, int *y) {
  int r0 = smp_load_acquire(y);  /* 플래그 acquire */
  int r1 = READ_ONCE(*x);        /* 데이터 읽기 */
}

(* 이 결과는 불가능해야 함: y=1 읽었는데 x=0 *)
exists (1:r0=1 /\ 1:r1=0)
# herd7로 litmus 테스트 실행
cd tools/memory-model
herd7 -conf linux-kernel.cfg litmus-tests/MP+pooncerelease+poacquireonce.litmus

# 결과: Never — release/acquire 쌍이 올바르게 순서 보장
# Test MP+pooncerelease+poacquireonce Allowed
# States 3
# 1:r0=0; 1:r1=0;
# 1:r0=0; 1:r1=1;
# 1:r0=1; 1:r1=1;
# No (forbidden state: 1:r0=1 /\ 1:r1=0)

# klitmus7: 실제 하드웨어에서 litmus 테스트 실행
klitmus7 -o /tmp/mp-test litmus-tests/MP+pooncerelease+poacquireonce.litmus
cd /tmp/mp-test && make && ./run.sh

LKMM 순서 보장 요약

프리미티브순서 유형보장 범위LKMM 관계
WRITE_ONCE()없음 (컴파일러만)단일 store 원자성, 컴파일러 최적화 방지없음 (data race 방지에 불충분)
READ_ONCE()없음 (컴파일러만)단일 load 원자성, 컴파일러 최적화 방지없음
smp_store_release()Release이전 모든 접근 → 이 storerelease 순서 → hb 생성
smp_load_acquire()Acquire이 load → 이후 모든 접근acquire 순서 → hb 생성
smp_mb()Full barrier이전 모든 접근 → 이후 모든 접근full fence → hb 생성
smp_wmb()Write barrier이전 쓰기 → 이후 쓰기cumul-fence (부분적 hb)
smp_rmb()Read barrier이전 읽기 → 이후 읽기cumul-fence (부분적 hb)
smp_mb__before_atomic()Full (atomic 전)이전 접근 → 다음 atomic RMWfull fence
smp_mb__after_atomic()Full (atomic 후)직전 atomic RMW → 이후 접근full fence
atomic_*_return()Fully ordered전후 모든 접근full fence (내장)
atomic_*_return_relaxed()Relaxed원자성만 (순서 없음)없음
xchg()Fully ordered전후 모든 접근full fence (내장)
xchg_relaxed()Relaxed원자성만없음
cmpxchg()Fully ordered (성공 시)전후 모든 접근full fence (성공 시)
cmpxchg_relaxed()Relaxed원자성만없음
rcu_read_lock()RCU read-sidegrace period 내 읽기 보호rcu-fence 관계
synchronize_rcu()RCU grace period이전 rcu_read_unlock() 완료 대기rcu-fence → hb 생성
ℹ️

LKMM 문서 위치: 커널 소스의 tools/memory-model/Documentation/에 상세 문서가 있습니다. 특히 explanation.txt는 전체 모델을 설명하고, recipes.txt는 일반적인 패턴별 올바른 동기화 방법을 안내합니다. litmus-tests/에는 70여 개의 검증 테스트가 포함되어 있습니다.

캐시 일관성과 원자 연산

원자 연산의 성능은 캐시 일관성 프로토콜에 직접 좌우됩니다. 현대 멀티코어 프로세서는 MESI(또는 MOESI/MESIF) 프로토콜로 캐시 일관성을 유지합니다. atomic 연산(특히 RMW)은 해당 캐시라인의 독점(Exclusive) 소유권을 요구하므로, 여러 CPU가 같은 변수를 경합하면 캐시라인 바운싱(cache line bouncing)이 발생합니다.

MESI 프로토콜 — 캐시라인 상태 전이 Modified (M) 로컬 수정, 메모리와 불일치 Exclusive (E) 독점 소유, 메모리와 일치 Shared (S) 여러 CPU 공유, 읽기만 가능 Invalid (I) 캐시에 없음 (무효) 다른 CPU 읽기 (Snoop) 로컬 쓰기 로컬 쓰기 (RFO) 읽기 (miss, 독점) 읽기 (miss, 공유) 다른 CPU 쓰기 다른 CPU 쓰기 다른 CPU 읽기 Atomic RMW: I/S → E → M 전이 필요 (Invalidate 메시지 전파) → 경합 시 캐시라인 바운싱 발생

캐시라인 바운싱과 False Sharing

여러 CPU가 동일 캐시라인의 변수에 atomic 연산을 수행하면 Invalidate 메시지가 핑퐁하며 성능이 급락합니다. 또한 관련 없는 변수가 같은 캐시라인에 있으면 false sharing이 발생합니다.

/* BAD: false sharing — 서로 다른 CPU가 다른 변수를 수정하지만 같은 캐시라인 */
struct bad_stats {
    atomic_t cpu0_counter;   /* 오프셋 0 */
    atomic_t cpu1_counter;   /* 오프셋 4 — 같은 64바이트 캐시라인! */
};

/* GOOD: 캐시라인 정렬로 false sharing 제거 */
struct good_stats {
    atomic_t cpu0_counter;
    u8       pad0[60];       /* 캐시라인 나머지 채움 */
    atomic_t cpu1_counter;    /* 다음 캐시라인 시작 */
} ____cacheline_aligned;

/* BEST: per-CPU 변수 사용 (커널이 자동 캐시라인 정렬) */
DEFINE_PER_CPU(long, my_counter);
this_cpu_inc(my_counter);  /* 캐시라인 바운싱 완전 제거 */

/* 커널의 ____cacheline_aligned_in_smp 매크로 */
struct my_data {
    atomic_t hot_counter ____cacheline_aligned_in_smp;
    /* SMP에서만 캐시라인 정렬, UP에서는 패딩 없음 */
};
상황캐시라인 상태성능 영향해결책
단일 CPU atomicM (Modified) 유지L1 캐시 히트, 최고 성능per-CPU / local_t
2-CPU 경합M↔I 핑퐁L1 미스 + Invalidate 왕복 (~40ns)____cacheline_aligned
다수 CPU 경합경합 CPU 수 비례 지연수백 ns + 버스 포화per-CPU 카운터 + 합산
False sharing불필요한 Invalidate관련 없는 변수 성능 저하패딩 / 구조체 분리
NUMA 교차 접근원격 노드 캐시라인 전송L3 미스 + QPI/UPI (~100ns+)NUMA-aware 배치
⚠️

perf로 캐시라인 바운싱 탐지:

perf c2c record -a -- sleep 5        # 캐시라인 공유 프로파일링
perf c2c report --stdio               # HITM(HITs Modified) 분석
perf stat -e cache-misses,bus-cycles   # 캐시 미스 + 버스 사이클

perf c2cHITM(modified 상태 캐시라인 히트)이 높으면 경합이 심한 것입니다.

아키텍처별 원자 명령어 상세

각 아키텍처는 원자 연산을 위해 근본적으로 다른 하드웨어 메커니즘을 사용합니다. 이 차이는 성능 특성과 스케일링 동작에 직접 영향을 미칩니다.

아키텍처별 Atomic 구현 비교 x86/x86-64 LOCK 접두사 방식 LOCK ADDL %1, (%0) • 캐시 락 (L1 캐시라인 독점) • TSO: acquire/release 무비용 • 1 instruction = 원자적 완료 • LOCK CMPXCHG: CAS 연산 장점: 단순, 실패 없음 ARM64 (AArch64) LL/SC + LSE 방식 LDXR/STXR (LL/SC) 또는 LDADD (LSE) • LL/SC: LDXR로 읽고 STXR로 쓰기 • STXR 실패 시 재시도 루프 • LSE (v8.1+): LDADD 하드웨어 atomic • DMB/DSB: 명시적 배리어 필요 장점: LSE로 고경합 스케일링 우수 RISC-V AMO + LR/SC 방식 AMOADD.W 또는 LR.W/SC.W • AMO: 하드웨어 atomic (add,and,or 등) • LR/SC: LL/SC 변형 (복잡한 CAS) • .aq/.rl 접미사: acquire/release • .aqrl: fully ordered 장점: ISA 설계 시 최신 모델 반영 atomic_add 구현 비교 x86-64 ARM64 (LL/SC) RISC-V (AMO) lock addl %esi, (%rdi) → 1 instruction → 캐시 라인 락 → 실패 없음 (항상 성공) 비용: ~20-40 사이클 1: ldxr w0, [x1] add w0, w0, w2 stxr w3, w0, [x1] cbnz w3, 1b 비용: ~10-60 사이클 (경합 비례) amoadd.w a0, a1, (a2) → 1 instruction → 하드웨어 원자적 수행 → .aq/.rl로 순서 지정 비용: 구현체 의존

아키텍처별 배리어 매핑

커널 APIx86-64ARM64RISC-V
smp_mb()lock addl $0, (%rsp)DMB ISHfence rw,rw
smp_rmb()barrier() (nop)DMB ISHLDfence r,r
smp_wmb()barrier() (nop)DMB ISHSTfence w,w
smp_store_release()MOV (TSO 자동)STLRfence rw,w; sw
smp_load_acquire()MOV (TSO 자동)LDARlw; fence r,rw
atomic_add()LOCK ADDLDXR/STXR 또는 STADD (LSE)AMOADD.W
atomic_add_return()LOCK XADDLDAXR/STLXR 또는 LDADD (LSE)AMOADD.W.aqrl
cmpxchg()LOCK CMPXCHGLDAXR+CMP+STLXR 또는 CAS (LSE)LR.W.aq/SC.W.rl
xchg()XCHG (암묵적 LOCK)LDAXR/STLXR 또는 SWP (LSE)AMOSWAP.W.aqrl

x86 LOCK 접두사 상세

; x86 LOCK 접두사 동작 원리
; 1. 캐시 락 (Cache Lock) — 일반적인 경우
;    대상 주소의 캐시라인을 Exclusive/Modified 상태로 확보
;    다른 코어의 해당 캐시라인 무효화
;    RMW 연산 동안 캐시라인 독점 유지
;    → L1 캐시 내에서 완료, 메모리 버스 차단 불필요

; 2. 버스 락 (Bus Lock) — 드문 경우
;    대상 주소가 캐시라인 경계를 걸칠 때 (misaligned)
;    또는 uncacheable 메모리 영역일 때
;    → #LOCK 신호로 메모리 버스 전체 차단 (매우 느림)

; 예시: atomic_add_return 구현
lock xadd %eax, (%rdi)    ; EAX ↔ [RDI] 원자적 교환 + 덧셈
                           ; 반환값: 교환 전 원래 값

; 예시: cmpxchg (CAS)
lock cmpxchg %ecx, (%rdi)  ; if ([RDI]==EAX) [RDI]=ECX, ZF=1
                            ; else EAX=[RDI], ZF=0

; x86 TSO (Total Store Order) 메모리 모델:
; - Store→Load 재배열만 가능 (다른 재배열 불가)
; - 따라서 smp_rmb(), smp_wmb()는 컴파일러 배리어만 필요
; - smp_mb()만 하드웨어 명령어 필요 (lock addl $0, (%rsp))

ARM64 LL/SC vs LSE

// ARM64 LL/SC (Load-Linked / Store-Conditional)
// ARMv8.0 기본 방식
// atomic_add_return 구현:
1:  ldxr    w0, [x1]       // Load-Exclusive: 값 읽기 + 모니터 설정
    add     w0, w0, w2     // 연산
    stxr    w3, w0, [x1]   // Store-Exclusive: 모니터 유효 시 쓰기
    cbnz    w3, 1b         // 실패(w3≠0) 시 재시도

// LL/SC 실패 원인:
// 1. 다른 코어가 같은 주소 쓰기 → exclusive 모니터 클리어
// 2. 인터럽트/예외 발생 → 컨텍스트 전환 시 모니터 클리어
// 3. 같은 캐시라인의 다른 주소 쓰기 (false exclusive)

// ARM64 LSE (Large System Extensions) — ARMv8.1+
// 하드웨어 atomic 명령어 (LL/SC 루프 불필요)
    ldadd   w2, w0, [x1]   // [x1] += w2, 이전값→w0 (relaxed)
    ldadda  w2, w0, [x1]   // acquire
    ldaddl  w2, w0, [x1]   // release
    ldaddal w2, w0, [x1]   // acquire + release (fully ordered)
    stadd   w2, [x1]       // [x1] += w2 (반환값 불필요 시, relaxed)

// LSE 장점: 고경합 환경에서 LL/SC 대비 2-5배 성능 향상
// 커널: CONFIG_ARM64_LSE_ATOMICS로 런타임 선택
//   부팅 시 CPU feature 감지 → static key로 LSE/LL/SC 분기

// ARM64 CAS 명령어 (LSE)
    cas     w0, w1, [x2]   // if ([x2]==w0) [x2]=w1 else w0=[x2]
    casa    w0, w1, [x2]   // acquire CAS
    casal   w0, w1, [x2]   // acquire+release CAS

// SWP (Swap) 명령어
    swp     w0, w1, [x2]   // w1=[x2], [x2]=w0 (atomic swap)

RISC-V AMO 명령어

# RISC-V Atomic Memory Operations (AMO)
# A 확장 (RV32A/RV64A)

# AMO 명령어: amoadd, amoswap, amoand, amoor, amoxor, amomin, amomax
amoadd.w  a0, a1, (a2)    # [a2] += a1, 이전값→a0 (relaxed)
amoadd.w.aq a0, a1, (a2)  # acquire
amoadd.w.rl a0, a1, (a2)  # release
amoadd.w.aqrl a0, a1, (a2) # fully ordered

# LR/SC (Load-Reserved / Store-Conditional) — 복잡한 CAS용
1:  lr.w    a0, (a2)       # 주소 예약 + 값 읽기
    bne     a0, a3, fail   # 기댓값과 비교
    sc.w    a1, a4, (a2)   # 예약 유효 시 쓰기 (a1=0:성공, 1:실패)
    bnez    a1, 1b         # 실패 시 재시도

# RISC-V 메모리 순서 접미사:
# (없음) = relaxed
# .aq    = acquire (이후 접근이 앞으로 이동 불가)
# .rl    = release (이전 접근이 뒤로 이동 불가)
# .aqrl  = sequentially consistent

# 명시적 fence 명령어
fence rw, rw               # smp_mb() — 전체 배리어
fence r, r                 # smp_rmb() — 읽기 배리어
fence w, w                 # smp_wmb() — 쓰기 배리어
fence.tso                  # TSO 호환 배리어 (Ztso 확장)
💡

아키텍처 선택과 성능:

  • x86: TSO 모델 덕분에 acquire/release가 무비용. 그러나 LOCK 접두사는 항상 full barrier 수준의 비용 발생
  • ARM64: LSE 지원 시 하드웨어 atomic이 LL/SC 대비 대폭 개선. CONFIG_ARM64_LSE_ATOMICS=y 확인
  • RISC-V: AMO가 가장 세밀한 순서 제어 가능. .aq/.rl 접미사로 필요한 만큼만 순서 부여
  • 커널은 arch/*/include/asm/atomic.h에서 아키텍처별 최적 구현 선택

시퀀스 잠금 (seqlock / seqcount)

Seqlock은 읽기가 압도적으로 많고 쓰기가 드문 경우에 최적화된 동기화 메커니즘입니다. 내부적으로 atomic_t 시퀀스 카운터를 사용하며, 읽기 측은 잠금을 획득하지 않고 시퀀스 번호 변화를 감지하여 일관성을 확인합니다.

seqlock 읽기/쓰기 프로토콜 Writer (쓰기 측) write_seqlock(&seq) → seq++ (홀수 = 쓰기 중) 데이터 수정 (data = new_value) write_sequnlock(&seq) → seq++ (짝수 = 쓰기 완료) seq: 0→1 (쓰기 중) → 2 (완료) 홀수 = 쓰기 진행 중, 짝수 = 안전한 상태 쓰기 측은 spinlock으로 직렬화 (다수 writer 보호) seqcount_t는 외부 잠금 사용, seqlock_t는 내장 spinlock Reader (읽기 측) seq1 = read_seqbegin(&seq) 데이터 읽기 (val = data) — 잠금 없이! read_seqretry(&seq, seq1)? seq 변경 또는 홀수? Yes No 완료 읽기 측은 잠금 없음 → 쓰기와 동시 실행 가능 일관성 위반 감지 시 재시도 (낙관적 읽기)
/* seqlock_t: 내장 spinlock + seqcount */
seqlock_t my_seqlock;
seqlock_init(&my_seqlock);

/* Writer — spinlock 보호 하에 쓰기 */
write_seqlock(&my_seqlock);       /* spin_lock + seq++ */
data.x = new_x;
data.y = new_y;
write_sequnlock(&my_seqlock);     /* seq++ + spin_unlock */

/* Reader — 잠금 없이 낙관적 읽기 */
unsigned int seq;
int x, y;
do {
    seq = read_seqbegin(&my_seqlock);
    x = data.x;          /* 쓰기 중이면 불일치 가능 */
    y = data.y;
} while (read_seqretry(&my_seqlock, seq));  /* seq 변경 시 재시도 */

/* seqcount_t: 외부 잠금 사용 시 (spinlock/mutex 등) */
seqcount_spinlock_t sc;
spinlock_t lock;

seqcount_spinlock_init(&sc, &lock);

/* Writer */
spin_lock(&lock);
write_seqcount_begin(&sc);
data.field = new_value;
write_seqcount_end(&sc);
spin_unlock(&lock);

/* 실전 사용 예: 커널의 jiffies / xtime 읽기 */
/* kernel/time/timekeeping.c — tk_core.seq */
do {
    seq = read_seqcount_begin(&tk_core.seq);
    ns = timekeeping_get_ns(&tk->tkr_mono);
} while (read_seqcount_retry(&tk_core.seq, seq));
특성seqlockrwlockRCU
읽기 비용낮음 (잠금 없음)중간 (락 획득)매우 낮음 (rcu_read_lock)
쓰기 비용중간 (spinlock)높음 (exclusive lock)높음 (grace period)
읽기 중 쓰기허용 (재시도)차단허용 (이전 데이터 읽기)
starvation읽기 starvation 가능쓰기 starvation 가능없음
포인터 보호불가 (읽기 중 해제 위험)가능가능 (grace period)
적합한 경우타임스탬프, 좌표 등 값 타입일반 읽기/쓰기포인터 기반 자료구조
⚠️

seqlock 사용 시 주의: 읽기 측에서 포인터를 역참조하면 안 됩니다. 쓰기 중에 포인터 대상이 해제될 수 있어 use-after-free가 발생합니다. 포인터를 보호하려면 RCU를 사용하세요. seqlock은 값 타입(정수, 좌표, 타임스탬프 등)에만 사용합니다.

스핀락 내부 구현과 원자 연산

스핀락(spinlock)은 atomic 연산을 기반으로 구현됩니다. 리눅스 커널은 ticket lock → MCS lock → qspinlock 순으로 발전했으며, 현재 기본 구현은 qspinlock입니다.

스핀락 발전 과정과 qspinlock 구조 Test-and-Set xchg(&lock, 1) ❌ 불공정 (starvation) ❌ 캐시라인 바운싱 ❌ NUMA 확장 불가 Ticket Lock next/owner 카운터 쌍 ✓ FIFO 공정성 ❌ 모든 CPU가 같은 라인 스핀 ❌ 해제 시 N개 캐시 무효화 MCS Lock per-CPU 큐 노드 ✓ 로컬 변수에서 스핀 ✓ NUMA 확장 가능 ❌ 구조체 크기 큼 (포인터) qspinlock 4바이트 + MCS 큐 ✓ 공정 + 로컬 스핀 ✓ 4바이트로 compactT ✓ 현재 커널 기본 qspinlock 32비트 레이아웃 (struct qspinlock) locked bit[0:7] pending bit[8] tail (MCS 큐 인코딩: cpu+idx) bit[9:31] — 대기 큐 꼬리 CPU 번호 + 중첩 인덱스 qspinlock 획득 경로 (3단계 fast→pending→slow) Fast Path lock == 0 (비어있음) atomic_cmpxchg(&lock, 0, 1) → 1 atomic 연산으로 획득 대부분의 경우 이 경로 (경합 없는 일반 상황) Pending Path locked=1, pending=0, tail=0 pending 비트 설정 후 locked 스핀 → 두 번째 대기자 최적화 MCS 큐 없이 직접 스핀 (2-CPU 경합 최적화) Slow Path (MCS 큐) 3개 이상 대기자 per-CPU MCS 노드에서 로컬 스핀 → 큐 기반 FIFO 대기 캐시라인 바운싱 최소화 (고경합 NUMA 확장)
/* qspinlock 핵심 구조 (include/asm-generic/qspinlock_types.h) */
typedef struct qspinlock {
    union {
        atomic_t val;     /* 전체 32비트를 atomic으로 접근 */
        struct {
            u8 locked;    /* bit[0:7]  — 잠금 상태 (0 or 1) */
            u8 pending;   /* bit[8]    — 두 번째 대기자 */
        };
        struct {
            u16 locked_pending;
            u16 tail;     /* bit[16:31] — MCS 큐 꼬리 */
        };
    };
} arch_spinlock_t;

/* Fast path: lock이 0이면 바로 획득 */
static inline void queued_spin_lock(struct qspinlock *lock)
{
    int val = 0;
    /* try_cmpxchg: val==0이면 _Q_LOCKED_VAL로 설정 */
    if (likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL)))
        return;
    /* 실패 시 slow path */
    queued_spin_lock_slowpath(lock, val);
}

/* Unlock: locked 바이트만 0으로 설정 */
static inline void queued_spin_unlock(struct qspinlock *lock)
{
    smp_store_release(&lock->locked, 0);
}

/* MCS 큐 노드 (per-CPU) */
struct qnode {
    struct mcs_spinlock mcs;
};
static DEFINE_PER_CPU_ALIGNED(struct qnode, qnodes[4]);
/* 4개: 일반/softirq/hardirq/NMI 각각 중첩 가능 */

Lock-Free 데이터 구조

커널은 atomic 연산을 활용한 여러 lock-free 자료구조를 제공합니다. 잠금 없이 여러 CPU가 동시에 접근할 수 있어 성능이 뛰어납니다.

커널 Lock-Free 자료구조 llist (Lock-Free Stack) MPSC: 다수 생산자, 단일 소비자 Node C Node B A llist_add(): cmpxchg로 head 교체 llist_del_all(): xchg로 전체 획득 IRQ↔process 통신에 적합 kfifo (Lock-Free Ring Buffer) SPSC: 단일 생산자, 단일 소비자 in→ ←out data[] in/out 카운터 자연 오버플로 smp_wmb/smp_rmb로 순서 보장 ptr_ring (포인터 링) MPSC: 포인터 전용 링 버퍼 NULL 슬롯 = 비어있음 xchg로 생산, consumer_head로 소비 page_pool, skb_array에서 사용 네트워크 패킷 처리 고속 경로 XArray (Radix Tree) RCU + atomic 기반 인덱스 배열 읽기: RCU 보호 (잠금 없음) 쓰기: xa_lock + RCU publish page cache의 핵심 자료구조 xa_store/xa_load/xa_erase 태그(marks) 기반 검색 지원 percpu_ref 하이브리드 참조 카운터 활성 모드: per-CPU (빠름) 비활성 모드: atomic_long_t percpu_ref_kill()로 전환 block I/O, cgroup에서 사용 hot path에서 잠금 완전 제거 atomic_t 플래그 패턴 원자적 상태 머신 atomic_cmpxchg로 상태 전이 정확히 한 스레드만 전이 성공 워크큐 일회 실행 보장 패턴 schedule_work + atomic flag 디바이스 드라이버 상태 관리
/* ===== llist: Lock-Free 스택 (MPSC) ===== */
#include <linux/llist.h>

struct work_item {
    struct llist_node node;
    int data;
};

LLIST_HEAD(work_queue);

/* 생산자 (여러 CPU에서 동시 호출 가능) */
void enqueue_work(struct work_item *item)
{
    /* 내부: cmpxchg로 head를 원자적 교체 */
    llist_add(&item->node, &work_queue);
}

/* 소비자 (단일 CPU/스레드) */
void process_all_work(void)
{
    struct llist_node *list;
    struct work_item *item;

    /* 전체 리스트를 원자적으로 빼옴 (xchg) */
    list = llist_del_all(&work_queue);

    /* 역순 정렬 필요 시 */
    list = llist_reverse_order(list);

    llist_for_each_entry(item, list, node) {
        handle(item->data);
        kfree(item);
    }
}

/* ===== kfifo: Lock-Free 링 버퍼 (SPSC) ===== */
#include <linux/kfifo.h>

/* 정적 선언 (크기는 2의 거듭제곱) */
DEFINE_KFIFO(my_fifo, int, 1024);

/* 생산자 */
int val = 42;
kfifo_put(&my_fifo, val);              /* 단일 요소 삽입 */
kfifo_in(&my_fifo, buf, count);        /* 다수 요소 삽입 */

/* 소비자 */
int out;
kfifo_get(&my_fifo, &out);             /* 단일 요소 추출 */
kfifo_out(&my_fifo, buf, count);       /* 다수 요소 추출 */

/* kfifo 내부: in/out 카운터 + smp_wmb/smp_rmb */
/* 크기가 2^n이면 마스크 연산으로 인덱스 계산 → 매우 효율적 */
/* 카운터 오버플로는 자연 순환 → unsigned 산술 활용 */

/* ===== percpu_ref: 하이브리드 참조 카운터 ===== */
#include <linux/percpu-refcount.h>

struct my_subsys {
    struct percpu_ref ref;
};

/* 초기화: 활성 모드 (per-CPU fast path) */
percpu_ref_init(&subsys->ref, my_release, 0, GFP_KERNEL);

/* Hot path: per-CPU 카운터 증감 (매우 빠름, 잠금 없음) */
percpu_ref_get(&subsys->ref);
/* ... 사용 ... */
percpu_ref_put(&subsys->ref);

/* 종료 시: atomic 모드로 전환 후 0 도달 대기 */
percpu_ref_kill(&subsys->ref);
/* → per-CPU 합산 → atomic_long_t로 전환 */
/* → 모든 참조 해제 시 my_release() 콜백 */

원자 연산 성능 분석

원자 연산의 실제 비용은 캐시 상태, 경합 수준, 아키텍처에 따라 크게 다릅니다. 아래는 대표적인 지연 시간(latency) 비교입니다.

연산캐시 히트 (비경합)2-CPU 경합8-CPU 경합NUMA 교차
일반 변수 읽기/쓰기~1 nsN/AN/AN/A
READ_ONCE()~1 nsN/AN/AN/A
atomic_read()~1 ns~1 ns~1 ns~1 ns
atomic_inc() (x86 LOCK ADD)~20 ns~40 ns~120 ns~200 ns
atomic_inc_relaxed() (ARM64)~10 ns~30 ns~100 ns~180 ns
cmpxchg()~20 ns~50 ns (재시도 포함)~200 ns~300 ns
smp_mb()~15 ns (x86)---
smp_mb()~30 ns (ARM64)---
this_cpu_inc()~3 ns~3 ns~3 ns~3 ns
local_inc()~5 ns~5 ns~5 ns~5 ns
spin_lock() (비경합)~25 ns~80 ns~500 ns~1000 ns
mutex_lock() (비경합)~30 ns~100 ns (sleep)~100 ns~200 ns
ℹ️

측정 참고사항: 위 수치는 대략적인 참고값으로, 실제 성능은 CPU 모델, 캐시 계층, NUMA 토폴로지, 컴파일러 최적화에 따라 달라집니다. 정확한 성능은 perfkbench로 대상 환경에서 직접 측정해야 합니다.

/* 성능 최적화 원칙 */

/* 원칙 1: 가장 가벼운 동기화 선택 */
this_cpu_inc(counter);                     /* 최고 (~3ns) */
local_inc(&counter);                       /* 좋음 (~5ns) */
atomic_add_return_relaxed(1, &counter);   /* 보통 (~10-20ns) */
atomic_inc_return(&counter);              /* 무거움 (~20-40ns) */
spin_lock(&lock);                          /* 가장 무거움 (~25ns+) */

/* 원칙 2: 경합 줄이기 (가장 큰 성능 영향) */
/* BAD: 전역 카운터 — 모든 CPU 경합 */
atomic_t global_count;
atomic_inc(&global_count);  /* 8 CPU 경합 시 ~120ns */

/* GOOD: per-CPU 카운터 — 경합 0 */
DEFINE_PER_CPU(long, per_cpu_count);
this_cpu_inc(per_cpu_count);  /* 항상 ~3ns */

/* 원칙 3: 읽기 경로 최적화 (읽기 >> 쓰기인 경우) */
/* RCU 읽기: 사실상 무비용 */
rcu_read_lock();
ptr = rcu_dereference(global_ptr);
rcu_read_unlock();

/* 원칙 4: 불필요한 배리어 제거 */
/* x86에서 smp_rmb/smp_wmb는 컴파일러 배리어만 (비용 0) */
/* ARM64에서는 실제 DMB 명령어 (비용 ~30ns) */
/* → relaxed 연산 + 필요한 곳만 acquire/release */
동기화 메커니즘 성능 스펙트럼 (비경합 기준) 빠름 (1-5ns) 보통 (10-40ns) 느림 (50ns+) this_cpu_inc ~3ns local_inc ~5ns atomic_relaxed ~10ns smp_mb ~15-30ns atomic_inc ~20ns spin_lock ~25ns cmpxchg ~20-50ns mutex ~30ns+ 경합(contention)의 성능 영향 경합에 영향 받지 않음 this_cpu_inc, local_inc, READ_ONCE rcu_read_lock, barrier() → CPU 수에 관계없이 일정한 성능 per-CPU 변수는 캐시라인 독점 → 바운싱 없음 경합에 비례하여 느려짐 atomic_inc, cmpxchg, spin_lock xchg, atomic_add_return → CPU 수 증가 시 선형~초선형 저하 NUMA 경계 넘으면 추가 100ns+ 지연

제어 의존성 (Control Dependencies)

제어 의존성은 조건문의 결과가 이후 메모리 접근의 순서를 보장하는 LKMM의 중요한 개념입니다. 그러나 컴파일러 최적화에 의해 쉽게 깨질 수 있어 세심한 주의가 필요합니다.

/* 제어 의존성: READ_ONCE → 조건 분기 → WRITE_ONCE */
/* 읽기 값에 따른 조건부 쓰기는 순서가 보장됨 (읽기→쓰기) */

/* CORRECT: 제어 의존성으로 읽기→쓰기 순서 보장 */
q = READ_ONCE(a);
if (q) {
    WRITE_ONCE(b, 1);  /* a 읽기 후에만 실행 보장 */
}

/* WRONG: 제어 의존성은 읽기→읽기를 보장하지 않음! */
q = READ_ONCE(a);
if (q) {
    p = READ_ONCE(b);  /* CPU가 투기적 실행(speculation)으로 먼저 읽을 수 있음 */
}
/* → 읽기→읽기 순서가 필요하면 smp_rmb() 또는 smp_load_acquire() 사용 */

/* WRONG: 컴파일러가 조건을 제거할 수 있음 */
q = READ_ONCE(a);
if (q) {
    WRITE_ONCE(b, 1);
} else {
    WRITE_ONCE(b, 1);  /* 양쪽 동일 → 컴파일러가 조건 제거 → 의존성 소멸 */
}

/* WRONG: barrier()로는 제어 의존성을 복구할 수 없음 */
q = READ_ONCE(a);
if (q) {
    barrier();        /* 컴파일러 배리어는 CPU 재배열 방지 불가 */
    p = READ_ONCE(b);  /* 여전히 투기적 읽기 가능 */
}

/* CORRECT: 읽기→읽기 순서가 필요할 때 */
q = READ_ONCE(a);
if (q) {
    smp_rmb();        /* 명시적 읽기 배리어 */
    p = READ_ONCE(b);  /* a 읽기 후 순서 보장 */
}
제어 의존성 유형보장 여부이유대안
READ_ONCE → if → WRITE_ONCE보장됨CPU가 쓰기를 투기적으로 수행하지 않음-
READ_ONCE → if → READ_ONCE보장 안 됨CPU가 읽기를 투기적으로 수행smp_rmb() 추가
READ_ONCE → if/else 동일 쓰기보장 안 됨컴파일러가 조건 제거서로 다른 값 쓰기
volatile 없는 읽기 → if보장 안 됨컴파일러가 읽기 최적화READ_ONCE() 사용
READ_ONCE → 연산 → WRITE_ONCE (조건 없음)데이터 의존성데이터 의존성은 별도 규칙아키텍처별 확인
💡

제어 의존성 규칙 요약:

  • 제어 의존성은 읽기→쓰기만 보장 (읽기→읽기는 보장 안 됨)
  • 반드시 READ_ONCE()/WRITE_ONCE() 사용 (컴파일러 최적화 방지)
  • ifelse 양쪽에 동일한 쓰기를 넣지 마세요 (조건 제거됨)
  • 확실하지 않으면 smp_rmb() 또는 smp_load_acquire()를 사용하세요
  • 커널 문서: Documentation/memory-barriers.txt "CONTROL DEPENDENCIES" 섹션 참고

커널 서브시스템별 원자 연산 활용

커널의 주요 서브시스템은 각기 다른 방식으로 원자 연산을 활용합니다. 사용 패턴을 이해하면 올바른 동기화 설계에 도움이 됩니다.

페이지 캐시 (Page Cache)

/* mm/filemap.c — 페이지 캐시의 원자 연산 사용 */

/* 1. struct page의 참조 카운터 */
/* page->_refcount: refcount_t (page_ref_inc/dec) */
page_ref_inc(page);           /* 참조 획득 */
put_page(page);               /* refcount_dec_and_test → 0이면 free */

/* 2. 페이지 플래그: 비트 원자 연산 */
SetPageDirty(page);           /* set_bit(PG_dirty, &page->flags) */
ClearPageLocked(page);        /* clear_bit(PG_locked, ...) + wake_up */
if (TestSetPageLocked(page))  /* test_and_set_bit → 이미 잠김 */
    __lock_page(page);         /* 대기 큐에서 sleep */

/* 3. XArray (radix tree): RCU + xa_lock */
/* 읽기: RCU 보호 (잠금 없음) */
rcu_read_lock();
page = xa_load(&mapping->i_pages, index);
rcu_read_unlock();

/* 쓰기: xa_lock + atomic 플래그 */
xa_lock_irq(&mapping->i_pages);
__xa_store(&mapping->i_pages, index, page, GFP_NOFS);
xa_unlock_irq(&mapping->i_pages);

스케줄러 (Scheduler)

/* kernel/sched/ — 스케줄러의 원자 연산 사용 */

/* 1. 런큐 잠금: raw_spinlock_t (qspinlock 기반) */
raw_spin_lock(&rq->__lock);

/* 2. task_struct 상태 변경: atomic 플래그 */
/* task->__state: WRITE_ONCE/READ_ONCE로 접근 */
WRITE_ONCE(current->__state, TASK_INTERRUPTIBLE);
smp_store_release(&t->on_cpu, 0);  /* 컨텍스트 스위치 완료 알림 */

/* 3. 부하 분산: per-CPU 부하 통계 */
/* 각 CPU의 런큐 부하를 per-CPU 변수로 추적 */
/* 부하 분산 시에만 다른 CPU 통계 합산 */
this_cpu_add(rq->avg.load_sum, delta);

/* 4. 웨이크업: atomic test-and-set */
/* try_to_wake_up에서 task->on_cpu 확인 */
while (READ_ONCE(p->on_cpu))
    cpu_relax();  /* 이전 CPU에서 컨텍스트 스위치 완료 대기 */

네트워크 스택

/* net/ — 네트워크 스택의 원자 연산 사용 */

/* 1. sk_buff 참조 카운터 */
skb_get(skb);                 /* refcount_inc(&skb->users) */
consume_skb(skb);             /* refcount_dec_and_test → kfree_skb */

/* 2. 소켓 참조 카운터 */
sock_hold(sk);               /* refcount_inc(&sk->sk_refcnt) */
sock_put(sk);                /* refcount_dec_and_test → sk_free */

/* 3. 네트워크 통계: per-CPU 카운터 */
/* SNMP 카운터 — 매 패킷마다 증가하므로 per-CPU 필수 */
DEFINE_SNMP_STAT(struct tcp_mib, tcp_statistics);
TCP_INC_STATS(net, TCP_MIB_ACTIVEOPENS);
/* → __this_cpu_inc(tcp_statistics->mibs[TCP_MIB_ACTIVEOPENS]) */

/* 4. conntrack: RCU + refcount */
rcu_read_lock();
ct = nf_ct_get(skb, &ctinfo);
if (ct && !refcount_inc_not_zero(&ct->ct_general.use))
    ct = NULL;
rcu_read_unlock();

/* 5. NAPI: atomic 비트 플래그로 스케줄링 */
if (test_and_set_bit(NAPI_STATE_SCHED, &napi->state))
    return;  /* 이미 스케줄됨 */

디바이스 드라이버 공통 패턴

/* 드라이버에서 자주 사용하는 atomic 패턴 */

/* 패턴 A: 일회성 초기화 (once flag) */
static atomic_t init_done = ATOMIC_INIT(0);
if (atomic_cmpxchg(&init_done, 0, 1) == 0) {
    /* 정확히 한 스레드만 여기 진입 */
    do_init();
}

/* 패턴 B: 오픈 카운트 제한 */
static atomic_t open_count = ATOMIC_INIT(0);
static int my_open(struct inode *i, struct file *f)
{
    if (!atomic_add_unless(&open_count, 1, MAX_OPENS))
        return -EBUSY;
    return 0;
}
static int my_release(struct inode *i, struct file *f)
{
    atomic_dec(&open_count);
    return 0;
}

/* 패턴 C: 상태 머신 (atomic CAS) */
enum { STATE_IDLE, STATE_RUNNING, STATE_STOPPING };
static atomic_t dev_state = ATOMIC_INIT(STATE_IDLE);

int start_device(void)
{
    if (atomic_cmpxchg(&dev_state, STATE_IDLE, STATE_RUNNING) != STATE_IDLE)
        return -EBUSY;  /* 이미 실행 중 */
    /* 디바이스 시작 */
    return 0;
}

void stop_device(void)
{
    if (atomic_cmpxchg(&dev_state, STATE_RUNNING, STATE_STOPPING) != STATE_RUNNING)
        return;
    /* 디바이스 정지 처리 */
    atomic_set(&dev_state, STATE_IDLE);
}

/* 패턴 D: 지연된 작업 일회 스케줄링 */
static atomic_t work_pending = ATOMIC_INIT(0);

void trigger_work(void)
{
    if (atomic_cmpxchg(&work_pending, 0, 1) == 0)
        schedule_work(&my_work);
}

void my_work_func(struct work_struct *work)
{
    atomic_set(&work_pending, 0);
    smp_mb__after_atomic();  /* work_pending=0 가시성 보장 */
    /* 실제 작업 수행 */
}

흔한 실수와 디버깅 가이드

atomic 연산 관련 버그는 재현이 어렵고 디버깅이 까다롭습니다. 아래는 커널 개발에서 자주 발생하는 실수와 해결 방법입니다.

실수문제해결
atomic_t를 참조 카운팅에 사용 0→1 전환 방지 없음 → UAF 취약 refcount_t 사용
atomic_read() 후 조건부 atomic_inc() TOCTOU(Time-Of-Check Time-Of-Use) — 읽기와 증가 사이 경합 atomic_add_unless() 또는 cmpxchg 루프
atomic_inc()로 통계 카운터 캐시라인 바운싱으로 성능 저하 this_cpu_inc() 또는 per-CPU 변수
naked counter++ (non-atomic) 데이터 레이스 → KCSAN 경고 atomic_inc() 또는 WRITE_ONCE()
relaxed 연산 후 데이터 의존 다른 CPU에서 데이터 미가시 _acquire/_release 변형 사용
if/else 양쪽 동일 WRITE_ONCE 제어 의존성 소멸 서로 다른 값 쓰기 또는 smp_mb()
atomic_set() → 초기화 후 공개 다른 CPU가 부분 초기화 관찰 smp_store_release()로 공개
seqlock으로 포인터 보호 읽기 중 대상 해제 → UAF RCU 사용 (포인터에는 seqlock 부적합)
smp_mb() 과도 사용 불필요한 성능 저하 acquire/release로 최소한의 순서만 지정
spinlock 내에서 sleep BUG: scheduling while atomic CONFIG_DEBUG_ATOMIC_SLEEP으로 탐지
# KCSAN으로 데이터 레이스 탐지
# .config에 CONFIG_KCSAN=y 설정 후 빌드
make LLVM=1 -j$(nproc)

# KCSAN 출력 예시:
# BUG: KCSAN: data-race in func_a / func_b
# write to 0xffff... of 4 bytes by task 1234:
#   func_a+0x.../0x... [module]
# read to 0xffff... of 4 bytes by task 5678:
#   func_b+0x.../0x... [module]

# lockdep로 잠금 순서 위반 탐지
# CONFIG_PROVE_LOCKING=y
# BUG: possible circular locking dependency detected

# atomic sleep 탐지
# CONFIG_DEBUG_ATOMIC_SLEEP=y
# BUG: sleeping function called from invalid context

# refcount 오류 탐지
# CONFIG_REFCOUNT_FULL=y (5.x+는 기본 활성)
# WARNING: refcount_t: underflow; use-after-free.

# ftrace로 atomic 연산 추적
echo function > /sys/kernel/debug/tracing/current_tracer
echo 'atomic_*' > /sys/kernel/debug/tracing/set_ftrace_filter
echo 1 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace
💡

디버깅 권장 CONFIG 조합 (개발 환경):

CONFIG_DEBUG_ATOMIC_SLEEP=y    # atomic context에서 sleep 탐지
CONFIG_PROVE_LOCKING=y         # 잠금 순서 위반 탐지 (lockdep)
CONFIG_KCSAN=y                 # 데이터 레이스 탐지
CONFIG_KASAN=y                 # use-after-free 탐지
CONFIG_REFCOUNT_FULL=y         # refcount 오버/언더플로 탐지
CONFIG_DEBUG_OBJECTS_RCU_HEAD=y # RCU 콜백 오용 탐지

Atomic 연산과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.