메모리 배리어 / 메모리 모델 심화
Linux 커널 메모리 배리어(memory barrier)와 LKMM(Linux Kernel Memory Model)의 원리, API, 아키텍처별 차이, 실전 패턴을 종합적으로 분석합니다.
Documentation/memory-barriers.txt
메모리 순서 문제 - 왜 배리어가 필요한가
현대 CPU와 컴파일러는 성능 최적화를 위해 메모리 접근 순서를 재배열(reordering)합니다. 단일 스레드에서는 프로그래머가 인지하지 못하지만, 멀티프로세서 환경에서는 치명적인 버그의 원인이 됩니다.
컴파일러 재배열
컴파일러는 as-if 규칙에 따라 단일 스레드의 관찰 결과가 동일하다면 명령어 순서를 자유롭게 변경할 수 있습니다. 예를 들어:
/* 원본 코드 */
int a = 1; /* (1) */
int b = 2; /* (2) */
int c = a; /* (3) */
/* 컴파일러는 (1)과 (3)을 연속 배치하고 (2)를 나중으로 이동할 수 있음 */
/* 결과: a = 1 → c = a → b = 2 (단일 스레드에서는 동일한 결과) */
CPU 재배열
CPU는 파이프라인 효율을 위해 메모리 연산의 실행 순서를 변경합니다. 주요 재배열 유형은 다음과 같습니다:
| 재배열 유형 | 설명 | x86 | ARM/POWER |
|---|---|---|---|
| Store-Store | 쓰기 연산 간 순서 변경 | 불가 | 가능 |
| Load-Load | 읽기 연산 간 순서 변경 | 불가 | 가능 |
| Load-Store | 읽기 후 쓰기의 순서 변경 | 불가 | 가능 |
| Store-Load | 쓰기 후 읽기의 순서 변경 | 가능 | 가능 |
x86은 TSO(Total Store Order) 모델로 비교적 강한 순서를 보장하지만, Store-Load 재배열은 발생합니다. "x86은 안전하다"는 인식은 위험한 오해입니다.
재배열로 인한 버그 예시
다음은 메모리 배리어 없이 플래그 기반 동기화를 시도했을 때 발생하는 전형적인 버그입니다:
/* 공유 변수 */
int data = 0;
int ready = 0;
/* CPU 0 (producer) */
data = 42; /* (1) 데이터 기록 */
ready = 1; /* (2) 플래그 설정 */
/* CPU 1 (consumer) */
while (!ready) /* (3) 플래그 확인 */
;
use(data); /* (4) 데이터 사용 */
/*
* 문제: CPU 0이 (1)과 (2)를 재배열하면
* CPU 1이 ready=1을 보지만 data는 아직 0일 수 있음!
* ARM/POWER에서 실제로 발생하는 버그
*/
컴파일러 배리어
컴파일러 배리어는 컴파일러의 최적화/재배열만 방지하며, CPU 레벨의 재배열에는 영향을 주지 않습니다.
barrier()
barrier()는 가장 기본적인 컴파일러 배리어입니다. 이 매크로 전후의 메모리 접근이 컴파일러에 의해 재배열되지 않도록 합니다.
/* include/linux/compiler.h */
#define barrier() __asm__ __volatile__("" ::: "memory")
/* 사용 예: busy-wait 루프에서 컴파일러가 조건을 레지스터에 캐시하지 않도록 */
while (condition) {
barrier(); /* 매 반복마다 메모리에서 condition을 다시 읽음 */
}
READ_ONCE() / WRITE_ONCE()
READ_ONCE()와 WRITE_ONCE()는 단일 변수에 대한 접근이 정확히 한 번, 원자적 크기로 수행되도록 보장합니다. 컴파일러가 읽기/쓰기를 제거하거나, 분할하거나, 병합하는 것을 방지합니다.
/* include/linux/compiler.h (단순화) */
#define READ_ONCE(x) (*((volatile typeof(x) *)&(x)))
#define WRITE_ONCE(x, val) \
(*((volatile typeof(x) *)&(x)) = (val))
/* 컴파일러가 할 수 있는 위험한 최적화 (READ_ONCE 없이) */
/* 1. 읽기 제거 (hoisting) */
if (flag) /* 컴파일러가 flag를 한 번만 읽고 */
while (flag) /* 레지스터 값을 재사용할 수 있음 → 무한 루프! */
cpu_relax();
/* 올바른 코드 */
if (READ_ONCE(flag))
while (READ_ONCE(flag))
cpu_relax();
/* 2. 쓰기 병합 방지 */
WRITE_ONCE(status, PREPARING);
do_work();
WRITE_ONCE(status, DONE); /* 없으면 컴파일러가 첫 쓰기를 제거할 수 있음 */
/* 3. 읽기 분할 방지 */
unsigned long val = READ_ONCE(shared_ptr);
/* 64비트 값이 두 번의 32비트 읽기로 분할되지 않음을 보장 */
READ_ONCE()/WRITE_ONCE()는 컴파일러 배리어가 아닙니다. 해당 변수 하나에 대한 접근만 제어하며, 다른 변수의 순서에는 영향을 주지 않습니다. 순서 보장이 필요하면 별도의 배리어를 함께 사용해야 합니다.
volatile의 문제
커널에서는 변수에 volatile을 직접 사용하는 것을 지양합니다. volatile은 의미가 모호하고, 필요한 순서 보장을 충분히 제공하지 못합니다. 대신 READ_ONCE()/WRITE_ONCE()와 적절한 배리어를 조합하는 것이 올바른 접근입니다.
/* 나쁜 예: volatile 사용 */
volatile int flag; /* 어떤 순서 보장도 없음 */
/* 좋은 예: READ_ONCE/WRITE_ONCE + 배리어 */
int flag;
WRITE_ONCE(flag, 1); /* 명시적, 의미 명확 */
if (READ_ONCE(flag)) /* 접근 제어 + 필요 시 배리어 추가 */
...
CPU 메모리 배리어
CPU 메모리 배리어는 프로세서의 메모리 접근 재배열을 방지합니다. Linux 커널은 세 가지 기본 배리어와 SMP 변형을 제공합니다.
기본 배리어
| API | 종류 | 보장 | 비용 |
|---|---|---|---|
mb() | Full barrier | 배리어 이전의 모든 읽기/쓰기가 이후의 모든 읽기/쓰기보다 먼저 완료 | 높음 |
rmb() | Read barrier | 배리어 이전의 읽기가 이후의 읽기보다 먼저 완료 | 중간 |
wmb() | Write barrier | 배리어 이전의 쓰기가 이후의 쓰기보다 먼저 완료 | 중간 |
/* include/asm-generic/barrier.h */
/* mb() — Full Memory Barrier */
/* 모든 이전 메모리 연산이 이후 연산보다 먼저 완료됨을 보장 */
data = 42;
mb(); /* data 쓰기가 반드시 ready 쓰기보다 먼저 완료 */
ready = 1;
/* rmb() — Read Memory Barrier */
/* 이전 읽기가 이후 읽기보다 먼저 완료됨을 보장 */
if (ready) {
rmb(); /* ready 읽기가 data 읽기보다 먼저 완료 */
use(data);
}
/* wmb() — Write Memory Barrier */
/* 이전 쓰기가 이후 쓰기보다 먼저 완료됨을 보장 */
obj->field = value;
wmb(); /* field 쓰기가 pointer 쓰기보다 먼저 완료 */
published_ptr = obj;
배리어 페어링
메모리 배리어는 반드시 쌍(pair)으로 사용해야 효과가 있습니다. 한쪽 CPU에서만 배리어를 사용하면 의미가 없습니다:
/* CPU 0 (writer) */ /* CPU 1 (reader) */
data = 42; while (!READ_ONCE(ready))
wmb(); /* Store-Store 배리어 */ cpu_relax();
WRITE_ONCE(ready, 1); rmb(); /* Load-Load 배리어 */
use(data); /* 42 보장 */
/*
* wmb()는 data→ready 쓰기 순서를 보장
* rmb()는 ready→data 읽기 순서를 보장
* 둘 다 있어야 올바르게 동작!
*/
SMP 배리어 vs 비SMP
Linux 커널은 smp_ 접두어가 붙은 SMP 전용 배리어를 제공합니다. 이 매크로들은 SMP 커널에서만 실제 CPU 배리어로 확장되고, UP(Uniprocessor) 커널에서는 컴파일러 배리어로 축소됩니다.
| SMP 배리어 | SMP 커널 확장 | UP 커널 확장 |
|---|---|---|
smp_mb() | mb() | barrier() |
smp_rmb() | rmb() | barrier() |
smp_wmb() | wmb() | barrier() |
smp_read_barrier_depends() | (arch별) | barrier() |
/* include/asm-generic/barrier.h (단순화) */
#ifdef CONFIG_SMP
#define smp_mb() mb()
#define smp_rmb() rmb()
#define smp_wmb() wmb()
#else
#define smp_mb() barrier()
#define smp_rmb() barrier()
#define smp_wmb() barrier()
#endif
규칙: CPU 간 동기화가 목적이면 smp_* 배리어를 사용하세요. mb()/rmb()/wmb()는 I/O 디바이스와의 순서 보장 등 UP에서도 필요한 경우에만 사용합니다.
SMP 배리어 사용 예
/* Producer-Consumer 패턴 (올바른 SMP 배리어 사용) */
/* 공유 데이터 */
struct shared_data {
int payload;
int ready;
};
/* CPU 0: Producer */
void produce(struct shared_data *s, int value)
{
WRITE_ONCE(s->payload, value);
smp_wmb(); /* payload 쓰기 → ready 쓰기 순서 보장 */
WRITE_ONCE(s->ready, 1);
}
/* CPU 1: Consumer */
int consume(struct shared_data *s)
{
while (!READ_ONCE(s->ready))
cpu_relax();
smp_rmb(); /* ready 읽기 → payload 읽기 순서 보장 */
return READ_ONCE(s->payload);
}
Acquire/Release 의미론
Acquire/Release 의미론은 full barrier보다 가벼우면서도 충분한 순서 보장을 제공하는 중요한 패턴입니다. 잠금(lock/unlock)의 메모리 순서 의미와 동일합니다.
개념
| 의미론 | 보장 | 비유 |
|---|---|---|
| Acquire | 이후의 모든 읽기/쓰기가 acquire 이전으로 재배열되지 않음 | 잠금 획득 (critical section 진입) |
| Release | 이전의 모든 읽기/쓰기가 release 이후로 재배열되지 않음 | 잠금 해제 (critical section 종료) |
/*
* Acquire: "여기서부터 아래는 위로 올라가지 않는다"
* ─── acquire ───
* ↓ (아래 연산은 이 위로 이동 불가)
*
* Release: "여기까지 위의 것은 아래로 내려가지 않는다"
* ↑ (위 연산은 이 아래로 이동 불가)
* ─── release ───
*/
smp_load_acquire() / smp_store_release()
/* include/asm-generic/barrier.h (단순화) */
#define smp_load_acquire(p) \
({ \
typeof(*p) ___p = READ_ONCE(*p); \
barrier(); \
___p; \
})
#define smp_store_release(p, v) \
do { \
barrier(); \
WRITE_ONCE(*p, v); \
} while (0)
/* Producer-Consumer (acquire/release 버전 — 더 효율적) */
/* CPU 0: Producer */
WRITE_ONCE(data, 42);
smp_store_release(&ready, 1); /* data 쓰기를 ready 아래로 이동 불가 */
/* CPU 1: Consumer */
while (!smp_load_acquire(&ready)) /* ready 읽기 위로 data 읽기 이동 불가 */
cpu_relax();
use(data); /* 42 보장 */
잠금과 acquire/release
Linux 커널의 모든 잠금 프리미티브는 암시적으로 acquire/release 의미론을 포함합니다:
/*
* spin_lock() → acquire 의미론 (lock 이후 연산이 위로 이동 불가)
* spin_unlock() → release 의미론 (unlock 이전 연산이 아래로 이동 불가)
*
* 따라서 critical section 내의 메모리 연산은
* lock/unlock 경계 밖으로 재배열되지 않음
*/
spin_lock(&lock); /* acquire 배리어 포함 */
/* ─── critical section ─── */
shared_var = new_val; /* 이 연산은 lock 밖으로 이동 불가 */
/* ─── critical section ─── */
spin_unlock(&lock); /* release 배리어 포함 */
/*
* 주의: spin_lock()은 full barrier가 아닙니다.
* lock 이전의 연산이 critical section 안으로 이동할 수 있습니다.
* (성능을 위해 의도적으로 허용)
*/
smp_load_acquire()/smp_store_release()는 smp_mb()보다 가볍습니다. Full barrier는 양방향 모두 차단하지만, acquire는 한 방향(아래→위), release는 반대 방향(위→아래)만 차단합니다. ARM64에서 LDAR/STLR 명령어로 효율적으로 구현됩니다.
아키텍처별 메모리 모델
각 CPU 아키텍처는 서로 다른 메모리 순서 모델을 가지며, 이는 배리어의 실제 구현과 비용에 직접적인 영향을 미칩니다.
x86: Total Store Order (TSO)
x86은 프로그래머에게 가장 친화적인 강한 메모리 모델을 제공합니다:
- Store-Store, Load-Load, Load-Store 재배열 불가
- Store-Load 재배열만 가능 (store buffer에 의해)
- 결과적으로
smp_wmb()와smp_rmb()는 컴파일러 배리어로 충분
/* arch/x86/include/asm/barrier.h (단순화) */
#define mb() asm volatile("mfence" ::: "memory")
#define rmb() asm volatile("lfence" ::: "memory")
#define wmb() asm volatile("sfence" ::: "memory")
/* TSO이므로 SMP 배리어는 가벼움 */
#define smp_rmb() barrier() /* 컴파일러 배리어만으로 충분 */
#define smp_wmb() barrier() /* 컴파일러 배리어만으로 충분 */
#define smp_mb() mb() /* Store-Load은 MFENCE 필요 */
ARM: Weakly Ordered
ARM은 약한(weak) 메모리 모델을 사용합니다. 모든 종류의 재배열이 가능하므로 명시적 배리어 명령어가 필요합니다:
/* arch/arm64/include/asm/barrier.h (단순화) */
#define mb() asm volatile("dsb sy" ::: "memory")
#define rmb() asm volatile("dsb ld" ::: "memory")
#define wmb() asm volatile("dsb st" ::: "memory")
/* DMB(Data Memory Barrier) — SMP 배리어에 사용 */
#define smp_mb() asm volatile("dmb ish" ::: "memory")
#define smp_rmb() asm volatile("dmb ishld" ::: "memory")
#define smp_wmb() asm volatile("dmb ishst" ::: "memory")
/* ARM64 acquire/release: 전용 명령어로 효율적 구현 */
/* LDAR (Load-Acquire Register) */
/* STLR (Store-Release Register) */
RISC-V: RVWMO (Weak Memory Ordering)
/* arch/riscv/include/asm/barrier.h (단순화) */
#define mb() asm volatile("fence iorw, iorw" ::: "memory")
#define rmb() asm volatile("fence ir, ir" ::: "memory")
#define wmb() asm volatile("fence ow, ow" ::: "memory")
#define smp_mb() asm volatile("fence rw, rw" ::: "memory")
#define smp_rmb() asm volatile("fence r, r" ::: "memory")
#define smp_wmb() asm volatile("fence w, w" ::: "memory")
아키텍처별 배리어 비교표
| 커널 API | x86 | ARM64 | RISC-V | POWER |
|---|---|---|---|---|
mb() | MFENCE | DSB SY | fence iorw,iorw | sync |
rmb() | LFENCE | DSB LD | fence ir,ir | sync |
wmb() | SFENCE | DSB ST | fence ow,ow | sync |
smp_mb() | MFENCE | DMB ISH | fence rw,rw | lwsync; sync |
smp_rmb() | barrier() | DMB ISHLD | fence r,r | lwsync |
smp_wmb() | barrier() | DMB ISHST | fence w,w | lwsync |
smp_load_acquire() | MOV (자연 보장) | LDAR | fence r,rw | ld; lwsync |
smp_store_release() | MOV (자연 보장) | STLR | fence rw,w | lwsync; st |
x86에서 테스트한 코드가 ARM이나 POWER에서 실패하는 일이 빈번합니다. x86의 TSO 모델이 많은 재배열을 자연적으로 차단하기 때문에, 배리어 누락 버그가 x86에서는 드러나지 않습니다. 반드시 약한 메모리 모델 아키텍처에서도 검증해야 합니다.
Linux Kernel Memory Model (LKMM)
LKMM은 Linux 커널의 공식 메모리 모델입니다. 커널 소스 트리의 tools/memory-model/에 위치하며, 커널의 동시성 프리미티브가 제공하는 순서 보장을 수학적으로 정의합니다.
LKMM 구성 요소
tools/memory-model/
├── Documentation/
│ ├── explanation.txt ← LKMM 상세 설명
│ ├── ordering.txt ← 순서 보장 규칙
│ └── recipes.txt ← 사용 패턴 레시피
├── linux-kernel.bell ← Bell 언어 메모리 모델 정의
├── linux-kernel.cat ← Cat 언어 일관성 조건
├── linux-kernel.cfg ← 설정
├── linux-kernel.def ← 매크로 정의
└── litmus-tests/ ← Litmus 테스트 모음
├── MP+wmb+rmb.litmus
├── SB+mb.litmus
└── ...
Litmus 테스트
Litmus 테스트는 특정 실행 순서가 허용되는지를 확인하는 소형 동시성 테스트입니다. herd7 도구로 LKMM 모델에 대해 검증합니다:
C MP+wmb+rmb
(* Message Passing with write/read barriers *)
{
int x = 0; (* 데이터 *)
int y = 0; (* 플래그 *)
}
P0(int *x, int *y) (* Producer *)
{
WRITE_ONCE(*x, 1);
smp_wmb();
WRITE_ONCE(*y, 1);
}
P1(int *x, int *y) (* Consumer *)
{
int r0 = READ_ONCE(*y);
smp_rmb();
int r1 = READ_ONCE(*x);
}
exists (1:r0=1 /\ 1:r1=0)
(* 결과: "Never" — wmb+rmb 페어링이 이 결과를 방지함 *)
herd7 사용법
# herd7 설치 (OPAM 기반)
sudo apt install opam
opam init
opam install herdtools7
# litmus 테스트 실행
herd7 -conf linux-kernel.cfg litmus-tests/MP+wmb+rmb.litmus
# 결과 예시:
# Test MP+wmb+rmb Allowed
# States 3
# 1:r0=0; 1:r1=0;
# 1:r0=0; 1:r1=1;
# 1:r0=1; 1:r1=1;
# No
# Witnesses
# Positive: 0 Negative: 3
# → "1:r0=1 /\ 1:r1=0"은 불가능 (배리어가 방지)
# klitmus7: 실제 하드웨어에서 테스트
klitmus7 litmus-tests/MP+wmb+rmb.litmus
# → C 코드를 생성하여 실제 CPU에서 실행 검증
happens-before (hb), propagation (pb), reads-before (rb) 등의 관계를 정의하여 허용되는 실행 순서를 결정합니다. 모든 배리어 API는 이 형식 모델에서 파생된 순서 보장을 구현합니다.
데이터 의존성 배리어
데이터 의존성(data dependency)은 어떤 읽기의 결과가 후속 읽기의 주소를 결정하는 관계입니다. 대부분의 CPU 아키텍처는 데이터 의존성에 의한 순서를 자연적으로 보장합니다.
주소 의존성 (Address Dependency)
/* 주소 의존성 예시 */
int **pp = &p;
/* CPU 1 */
int *local_p = READ_ONCE(*pp); /* (1) 포인터 읽기 */
int val = *local_p; /* (2) 역참조 — (1)의 결과에 의존 */
/*
* (2)는 (1)에 주소 의존성이 있으므로
* CPU가 자연적으로 순서를 보장합니다.
* 별도의 rmb()가 필요하지 않습니다.
*
* 단, 컴파일러가 이 의존성을 깨뜨릴 수 있으므로
* READ_ONCE()를 반드시 사용해야 합니다.
*/
RCU와 데이터 의존성
RCU의 rcu_dereference()는 데이터 의존성을 활용한 대표적 패턴입니다:
/* RCU 읽기 측 — 데이터 의존성으로 순서 보장 */
rcu_read_lock();
struct foo *p = rcu_dereference(global_ptr);
/*
* rcu_dereference()는 READ_ONCE() + 의존성 보존을 보장
* p를 통한 후속 접근은 포인터 읽기 이후에 반드시 수행됨
*/
if (p)
do_something(p->field); /* 안전: 의존성이 순서 보장 */
rcu_read_unlock();
/* RCU 갱신 측 — release 의미론으로 발행 */
struct foo *new_p = kmalloc(sizeof(*new_p), GFP_KERNEL);
new_p->field = new_value;
rcu_assign_pointer(global_ptr, new_p);
/*
* rcu_assign_pointer()는 smp_store_release()를 포함하여
* new_p의 필드 초기화가 포인터 발행 전에 완료됨을 보장
*/
DEC Alpha의 특이성: DEC Alpha는 데이터 의존성에 의한 순서조차 보장하지 않는 유일한 아키텍처였습니다. 이 때문에 smp_read_barrier_depends()가 존재했으나, Alpha 지원 제거(커널 6.x) 이후 이 매크로는 모든 아키텍처에서 no-op이 되었습니다. 최신 커널에서는 READ_ONCE()만으로 충분합니다.
제어 의존성 (Control Dependency)
제어 의존성은 조건 분기를 통한 순서 관계입니다. 주소 의존성보다 더 약한 보장을 제공합니다:
/* 제어 의존성: 읽기→쓰기 순서만 보장 (읽기→읽기는 보장 안 됨!) */
int r = READ_ONCE(flag);
if (r) {
WRITE_ONCE(data, 42); /* OK: 제어 의존성이 순서 보장 */
}
/* 위험: 읽기→읽기는 제어 의존성으로 보장되지 않음 */
int r = READ_ONCE(flag);
if (r) {
int val = READ_ONCE(data); /* 위험: 읽기 순서 미보장! */
/* smp_rmb()가 필요 */
}
/* 위험: 컴파일러가 제어 의존성을 제거할 수 있음 */
int r = READ_ONCE(flag);
if (r)
WRITE_ONCE(data, 42);
else
WRITE_ONCE(data, 42);
/* 컴파일러가 "항상 WRITE_ONCE(data, 42)"로 최적화 → 의존성 소멸! */
I/O 배리어
I/O 배리어는 CPU와 디바이스 간의 메모리 매핑된 I/O(MMIO) 접근 순서를 보장합니다. SMP 배리어와 달리 UP 시스템에서도 반드시 필요합니다.
MMIO 순서 보장
/* I/O 접근자 함수와 배리어 */
#include <linux/io.h>
/* readl/writel — 암시적 배리어 포함 (순서 보장) */
u32 val = readl(mmio_base + REG_STATUS); /* 이전 쓰기 완료 후 읽기 */
writel(cmd, mmio_base + REG_COMMAND); /* 이전 접근 완료 후 쓰기 */
/* readl_relaxed/writel_relaxed — 배리어 없음 (성능 우선) */
u32 val = readl_relaxed(mmio_base + REG_DATA);
writel_relaxed(data, mmio_base + REG_DATA);
/* relaxed 접근 사이에 순서가 필요하면 명시적 배리어 사용 */
writel_relaxed(addr, mmio_base + REG_ADDR);
wmb(); /* MMIO 쓰기 순서 보장 (smp_wmb() 아님!) */
writel_relaxed(cmd, mmio_base + REG_CMD);
I/O 관련 배리어 분류
| 함수 | 암시적 배리어 | 사용 장소 |
|---|---|---|
readl() / writel() | 포함 (full) | 일반 MMIO (순서 중요) |
readl_relaxed() / writel_relaxed() | 없음 | 성능 우선, 순서 무관 |
ioread32() / iowrite32() | 포함 | 포트/MMIO 추상화 |
inl() / outl() | 포함 (직렬화) | x86 포트 I/O |
__raw_readl() / __raw_writel() | 없음, 바이트 순서 없음 | 최저 수준 접근 |
/* DMA 배리어 — CPU와 DMA 엔진 간 순서 보장 */
/* dma_wmb(): CPU 쓰기가 DMA 디스크립터에 반영된 후 디바이스가 읽도록 */
desc->addr = cpu_to_le64(dma_addr);
desc->len = cpu_to_le32(len);
dma_wmb(); /* addr, len 쓰기가 완료된 후 */
desc->cmd = cpu_to_le32(CMD_START); /* 디바이스에 시작 신호 */
/* dma_rmb(): 디바이스가 쓴 데이터를 CPU가 올바른 순서로 읽도록 */
if (desc->status & DONE) {
dma_rmb(); /* status 읽기 후 나머지 필드 읽기 */
process(desc->result);
}
dma_wmb()/dma_rmb()는 smp_wmb()/smp_rmb()보다 강하지만 mb()/rmb()보다 약합니다. 디바이스와의 shared memory에서 coherent DMA 매핑을 사용할 때 적합합니다.
배리어와 동기화 프리미티브
Linux 커널의 동기화 프리미티브는 내부적으로 적절한 메모리 배리어를 포함합니다. 각 프리미티브가 제공하는 배리어 의미를 이해하면 불필요한 추가 배리어를 방지할 수 있습니다.
잠금의 배리어
| 프리미티브 | acquire 배리어 | release 배리어 | 비고 |
|---|---|---|---|
spin_lock() | 포함 | - | lock 이후 연산은 위로 이동 불가 |
spin_unlock() | - | 포함 | unlock 이전 연산은 아래로 이동 불가 |
mutex_lock() | 포함 | - | 동일 |
mutex_unlock() | - | 포함 | 동일 |
spin_lock() + spin_unlock() | acquire | release | pair로 full barrier 효과 (단방향 각각) |
/*
* 잠금 페어링에 의한 가시성 보장:
*
* CPU 0: CPU 1:
* WRITE_ONCE(a, 1); spin_lock(&lock);
* spin_lock(&lock); ← acquire barrier
* WRITE_ONCE(b, 2); r1 = READ_ONCE(b); // b=2이면
* spin_unlock(&lock); smp_mb(); // 추가 필요?
* ← release barrier r2 = READ_ONCE(a); // a=1 보장?
* spin_unlock(&lock);
*
* 주의: a 쓰기는 lock 바깥이므로 자동 보장되지 않음!
* lock/unlock은 critical section 내부의 연산만 순서 보장합니다.
*/
atomic 연산의 배리어
/* atomic 연산의 메모리 순서 */
/* Full barrier (순서 보장) */
atomic_add_return(1, &v); /* full mb 포함 */
atomic_sub_return(1, &v); /* full mb 포함 */
atomic_xchg(&v, new); /* full mb 포함 */
atomic_cmpxchg(&v, old, new); /* full mb 포함 */
/* 배리어 없음 (relaxed) */
atomic_read(&v); /* 배리어 없음 */
atomic_set(&v, 0); /* 배리어 없음 */
atomic_add(1, &v); /* 배리어 없음 (반환값 없는 버전) */
atomic_inc(&v); /* 배리어 없음 */
/* _acquire / _release 변형 */
atomic_add_return_acquire(1, &v); /* acquire 배리어만 */
atomic_add_return_release(1, &v); /* release 배리어만 */
atomic_add_return_relaxed(1, &v); /* 배리어 없음 */
/* 조건부 배리어: test_and_set_bit 등 */
if (test_and_set_bit(LOCK_BIT, &flags)) {
/* bit가 이미 설정됨 — acquire 배리어 포함 */
}
clear_bit_unlock(LOCK_BIT, &flags); /* release 배리어 포함 */
smp_mb__before/after 매크로
/*
* atomic_inc() 등 배리어 없는 연산에 배리어를 추가할 때:
* smp_mb()를 직접 사용하면 아키텍처별 비효율 발생 가능
* 대신 전용 매크로를 사용
*/
/* 나쁜 예 */
smp_mb();
atomic_inc(&v);
/* 좋은 예 */
atomic_inc(&v);
smp_mb__after_atomic(); /* 일부 아키텍처에서 더 효율적 */
/* 비트 연산용 */
set_bit(FLAG, &bitmap);
smp_mb__after_atomic(); /* set_bit 이후 full barrier */
/* spin_unlock 전용 */
smp_mb__before_spinlock(); /* 특수 케이스 */
실전 패턴
Lockless Ring Buffer (Producer-Consumer)
/* 단일 producer, 단일 consumer lockless ring buffer */
struct ring_buffer {
unsigned long head; /* producer가 쓰기 */
unsigned long tail; /* consumer가 쓰기 */
void *data[RING_SIZE];
};
/* Producer (CPU 0) */
bool ring_produce(struct ring_buffer *rb, void *item)
{
unsigned long head = rb->head;
unsigned long next = (head + 1) % RING_SIZE;
/* tail 읽기 — consumer가 갱신한 값 확인 */
if (next == smp_load_acquire(&rb->tail))
return false; /* 버퍼 가득 참 */
rb->data[head] = item;
/* data 쓰기 후 head 갱신 — release 의미론 */
smp_store_release(&rb->head, next);
return true;
}
/* Consumer (CPU 1) */
void *ring_consume(struct ring_buffer *rb)
{
unsigned long tail = rb->tail;
void *item;
/* head 읽기 — producer가 갱신한 값 확인 */
if (tail == smp_load_acquire(&rb->head))
return NULL; /* 버퍼 비어 있음 */
item = rb->data[tail];
/* data 읽기 후 tail 갱신 — release 의미론 */
smp_store_release(&rb->tail, (tail + 1) % RING_SIZE);
return item;
}
Seqcount 패턴
/* seqcount: writer 우선 lockless 읽기 */
seqcount_t seq;
struct data {
u64 timestamp;
u64 counter;
};
struct data shared;
/* Writer (spinlock 보호 하에 호출) */
void update_data(u64 ts, u64 cnt)
{
write_seqcount_begin(&seq);
/* wmb() 포함 — 시퀀스 증가 전 */
shared.timestamp = ts;
shared.counter = cnt;
write_seqcount_end(&seq);
/* wmb() 포함 — 데이터 쓰기 후 시퀀스 증가 */
}
/* Reader (잠금 불필요, 재시도 가능) */
void read_data(u64 *ts, u64 *cnt)
{
unsigned int s;
do {
s = read_seqcount_begin(&seq);
/* smp_rmb() 포함 — 시퀀스 읽기 후 데이터 읽기 */
*ts = shared.timestamp;
*cnt = shared.counter;
} while (read_seqcount_retry(&seq, s));
/* smp_rmb() 포함 — 데이터 읽기 후 시퀀스 재확인 */
}
상태 플래그 패턴
/* 흔한 패턴: 완료 플래그로 데이터 발행 */
struct work_item {
int result;
bool done;
};
/* Worker 스레드 */
void worker(struct work_item *w)
{
w->result = compute();
/* result가 확실히 기록된 후 done 설정 */
smp_store_release(&w->done, true);
}
/* 대기 스레드 */
int wait_result(struct work_item *w)
{
while (!smp_load_acquire(&w->done))
cpu_relax();
return w->result; /* 안전: acquire가 순서 보장 */
}
KCSAN (Kernel Concurrency Sanitizer)
KCSAN은 컴파일 타임 계측(instrumentation)을 사용하여 런타임에 데이터 레이스를 탐지하는 커널 도구입니다. 배리어나 적절한 동기화 없이 공유 변수에 접근하는 코드를 찾아냅니다.
KCSAN 활성화
# 커널 설정
CONFIG_KCSAN=y
CONFIG_KCSAN_STRICT=y # 엄격 모드 (더 많은 경고)
CONFIG_KCSAN_REPORT_ONCE_IN_MS=0 # 동일 위치 중복 보고 (0=무제한)
KCSAN 보고서 예시
==================================================================
BUG: KCSAN: data-race in producer+0x1a/0x30 / consumer+0x22/0x40
write to 0xffff8880aabbccdd of 4 bytes by task 123 on cpu 0:
producer+0x1a/0x30
kthread+0x112/0x130
read to 0xffff8880aabbccdd of 4 bytes by task 456 on cpu 1:
consumer+0x22/0x40
kthread+0x112/0x130
value changed: 0x00000000 -> 0x0000002a
Reported by Kernel Concurrency Sanitizer on:
CPU: 0 PID: 123 Comm: producer
CPU: 1 PID: 456 Comm: consumer
==================================================================
KCSAN 어노테이션
/* 의도적인 데이터 레이스 표시 (KCSAN 경고 억제) */
/* 방법 1: data_race() 매크로 — 값은 사용하되 레이스 무시 */
int cnt = data_race(shared_counter);
/* 통계 카운터 등 정확한 값이 필요 없는 경우 */
/* 방법 2: KCSAN 검사 비활성화 구간 */
kcsan_disable_current();
/* 의도적으로 동기화 없이 접근하는 코드 */
kcsan_enable_current();
/* 방법 3: 올바른 수정 — READ_ONCE/WRITE_ONCE 사용 */
/* KCSAN이 보고한 레이스의 대부분은 이것으로 해결 */
int val = READ_ONCE(shared_var); /* 마킹된 접근 → 레이스 아님 */
WRITE_ONCE(shared_var, new_val); /* 마킹된 접근 → 레이스 아님 */
READ_ONCE()/WRITE_ONCE()로 마킹된 접근은 "의도적"으로 간주하여 보고하지 않습니다.
흔한 실수와 디버깅
실수 1: 배리어 누락
/* 버그: 배리어 없는 플래그 동기화 */
/* CPU 0 */ /* CPU 1 */
data = 42; while (!ready) /* busy-wait */
ready = 1; ;
use(data); /* data가 0일 수 있음! */
/* 수정 1: wmb/rmb 페어링 */
/* CPU 0 */ /* CPU 1 */
WRITE_ONCE(data, 42); while (!READ_ONCE(ready))
smp_wmb(); cpu_relax();
WRITE_ONCE(ready, 1); smp_rmb();
use(READ_ONCE(data)); /* 42 보장 */
/* 수정 2: acquire/release (더 간결) */
/* CPU 0 */ /* CPU 1 */
WRITE_ONCE(data, 42); while (!smp_load_acquire(&ready))
smp_store_release(&ready, 1); cpu_relax();
use(data); /* 42 보장 */
실수 2: READ_ONCE/WRITE_ONCE 누락
/* 버그: READ_ONCE 없이 공유 변수 폴링 */
while (!stop_flag) /* 컴파일러가 레지스터에 캐시 → 무한 루프! */
do_work();
/* 수정: */
while (!READ_ONCE(stop_flag))
do_work();
/* 버그: WRITE_ONCE 없이 쓰기 (store tearing 가능) */
shared_struct.field = value; /* 비원자적 쓰기 가능 */
/* 수정: */
WRITE_ONCE(shared_struct.field, value);
실수 3: 잘못된 배리어 선택
/* 버그: MMIO에 smp_wmb() 사용 (UP에서 no-op!) */
writel_relaxed(addr, reg_base + ADDR);
smp_wmb(); /* UP 커널에서 barrier()로 축소 → MMIO 순서 미보장 */
writel_relaxed(cmd, reg_base + CMD);
/* 수정: MMIO에는 wmb() 사용 */
writel_relaxed(addr, reg_base + ADDR);
wmb(); /* UP에서도 실제 CPU 배리어 */
writel_relaxed(cmd, reg_base + CMD);
/* 또는 writel() 사용 (암시적 배리어 포함) */
writel(addr, reg_base + ADDR);
writel(cmd, reg_base + CMD);
실수 4: 과도한 배리어 사용
/* 비효율적: 불필요한 full barrier */
smp_mb(); /* 정말 필요한가? */
atomic_inc(&counter);
smp_mb(); /* 정말 필요한가? */
/* 개선: 실제 필요한 배리어만 사용 */
atomic_inc_return(&counter); /* full barrier 내장 */
/* 또는 반환값이 불필요하면: */
atomic_inc(&counter);
smp_mb__after_atomic(); /* 아키텍처 최적화된 배리어 */
디버깅 기법
| 기법 | 설명 | 활성화 |
|---|---|---|
| KCSAN | 데이터 레이스 런타임 탐지 | CONFIG_KCSAN=y |
| KTSAN (실험적) | Thread Sanitizer 기반 정밀 분석 | 별도 패치 필요 |
| LKMM + herd7 | 공식 메모리 모델 기반 정적 검증 | tools/memory-model/ |
| klitmus7 | 실제 하드웨어에서 litmus 테스트 실행 | herdtools7 설치 |
| ARM 테스트 | 약한 메모리 모델 실기에서 검증 | ARM/POWER 하드웨어 |
| lockdep | 잠금 순서 위반 탐지 | CONFIG_LOCKDEP=y |
pr_debug + trace_printk | 배리어 전후 값 확인 | 런타임 |
배리어 선택 가이드
Q: 어떤 배리어를 사용해야 하는가?
1. 잠금(spinlock/mutex) 안에서? → 추가 배리어 불필요
2. 단일 변수 접근 보호? → READ_ONCE() / WRITE_ONCE()
3. CPU 간 순서 보장? → smp_load_acquire() / smp_store_release()
4. Producer-Consumer 패턴? → smp_store_release() + smp_load_acquire()
5. Full barrier 필요? → smp_mb()
6. MMIO 디바이스 순서? → mb() / rmb() / wmb() 또는 readl() / writel()
7. DMA 디스크립터 순서? → dma_wmb() / dma_rmb()
8. atomic 연산 후 배리어? → smp_mb__after_atomic()
핵심 원칙: 배리어는 항상 쌍(pair)으로 사용해야 합니다. writer 측의 배리어만으로는 reader에게 순서가 보장되지 않습니다. 또한 가능하면 낮은 수준의 배리어보다 상위 추상화(잠금, RCU, atomic 연산)를 사용하세요. 직접적인 배리어 사용은 최후의 수단이어야 합니다.
요약
| 계층 | API | 범위 | 용도 |
|---|---|---|---|
| 컴파일러 | barrier(), READ_ONCE(), WRITE_ONCE() | 컴파일러 최적화 방지 | 변수 접근 보호, 폴링 루프 |
| SMP CPU | smp_mb(), smp_rmb(), smp_wmb() | CPU 간 순서 보장 | lockless 알고리즘 |
| Acquire/Release | smp_load_acquire(), smp_store_release() | 단방향 순서 보장 | Producer-Consumer, 플래그 |
| Full CPU | mb(), rmb(), wmb() | 모든 CPU (UP 포함) | MMIO, I/O 순서 |
| DMA | dma_wmb(), dma_rmb() | CPU-디바이스 간 | DMA 디스크립터 |
| 프리미티브 | spin_lock(), mutex_lock(), etc. | 암시적 배리어 내장 | 일반 동기화 |