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를 줄이는 실전 패턴까지 매우 상세히 다룹니다.
핵심 요약
- 타입 선택 — 일반 카운터는
atomic_t, 참조 카운트는refcount_t가 기본입니다. - 핵심 연산 —
cmpxchg/try_cmpxchg루프가 lock-free 갱신의 중심입니다. - 순서 의미론 —
_relaxed,_acquire,_release의 차이를 구분해야 합니다. - 보조 도구 —
READ_ONCE/WRITE_ONCE, per-CPU 변수와 함께 사용 시 효과가 큽니다. - 경계 조건 — 원자성 보장과 메모리 순서 보장은 별개라는 점을 항상 확인합니다.
단계별 이해
- 공유 상태 범위 확정
단일 변수만 보호하면 되는지, 구조체 전체 일관성이 필요한지 먼저 판별합니다. - 연산자 선택
단순 증감은 atomic API, 조건부 갱신은 cmpxchg 계열로 분리합니다. - 메모리 순서 추가
가시성 요구가 있으면 acquire/release 또는 배리어를 함께 배치합니다. - 실측 검증
고경합 경로에서 lock 대비 성능과 실패 재시도 비용을 함께 측정합니다.
Atomic 연산 개요
Atomic 연산은 다른 CPU나 인터럽트에 의해 중간에 끼어들 수 없는 불가분(indivisible) 연산입니다. 단순한 카운터나 플래그에 대해 무거운 잠금 없이 안전한 동시 접근을 제공합니다.
Race Condition 발생 원리
멀티코어 환경에서 counter++는 Read–Modify–Write 세 단계로 실행됩니다. 두 CPU가 동시에 실행하면 갱신이 유실됩니다.
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→ 연산 후 새 값 반환 (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와 컴파일러는 성능 최적화를 위해 명령어 순서를 변경합니다. 메모리 배리어는 특정 지점에서 순서를 강제합니다.
/* 컴파일러 배리어 (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마다 독립적인 복사본을 가지므로, 동기화 없이 안전하게 접근할 수 있습니다 (선점만 비활성화하면 됩니다).
#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)
#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()는 마지막 참조 여부를 알 수 없어 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 알고리즘의 핵심 프리미티브입니다:
/* 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 문제가 발생할 수 있습니다.
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 | 완전한 순서 보장 (가장 무거움) |
_relaxed | None | 원자성만 보장, 순서 없음 (가장 가벼움) |
_acquire | Acquire | 이후 읽기/쓰기가 이 연산 전으로 이동 불가 |
_release | Release | 이전 읽기/쓰기가 이 연산 후로 이동 불가 |
/* 성능이 중요한 경우: 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가 방지하는 대표적인 버그 유형입니다.
실전 패턴 (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 | 자료구조 전체를 한 단위로 보호 | 매우 높음 |
패턴 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_KCSAN과 CONFIG_KASAN은 선별적으로 사용하세요.
Linux Kernel Memory Model (LKMM)
LKMM은 커널 코드에서 메모리 접근의 순서와 가시성을 형식적으로 정의하는 모델입니다. C11 메모리 모델을 기반으로 하되, 커널 고유의 smp_store_release(), smp_load_acquire(), RCU 등을 포함합니다. LKMM의 핵심은 happens-before 관계와 이를 구성하는 여러 순서 관계입니다.
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 | 이전 모든 접근 → 이 store | release 순서 → 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 RMW | full 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-side | grace 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)이 발생합니다.
캐시라인 바운싱과 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 atomic | M (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 c2c의 HITM(modified 상태 캐시라인 히트)이 높으면 경합이 심한 것입니다.
아키텍처별 원자 명령어 상세
각 아키텍처는 원자 연산을 위해 근본적으로 다른 하드웨어 메커니즘을 사용합니다. 이 차이는 성능 특성과 스케일링 동작에 직접 영향을 미칩니다.
아키텍처별 배리어 매핑
| 커널 API | x86-64 | ARM64 | RISC-V |
|---|---|---|---|
smp_mb() | lock addl $0, (%rsp) | DMB ISH | fence rw,rw |
smp_rmb() | barrier() (nop) | DMB ISHLD | fence r,r |
smp_wmb() | barrier() (nop) | DMB ISHST | fence w,w |
smp_store_release() | MOV (TSO 자동) | STLR | fence rw,w; sw |
smp_load_acquire() | MOV (TSO 자동) | LDAR | lw; fence r,rw |
atomic_add() | LOCK ADD | LDXR/STXR 또는 STADD (LSE) | AMOADD.W |
atomic_add_return() | LOCK XADD | LDAXR/STLXR 또는 LDADD (LSE) | AMOADD.W.aqrl |
cmpxchg() | LOCK CMPXCHG | LDAXR+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_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));
| 특성 | seqlock | rwlock | RCU |
|---|---|---|---|
| 읽기 비용 | 낮음 (잠금 없음) | 중간 (락 획득) | 매우 낮음 (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 핵심 구조 (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가 동시에 접근할 수 있어 성능이 뛰어납니다.
/* ===== 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 ns | N/A | N/A | N/A |
READ_ONCE() | ~1 ns | N/A | N/A | N/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 토폴로지, 컴파일러 최적화에 따라 달라집니다. 정확한 성능은 perf나 kbench로 대상 환경에서 직접 측정해야 합니다.
/* 성능 최적화 원칙 */
/* 원칙 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 */
제어 의존성 (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()사용 (컴파일러 최적화 방지) if와else양쪽에 동일한 쓰기를 넣지 마세요 (조건 제거됨)- 확실하지 않으면
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 연산과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.