메모리 배리어 / 메모리 모델 심화
Linux 커널 메모리 배리어(memory barrier)와 LKMM(Linux Kernel Memory Model)을 기반으로 mb/rmb/wmb, smp_* 배리어, READ_ONCE/WRITE_ONCE, acquire/release를 체계적으로 정리합니다. 또한 waitqueue_active() 최적화, dma_wmb() + MMIO 도어벨, 포인터 발행, 지연 초기화, lockless 링 버퍼, x86/ARM/RISC-V의 재배열 차이, KCSAN/litmus 기반 검증 절차까지 실무 관점에서 매우 상세히 다룹니다.
핵심 요약
- 재배열은 두 곳에서 발생 — 컴파일러(as-if 최적화)와 CPU 하드웨어(스토어 버퍼, 추측 실행, 캐시 일관성 지연 등) 모두 원인이 될 수 있습니다. 구현은 아키텍처마다 달라도, 배리어 API는 관찰 가능한 순서 계약으로 이해해야 합니다.
- 스토어 버퍼/무효화 큐는 직관 모델 — 이 문서의 하드웨어 설명은 약한 메모리 모델을 이해하기 위한 대표 예시입니다.
wmb()/rmb()의 의미를 특정 큐를 반드시 비우는 동작으로 고정해서 해석하면 안 됩니다. - 배리어의 핵심 — 중요한 것은 "이전 접근이 이후 접근보다 먼저 관찰된다"는 계약입니다. 구현은 CPU 명령어, 캐시 일관성 프로토콜, 컴파일러 장벽의 조합으로 달라질 수 있습니다.
- 배리어는 반드시 쌍 — writer 측
smp_wmb()(또는smp_store_release())와 reader 측smp_rmb()(또는smp_load_acquire())가 짝을 이룰 때만 happens-before 체인이 완성됩니다. - API 계층 선택 — 잠금 안 → 배리어 불필요 | CPU 간 공유 →
smp_*| MMIO/DMA →mb()/wmb()/rmb()또는readl()/writel() - x86 ≠ 안전 — x86 TSO가 Store-Store/Load-Load를 자동 차단하므로 x86에서 통과한 코드가 ARM/POWER에서 실패할 수 있습니다.
- 검증 3단계 — ①
READ_ONCE/WRITE_ONCE마킹 → ② KCSAN으로 런타임 탐지 → ③ litmus/herd7로 LKMM 정적 검증
단계별 이해
- 하드웨어 재배열 원인 파악
스토어 버퍼(쓰기 지연)와 무효화 큐(읽기 갱신 지연)가 어떻게 재배열을 유발하는지 이해합니다. "왜 배리어가 필요한가?" 섹션의 SVG 다이어그램을 먼저 보세요. - 재배열 가능성을 항상 가정
"x86이니까 괜찮다"는 생각을 버립니다. ARM/POWER에서의 동작을 기준으로 코드를 설계합니다. - 공유 변수에 READ_ONCE/WRITE_ONCE 마킹
여러 CPU가 접근하는 모든 변수를 먼저 명확히 표시합니다. 이것만으로 컴파일러 재배열과 tearing을 막을 수 있습니다. - 동기화 패턴 선택: 잠금 → RCU → acquire/release → wmb/rmb 순서로 검토
가능하면 상위 추상화(잠금, RCU)를 먼저 사용합니다. lockless 알고리즘이 필요할 때만 직접 배리어를 사용합니다. - release/acquire 쌍 배치
producer 측에smp_store_release(), consumer 측에smp_load_acquire()를 짝으로 배치합니다. happens-before 체인이 완성되는지 확인합니다. - KCSAN으로 런타임 탐지
CONFIG_KCSAN=y로 빌드하여 배리어 누락 데이터 레이스를 런타임에 발견합니다. - ARM/RISC-V에서 실기 검증
약한 메모리 모델 하드웨어에서 동일한 테스트를 실행하여 최종 확인합니다. herd7/klitmus7로 정적 분석도 병행합니다.
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 상세 설명과 아키텍처별 배리어 비교표를 참조하세요.
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 내부에서 왜 재배열이 발생하는지를 알아야 합니다. 주요 원인은 세 가지입니다: 스토어 버퍼, 무효화 큐, 쓰기 결합 버퍼.
스토어 버퍼 (Store Buffer)
CPU가 쓰기 연산을 수행할 때, 캐시 라인이 Exclusive 또는 Modified 상태가 아니면 즉시 캐시에 쓸 수 없습니다. 다른 CPU의 캐시에서 해당 라인의 소유권을 받아와야 하기 때문입니다. 이 대기 시간 동안 CPU가 멈추지 않도록 쓰기 요청을 임시로 보관하는 버퍼가 스토어 버퍼입니다.
스토어 버퍼는 L1 캐시와 완전히 다른 하드웨어 구조입니다. 쓰기 데이터의 전파 경로를 보면 위치 차이가 명확합니다:
실행 유닛 → [스토어 버퍼] → L1 캐시 → L2 → L3 → DRAM
↑ ↑
CPU 내부 전용 MESI 프로토콜 참여
타 CPU에 불가시 캐시 일관성으로 타 CPU에 가시
| 스토어 버퍼 | L1 캐시 | |
|---|---|---|
| 위치 | 실행 유닛과 L1 캐시 사이 | 캐시 계층 첫 단계 (코어 바로 옆) |
| 타 CPU 가시성 | 없음 — drain 전까지 완전 비공개 | MESI 프로토콜로 가시 |
| 목적 | 캐시 라인 소유권 대기 중 CPU 멈춤 방지 | 메모리 접근 지연 감소 |
wmb()와의 관계 | 일부 아키텍처에서는 이런 버퍼가 직관 모델이 되지만, 공식 의미는 이전 store가 이후 store보다 먼저 관찰되도록 제한하는 것 | 해당 없음 |
스토어 버퍼 설명은 많은 아키텍처에서 유용한 직관을 주지만, 커널 코드가 의존해야 하는 계약은 "특정 버퍼가 실제로 언제 비워지느냐"가 아니라 배리어 전후 접근의 관찰 순서입니다. 따라서 하드웨어 내부 구조를 LKMM 자체와 동일시하면 안 됩니다.
스토어 버퍼로 인한 재배열 예시
다음은 x86(TSO)에서도 발생하는 Store-Load 재배열의 고전적 예시입니다. Intel x86 SDM에 공식 수록된 예제입니다.
/* Litmus test: Store Buffer (SB) — x86 TSO에서도 발생 */
/* 초기값: x = 0, y = 0 */
/* CPU 0 */ /* CPU 1 */
WRITE_ONCE(x, 1); WRITE_ONCE(y, 1);
r0 = READ_ONCE(y); r1 = READ_ONCE(x);
/*
* 문제: r0=0, r1=0 이 동시에 가능할까?
*
* CPU 0의 WRITE_ONCE(x,1)이 스토어 버퍼에 대기 중일 때
* CPU 1의 READ_ONCE(x)가 실행되면 x=0을 읽음 (r1=0).
* 마찬가지로 CPU 1의 WRITE_ONCE(y,1)이 스토어 버퍼에 대기 중일 때
* CPU 0의 READ_ONCE(y)가 실행되면 y=0을 읽음 (r0=0).
*
* → r0=0, r1=0 이 동시에 관찰 가능! (x86 TSO에서 실제 발생)
* → 해결: 각 CPU에 smp_mb() 추가 (MFENCE 발행 → 스토어 버퍼 drain)
*/
/* 수정 */
/* CPU 0 */ /* CPU 1 */
WRITE_ONCE(x, 1); WRITE_ONCE(y, 1);
smp_mb(); smp_mb();
r0 = READ_ONCE(y); r1 = READ_ONCE(x);
/* 이제 r0=0, r1=0 은 불가능 */
MESI 프로토콜과 배리어의 관계
캐시 일관성은 MESI(Modified·Exclusive·Shared·Invalid) 프로토콜로 하드웨어가 자동으로 처리합니다. 그러나 프로토콜이 작동하는 타이밍이 프로그래머의 기대와 다를 수 있습니다.
| MESI 상태 | 의미 | 다른 CPU의 읽기 | 배리어 역할 |
|---|---|---|---|
| M (Modified) | 이 CPU만 최신값 보유, 메모리와 불일치 | 스누프로 Write-Back 후 S로 전환 | wmb()로 스토어 버퍼 drain → M→S 플러시 |
| E (Exclusive) | 이 CPU만 보유, 메모리와 일치 | 스누프로 S로 전환 | 일반적으로 문제 없음 |
| S (Shared) | 여러 CPU가 동일 복사본 보유 | 즉시 읽기 가능 | — |
| I (Invalid) | 이 CPU의 복사본 무효 | 메모리/다른 CPU에서 재로드 필요 | rmb()로 무효화 큐 flush 후 읽기 |
스토어 버퍼는 우체통과 같습니다. 편지를 써서 넣었지만 집배원이 아직 수거하지 않은 상태입니다. 다른 사람(CPU)은 우체통 속 편지를 직접 볼 수 없고, 중앙 우체국(공유 캐시)에 도착한 후에야 볼 수 있습니다.
wmb()는 집배원을 즉시 불러 우체통을 비우는 것입니다.
무효화 큐는 "다른 사람이 이 주소의 편지를 갱신했다"는 알림이 쌓여 있는 수신함입니다.
rmb()는 알림을 모두 처리하여 최신 주소록을 확인한 후에 편지를 읽는 것입니다.
스토어 버퍼 vs 무효화 큐 — 대칭 구조 비교
두 버퍼는 메모리 계층에서 L1 캐시를 중심으로 대칭된 위치에 존재합니다. 스토어 버퍼는 쓰기 경로의 출력 측에서 쓰기를 지연시키고, 무효화 큐는 쓰기 경로의 입력 측에서 무효화를 지연시킵니다.
쓰기 결합 버퍼 (Write Combining Buffer, WCB)
쓰기 결합 버퍼(WCB)는 스토어 버퍼·무효화 큐와 달리 캐시 비히트 경로에 특화된 버퍼입니다. 연속된 주소 범위로 향하는 여러 쓰기 연산을 모아 하나의 버스 트랜잭션으로 결합해 메모리 버스 대역폭을 극대화합니다. 그래픽 프레임버퍼처럼 CPU 캐시에 들어오지 않는 WC(Write Combining) 타입 메모리에서 핵심 역할을 합니다.
WC 메모리 타입과 커널 패턴
x86에서 MTRR/PAT 레지스터로 메모리 영역별 캐시 타입을 지정합니다. ioremap_wc()는 디바이스 메모리를 WC 타입으로 매핑하여 WCB의 이점을 활용합니다.
| 메모리 타입 | WCB 사용 | 캐시 가능 | 주요 용도 |
|---|---|---|---|
UC (Uncacheable) |
없음 | 불가 | 레거시 I/O, 강력한 순서 보장 필요 시 |
WC (Write Combining) |
사용 | 불가 | 프레임버퍼, GPU VRAM 쓰기 집중 영역 |
WT (Write Through) |
없음 | 읽기만 | 읽기 집중 디바이스 메모리 |
WB (Write Back) |
없음 | 가능 | 일반 RAM (기본값) |
커널 코드 패턴
/* 프레임버퍼 쓰기: WCB 활용 패턴 */
void __iomem *fb = ioremap_wc(phys_addr, size); /* WC 타입 매핑 */
/* 연속 쓰기 → WCB가 모아서 64B 단위로 전송 */
writel_relaxed(pixel0, fb + 0);
writel_relaxed(pixel1, fb + 4);
writel_relaxed(pixel2, fb + 8);
/* ... */
writel_relaxed(pixel15, fb + 60);
wmb(); /* WCB 플러시: 모든 픽셀이 메모리에 도달 보장 */
/* x86: sfence 명령, ARM64: dmb oshst */
/* GPU DMA 시작 전 wmb() 필수 — GPU가 오래된 픽셀을 읽지 않도록 */
iowrite32(DMA_START, dma_ctrl_reg);
- 스토어 버퍼: 캐시 히트 경로, MESI 프로토콜과 연동, 타 CPU 스누프 가능
- WCB: 캐시 미스 경로(WC/UC 메모리), MESI 외부, 타 CPU 스누프 불가
- 공통점: 둘 다
wmb()/smp_wmb()로 플러시 가능 (x86:sfence) - ARM64는 WCB 개념이 없고 쓰기 버퍼(Write Buffer)로 통합 처리 —
dmb ishst로 드레인
비순서 실행(Out-of-Order) 파이프라인
현대 CPU의 성능 핵심은 비순서 실행(Out-of-Order Execution, OOO)입니다. 프로그램이 기술한 순서와 다른 순서로 명령어를 실행하여 처리량을 극대화하지만, 단일 스레드에서는 데이터 의존성을 지키므로 겉으로는 올바르게 동작합니다. 그러나 멀티 스레드 환경에서는 다른 CPU가 이 재배열을 직접 관찰할 수 있습니다.
파이프라인 구조와 재배열 발생
단일 스레드와 멀티 스레드의 차이
비순서 실행은 단일 스레드에서는 보이지 않습니다. CPU가 ROB를 통해 순서대로 retire하고, 로컬 데이터 의존성을 하드웨어가 추적하기 때문입니다. 하지만 다른 CPU는 Store Buffer에 아직 commit되지 않은 값을 볼 수 없고, 같은 CPU가 이미 캐시에서 무효화된 행을 투기적으로 읽는 것도 볼 수 없습니다.
| 관점 | 단일 스레드 | 멀티 스레드 |
|---|---|---|
| Store 재배열 | 보이지 않음 (ROB가 순서대로 retire) | 스토어 버퍼가 다른 CPU에 비순서 노출 |
| Load 재배열 | 보이지 않음 (로컬 의존성 추적) | 투기적 로드가 다른 CPU의 쓰기보다 먼저 실행 |
| 해결책 | 없음 (자동 보장) | smp_mb/rmb/wmb, acquire/release |
| x86 TSO | 자동 보장 | Store-Load만 허용 (스토어 버퍼) |
| ARM64 | 자동 보장 | 모든 재배열 허용 → 모든 경우 배리어 필요 |
컴파일러 배리어
컴파일러 배리어는 컴파일러의 최적화/재배열만 방지하며, 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(Symmetric Multi-Processing, 대칭형 다중 처리) 변형을 제공합니다.
기본 배리어
| 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 읽기 순서를 보장
* 둘 다 있어야 올바르게 동작!
*/
한쪽 배리어만 사용하면 왜 실패하는가
배리어를 처음 사용할 때 가장 흔한 오해는 "writer 쪽에만 smp_wmb()를 넣으면 충분하다" 또는 "reader 쪽에만 smp_rmb()를 넣으면 충분하다"는 생각입니다. 하지만 메모리 순서 문제는 발행(publish)과 관찰(observe)이 모두 얽힌 문제이므로, 한쪽 CPU의 재배열만 막아서는 전체 happens-before 체인이 완성되지 않습니다.
| 패턴 | 남는 문제 | 왜 실패하는가 |
|---|---|---|
| writer만 배리어 | reader가 ready보다 먼저 data를 읽을 수 있음 | 발행 순서는 맞췄지만 관찰 순서가 없음 |
| reader만 배리어 | writer가 data보다 먼저 ready를 발행할 수 있음 | 관찰 순서는 맞췄지만 발행 순서가 없음 |
| 양쪽 모두 배리어 | 없음 | 발행 순서와 관찰 순서가 모두 연결되어 happens-before 성립 |
/* 케이스 A: writer만 배리어 → reader 쪽 재배열이 남음 */
/* CPU 0 */ /* CPU 1 */
WRITE_ONCE(data, 42); while (!READ_ONCE(ready))
smp_wmb(); cpu_relax();
WRITE_ONCE(ready, 1); use(READ_ONCE(data)); /* stale read 가능 */
/* 케이스 B: reader만 배리어 → writer 쪽 재배열이 남음 */
/* CPU 0 */ /* CPU 1 */
WRITE_ONCE(data, 42); while (!READ_ONCE(ready))
WRITE_ONCE(ready, 1); cpu_relax();
smp_rmb();
use(READ_ONCE(data)); /* stale read 가능 */
/* 케이스 C: 양쪽 모두 배리어 */
/* 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 보장 */
data를 먼저 내보내라"는 계약이고, reader 쪽 배리어는 "ready를 본 뒤에야 data를 읽어라"는 계약입니다.
이 두 계약이 동시에 있어야만 "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() (제거됨) | 현행 커널에서는 제거됨 (역사적 참고) | |
/* 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 종료) |
smp_load_acquire() / smp_store_release()
/*
* include/asm-generic/barrier.h (단순화 — x86 기준 표현)
*
* 실제 구현은 아키텍처마다 완전히 다릅니다:
* x86: barrier() = 컴파일러 배리어만. TSO가 나머지를 보장
* ARM64: smp_load_acquire → LDAR 단일 명령어
* smp_store_release → STLR 단일 명령어
* POWER: smp_load_acquire → ld + lwsync
* smp_store_release → lwsync + st
* 아래 barrier() 기반 표현은 의미론적 동등성만 보여줍니다.
*/
#define smp_load_acquire(p) \
({ \
typeof(*p) ___p = READ_ONCE(*p); \
barrier(); \
___p; \
})
#define smp_store_release(p, v) \
do { \
barrier(); \ /* release: 이전 연산이 store 아래로 내려가지 못하게 */
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가 아닙니다.
* acquire(lock): critical section 이후 연산이 lock 위로 올라갈 수 없음
* 그러나 lock 이전 연산이 아래(critical section)로 내려오는 것은 허용
* release(unlock): critical section 이전 연산이 unlock 아래로 내려갈 수 없음
* 그러나 unlock 이후 연산이 위(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()는 컴파일러 배리어로 충분
x86 TSO에서 무효화 큐는 소프트웨어에 보이지 않는다
x86 프로세서 내부에도 다양한 마이크로아키텍처 버퍼가 있을 수 있지만, 소프트웨어가 보는 계약은 TSO가 Load-Load 재배열을 노출하지 않는다는 점입니다. ARM/POWER 설명에서 사용하는 "무효화 큐"는 약한 모델을 이해하기 위한 대표 직관이며, x86에서의 배리어 의미를 그 구조의 존재 여부로 설명할 필요는 없습니다.
| 재배열 종류 | x86 TSO | ARM/POWER | 주요 원인 |
|---|---|---|---|
| Store-Store | ❌ 불가 | ✅ 가능 | 스토어 버퍼 (비순서 commit) |
| Load-Load | ❌ 불가 | ✅ 가능 | 무효화 큐 (지연 처리) |
| Load-Store | ❌ 불가 | ✅ 가능 | 비순서 실행 |
| Store-Load | ✅ 가능 | ✅ 가능 | 스토어 버퍼 |
smp_rmb()의 핵심은 Load-Load 순서를 추가로 보장하는 것입니다. x86은 이를 하드웨어 메모리 모델이 이미 제공하므로 컴파일러 장벽으로 충분하고, ARM64는 DMB ISHLD 같은 명령어가 필요합니다. 이를 "무효화 큐를 직접 flush한다"는 식으로 일반화하면 배리어의 공식 의미보다 구현 세부가 앞서게 됩니다.
/* x86: smp_rmb() = barrier() = asm volatile("" ::: "memory") (CPU 명령어 없음) */
/* ARM64: smp_rmb() = asm volatile("dmb ishld" ::: "memory") (실제 명령어 필요) */
/* 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) */
DMB/DSB 파라미터 체계: Shareability Domain × Access Type
ARM64의 DMB/DSB는 두 개의 파라미터를 조합하여 배리어의 범위를 정밀하게 지정합니다:
DMB / DSB [Shareability Domain] [Access Type]
↑ ↑
관찰자 범위 접근 종류 제한
Shareability Domain — 배리어가 순서를 보장해야 할 관찰자 범위:
| 도메인 | 포함 대상 | 대표 용도 |
|---|---|---|
ISH (Inner Shareable) | 캐시 일관성을 공유하는 CPU 클러스터 (보통 동일 SoC 내) | smp_* — CPU 간 동기화 |
OSH (Outer Shareable) | 외부 공유 도메인 (일부 GPU 등) | 드물게 사용 |
SY (System) | 시스템 전체 — GPU, DMA 컨트롤러, I/O 장치 포함 | mb()/rmb()/wmb() — MMIO/DMA |
Access Type — 배리어가 순서를 제한할 메모리 접근 종류:
| 접미사 | 의미 | 막는 재배열 |
|---|---|---|
LD | Load 관련 접근만 | Load-Load, Store-Load 순서 보장 |
ST | Store 관련 접근만 | Store-Store 순서 보장 |
| (없음) | Load + Store 모두 | 모든 재배열 방지 |
DSB vs DMB: 파이프라인 직렬화 차이
Shareability Domain과 Access Type이 같더라도, DMB와 DSB는 근본적으로 다릅니다. 이 차이는 파라미터가 아니라 명령어 자체의 강도에서 옵니다:
| DMB | DSB | |
|---|---|---|
| 메모리 접근 순서 보장 | ✅ | ✅ |
| 캐시 유지보수(DC, IC) 완료 대기 | ❌ | ✅ |
| TLB 유지보수(TLBI) 완료 대기 | ❌ | ✅ |
| 파이프라인 직렬화 (이후 명령 블록) | ❌ | ✅ |
smp_* 구현 | ✅ 충분 | 과도함 |
MMIO/DMA (mb()) | 부족 | ✅ 필요 |
| 페이지 테이블·TLB 조작 | 부족 | ✅ 필요 |
ISB: 명령어 파이프라인 배리어
ARM64에는 DMB/DSB 외에 세 번째 배리어 명령어인 ISB(Instruction Synchronization Barrier)가 있습니다. ISB는 메모리 배리어가 아닙니다. 데이터 접근 순서가 아니라 명령어 파이프라인 자체를 리셋하는 것이 목적입니다.
/*
* ISB 효과:
* ISB 이전에 fetch/decode된 명령어를 파이프라인에서 모두 버리고
* ISB 이후 명령어를 처음부터 다시 fetch하여 실행
*
* → CPU가 "지금 이 시점의 시스템 상태"를 기준으로 명령어를 다시 읽도록 강제
*/
파이프라인은 명령어를 미리 fetch하고 decode합니다. 이때 아직 적용되지 않은 상태를 기준으로 읽어들일 수 있어서 문제가 발생합니다. ISB가 필요한 세 가지 대표 상황:
1. 코드 자체가 바뀐 경우 (JIT, 커널 코드 패치)
/* arch/arm64/kernel/insn.c 패턴 */
aarch64_insn_patch_text(addr, new_insn); /* 새 명령어를 메모리에 씀 */
dsb(ish); /* 쓰기 완료 + I-cache coherency 보장 */
isb(); /* 파이프라인 flush → 이후 실행은 반드시 새 코드 */
/* ISB 없으면: 파이프라인에 이미 fetch된 옛 명령어가 실행될 수 있음 */
2. 시스템 레지스터 변경 후
/* SCTLR_EL1 변경 (MMU on/off, 캐시 활성화 등) */
write_sysreg(val, sctlr_el1);
isb(); /* 이후 명령어는 새 SCTLR 기준으로 fetch/실행 */
/* ISB 없으면: 파이프라인이 이미 fetch한 명령어들은 */
/* 이전 SCTLR 기준(예: MMU off)으로 실행될 수 있음 */
3. TLB 무효화 직후 (DSB + ISB 쌍으로 사용)
/* 페이지 테이블 변경 → TLB 무효화 → 새 매핑으로 실행 */
set_pte(pte, new_entry);
dsb(ishst); /* pte 쓰기 완료 */
tlbi(vmalle1is); /* TLB 무효화 (전체 CPU에 broadcast) */
dsb(ish); /* TLBI 완료 대기 (DSB만 TLBI를 기다릴 수 있음, DMB 불가) */
isb(); /* 파이프라인 flush → 이후 접근은 새 페이지 테이블 기준 */
| DMB | DSB | ISB | |
|---|---|---|---|
| 대상 | 메모리 접근 순서 | 메모리 + 캐시/TLB 유지보수 | 명령어 파이프라인 |
| 파이프라인 flush | ❌ | ❌ | ✅ |
| 메모리 접근 순서 보장 | ✅ | ✅ | ❌ |
| 시스템 레지스터 반영 | ❌ | ❌ | ✅ |
| 코드 패치 후 필요 | ❌ | DSB 선행 필요 | ✅ (DSB 다음에) |
| TLB 무효화 후 | ❌ | ✅ (TLBI 완료 대기) | ✅ (DSB 다음에) |
ISB는 파이프라인을 비우지만, ISB 이전에 아직 완료되지 않은 TLBI나 캐시 유지보수가 있다면 파이프라인을 비운 후 다시 fetch할 때 여전히 낡은 상태가 반영될 수 있습니다. 따라서 DSB로 모든 유지보수 연산을 완료시킨 후에 ISB로 파이프라인을 리셋해야 합니다.
TLBI → DSB → ISB가 ARM64 커널 전체에서 반복되는 관용 패턴인 이유입니다.
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")
fence [pred], [succ] — predecessor와 successor 순서를 지정합니다.
각 필드는 i(device input), o(device output), r(memory reads), w(memory writes) 비트의 조합입니다.
예: fence ir, ir은 predecessor의 I/O reads+memory reads가 successor의 I/O reads+memory reads보다 먼저 완료됨을 보장합니다.
SMP 배리어(smp_*)는 디바이스 I/O 필드 없이 r/w만 사용합니다.
POWER 아키텍처: lwsync vs sync
IBM POWER는 현존하는 서버 아키텍처 중 가장 약한 메모리 모델을 가집니다. 투기적 실행, 멀티 스레드 SMT, 캐시 계층 구조가 복합적으로 작용하여 매우 다양한 재배열이 관찰됩니다. POWER 배리어는 세 종류가 있습니다:
/* arch/powerpc/include/asm/barrier.h (단순화) */
/*
* sync (heavyweight sync): 완전한 메모리 배리어
* - 모든 종류의 Store-Store, Load-Load, Load-Store, Store-Load 재배열 방지
* - 투기적 로드도 완전히 차단
* - 매우 비용이 큼 (수십~수백 사이클)
* - mb(), smp_mb() 구현에 사용
*/
#define mb() asm volatile("sync" ::: "memory")
/*
* lwsync (lightweight sync): Store-Load 제외한 배리어
* - Store-Store, Load-Load, Load-Store 재배열 방지
* - Store-Load 재배열은 허용 (sync보다 저렴)
* - smp_wmb(), smp_rmb() 구현에 사용
* - 주의: lwsync는 store→load 순서를 보장하지 않으므로
* smp_mb() 대신 lwsync를 쓰면 SB(Store Buffer) 문제가 발생!
*/
#define smp_wmb() asm volatile("lwsync" ::: "memory")
#define smp_rmb() asm volatile("lwsync" ::: "memory")
/*
* isync (instruction synchronization):
* - 선행 명령어 완료 + 후속 명령어를 새로 fetch/decode
* - 조건 분기 후 투기적 실행을 막는 데 사용
* - 메모리 배리어가 아니라 "명령어 캐시" 배리어에 가까움
* - spin_lock acquire 구현에 사용 (투기적 진입 방지)
*/
#define isync() asm volatile("isync" ::: "memory")
/*
* eieio (Enforce In-order Execution of I/O):
* - I/O 메모리 접근에 특화된 배리어 (Store-Store I/O 전용)
* - 일반 캐시 가능 메모리에는 효과 없음
* - writel()/iowrite32() 내부 구현
*/
#define eieio() asm volatile("eieio" ::: "memory")
| 명령어 | SS | LL | LS | SL | I/O | 비용 | 용도 |
|---|---|---|---|---|---|---|---|
sync | ✅ | ✅ | ✅ | ✅ | ✅ | 매우 높음 | mb(), smp_mb() |
lwsync | ✅ | ✅ | ✅ | ❌ | ❌ | 중간 | smp_wmb(), smp_rmb() |
isync | ❌ | ❌ | ❌ | ❌ | ❌ | 낮음 | 분기 후 투기 차단 |
eieio | ✅(I/O) | ❌ | ❌ | ❌ | ✅ | 낮음 | MMIO writel() |
SS=Store-Store, LL=Load-Load, LS=Load-Store, SL=Store-Load 순서 보장 여부.
smp_load_acquire() = ld; cmp; bc; isync — 조건 분기 + isync로 투기적 로드 차단smp_store_release() = lwsync; st — lwsync로 이전 연산이 store 전에 완료됨 보장ARM64의 LDAR/STLR에 비해 훨씬 복잡하고 비용이 큽니다.
아키텍처별 배리어 비교표
| 커널 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 | 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 |
† POWER smp_mb(): 일반적으로 sync 명령어를 사용합니다.
smp_rmb()/smp_wmb()에는 lwsync를 사용합니다
(arch/powerpc/include/asm/barrier.h 참조).
x86에서 테스트한 코드가 ARM이나 POWER에서 실패하는 일이 빈번합니다. x86의 TSO 모델이 많은 재배열을 자연적으로 차단하기 때문에, 배리어 누락 버그가 x86에서는 드러나지 않습니다. 반드시 약한 메모리 모델 아키텍처에서도 검증해야 합니다.
C11 메모리 모델과 Linux 커널 대응
C11 표준(<stdatomic.h>)은 사용자 공간 프로그램을 위한 공식 메모리 모델을 제공합니다. Linux 커널은 C11 모델을 직접 사용하지 않고 자체 API를 제공하지만, 두 모델은 밀접하게 대응됩니다.
C11 memory_order와 커널 API 대응
memory_order_consume 주의:
C11의 consume은 데이터 의존성 체인(포인터를 통한 역참조)을 통해서만 순서를 보장하는 약한 acquire입니다. 그러나 GCC/Clang을 포함한 대부분의 컴파일러가 의존성 추적의 복잡성 때문에 consume을 acquire로 승격시켜 구현합니다. 이 때문에 성능 이점이 사라지고 의미도 불명확해져 C11 consume은 사실상 사용 불가 상태입니다.Linux 커널은 이 문제를 피하기 위해
rcu_dereference()와 LKMM의 ppo(preserved program order) 관계를 통해 데이터 의존성을 독자적으로 모델링합니다. READ_ONCE()는 컴파일러가 의존성을 끊지 못하도록 보호하지만 그 자체가 consume 의미론을 제공하지는 않습니다.
| C11 memory_order | 의미 | Linux 커널 대응 | 비고 |
|---|---|---|---|
memory_order_relaxed | 배리어 없음, 원자성만 보장 | atomic_read(), atomic_set() | 카운터 갱신 등 |
memory_order_consume | 데이터 의존성 배리어 (포인터 역참조 체인) | rcu_dereference() | 포인터 역참조 — READ_ONCE()는 consume이 아님, 주의 |
memory_order_acquire | acquire 배리어 (단방향) | smp_load_acquire() | lock 획득, 플래그 읽기 |
memory_order_release | release 배리어 (단방향) | smp_store_release() | lock 해제, 플래그 설정 |
memory_order_acq_rel | acquire+release (양방향) | atomic_xchg(), cmpxchg() | read-modify-write |
memory_order_seq_cst | 완전 순서 보장 (전역) | smp_mb() + atomic_*_return() | 가장 강한 보장 |
코드 비교: C11 vs Linux 커널
/* ====== C11 (사용자 공간) ====== */
#include <stdatomic.h>
atomic_int data = ATOMIC_VAR_INIT(0);
atomic_int ready = ATOMIC_VAR_INIT(0);
/* Producer */
void producer(void) {
atomic_store_explicit(&data, 42, memory_order_relaxed);
atomic_store_explicit(&ready, 1, memory_order_release); /* release */
}
/* Consumer */
void consumer(void) {
while (!atomic_load_explicit(&ready, memory_order_acquire)) /* acquire */
;
int val = atomic_load_explicit(&data, memory_order_relaxed);
/* val == 42 보장 */
}
/* ====== Linux 커널 (동일한 패턴) ====== */
int data;
int ready;
/* Producer */
void producer(void) {
WRITE_ONCE(data, 42);
smp_store_release(&ready, 1); /* release */
}
/* Consumer */
void consumer(void) {
while (!smp_load_acquire(&ready)) /* acquire */
cpu_relax();
int val = READ_ONCE(data);
/* val == 42 보장 */
}
sequential consistency와 완전 배리어
/* C11: memory_order_seq_cst — 전역 전체 순서 보장 */
atomic_store(&x, 1); /* seq_cst store */
int r = atomic_load(&y); /* seq_cst load */
/* = x86: MFENCE 포함 */
/* Linux 커널 대응 */
smp_store_mb(x, 1); /* store + smp_mb() (일부 아키텍처) */
smp_mb();
r = READ_ONCE(y);
/*
* 차이점:
* C11 seq_cst는 모든 seq_cst 연산에 걸쳐 전역 전체 순서를 보장.
* Linux smp_mb()는 특정 쌍 사이의 순서만 보장.
* LKMM(Linux Kernel Memory Model)은 C11보다 더 정교한 부분 순서 모델.
*/
Linux 커널은 C11 이전부터 독자적 원자 API를 발전시켰습니다. 또한 커널에서는 아키텍처별 최적화, UP/SMP 전환, 인터럽트 컨텍스트 지원 등 C11 표준이 다루지 않는 요구사항이 있습니다. Linux v5.x부터는 내부적으로 C11 원자와의 호환성을 점진적으로 강화하고 있습니다.
LL/SC — Load-Link/Store-Conditional
ARM64, RISC-V, PowerPC 등 RISC 계열 아키텍처는 원자 연산을 LL/SC(Load-Link/Store-Conditional) 쌍으로 구현합니다. x86의 LOCK 접두사와 달리, LL/SC는 버스 잠금 없이 하드웨어 exclusivity monitor를 활용합니다.
| 아키텍처 | Load-Link | Store-Conditional | 배리어 통합 버전 |
|---|---|---|---|
| ARM64 | LDXR | STXR | LDAXR / STLXR (acquire/release) |
| RISC-V | LR.W | SC.W | LR.W.AQ / SC.W.RL |
| PowerPC | lwarx | stwcx. | lwarx + sync |
| x86 | LOCK CMPXCHG (버스 잠금, LL/SC 아님) | 자동으로 전체 배리어 | |
/* 커널의 cmpxchg() → 아키텍처별 LL/SC 또는 LOCK 명령어로 컴파일 */
int old = cmpxchg(&var, expected, new_val);
/* ARM64에서의 실제 구현 (ldaxr/stlxr): */
/* ldaxr w0, [x1] ; acquire load + exclusive monitor */
/* cmp w0, w2 ; expected와 비교 */
/* b.ne fail ; 불일치 시 실패 */
/* stlxr w3, w4, [x1] ; release store conditional */
/* cbnz w3, retry ; exclusive monitor 실패 시 재시도 */
/* LL/SC는 ABA-safe: B → A로 다시 바뀌면 SC가 실패 */
/* (x86 CMPXCHG는 ABA를 감지 못함 — 별도 버전 카운터 필요) */
/* try_cmpxchg: 실패 시 현재값을 old에 업데이트 */
if (!try_cmpxchg(&var, &expected, new_val)) {
/* expected에 현재값이 들어 있으므로 재시도 로직 구성 */
}
LL/SC와 메모리 순서
ARM64의 LDAXR(acquire load-exclusive) / STLXR(release store-exclusive) 조합은 lock-acquire semantics를 단일 명령어로 제공합니다. 이 덕분에 커널의 atomic_cmpxchg_acquire() / atomic_cmpxchg_release()가 ARM64에서 추가 배리어 없이 구현됩니다.
/* 커널 atomic API → ARM64 명령어 매핑 */
/* acquire (이후 접근이 앞에 오지 않도록) */
atomic_cmpxchg_acquire(&v, old, new); /* → ldaxr/stxr */
/* release (이전 접근이 뒤로 밀리지 않도록) */
atomic_cmpxchg_release(&v, old, new); /* → ldxr/stlxr */
/* full barrier (양방향) */
atomic_cmpxchg(&v, old, new); /* → ldaxr/stlxr + dmb ish */
LL/SC의 ABA 안전성: x86 LOCK CMPXCHG는 값이 같으면 성공하므로 ABA 문제(A→B→A)를 감지하지 못합니다. 반면 ARM64 LL/SC는 하드웨어 exclusivity monitor가 다른 CPU의 개입을 감지하므로, B→A로 되돌아온 경우에도 SC가 실패할 수 있어 ABA에 더 강합니다 (단 보장은 아님).
Happens-Before 관계와 동기화 연결
Happens-Before(HB)는 "연산 A가 연산 B보다 먼저 일어난 것이 다른 스레드에게 관찰 가능하다"는 형식적 관계입니다. 배리어는 happens-before 관계를 프로그래머가 명시적으로 생성하는 도구입니다.
Happens-Before 성립 조건
| 조건 | 설명 | 커널 API 예 |
|---|---|---|
| 프로그램 순서 (PO) | 같은 CPU/스레드에서 먼저 실행된 연산은 이후 연산에 HB | 일반 순차 코드 |
| Release → Acquire 동기화 | release 쓰기를 acquire 읽기가 관찰하면 release 이전 연산이 acquire 이후 연산에 HB | smp_store_release + smp_load_acquire |
| 잠금 (lock/unlock) | unlock이 다음 lock보다 HB → critical section 내용이 이후 보유자에게 가시 | spin_lock/unlock, mutex_lock/unlock |
| Full Barrier 페어링 | wmb()로 기록 후 rmb()로 읽으면 HB 성립 | smp_wmb + smp_rmb |
| RCU | rcu_assign_pointer → synchronize_rcu → rcu_dereference 체인으로 HB 성립 | RCU 읽기 측 lock-free |
Happens-Before 예시 분석
/*
* [초급] smp_store_release + smp_load_acquire의 HB 체인
*
* CPU 0: CPU 1:
* data = 42; ─── (A) while (!smp_load_acquire(&flag))
* smp_store_release(&flag, 1); (B) cpu_relax(); ─── (C)
* use(data); ─── (D)
*
* B를 C가 관찰(flag=1 읽기) → B release : C acquire 동기화 성립
* A → B (프로그램 순서, PO)
* B →sync C (release-acquire 쌍)
* C → D (프로그램 순서, PO)
* 따라서 A →hb D: CPU 1에서 D를 실행할 때 data=42 관찰 가능 (보장!)
*
* [중급] 배리어가 없으면 HB 없음:
* WRITE_ONCE(data, 42)과 WRITE_ONCE(flag, 1) 사이 wmb() 없으면
* ARM/POWER에서 flag=1을 본 후에도 data=0을 읽을 수 있음
* → HB 체인이 끊어짐!
*/
/* [고급] RCU의 happens-before 체인 */
/* 업데이터 */
struct foo *new = kmalloc(...);
new->val = 42; /* (A) 데이터 초기화 */
rcu_assign_pointer(global, new); /* (B) smp_store_release 포함 */
synchronize_rcu(); /* (C) grace period: 모든 reader 완료 대기 */
kfree(old); /* (D) 안전하게 해제 */
/* 읽기 측 */
rcu_read_lock();
struct foo *p = rcu_dereference(global); /* (E) smp_load_acquire 포함 */
if (p) use(p->val); /* (F) val=42 관찰 가능 */
rcu_read_unlock();
/*
* A →po B: 프로그램 순서
* B →sync E: rcu_assign_pointer(release) ↔ rcu_dereference(acquire)
* E →po F: 프로그램 순서
* ∴ A →hb F: new->val=42를 F에서 반드시 관찰
*
* C(synchronize_rcu) 이후 D: 모든 reader가 p를 참조하지 않음이 보장
* ∴ kfree(old) 안전
*/
hb 관계를 prop(전파), pb(전파 이전), co(코히런스 순서), fr(읽기 이후) 관계의 합성으로 수학적으로 정의합니다. tools/memory-model/linux-kernel.cat에서 형식적 정의를 확인할 수 있습니다.
Linux Kernel Memory Model (LKMM)
LKMM은 Linux 커널의 공식 메모리 모델입니다. 커널 소스 트리의 tools/memory-model/에 위치하며, 커널의 동시성 프리미티브가 제공하는 순서 보장을 수학적으로 정의합니다.
LKMM 구성 요소
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 지원 및 관련 API 정리(v5.9)로 해당 매크로는 제거되었고, 현재는 의존성 기반 읽기에서 READ_ONCE()/rcu_dereference() 패턴을 사용합니다.
제어 의존성 (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)"로 최적화 → 의존성 소멸! */
의존성 규칙을 실전 코드에 적용하는 방법
실무에서는 주소 의존성만 믿고 코드를 짜기보다, 읽은 값으로 다른 데이터를 공개적으로 해석하는 순간에는 acquire/release로 승격하는 편이 안전합니다. 특히 "플래그를 보고 구조체 필드를 읽는다" 형태는 주소 의존성이 아니라 제어 의존성이므로 smp_rmb() 또는 smp_load_acquire()가 필요합니다.
/* 잘못된 패턴: flag가 true이면 payload를 읽음 */
if (READ_ONCE(msg->ready)) {
u32 payload = READ_ONCE(msg->payload); /* 읽기→읽기 순서 미보장 */
consume(payload);
}
/* 수정 1: 가장 권장되는 acquire/release 쌍 */
/* writer */
WRITE_ONCE(msg->payload, payload);
smp_store_release(&msg->ready, 1);
/* reader */
if (smp_load_acquire(&msg->ready)) {
u32 payload = READ_ONCE(msg->payload);
consume(payload);
}
/* 수정 2: 기존 flag 프로토콜을 유지해야 하면 rmb()를 명시 */
if (READ_ONCE(msg->ready)) {
smp_rmb();
u32 payload = READ_ONCE(msg->payload);
consume(payload);
}
왜 제어 의존성만으로는 부족한가
-
1-4행
if (ready)는 분기만 만들 뿐, CPU가payload읽기를 앞당기지 못하게 하는 강한 읽기 장벽이 아닙니다. 특히 ARM, POWER에서는 분기 예측과 speculative load 때문에 취약합니다. -
7-13행
smp_store_release()는 writer의 payload 초기화를 ready 쓰기 앞에 고정하고,smp_load_acquire()는 reader의 payload 읽기를 ready 확인 뒤로 고정합니다. 가장 읽기 쉬운 정답 패턴입니다. -
16-20행
기존 플래그 프로토콜을 유지해야 한다면
smp_rmb()로 읽기 순서를 직접 세웁니다. 다만 새 코드라면 acquire/release를 우선 선택하는 편이 의도가 더 명확합니다.
Per-CPU 변수와 메모리 순서
Linux 커널의 per-CPU 변수(DEFINE_PER_CPU)는 각 CPU 전용 메모리 영역을 사용하므로, 같은 CPU 내에서는 원자적 접근이나 락 없이 안전하게 사용할 수 있습니다. 그러나 다른 CPU가 관찰할 때는 여전히 배리어가 필요합니다.
Per-CPU 변수 기본 접근
/* 선언 */
DEFINE_PER_CPU(int, my_counter);
DEFINE_PER_CPU(struct my_stats, cpu_stats);
/* 현재 CPU에서 접근 (인터럽트 비활성화 불필요) */
int val = this_cpu_read(my_counter);
this_cpu_write(my_counter, 42);
this_cpu_inc(my_counter);
/* 원자 연산 (인터럽트 컨텍스트에서도 안전) */
this_cpu_add(my_counter, 10);
int old = this_cpu_xchg(my_counter, 0); /* 교환 */
/*
* 주의: this_cpu_* 연산은 선점(preemption) 비활성화를 전제합니다.
* 선점이 활성화된 컨텍스트에서는 get_cpu()/put_cpu()로 감싸야 합니다.
*/
int cpu = get_cpu(); /* preempt_disable() + smp_processor_id() */
this_cpu_inc(my_counter);
put_cpu(); /* preempt_enable() */
Per-CPU 변수에서 배리어가 필요한 경우
/*
* 시나리오: CPU A가 per-CPU 변수를 갱신하고, CPU B가 이를 관찰
* (예: 워크큐, 스케줄러 통계, 네트워크 큐)
*/
/* ===== CPU A (갱신) ===== */
this_cpu_write(work_pending, 1);
smp_wmb(); /* per-CPU 쓰기가 다른 CPU에 보이도록 */
/* ===== CPU B (폴링) ===== */
for_each_online_cpu(cpu) {
smp_rmb(); /* CPU A의 쓰기를 최신 상태로 읽기 위해 */
if (per_cpu(work_pending, cpu)) {
schedule_work_on(cpu, &work);
}
}
/*
* per-CPU 통계 집계 패턴 (예: 네트워크 드라이버 rx_packets)
*/
static DEFINE_PER_CPU(u64, rx_packets);
/* RX 인터럽트 핸들러 (해당 CPU에서 실행) */
static irqreturn_t rx_irq(int irq, void *dev)
{
this_cpu_inc(rx_packets);
/* 배리어 불필요: 이 CPU에서만 쓰기 */
return IRQ_HANDLED;
}
/* ethtool 통계 수집 (다른 컨텍스트) */
u64 get_total_rx(void)
{
u64 total = 0;
int cpu;
for_each_possible_cpu(cpu)
total += per_cpu(rx_packets, cpu);
/* 여기서는 배리어 없어도 OK: 통계는 근사치 허용 */
/* 정확한 값이 필요하다면 smp_mb()로 감싸야 함 */
return total;
}
| 상황 | 배리어 필요 여부 | 이유 |
|---|---|---|
| 같은 CPU에서 읽고 쓰기 | 불필요 | 동일 CPU → 순서 자동 보장 |
| 다른 CPU에서 per-CPU 변수 읽기 | smp_rmb() 필요 | 다른 CPU의 캐시 갱신 지연 |
| 다른 CPU가 볼 값 쓴 후 신호 | smp_wmb() 필요 | 스토어 버퍼 drain 필요 |
| this_cpu_cmpxchg() | 내장 없음 | 원자성만 보장, 배리어는 별도 |
실전 예시: 스케줄러와 네트워크 드라이버
스케줄러 태스크 깨우기 (try_to_wake_up)
태스크 상태 변경은 다른 CPU가 관찰하는 대표적인 per-CPU 순서 문제입니다. try_to_wake_up()은 잠든 태스크를 런큐에 올리기 전에 smp_mb()로 양방향 순서를 보장합니다.
/*
* 커널 kernel/sched/core.c: try_to_wake_up() 핵심 패턴
*
* 문제: CPU A가 태스크를 잠들게 한 후 CPU B가 깨울 때
* 태스크의 state/on_rq 관찰 순서가 어긋나면 안 됨
*/
/* ── CPU A (태스크 잠들기) ── */
set_current_state(TASK_INTERRUPTIBLE); /* smp_store_mb(): state 쓰고 full barrier */
if (condition_met())
__set_current_state(TASK_RUNNING); /* barrier() only — 컴파일러 수준 */
else
schedule(); /* 실제 잠들기 */
/* ── CPU B (태스크 깨우기, try_to_wake_up 내부) ── */
unsigned long flags;
raw_spin_lock_irqsave(&p->pi_lock, flags);
/* p->on_cpu 확인 전에 acquire barrier (lock 자체가 보장) */
if (!(p->state & state)) /* 깨울 수 있는 상태인지 확인 */
goto out;
/* READ_ONCE: p->on_rq 를 반드시 레지스터 캐시 없이 읽음 */
if (READ_ONCE(p->on_rq) && ttwu_runnable(p, wake_flags))
goto out_stat;
smp_mb(); /* p->state 읽기와 p->on_cpu 읽기 사이 full barrier */
/* CPU A가 set_current_state의 smp_store_mb와 동기화 */
if (READ_ONCE(p->on_cpu)) {
cpu_relax(); /* 아직 실행 중: 마이그레이션 대기 */
}
WRITE_ONCE(p->state, TASK_RUNNING);
ttwu_queue(p, cpu, wake_flags); /* 런큐에 삽입 */
out:
raw_spin_unlock_irqrestore(&p->pi_lock, flags);
set_current_state(TASK_X)는 smp_store_mb(¤t->state, TASK_X)로 구현됩니다. 이는 state를 기록한 뒤 smp_mb()를 실행하여 "잠들기 전의 모든 작업이 타 CPU에 보이도록" 보장합니다. CPU B의 ttwu가 state를 읽기 전 smp_mb()와 페어링되어 교차 CPU 동기화를 완성합니다.
네트워크 드라이버: TX 링 버퍼 완료 처리
NAPI 기반 네트워크 드라이버에서 TX 완료 처리는 per-CPU 통계와 링 버퍼 상태를 갱신합니다. 하드웨어 디스크립터와 소프트웨어 포인터 간 순서가 어긋나면 더블 프리(double-free) 또는 패킷 손실이 발생합니다.
/*
* TX 링 버퍼 완료 처리 패턴 (e.g. igb, e1000e, mlx5 드라이버)
*
* TX 링: [desc0][desc1][desc2]...[descN]
* ↑ head(하드웨어가 업데이트)
* ↑ tail(소프트웨어가 업데이트)
*/
struct tx_ring {
struct tx_desc *desc; /* DMA 디스크립터 배열 */
struct sk_buff **skbuff; /* sk_buff 포인터 배열 */
u32 next_to_clean; /* per-CPU, 소프트웨어 전용 */
u32 next_to_use; /* TX 큐 tail */
};
static void tx_clean(struct tx_ring *ring, int budget)
{
u32 ntc = ring->next_to_clean;
while (budget--) {
struct tx_desc *desc = &ring->desc[ntc];
/* 디스크립터의 DD(Descriptor Done) 비트 확인
* — 하드웨어가 DMA 완료 후 쓰는 값 */
if (!(READ_ONCE(desc->status) & TX_DESC_DONE))
break;
/* DD 비트 읽기 이후 다른 필드 접근 전에 rmb()
* — 하드웨어가 DD를 먼저 쓰고 나머지 필드를 나중에 쓸 수 있음 */
dma_rmb();
dev_kfree_skb_any(ring->skbuff[ntc]);
ring->skbuff[ntc] = NULL;
this_cpu_inc(ring->stats->tx_done); /* 배리어 불필요: 이 CPU만 쓰기 */
ntc = (ntc + 1) % ring->count;
}
/* next_to_clean 갱신은 WRITE_ONCE로 원자성 확보 */
WRITE_ONCE(ring->next_to_clean, ntc);
/* 다른 CPU의 TX 큐 활성화 신호 전 wmb():
* next_to_clean 갱신이 가시화된 후 wakeup 신호 전송 */
smp_wmb();
if (netif_tx_queue_stopped(ring->txq))
netif_tx_wake_queue(ring->txq);
}
dma_rmb()는 디바이스(DMA 엔진)와 CPU 사이의 순서를 보장합니다. smp_rmb()는 CPU들 사이만 보장하므로 비-coherent DMA에서는 부족합니다. 실제 드라이버에서 DD 비트 확인 후에는 반드시 dma_rmb()를 사용해야 합니다.
I/O 배리어
I/O 배리어는 CPU와 디바이스 간의 메모리 매핑된 I/O(MMIO) 접근 순서를 보장합니다. SMP 배리어와 달리 UP 시스템에서도 반드시 필요합니다.
MMIO 순서 보장
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 매핑을 사용할 때 적합합니다.
DMA 디스크립터 링과 MMIO 도어벨 패턴
실제 네트워크/NVMe/가속기 드라이버에서 가장 자주 만나는 순서 문제는 "일반 RAM에 있는 디스크립터"와 "MMIO 도어벨 레지스터"를 함께 다룰 때 발생합니다. 여기서는 dma_wmb()와 writel()이 서로 다른 층위의 순서를 담당합니다.
/* TX 제출: 일반 메모리의 디스크립터를 채운 뒤 MMIO 도어벨을 울림 */
struct tx_desc *desc = &ring->desc[prod];
desc->addr = cpu_to_le64(dma_addr);
desc->len = cpu_to_le32(len);
desc->cookie = cpu_to_le32(cookie);
desc->flags = cpu_to_le32(TXD_OWN | TXD_INT);
dma_wmb(); /* 디스크립터 필드가 디바이스에 먼저 보이도록 */
WRITE_ONCE(ring->prod, next); /* 소프트웨어 shadow index */
writel(next, ring->db); /* MMIO doorbell — 이제 디바이스가 읽어도 안전 */
/* 완료 처리: 디바이스 status를 확인한 뒤 결과 필드를 읽음 */
if (READ_ONCE(desc->status) & TXD_DONE) {
dma_rmb(); /* status 이후 result/error 필드 읽기 */
result = le32_to_cpu(desc->result);
error = le32_to_cpu(desc->error);
}
이 패턴에서 배리어가 각각 맡는 역할
- 1-6행 디스크립터는 CPU 입장에서는 일반 캐시 가능한 메모리입니다. 단순 대입만으로는 디바이스가 같은 순서로 본다고 가정할 수 없습니다.
-
8행
dma_wmb()는 "디스크립터 필드를 다 써 놓았으니 이제 디바이스가 읽어도 된다"는 지점을 만듭니다.smp_wmb()로는 디바이스 관찰 순서를 보장할 수 없습니다. -
9-10행
writel()은 MMIO 접근입니다. 디스크립터가 RAM에 준비된 뒤에야 도어벨을 울려야 하므로, shared-memory 배리어와 MMIO 쓰기가 연달아 나와야 합니다. -
13-16행
완료 비트만 먼저 보이고 나머지 결과 필드는 아직 낡았을 수 있으므로
dma_rmb()가 필요합니다. 이 패턴이 빠지면 "DONE인데 result는 이전 요청 값" 같은 버그가 나옵니다.
dma_wmb()는 공유 메모리(RAM) 쪽 순서를 맞추고, writel() 또는 wmb() + writel_relaxed()는 MMIO 쪽 순서를 맞춥니다.
둘 중 하나만 있어도 불완전합니다.
배리어와 동기화 프리미티브
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); ← lock 바깥!
spin_lock(&lock); spin_lock(&lock); ← acquire
WRITE_ONCE(b, 2); r1 = READ_ONCE(b); // b=2이면
spin_unlock(&lock); ← release r2 = READ_ONCE(a); // a=1 보장?
spin_unlock(&lock);
잠금의 acquire/release는 critical section 내부의 연산만 순서 보장합니다.위 예에서
WRITE_ONCE(a,1)은 lock 바깥이므로 CPU 1에서 r2=a=1이 보장되지 않습니다.b=2를 본 후 a=1도 보려면 CPU 1에서
smp_mb()를 r1과 r2 사이에 추가하거나, CPU 0에서 a 쓰기도 lock 안으로 이동해야 합니다.
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 배리어 포함 */
completion 메커니즘과 배리어
complete() / wait_for_completion()은 드라이버·블록·네트워크 서브시스템에서 광범위하게 사용되는 동기화 프리미티브로, 내부에 smp_mb()를 내장합니다. 별도로 smp_mb()를 추가할 필요가 없습니다.
/*
* complete() / wait_for_completion() — 내부에 smp_mb() 내장
* complete() : release 의미론 — 이전 연산이 waiter에게 반드시 보임
* wait_for_completion(): acquire 의미론 — 깨어난 후 이전 연산이 보임
*/
static DECLARE_COMPLETION(init_done);
/* Thread A: 초기화 수행 후 통지 */
void init_worker(void *arg)
{
device_setup(); /* 이 연산은 complete() 이전에 보임이 보장됨 */
WRITE_ONCE(ready, 1);
complete(&init_done); /* smp_mb() 내장: release 의미론 */
}
/* Thread B: 초기화 완료 대기 후 사용 */
void use_device(void)
{
wait_for_completion(&init_done); /* smp_mb() 내장: acquire 의미론 */
/* 여기서 device_setup() 결과와 ready=1이 반드시 보임 */
r = READ_ONCE(ready); /* r == 1 보장 */
}
| API | 배리어 의미론 | 비고 |
|---|---|---|
complete() | release (smp_mb 내장) | waiter를 깨우기 전 이전 쓰기 완료 보장 |
wait_for_completion() | acquire (smp_mb 내장) | 깨어난 후 complete() 이전 연산이 보임 |
complete_all() | release | 모든 waiter를 동시에 깨움 |
try_wait_for_completion() | acquire (성공 시) | 블로킹 없이 완료 여부 확인 |
complete() 내부에서 spin_lock_irqsave()와 함께 waiter를 깨우므로, wake_up_state()를 통한 암시적 배리어도 포함됩니다. 완료 통지·대기 패턴에서 별도로 smp_mb()를 추가하면 중복입니다.
waitqueue_active() 최적화와 깨우기 레이스
waitqueue_active()는 대기자가 없으면 wake_up() 호출을 건너뛰기 위한 lockless 최적화입니다. 하지만 이는 단순한 편의 함수가 아니라, 조건 변수 쓰기와 waitqueue 검사 사이에 배리어가 필요하다는 뜻이기도 합니다.
/* CPU 0: waker */
WRITE_ONCE(ctx->done, 1);
smp_mb(); /* done store가 waitqueue_active load보다 먼저 관찰되도록 */
if (waitqueue_active(&ctx->wq))
wake_up(&ctx->wq);
/* CPU 1: waiter */
DEFINE_WAIT(wait);
for (;;) {
prepare_to_wait(&ctx->wq, &wait, TASK_INTERRUPTIBLE);
if (READ_ONCE(ctx->done))
break;
schedule();
}
finish_wait(&ctx->wq, &wait);
위 코드에서 smp_mb()가 빠지면 다음과 같은 lost wake-up 시나리오가 가능합니다.
- CPU 0이
ctx->done = 1쓰기보다waitqueue_active()읽기를 먼저 수행해, 아직 비어 있는 대기열을 관찰합니다. - CPU 1은
prepare_to_wait()후ctx->done을 검사하지만, CPU 0의 store가 아직 보이지 않아 0으로 읽습니다. - CPU 0은 이미 "대기자 없음"이라고 판단했으므로
wake_up()를 건너뜁니다. - CPU 1은 이제
schedule()로 잠들고, 조건은 이미 만족되었는데도 깨우는 주체가 없어지는 유실 깨우기 버그가 됩니다.
waitqueue_active()는 정말 wake-up 호출 비용이 문제인 hot path에서만 사용하세요.
그렇지 않다면 그냥 wake_up()를 무조건 호출하는 편이 더 단순하고 안전합니다.
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 */
/*
* smp_mb__before_spinlock(): 특수 케이스 — 사용 조건
*
* spin_lock()은 acquire 의미론을 가지므로 lock 이후 연산이
* lock 이전으로 올라가지 않음을 보장합니다. 그러나 lock 이전
* 연산이 lock 이후로 내려가는 것까지 막지는 않습니다.
*
* 이 매크로가 필요한 상황:
* - schedule(), __cond_resched() 등 컨텍스트 전환 직전
* - spin_lock()의 acquire 보장이 그 이전의 특정 쓰기를
* 완전히 포괄하지 못하는 아키텍처 (POWER 등)
* - TIF_NEED_RESCHED 같은 플래그 관찰 순서 보장
*
* 대부분의 일반 드라이버 코드에서는 필요하지 않습니다.
*/
smp_mb__before_spinlock();
spin_lock(&lock);
비트 플래그 설정 후 깨우기까지 묶는 패턴
반환값이 없는 set_bit(), clear_bit(), atomic_inc()는 원자성은 보장하지만 순서는 보장하지 않습니다. 그래서 이런 연산 직후에 wake_up(), kick_thread(), queue_work_on() 같은 후속 동작을 이어 붙일 때는 smp_mb__before_atomic()와 smp_mb__after_atomic()가 실전적으로 중요합니다.
/* producer */
WRITE_ONCE(job->payload, payload);
smp_mb__before_atomic(); /* payload 쓰기를 bit op 앞에 고정 */
set_bit(JOB_READY, &job->flags);
smp_mb__after_atomic(); /* bit op를 wake_up 앞에 고정 */
wake_up(&job->wq);
/* consumer */
wait_event(job->wq, test_bit(JOB_READY, &job->flags));
smp_rmb(); /* ready bit 확인 후 payload 읽기 */
handle(READ_ONCE(job->payload));
smp_mb__before_atomic()는 payload가 bit보다 늦게 보이는 문제를 막고,
smp_mb__after_atomic()는 bit 설정이 wake-up보다 늦게 보이는 문제를 막습니다.
즉 "데이터 → 플래그 → 깨우기"라는 3단계 순서를 명시적으로 연결하는 것입니다.
인터럽트/softirq 컨텍스트와 배리어
인터럽트와 softirq 컨텍스트에서는 일반 SMP 배리어 외에도 인터럽트 비활성화/활성화에 따른 암시적 배리어와 컨텍스트 전환 순서를 이해해야 합니다.
local_irq_disable/enable의 메모리 순서
/*
* local_irq_disable() / local_irq_enable()은
* 컴파일러 배리어를 포함하지만 CPU 배리어가 아닙니다.
* 다른 CPU와의 순서 보장이 필요하면 smp_mb()를 별도로 사용해야 합니다.
*/
void irq_handler_example(void)
{
local_irq_disable(); /* 컴파일러 배리어 포함 — CPU 배리어 아님 */
/* 이 구간에서 인터럽트 핸들러가 끼어들지 않음 (로컬 CPU) */
shared_var = 42; /* 로컬에서 안전 */
flag = 1;
local_irq_enable(); /* 컴파일러 배리어 포함 */
/* 다른 CPU가 flag를 볼 때 shared_var도 42임을 보장하려면? */
/* → smp_store_release() 또는 명시적 smp_wmb() 필요 */
}
/* 올바른 패턴: IRQ 보호 구간 내에서 release 의미론 */
void correct_irq_publish(void)
{
local_irq_disable();
shared_var = 42;
local_irq_enable();
/* IRQ 구간 밖에서 다른 CPU에게 데이터 발행 */
smp_store_release(&flag, 1); /* shared_var=42가 먼저 보이도록 */
}
IRQ, softirq, 태스크렛 배리어 비교
| 컨텍스트 | 같은 CPU 순서 | 다른 CPU 순서 | 배리어 필요성 |
|---|---|---|---|
| 프로세스 컨텍스트 | 순서 보장 (단일 CPU) | 배리어 필요 | smp_mb/rmb/wmb |
| softirq (같은 CPU) | 인터럽트 비활성화로 보호 | 배리어 필요 | smp_mb/rmb/wmb |
| IRQ 핸들러 | 하드 IRQ 비활성화로 보호 | 배리어 필요 | smp_mb/rmb/wmb |
| NMI | 선점 불가, 단일 CPU | 배리어 필요 | mb/rmb/wmb (not smp_*) |
스핀락과 IRQ 조합
/* 프로세스와 IRQ가 공유 데이터를 접근하는 패턴 */
DEFINE_SPINLOCK(my_lock);
struct shared_data data;
/* 프로세스 컨텍스트 */
void process_context(void)
{
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* spin_lock_irqsave:
* 1. local_irq_save() — IRQ 비활성화
* 2. spin_lock() — acquire 배리어 포함
* → 이 구간: IRQ도 없고, 다른 CPU도 lock으로 배제
*/
data.value = 100;
data.valid = 1;
spin_unlock_irqrestore(&my_lock, flags);
/* spin_unlock_irqrestore:
* 1. spin_unlock() — release 배리어 포함
* 2. local_irq_restore() — IRQ 복원
*/
}
/* IRQ 핸들러 */
irqreturn_t my_irq_handler(int irq, void *dev)
{
spin_lock(&my_lock);
/* IRQ 컨텍스트에서는 spin_lock()만 사용
* (IRQ 안에서 IRQ는 이미 비활성화됨)
*/
if (data.valid)
handle(data.value);
spin_unlock(&my_lock);
return IRQ_HANDLED;
}
/*
* 핵심: spin_lock()의 acquire/release가 내부적으로
* smp_mb()에 준하는 배리어를 포함하므로
* lock 안에서는 별도 smp_mb() 불필요
*/
NMI 핸들러는 IRQ 비활성화 상태에서도 실행됩니다. NMI와 일반 코드가 공유 데이터를 접근한다면
smp_mb()가 아닌 mb()를 사용해야 합니다. NMI는 UP 시스템에서도 발생하므로 smp_* 배리어(UP에서 no-op)로는 부족합니다.
실전 패턴
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;
}
ABA 문제와 태그드 포인터 해결책
ABA 문제는 CAS(Compare-And-Swap) 기반 락-프리 알고리즘에서 발생하는 고전적 버그입니다. 공유 변수의 값이 A → B → A로 변했지만, 마지막 상태를 관찰한 스레드는 값이 여전히 A이므로 변화가 없었다고 잘못 판단합니다. 값은 같지만 메모리 구조(포인터가 가리키는 객체)는 이미 교체된 상태입니다.
1. CPU 0:
top = A를 읽음 (A → B → C 스택)2. CPU 1: pop → pop → push(A) (이제 스택은 A → C, 단 A는 재할당된 새 객체)
3. CPU 0: CAS(top, A, B) 성공 — 값은 A로 같지만 B는 이미 해제된 메모리!
결과: use-after-free, 스택 구조 파괴
/* ===== 잘못된 CAS 코드: ABA 문제 발생 ===== */
struct node {
int val;
struct node *next;
};
/* 락-프리 스택 헤드 — 단순 포인터 (위험!) */
struct node *volatile top;
struct node *buggy_pop(void)
{
struct node *old_top, *new_top;
do {
old_top = READ_ONCE(top);
if (!old_top)
return NULL;
new_top = old_top->next;
/*
* 문제: old_top이 이미 kfree되고 재할당된 후
* 우연히 같은 주소에 새 노드가 배치될 수 있음.
* cmpxchg 성공 → new_top은 해제된 메모리를 가리킴!
*/
} while (cmpxchg(&top, old_top, new_top) != old_top);
return old_top; /* use-after-free 가능성 */
}
/* ===== 해결책: Tagged Pointer (버전 카운터) ===== */
/*
* atomic64_t의 상위 16비트를 버전 태그로 활용.
* 주소값은 하위 48비트 (x86-64 사용자 공간 기준).
* 커널에서는 상위 비트 사용 방식을 아키텍처별로 확인 필요.
*/
#define TAG_SHIFT 48
#define PTR_MASK ((1ULL << TAG_SHIFT) - 1)
static inline u64 make_tagged(struct node *ptr, u64 tag)
{
return ((u64)ptr & PTR_MASK) | (tag << TAG_SHIFT);
}
static inline struct node *tagged_ptr(u64 tagged)
{
return (struct node *)(tagged & PTR_MASK);
}
static inline u64 tagged_ver(u64 tagged)
{
return tagged >> TAG_SHIFT;
}
/* 락-프리 스택 헤드: 포인터 + 버전 태그 원자적으로 관리 */
atomic64_t tagged_top = ATOMIC64_INIT(0);
void safe_push(struct node *n)
{
u64 old, new_val;
do {
old = (u64)atomic64_read(&tagged_top);
n->next = tagged_ptr(old);
/* 새 포인터 + 버전 +1 → ABA 구분 */
new_val = make_tagged(n, tagged_ver(old) + 1);
} while (atomic64_cmpxchg(&tagged_top, (s64)old, (s64)new_val) != (s64)old);
}
struct node *safe_pop(void)
{
u64 old, new_val;
struct node *top_node;
do {
old = (u64)atomic64_read(&tagged_top);
top_node = tagged_ptr(old);
if (!top_node)
return NULL;
/* 버전 태그도 함께 비교 → 재할당된 동일 주소 구분 가능 */
new_val = make_tagged(top_node->next, tagged_ver(old) + 1);
} while (atomic64_cmpxchg(&tagged_top, (s64)old, (s64)new_val) != (s64)old);
return top_node;
}
cmpxchg() + 버전 카운터 패턴:커널에서는 포인터 상위 비트 활용이 아키텍처별로 제약될 수 있습니다. 대신 별도 버전 카운터를 함께 관리하는 방식이 더 일반적입니다. 예:
struct { void *ptr; unsigned long ver; }를 cmpxchg128()(x86_64 CMPXCHG16B) 또는 seqcount와 조합하여 ABA-안전 갱신을 구현합니다.
일반적으로는 복잡한 락-프리 구조 대신 RCU 또는 hazard pointer를 우선 검토하는 것이 권장됩니다.
seqlock_t vs seqcount_t 비교
Linux 커널에는 seqcount 기반 두 가지 타입이 있습니다. seqcount_t는 writer 직렬화를 외부 락에 위임하고, seqlock_t는 spinlock을 내장합니다.
| 특성 | seqcount_t | seqlock_t |
|---|---|---|
| spinlock 포함 | ❌ 별도 spinlock 필요 | ✅ 내장 |
| Writer 직렬화 | 외부 락으로 직렬화 | 내장 spinlock으로 직렬화 |
| Reader 대기 | 재시도만 (블로킹 없음) | 재시도만 (블로킹 없음) |
| Writer API | write_seqcount_begin/end() | write_seqlock/sequnlock() |
| Reader API | read_seqcount_begin/retry() | read_seqbegin/seqretry() |
| 사용 시점 | 이미 다른 락이 있는 경우 | 독립적인 seqlock이 필요한 경우 |
/* seqlock_t — spinlock 내장: write_seqlock()이 락 획득 + 시퀀스 증가 처리 */
seqlock_t my_seqlock = __SEQLOCK_UNLOCKED(my_seqlock);
/* Writer */
write_seqlock(&my_seqlock); /* spin_lock + 시퀀스 홀수로 증가 */
data.x = new_x;
data.y = new_y;
write_sequnlock(&my_seqlock); /* 시퀀스 짝수로 증가 + spin_unlock */
/* Reader: seqcount_t와 API 거의 동일 */
unsigned int s;
do {
s = read_seqbegin(&my_seqlock);
local_x = data.x;
local_y = data.y;
} while (read_seqretry(&my_seqlock, s));
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() 포함 — 데이터 읽기 후 시퀀스 재확인 */
}
Seqcount 타임라인 (Writer/Reader 상호작용)
상태 플래그 패턴
/* 흔한 패턴: 완료 플래그로 데이터 발행 */
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가 순서 보장 */
}
포인터 발행(Pointer Publication) 패턴
포인터를 통해 구조체를 다른 스레드에 "발행"할 때 가장 흔하게 사용하는 패턴입니다. 포인터 자체는 원자적이지만, 포인터가 가리키는 데이터의 초기화 완료를 보장하려면 배리어가 필요합니다.
/* 완전한 예시: 동적 설정 갱신 */
struct config {
int timeout;
bool enabled;
char name[64];
};
static struct config __rcu *current_config;
/* 설정 갱신 (RCU 보호, spinlock과 함께) */
void update_config(int timeout, bool enabled)
{
struct config *new_cfg = kmalloc(sizeof(*new_cfg), GFP_KERNEL);
struct config *old_cfg;
new_cfg->timeout = timeout;
new_cfg->enabled = enabled;
strscpy(new_cfg->name, "new config", sizeof(new_cfg->name));
spin_lock(&config_lock);
old_cfg = rcu_dereference_protected(current_config, lockdep_is_held(&config_lock));
rcu_assign_pointer(current_config, new_cfg); /* smp_store_release 포함 */
spin_unlock(&config_lock);
synchronize_rcu();
kfree(old_cfg);
}
/* 설정 읽기 (RCU 읽기 락) */
int get_timeout(void)
{
struct config *cfg;
int timeout;
rcu_read_lock();
cfg = rcu_dereference(current_config); /* 데이터 의존성 보호 */
timeout = cfg ? cfg->timeout : DEFAULT_TIMEOUT;
rcu_read_unlock();
return timeout;
}
지연 초기화와 빠른 경로 발행 패턴
메모리 배리어가 실제로 가장 자주 "보이는" 곳은 완전한 락-프리 알고리즘보다도, 한 번만 초기화한 객체를 이후에는 잠금 없이 읽고 싶은 fast path입니다. 이때 필요한 것은 CAS 난사가 아니라, 느린 경로에서는 잠금으로 중복 초기화를 막고 빠른 경로에서는 acquire/release로 초기화 완료만 보장하는 구조입니다.
struct fast_ops {
u32 mode;
u32 queue_depth;
bool use_irq;
};
static struct fast_ops *global_ops;
DEFINE_MUTEX(fast_ops_init_lock);
struct fast_ops *get_fast_ops(void)
{
struct fast_ops *ops;
ops = smp_load_acquire(&global_ops);
if (ops)
return ops; /* 빠른 경로: 락 없이 초기화 완료 객체 획득 */
mutex_lock(&fast_ops_init_lock);
ops = READ_ONCE(global_ops);
if (!ops) {
ops = kzalloc(sizeof(*ops), GFP_KERNEL);
if (ops) {
ops->mode = FAST_MODE_BATCH;
ops->queue_depth = 1024;
ops->use_irq = true;
smp_store_release(&global_ops, ops);
}
}
mutex_unlock(&fast_ops_init_lock);
return ops;
}
이 지연 초기화 패턴이 안전한 이유
-
9-12행
빠른 경로에서는
smp_load_acquire()만으로 충분합니다. 포인터를 얻은 이후 구조체 필드를 읽는 연산이 acquire 이전으로 이동하지 못하므로, partially initialized 객체를 볼 위험이 없습니다. -
14-16행
느린 경로의 mutex는 "중복 초기화" 문제를 해결합니다. 메모리 순서 자체는 마지막의
smp_store_release()가 담당하고, mutex는 초기화 작업을 직렬화합니다. -
18-24행
구조체 필드 초기화를 모두 끝낸 뒤에만
global_ops를 발행합니다. 만약 단순WRITE_ONCE(global_ops, ops)를 썼다면 다른 CPU가 포인터는 보지만 필드는 아직 이전 값처럼 보일 수 있습니다.
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()로 마킹된 접근은 "의도적"으로 간주하여 보고하지 않습니다.
KCSAN 한계와 주의사항
KCSAN은 강력한 도구이지만 watchpoint 기반 샘플링 방식으로 작동하므로 다음 한계를 이해하고 사용해야 합니다:
| 한계 | 설명 | 보완 방법 |
|---|---|---|
| 샘플링 기반 미탐지 | KCSAN은 접근마다 watchpoint를 설정하지 않고 확률적으로 설정합니다. 빠르게 발생하는 레이스는 탐지되지 않을 수 있습니다. | kcsan.rate 파라미터로 샘플링 빈도 조정 |
| Benign race 오탐 | 의도적인 잠금 없는 접근(통계 카운터 등)을 레이스로 보고할 수 있습니다. | data_race() / READ_ONCE() 어노테이션으로 억제 |
| 성능 오버헤드 | KCSAN 활성화 시 메모리 접근마다 watchpoint 검사가 추가되어 성능이 크게 저하됩니다. | 개발/테스트 환경에서만 활성화 (CONFIG_KCSAN=y) |
| 아토믹 연산 미검사 | atomic_* API를 통한 접근은 레이스로 보고하지 않습니다. | 정상 동작 — atomic 연산은 본질적으로 동기화됨 |
# KCSAN 샘플링 빈도 조정 (기본값: 8192 접근 중 1개 watchpoint)
echo 4096 > /sys/module/kcsan/parameters/skip_watch
# KCSAN 보고 억제 (특정 레이스가 benign임을 표시)
# 코드에서: data_race(READ_ONCE(stats->count))
# 또는: READ_ONCE(stats->count) ← 단순 통계는 이걸로 충분
실제 커널 버그 사례 분석
배리어 누락 버그는 재현이 어렵고 아키텍처 의존적이기 때문에 오랫동안 숨어있다가 ARM/POWER 이식 시 발견되는 경우가 많습니다. 실제로 보고된 패턴들을 분석합니다.
사례 1: 네트워크 인터페이스 상태 경쟁
/*
* 버그 패턴: netif_running() + 플래그 갱신 경쟁
* 실제 네트워크 드라이버에서 발견된 패턴
*/
/* TX 경로 (CPU A) */
void start_xmit(struct sk_buff *skb, struct net_device *dev)
{
/* 버그: WRITE_ONCE 없이 hw_queue에 skb 저장 후 */
priv->tx_ring[priv->tx_head] = skb; /* ← (1) */
priv->tx_head++; /* ← (2) — (1)보다 먼저 보일 수 있음! */
trigger_tx(priv);
}
/* TX 완료 인터럽트 (CPU B) */
irqreturn_t tx_irq(int irq, void *dev)
{
/* 버그: tx_head 읽기 후 tx_ring 접근 — NULL 포인터 역참조! */
while (priv->tx_tail != priv->tx_head) {
struct sk_buff *skb = priv->tx_ring[priv->tx_tail];
consume_skb(skb); /* skb가 아직 NULL이면 크래시 */
priv->tx_tail++;
}
return IRQ_HANDLED;
}
/* 수정: store_release/load_acquire 사용 */
void start_xmit_fixed(struct sk_buff *skb, struct net_device *dev)
{
WRITE_ONCE(priv->tx_ring[priv->tx_head], skb);
smp_store_release(&priv->tx_head, priv->tx_head + 1);
trigger_tx(priv);
}
irqreturn_t tx_irq_fixed(int irq, void *dev)
{
unsigned int head = smp_load_acquire(&priv->tx_head);
while (priv->tx_tail != head) {
struct sk_buff *skb = READ_ONCE(priv->tx_ring[priv->tx_tail]);
consume_skb(skb);
smp_store_release(&priv->tx_tail, priv->tx_tail + 1);
}
return IRQ_HANDLED;
}
사례 2: 리스트 삽입 후 포인터 발행 경쟁
/*
* 버그 패턴: list_add() 후 전역 포인터 설정
* ARM64/POWER에서 list_add가 완료되기 전에 포인터가 보일 수 있음
*/
/* 버그 버전 */
void register_handler(struct handler *h)
{
list_add_rcu(&h->list, &handler_list); /* smp_wmb 포함 */
WRITE_ONCE(fast_handler, h); /* 그러나... */
/* 문제: fast_handler를 읽는 쪽이 smp_load_acquire 없이 읽으면
* h->list 삽입이 아직 보이지 않을 수 있음 (reader 쪽 무효화 큐 미처리) */
}
/* 읽기 측에서 사용하는 fast path */
bool handle_fast(void *data)
{
struct handler *h = READ_ONCE(fast_handler); /* ← 여기가 문제 */
if (!h)
return false;
/* h->list 멤버 접근: h가 아직 리스트에 완전히 삽입 안 됐을 수 있음 */
return h->ops->handle(data);
}
/* 수정: acquire/release 페어 사용 */
void register_handler_fixed(struct handler *h)
{
list_add_rcu(&h->list, &handler_list);
smp_store_release(&fast_handler, h); /* list_add 완료 후 발행 */
}
bool handle_fast_fixed(void *data)
{
struct handler *h = smp_load_acquire(&fast_handler); /* acquire */
if (!h)
return false;
return h->ops->handle(data); /* 안전: list_add 완료 보장 */
}
사례 3: x86에서만 테스트해 놓친 버그
/*
* x86 TSO에서는 smp_wmb()가 컴파일러 배리어만이므로
* 아래 코드가 x86에서는 정상 동작하지만 ARM64에서 실패
*/
static int initialized;
static struct device_state state;
void device_init(void)
{
state.reg_a = read_hw_reg(REG_A);
state.reg_b = read_hw_reg(REG_B);
smp_wmb(); /* x86: barrier() → CPU 배리어 없음! ARM64: dmb ishst */
WRITE_ONCE(initialized, 1);
}
int get_reg_a(void)
{
if (!READ_ONCE(initialized)) /* rmb 없이 읽기 → ARM64에서 stale 가능 */
return -1;
/* x86: WRITE_ONCE(initialized,1) 이후 state.reg_a 읽으면 OK */
/* ARM64: 무효화 큐 미처리 → state.reg_a = 0 읽을 수 있음 */
return READ_ONCE(state.reg_a);
}
/* 수정: acquire/release 쌍 */
void device_init_fixed(void)
{
state.reg_a = read_hw_reg(REG_A);
state.reg_b = read_hw_reg(REG_B);
smp_store_release(&initialized, 1); /* smp_wmb() + store */
}
int get_reg_a_fixed(void)
{
if (!smp_load_acquire(&initialized)) /* load + smp_rmb() */
return -1;
return READ_ONCE(state.reg_a); /* 이제 최신값 보장 */
}
위 버그들은 QEMU/KVM으로 ARM64 환경에서 KCSAN을 활성화하면 빠르게 발견할 수 있습니다. x86에서는 TSO 덕분에 재현이 거의 불가능합니다. 또한
tools/memory-model/의 herd7 도구로 litmus test를 작성하면 정형 검증으로 버그를 미리 확인할 수 있습니다.
흔한 실수와 디버깅
실수 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: 잘못된 배리어 선택
실수 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 | 배리어 전후 값 확인 | 런타임 |
배리어 선택 플로우차트
핵심 원칙: 배리어는 항상 쌍(pair)으로 사용해야 합니다. writer 측의 배리어만으로는 reader에게 순서가 보장되지 않습니다. 또한 가능하면 낮은 수준의 배리어보다 상위 추상화(잠금, RCU, atomic 연산)를 사용하세요. 직접적인 배리어 사용은 최후의 수단이어야 합니다.
선택 기준 상세 설명
| 질문 | YES → 선택 | NO → 다음 질문 |
|---|---|---|
| ①잠금(lock/unlock)으로 보호되는가? | 배리어 불필요 — lock/unlock이 acquire/release 내재 | ② 확인 |
| ② 단일 변수만 공유하는가? | READ_ONCE() / WRITE_ONCE() — 컴파일러 배리어만 필요 |
③ 확인 |
| ③ MMIO 디바이스 / DMA 순서 보장인가? | MMIO: mb() / rmb() / wmb()DMA: dma_wmb() / dma_rmb() |
④ 확인 |
| ④ 생산자-소비자 방향성이 있는가? | 생산자: smp_wmb() 또는 smp_store_release()소비자: smp_rmb() 또는 smp_load_acquire() |
⑤ 확인 |
| ⑤ atomic_inc/set_bit 후 순서 보장인가? | smp_mb__after_atomic() 또는 atomic_*_return() |
⑥ 확인 |
| ⑥ 양방향 완전 순서 보장이 필요한가? | smp_mb() — 전체 배리어 (비용 높음, 최후 수단) |
설계 재검토 또는 전문가 상담 |
핵심 원칙: 배리어는 가능한 한 가장 약한 것을 사용하세요. smp_load_acquire()/smp_store_release() 쌍은 ARM64에서 LDAR/STLR 단일 명령어로 구현되어 smp_mb()보다 훨씬 저렴합니다. x86에서는 store-load 재배열을 제외하면 모든 load/store가 acquire/release 의미론을 이미 가지므로 사실상 무비용입니다.
배리어 성능 가이드
배리어는 성능에 직접적인 영향을 미칩니다. 올바른 배리어를 선택하면 정확성을 유지하면서 성능 손실을 최소화할 수 있습니다.
아키텍처별 배리어 비용
| 배리어 | x86-64 비용 | ARM64 비용 | POWER 비용 | 설명 |
|---|---|---|---|---|
barrier() | 0 사이클 (컴파일러만) | 0 사이클 | 0 사이클 | 컴파일러 최적화만 차단 |
smp_wmb() | 0 사이클 (barrier()) | ~5-10 사이클 | ~20-50 사이클 (lwsync) | Store-Store 순서 |
smp_rmb() | 0 사이클 (barrier()) | ~5-10 사이클 | ~20-50 사이클 (lwsync) | Load-Load 순서 |
smp_load_acquire() | ~1 사이클 (MOV) | ~5-10 사이클 (LDAR) | ~30-60 사이클 | 단방향, 보통 최적 선택 |
smp_store_release() | ~1 사이클 (MOV) | ~5-10 사이클 (STLR) | ~30-60 사이클 | 단방향, 보통 최적 선택 |
smp_mb() | ~10-100 사이클 (MFENCE) | ~30-100 사이클 (DMB) | ~100-300 사이클 (sync) | 양방향, 가장 비쌈 |
mb() | ~10-100 사이클 (MFENCE) | ~50-200 사이클 (DSB SY) | ~100-300 사이클 (sync) | MMIO용, UP에서도 동작 |
* 사이클 수는 메모리 상태, 캐시 라인 경쟁, CPU 마이크로아키텍처에 따라 크게 달라집니다. 실측이 중요합니다.
성능을 위한 배리어 선택 원칙
/*
* 성능 우선 배리어 선택 가이드:
*
* 1. 잠금(spin_lock/mutex)으로 보호 가능하면 → 잠금 사용 (배리어 불필요)
* 이미 acquire/release가 내장되어 있음
*
* 2. 단방향 보장이 필요한 경우 → acquire/release 우선
* smp_mb() 대신 smp_load_acquire() + smp_store_release()
* (ARM64/POWER에서 LDAR/STLR이 DMB보다 효율적)
*
* 3. write가 드문 경우 → RCU 고려
* reader 측에서 배리어가 거의 없음 (데이터 의존성만)
*
* 4. 통계 카운터처럼 정확도가 불필요한 경우 → relaxed 접근
* WRITE_ONCE()/READ_ONCE() + 배리어 없음
*
* 5. per-CPU 변수 → 같은 CPU에서 접근 시 배리어 불필요
* 다른 CPU에서 관찰 시에만 smp_mb() 추가
*/
/* 비효율적 패턴 (개선 전) */
smp_mb(); /* 양방향, 비쌈 */
flag = 1;
smp_mb(); /* 또 양방향, 더 비쌈 */
/* 효율적 패턴 (개선 후) */
smp_store_release(&flag, 1); /* 단방향 release — 앞의 연산만 순서 보장 */
/* 더 비효율적 패턴 */
spin_lock(&lock);
counter++;
spin_unlock(&lock);
smp_mb(); /* unlock이 이미 release를 포함하므로 완전히 불필요 */
배리어 성능 측정
/* 커널 내에서 배리어 비용 측정 방법 */
/* 1. perf stat으로 MFENCE 등 명령어 횟수 측정 */
/* perf stat -e cpu/mem-stores/,cycles ./test_program */
/* 2. 직접 rdtsc로 측정 (x86) */
static inline u64 rdtsc(void)
{
u32 lo, hi;
asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
return ((u64)hi << 32) | lo;
}
void measure_barrier_cost(void)
{
u64 t1, t2;
int i;
t1 = rdtsc();
for (i = 0; i < 1000000; i++)
smp_mb();
t2 = rdtsc();
pr_info("smp_mb() cost: ~%llu cycles/op\n", (t2 - t1) / 1000000);
}
1. 최우선: 잠금/RCU 등 상위 추상화 사용 — 복잡도 낮고 올바르게 구현되어 있음
2. 차선:
smp_load_acquire() / smp_store_release() — 단방향이라 smp_mb()보다 효율적3. 최후:
smp_mb() — 정말 필요한 경우만 (SB litmus test, 복잡한 lockless 구조)4. x86에서는
smp_wmb()/smp_rmb()가 공짜지만 ARM64/POWER에서는 비쌉니다. 항상 약한 아키텍처 기준으로 성능을 측정하세요.
요약
| 계층 | 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. | 암시적 배리어 내장 | 일반 동기화 |
관련 문서
메모리 배리어와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.