메모리 배리어 / 메모리 모델 심화

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 기반 검증 절차까지 실무 관점에서 매우 상세히 다룹니다.

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

핵심 요약

  • 재배열은 두 곳에서 발생컴파일러(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 정적 검증

단계별 이해

  1. 하드웨어 재배열 원인 파악
    스토어 버퍼(쓰기 지연)와 무효화 큐(읽기 갱신 지연)가 어떻게 재배열을 유발하는지 이해합니다. "왜 배리어가 필요한가?" 섹션의 SVG 다이어그램을 먼저 보세요.
  2. 재배열 가능성을 항상 가정
    "x86이니까 괜찮다"는 생각을 버립니다. ARM/POWER에서의 동작을 기준으로 코드를 설계합니다.
  3. 공유 변수에 READ_ONCE/WRITE_ONCE 마킹
    여러 CPU가 접근하는 모든 변수를 먼저 명확히 표시합니다. 이것만으로 컴파일러 재배열과 tearing을 막을 수 있습니다.
  4. 동기화 패턴 선택: 잠금 → RCU → acquire/release → wmb/rmb 순서로 검토
    가능하면 상위 추상화(잠금, RCU)를 먼저 사용합니다. lockless 알고리즘이 필요할 때만 직접 배리어를 사용합니다.
  5. release/acquire 쌍 배치
    producer 측에 smp_store_release(), consumer 측에 smp_load_acquire()를 짝으로 배치합니다. happens-before 체인이 완성되는지 확인합니다.
  6. KCSAN으로 런타임 탐지
    CONFIG_KCSAN=y로 빌드하여 배리어 누락 데이터 레이스를 런타임에 발견합니다.
  7. ARM/RISC-V에서 실기 검증
    약한 메모리 모델 하드웨어에서 동일한 테스트를 실행하여 최종 확인합니다. herd7/klitmus7로 정적 분석도 병행합니다.
모델 해석 주의: 아래의 스토어 버퍼/무효화 큐 그림은 약한 메모리 모델을 설명하기 위한 개념도입니다. 커널 배리어 API의 공식 의미는 특정 하드웨어 큐를 비운다는 보장이 아니라, 다른 CPU나 디바이스가 관찰하는 읽기·쓰기 순서를 제한하는 데 있습니다.
관련 표준: C11/C++11 Memory Model, LKMM (Linux Kernel Memory Model) — 메모리 순서 보장 및 동기화 모델입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
관련 문서: 이 문서는 동기화 기법, RCU, Atomic 연산 문서와 밀접하게 연관됩니다. 메모리 배리어는 이러한 동기화 프리미티브의 기반이 되는 저수준 메커니즘입니다. 커널 공식 문서: Documentation/memory-barriers.txt
CPU 0 (Producer) 파이프라인 실행 유닛 ① WRITE_ONCE(data, 42) ② smp_wmb() ← 배리어 ③ WRITE_ONCE(ready, 1) 스토어 버퍼 wmb() → 이전 store가 이후 store보다 먼저 관찰 ① 완료 후 ③ 진행 보장 CPU 1 (Consumer) 파이프라인 실행 유닛 ④ while(!READ_ONCE(ready)) ⑤ smp_rmb() ← 배리어 ⑥ use(READ_ONCE(data)) 무효화 큐 rmb() → 이전 load가 이후 load보다 먼저 관찰 ④ 완료 후 ⑥ 진행 보장 공유 캐시 / 메모리 data = 42 (쓰기 완료 후) ready = 1 (data 이후 보장) 배리어 없으면 발생하는 문제 CPU 0: 스토어 버퍼가 ①(data)보다 ③(ready)을 먼저 공유 메모리에 반영할 수 있음 → CPU 1이 ready=1을 봐도 data=0 CPU 1: 무효화 큐에 data 캐시 무효화가 쌓여 있어 ⑥에서 여전히 data=0을 읽을 수 있음 해결: writer/reader 양쪽에서 배리어를 사용해 data와 ready의 관찰 순서를 맞춘다 x86(TSO): 스토어 버퍼는 있으나 Store-Store 순서는 하드웨어가 보장 → smp_wmb()는 컴파일러 배리어만으로 충분 ARM/POWER: 모든 재배열 가능 → 명시적 DMB/DSB 명령어 필요

메모리 순서 문제 - 왜 배리어가 필요한가

현대 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는 파이프라인 효율을 위해 메모리 연산의 실행 순서를 변경합니다. 주요 재배열 유형은 다음과 같습니다:

재배열 유형설명x86ARM/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 자체와 동일시하면 안 됩니다.

스토어 버퍼 / 무효화 큐 — Store-Load 재배열의 원인 CPU 0 실행 유닛 (Out-of-Order) 스토어 버퍼 entry[0]: data=42 (미커밋) entry[1]: ready=1 (미커밋) ⚠ ARM/POWER: 비순서 커밋 가능 (x86 TSO는 SS 순서 보장) L1 캐시 (로컬) MESI 프로토콜로 다른 CPU와 일관성 wmb() 효과 1. 스토어 버퍼의 모든 항목을 캐시에 커밋(drain)할 때까지 대기 2. 이후 새 쓰기는 커밋 완료 후 진행 → data가 ready보다 먼저 캐시에 반영 ARM64: dmb ishst, RISC-V: fence w,w CPU 1 실행 유닛 (Out-of-Order) 무효화 큐 (Invalidation Queue) inval[0]: data 캐시라인 무효화 inval[1]: ready 캐시라인 무효화 ⚠ 큐 미처리 시 stale 값 읽기! L1 캐시 (로컬) 무효화 전: data=0 (구버전) rmb() 효과 1. 무효화 큐의 모든 항목을 처리(flush)할 때까지 대기 2. 이후 새 읽기는 최신 캐시 상태로 → ready 확인 후 data 최신값 읽기 ARM64: dmb ishld, RISC-V: fence r,r MESI 캐시 일관성 프로토콜 M(Modified) E(Exclusive) S(Shared) I(Invalid) data 캐시라인 CPU1: Invalid(I) → 재읽기 필요 ready 캐시라인 CPU1: Shared(S) → 읽기 가능 스누프 버스 / 인터커넥트 (SNOOP / Coherence) 핵심: 스토어 버퍼는 쓰기 지연으로 StoreLoad/StoreStore 재배열 유발 | 무효화 큐는 읽기 캐시 갱신 지연으로 LoadLoad 재배열 유발

스토어 버퍼로 인한 재배열 예시

다음은 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 후 읽기
wmb()의 두 가지 표현: "스토어 버퍼 drain"(CPU 파이프라인 관점)과 "M→S 강제 플러시"(캐시 일관성 관점)는 같은 현상의 다른 추상화 수준 표현입니다. wmb()가 스토어 버퍼를 drain하면, 버퍼에 있던 쓰기가 L1 캐시에 커밋되어 Modified 상태가 되고, 이후 MESI 프로토콜이 다른 CPU에게 전파합니다.
MESI 프로토콜 상태 전이도 M Modified (독점·변경됨) E Exclusive (독점·청결) S Shared (공유·읽기전용) I Invalid (무효·없음) 로컬 읽기 미스 (다른 CPU 미보유) 읽기 미스 (다른 CPU 보유 → 공유) 로컬 쓰기 히트 (E → M) 스누프 읽기 요청 (Write-Back 후 공유) 다른 CPU 쓰기 → 무효화 다른 CPU 쓰기 (M→I: 무효화) 다른 CPU 쓰기 (E→I: 무효화) 로컬 쓰기 히트 (S → M) (타 CPU에 Invalidate 전송 후 독점) M: 로컬만 최신 (메모리 불일치) | E: 로컬만 보유 (메모리 일치) | S: 여러 CPU 공유 (읽기전용) | I: 캐시 미보유 (읽기 시 재로드 필요) 배리어 관련: wmb()는 M 상태 Write-Back(→S/I) | S→M은 Invalidate 전송 | rmb()는 무효화 큐 처리 후 I 상태 재로드
직관적 비유 (초급자용):
스토어 버퍼는 우체통과 같습니다. 편지를 써서 넣었지만 집배원이 아직 수거하지 않은 상태입니다. 다른 사람(CPU)은 우체통 속 편지를 직접 볼 수 없고, 중앙 우체국(공유 캐시)에 도착한 후에야 볼 수 있습니다.
wmb()는 집배원을 즉시 불러 우체통을 비우는 것입니다.
무효화 큐는 "다른 사람이 이 주소의 편지를 갱신했다"는 알림이 쌓여 있는 수신함입니다.
rmb()는 알림을 모두 처리하여 최신 주소록을 확인한 후에 편지를 읽는 것입니다.

스토어 버퍼 vs 무효화 큐 — 대칭 구조 비교

두 버퍼는 메모리 계층에서 L1 캐시를 중심으로 대칭된 위치에 존재합니다. 스토어 버퍼는 쓰기 경로의 출력 측에서 쓰기를 지연시키고, 무효화 큐는 쓰기 경로의 입력 측에서 무효화를 지연시킵니다.

스토어 버퍼 vs 무효화 큐 — L1 캐시를 중심으로 한 대칭 구조 CPU 0 — 쓰기 경로 (출력 측) 실행 유닛 (WRITE_ONCE 수행) 쓰기 명령 발생 스토어 버퍼 (Store Buffer) 쓰기 요청을 임시 보관 — L1 소유권 대기 중 ⚠ drain 전까지 타 CPU에 완전 불가시 wmb() / smp_wmb() → 전체 drain 후 다음 Store 허용 drain 완료 → L1 커밋 L1 캐시 → Invalidate 메시지 전송 인터커넥트 MESI 스누프 버스 Invalidate 메시지 전송 →→→ 수신 CPU 1 — 읽기 경로 (입력 측) 실행 유닛 (READ_ONCE 수행) stale 값 읽기 (큐 미처리 시) 무효화 큐 (Invalidation Queue) Invalidate 메시지를 큐에 보관 — 나중에 L1 적용 ⚠ flush 전까지 L1에 구버전(stale) 잔류 rmb() / smp_rmb() → 전체 flush 후 다음 Load 허용 flush → L1 갱신 후 최신값 읽기 L1 캐시 ← Invalidate 메시지 수신 대기 중 항목 스토어 버퍼 (쓰기 출력 측) 무효화 큐 (쓰기 입력 측) 위치 실행 유닛 ↔ L1 캐시 출력 인터커넥트 ↔ L1 캐시 입력 문제 내 쓰기가 타 CPU에 보이지 않음 (StoreStore · StoreLoad 재배열) 타 CPU 쓰기가 내 L1에 반영 안 됨 (LoadLoad 재배열) 배리어 wmb() / smp_wmb() → ARM64: dmb ishst rmb() / smp_rmb() → ARM64: dmb ishld x86 TSO 스토어 버퍼 있음 · Store-Load만 가능 → smp_wmb() = barrier() 소프트웨어에 불가시 (Load-Load 불가) → smp_rmb() = barrier()

쓰기 결합 버퍼 (Write Combining Buffer, WCB)

쓰기 결합 버퍼(WCB)는 스토어 버퍼·무효화 큐와 달리 캐시 비히트 경로에 특화된 버퍼입니다. 연속된 주소 범위로 향하는 여러 쓰기 연산을 모아 하나의 버스 트랜잭션으로 결합해 메모리 버스 대역폭을 극대화합니다. 그래픽 프레임버퍼처럼 CPU 캐시에 들어오지 않는 WC(Write Combining) 타입 메모리에서 핵심 역할을 합니다.

쓰기 결합 버퍼 (WCB) 동작 흐름 CPU 실행 유닛 store [fb+0x0], 0x01 store [fb+0x4], 0x02 … 쓰기 발생 쓰기 결합 버퍼 (WCB) 주소: fb+0x000 데이터: 0x01______ 주소: fb+0x004 데이터: 0x02______ 주소: fb+0x008 데이터: ________ ⋮ (캐시 라인 64B 채울 때까지 대기) 플러시 조건: 64B 완성 | sfence | wmb() | 다른 주소 범위 접근 | 타이머 만료 단일 버스 트랜잭션 (64B 한 번에 전송) 메모리 버스 64B 단일 트랜잭션 vs. 개별 쓰기 16건 → 버스 효율 극대화 WC 메모리 프레임버퍼 VRAM 등 (캐시 비가능) ⚠ WCB는 스토어 버퍼와 달리 MESI 스누프 불가 — 다른 CPU가 WCB 내용을 볼 수 없음 wmb() / sfence로 플러시하기 전까지 동일 CPU의 이후 읽기도 오래된 값을 볼 수 있음

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);
WCB vs 스토어 버퍼 차이점
  • 스토어 버퍼: 캐시 히트 경로, 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가 이 재배열을 직접 관찰할 수 있습니다.

파이프라인 구조와 재배열 발생

Out-of-Order 실행 파이프라인과 메모리 재배열 Frontend Fetch (명령어 인출) Decode (디코드) Rename (레지스터) Dispatch ROB (Reorder Buffer) RS (Reservation Station) LSQ (Load/Store Queue) Execution Units (OOO) ALU (정수/논리 연산) FPU (부동소수점) Load Unit ← 투기적 로드 Store Unit → 스토어 버퍼 ▲ 여기서 Store→Store 재배열 발생 Retire / Commit ROB 순서대로 완료 Store → 캐시 커밋 예외/인터럽트 처리 Memory Subsystem L1/L2 Cache L3 / Shared Cache DRAM Store Buffer (임시 쓰기 저장소) • 실행 순서와 다르게 commit 가능 • 다른 CPU에게 아직 보이지 않음 • wmb() = 버퍼를 전부 drain할 때까지 대기 → Store-Load, Store-Store 재배열 원인 투기적 로드 (Speculative Load) • 분기 결과 전에 미리 데이터 로드 • 프로그램 순서보다 일찍 메모리 읽기 • rmb() = 무효화 큐 flush 후 재로드 → Load-Load, Load-Store 재배열 원인 메모리 배리어의 역할 smp_wmb(): Store Buffer drain 완료까지 후속 store를 블록 → Store Unit이 버퍼를 비울 때까지 Retire 단계 정지 smp_rmb(): 무효화 큐(Invalidation Queue) flush 완료 후 Load Unit이 캐시에서 재로드 → Load-Load 순서 보장 smp_mb(): wmb + rmb의 효과 모두 적용. x86에서는 MFENCE가 Store Buffer를 완전히 drain하고 파이프라인을 직렬화

단일 스레드와 멀티 스레드의 차이

비순서 실행은 단일 스레드에서는 보이지 않습니다. 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 읽기 순서를 보장
 * 둘 다 있어야 올바르게 동작!
 */
배리어 전후 실행 순서 비교 (타임라인) ❌ 배리어 없음 — 재배열 발생 CPU 0 (시간 →) data=42 ready=1 재배열 가능! CPU 1 (시간 →) rd ready rd data 재배열 가능! 가능한 실행 시나리오 시나리오A: CPU0이 ready=1을 먼저 쓰고 CPU1이 ready=1 확인 후 data=0 읽음 → 버그! 시나리오B: CPU1이 rd_data를 rd_ready 전에 수행 data=0 읽음 (stale) → 버그! x86: 시나리오A 가능 / ARM·POWER: A,B 모두 가능 ✅ 배리어 사용 — 순서 보장 CPU 0 (시간 →) data=42 wmb() ready=1 CPU 1 (시간 →) rd ready rmb() rd data 보장된 실행 순서 CPU0: data=42 → wmb() → ready=1 순서 보장 wmb()가 스토어 버퍼를 비워 data가 먼저 커밋됨 CPU1: ready 확인 → rmb() → data 읽기 순서 보장 rmb()가 무효화 큐를 소진하여 최신 data 획득 CPU1이 ready=1을 보면 반드시 data=42를 읽음 — 모든 아키텍처

한쪽 배리어만 사용하면 왜 실패하는가

배리어를 처음 사용할 때 가장 흔한 오해는 "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 보장 */
실무 해석: writer 쪽 배리어는 "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 종료)
Acquire / Release 단방향 펜스 (vs Full Barrier) ACQUIRE (smp_load_acquire) "잠금 획득" 의미론 이전 연산 A (위) 이전 연산 B (위) ━━ ACQUIRE ━━ 이후 연산 C (아래) ↓ 이후 연산 D (아래) ↓ ✅ C, D는 ACQUIRE 위로 이동 불가 A, B는 아래로 이동 허용 (단방향 — 아래에서 위로만 차단) ARM64: LDAR RISC-V: fence r,rw 차단! FULL BARRIER (smp_mb) "양방향 완전 차단" 이전 연산 A (위) 이전 연산 B (위) ━━ FULL BARRIER ━━ 이후 연산 C (아래) ↓ 이후 연산 D (아래) ↓ ✅ A, B는 아래로 이동 불가 ✅ C, D는 위로 이동 불가 (양방향 — 가장 강한 보장) x86: MFENCE ARM64: dmb ish RISC-V: fence rw,rw RELEASE (smp_store_release) "잠금 해제" 의미론 이전 연산 A (위) ↑ 이전 연산 B (위) ↑ ━━ RELEASE ━━ 이후 연산 C (아래) 이후 연산 D (아래) ✅ A, B는 RELEASE 아래로 이동 불가 C, D는 위로 이동 허용 (단방향 — 위에서 아래로만 차단) ARM64: STLR RISC-V: fence rw,w 차단!

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)로 올라오는 것은 허용
 * (비대칭적 단방향 차단 — 성능을 위해 의도적으로 설계)
 */
Acquire/Release vs Full Barrier: smp_load_acquire()/smp_store_release()smp_mb()보다 가볍습니다. Full barrier는 양방향 모두 차단하지만, acquire는 한 방향(아래→위), release는 반대 방향(위→아래)만 차단합니다. ARM64에서 LDAR/STLR 명령어로 효율적으로 구현됩니다.

아키텍처별 메모리 모델

각 CPU 아키텍처는 서로 다른 메모리 순서 모델을 가지며, 이는 배리어의 실제 구현과 비용에 직접적인 영향을 미칩니다.

x86: Total Store Order (TSO)

x86은 프로그래머에게 가장 친화적인 강한 메모리 모델을 제공합니다:

x86 TSO에서 무효화 큐는 소프트웨어에 보이지 않는다

x86 프로세서 내부에도 다양한 마이크로아키텍처 버퍼가 있을 수 있지만, 소프트웨어가 보는 계약은 TSO가 Load-Load 재배열을 노출하지 않는다는 점입니다. ARM/POWER 설명에서 사용하는 "무효화 큐"는 약한 모델을 이해하기 위한 대표 직관이며, x86에서의 배리어 의미를 그 구조의 존재 여부로 설명할 필요는 없습니다.

재배열 종류x86 TSOARM/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 — 배리어가 순서를 제한할 메모리 접근 종류:

접미사의미막는 재배열
LDLoad 관련 접근만Load-Load, Store-Load 순서 보장
STStore 관련 접근만Store-Store 순서 보장
(없음)Load + Store 모두모든 재배열 방지
ARM64 DMB/DSB 파라미터 매핑 — 커널 API 대응표 LD (Load만) ST (Store만) (없음 = LD+ST 전체) ↑ Access Type ISH (CPU 클러스터) SY (시스템 전체) Shareability Domain ↑ DMB ISHLD Load-Load 순서 보장 무효화 큐 flush → smp_rmb() DMB ISHST Store-Store 순서 보장 스토어 버퍼 drain → smp_wmb() DMB ISH 모든 재배열 방지 (LD+ST 양방향) → smp_mb() DSB LD + 파이프라인 직렬화 + 시스템 전체 범위 → rmb() (MMIO용) DSB ST + 파이프라인 직렬화 + 시스템 전체 범위 → wmb() (MMIO용) DSB SY 가장 강한 배리어 GPU·DMA 포함 전체 → mb() (MMIO용) DMB = 메모리 접근 순서만 보장 (파이프라인 계속 실행 가능) | DSB = DMB 효과 + 파이프라인 완전 직렬화 + 캐시·TLB 유지보수 완료 대기

DSB vs DMB: 파이프라인 직렬화 차이

Shareability Domain과 Access Type이 같더라도, DMBDSB는 근본적으로 다릅니다. 이 차이는 파라미터가 아니라 명령어 자체의 강도에서 옵니다:

DMB vs DSB — 파이프라인 직렬화 차이 Fetch/Decode Execute Memory Cache Maint. Retire DMB (ish 등) 배리어 이전 Load/Store 완료까지 대기 DMB: 메모리 접근 순서 보장 이후 명령어 투기적 실행 허용 → 캐시/TLB 유지보수 명령(DC, IC, TLBI)은 대기하지 않음 DSB (sy 등) 배리어 이전 Load/Store + DC/IC/TLBI 완료까지 대기 DSB: 파이프라인 완전 직렬화 — 이후 명령어는 DSB 완료 후에야 실행 캐시/TLB 유지보수(DC CIVAC, TLBI VMALLE1IS 등) 완료 보장 후 다음 명령 실행 언제 DSB가 필요한가? TLB 무효화 후: TLBI → DSB ISH → ISB 순서로 사용. DSB 없이 ISB만 쓰면 TLBI 완료 전에 다음 명령이 새 페이지 테이블로 접근할 수 있음 MMIO/DMA: 장치는 ISH 도메인 밖 → DSB SY로 시스템 전체 범위 + 파이프라인 직렬화 필요
DMBDSB
메모리 접근 순서 보장
캐시 유지보수(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 → 이후 접근은 새 페이지 테이블 기준 */
DMBDSBISB
대상메모리 접근 순서메모리 + 캐시/TLB 유지보수명령어 파이프라인
파이프라인 flush
메모리 접근 순서 보장
시스템 레지스터 반영
코드 패치 후 필요DSB 선행 필요✅ (DSB 다음에)
TLB 무효화 후✅ (TLBI 완료 대기)✅ (DSB 다음에)
DSB → ISB 순서가 중요한 이유:
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")
RISC-V fence 필드 (RISC-V ISA Spec §2.7): 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")
명령어SSLLLSSLI/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 순서 보장 여부.

POWER acquire/release 구현:
smp_load_acquire() = ld; cmp; bc; isync — 조건 분기 + isync로 투기적 로드 차단
smp_store_release() = lwsync; st — lwsync로 이전 연산이 store 전에 완료됨 보장
ARM64의 LDAR/STLR에 비해 훨씬 복잡하고 비용이 큽니다.

아키텍처별 배리어 비교표

커널 APIx86ARM64RISC-VPOWER
mb()MFENCEDSB SYfence iorw,iorwsync
rmb()LFENCEDSB LDfence ir,irsync
wmb()SFENCEDSB STfence ow,owsync
smp_mb()MFENCEDMB ISHfence rw,rwsync
smp_rmb()barrier()DMB ISHLDfence r,rlwsync
smp_wmb()barrier()DMB ISHSTfence w,wlwsync
smp_load_acquire()MOV (자연 보장)LDARfence r,rwld; lwsync
smp_store_release()MOV (자연 보장)STLRfence rw,wlwsync; st

† POWER smp_mb(): 일반적으로 sync 명령어를 사용합니다. smp_rmb()/smp_wmb()에는 lwsync를 사용합니다 (arch/powerpc/include/asm/barrier.h 참조).

아키텍처 메모리 모델 강도 스펙트럼 강함(Strong) 약함(Weak) Sequential Consistency (이상향, 현실에 없음) x86 / x86-64 TSO (Total Store Order) ✅ Store-Store 보장 ✅ Load-Load 보장 ✅ Load-Store 보장 ❌ Store-Load 가능 smp_wmb() = barrier() smp_mb() = MFENCE ARM64 / AArch64 Weakly Ordered ❌ Store-Store 가능 ❌ Load-Load 가능 ❌ Load-Store 가능 ❌ Store-Load 가능 smp_wmb()=dmb ishst LDAR/STLR로 acquire/release RISC-V RVWMO ❌ 모든 재배열 가능 (의존성만 자동 보장) fence 명령어로 제어 fence w,w fence r,r fence rw,rw (full) POWER 매우 약한 모델 ❌ 모든 재배열 가능 ❌ 투기적 실행 포함 sync (full) / lwsync isync (명령 캐시) eieio (I/O 전용) ⚠ x86에서 통과한 테스트가 ARM/POWER에서 실패하는 이유: x86이 자동으로 차단하는 재배열을 ARM/POWER는 허용합니다. 반드시 약한 모델에서 검증하세요.
⚠️

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을 포함한 대부분의 컴파일러가 의존성 추적의 복잡성 때문에 consumeacquire로 승격시켜 구현합니다. 이 때문에 성능 이점이 사라지고 의미도 불명확해져 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_acquireacquire 배리어 (단방향)smp_load_acquire()lock 획득, 플래그 읽기
memory_order_releaserelease 배리어 (단방향)smp_store_release()lock 해제, 플래그 설정
memory_order_acq_relacquire+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보다 더 정교한 부분 순서 모델.
 */
왜 커널은 C11 원자를 직접 사용하지 않는가?
Linux 커널은 C11 이전부터 독자적 원자 API를 발전시켰습니다. 또한 커널에서는 아키텍처별 최적화, UP/SMP 전환, 인터럽트 컨텍스트 지원 등 C11 표준이 다루지 않는 요구사항이 있습니다. Linux v5.x부터는 내부적으로 C11 원자와의 호환성을 점진적으로 강화하고 있습니다.
C11 memory_order ↔ Linux 커널 API 대응 C11 (사용자 공간) Linux 커널 API memory_order_relaxed 원자성만 보장, 배리어 없음 READ_ONCE() / WRITE_ONCE() 컴파일러 재배열 방지, HW 배리어 없음 memory_order_consume 데이터 의존성 배리어 rcu_dereference() 포인터 의존성 보장 (DEC Alpha용) memory_order_acquire acquire 단방향 배리어 (로드) smp_load_acquire() 로드 + acquire 배리어 memory_order_release release 단방향 배리어 (스토어) smp_store_release() 스토어 + release 배리어 memory_order_acq_rel RMW: acquire+release 양방향 atomic_xchg() / cmpxchg() Read-Modify-Write 연산 memory_order_seq_cst → smp_mb() + atomic_*_return() [전역 완전 순서, 가장 강한 보장]

LL/SC — Load-Link/Store-Conditional

ARM64, RISC-V, PowerPC 등 RISC 계열 아키텍처는 원자 연산을 LL/SC(Load-Link/Store-Conditional) 쌍으로 구현합니다. x86의 LOCK 접두사와 달리, LL/SC는 버스 잠금 없이 하드웨어 exclusivity monitor를 활용합니다.

아키텍처Load-LinkStore-Conditional배리어 통합 버전
ARM64LDXRSTXRLDAXR / STLXR (acquire/release)
RISC-VLR.WSC.WLR.W.AQ / SC.W.RL
PowerPClwarxstwcx.lwarx + sync
x86LOCK 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 이후 연산에 HBsmp_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
RCUrcu_assign_pointersynchronize_rcurcu_dereference 체인으로 HB 성립RCU 읽기 측 lock-free
Happens-Before 타임라인 (Release–Acquire 동기화) 시간 ↓ Thread A (CPU 0) data = 42 (A) smp_store_release (&flag, 1) (B) 이후 연산 (C) 공유 메모리 data: 0 → 42 (Thread A가 기록) flag: 0 → 1 (release 쓰기 — 동기화 포인트) HB 체인 성립 조건 ① A →po B (프로그램 순서) ② B →sync D (release-acquire 쌍) ③ D →po E (프로그램 순서) ∴ A →hb E (data=42 관찰 보장) Thread B (CPU 1) 폴링 중... smp_load_acquire (&flag) == 1 (D) use(data) (E) → 42 관찰 보장! ←────── release → acquire 동기화 연결 ──────→

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) 안전
 */
LKMM에서의 HB 구현: LKMM은 hb 관계를 prop(전파), pb(전파 이전), co(코히런스 순서), fr(읽기 이후) 관계의 합성으로 수학적으로 정의합니다. tools/memory-model/linux-kernel.cat에서 형식적 정의를 확인할 수 있습니다.

Linux Kernel Memory Model (LKMM)

LKMM은 Linux 커널의 공식 메모리 모델입니다. 커널 소스 트리의 tools/memory-model/에 위치하며, 커널의 동시성 프리미티브가 제공하는 순서 보장을 수학적으로 정의합니다.

LKMM 구성 요소

LKMM 핵심 관계 구조 (hb · co · fr · pb) rf reads-from W → R (R이 W값 읽음) hb 동기화 엣지 생성 co coherence 같은 위치 쓰기 전체 순서 W1 → W2 (co 순서) fr from-reads R 이후의 co-후속 쓰기 = co ∘ rf⁻¹ (역 유도) ppo preserved program order 아키텍처가 보존하는 순서 (의존성·분기 뒤 접근 등) fence 명시적 배리어 순서 wmb / rmb / mb smp_store_release/acquire 포함 실행 관계 (execution relations) 순서 제약 (ordering constraints) rf;smc 일관성 hb (happens-before) = ppo ∪ fence ∪ (rf ; smc) ∪ data-dep A hb B: B 실행 전에 A의 효과가 반드시 가시 → acquire/release 쌍, 배리어가 이 관계를 구축 pb (propagates-before) = cumul-fence · strong-fence 체인 전역 전파 순서 (POWER/ARM 약한 모델) smp_mb()의 full-fence가 pb 엣지 생성 일관성 검사 (Consistency Axioms) hb ∪ co ∪ fr ∪ pb 관계에 순환(cycle)이 없으면 → 실행 허용 (litmus "Sometimes") 순환 존재 → 실행 금지 (litmus "Never") 예: r0=1 ∧ r1=0 in MP+wmb+rmb ≡ hb에 모순 → Never herd7 도구로 검증: herd7 -conf linux-kernel.cfg litmus-tests/MP+wmb+rmb.litmus 커널 소스: tools/memory-model/linux-kernel.cat (관계 정의) · .bell (이벤트 구조) · litmus-tests/ (테스트)

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에서 실행 검증
LKMM의 핵심 관계: LKMM은 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()의 내부: set_current_state(TASK_X)smp_store_mb(&current->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);
}
TX 완료에서 dma_rmb() vs smp_rmb(): dma_rmb()는 디바이스(DMA 엔진)와 CPU 사이의 순서를 보장합니다. smp_rmb()는 CPU들 사이만 보장하므로 비-coherent DMA에서는 부족합니다. 실제 드라이버에서 DD 비트 확인 후에는 반드시 dma_rmb()를 사용해야 합니다.

I/O 배리어

I/O 배리어는 CPU와 디바이스 간의 메모리 매핑된 I/O(MMIO) 접근 순서를 보장합니다. SMP 배리어와 달리 UP 시스템에서도 반드시 필요합니다.

MMIO 순서 보장

MMIO: CPU → 버스 → 디바이스 데이터 흐름과 wmb() CPU (드라이버 코드) ① writel_relaxed(desc→addr, dma_addr) DMA 주소 기록 (배리어 없음) ② writel_relaxed(desc→len, len) DMA 크기 기록 (배리어 없음) wmb() / dma_wmb() ①②가 버스에 도달한 후 ③ 전송 보장 ③ writel_relaxed(desc→cmd, CMD_START) DMA 시작 명령 (wmb 이후) ⚠ smp_wmb() 사용 금지 smp_wmb()는 CPU↔CPU만 보장 MMIO는 wmb() 또는 dma_wmb() 사용 (비-coherent 버스 포함 전파 보장) MMIO 버스 (PCI-e / AXI 등) wmb() 이전 트랜잭션 ① addr / ② len 전송 중 (순서 미보장 — 재배열 가능) wmb() 드레인 포인트 ①②가 디바이스에 완전히 도달할 때까지 대기 wmb() 이후 트랜잭션 ③ CMD 전송 (①② 완료 이후 보장) readl() vs readl_relaxed() readl(): 암시적 배리어 포함 readl_relaxed(): 없음 → rmb() 직접 삽입 writel(): 암시적 배리어, 일반 MMIO에 권장 디바이스 MMIO 레지스터 DMA 주소 레지스터 (addr) ← ① CPU가 기록 DMA 크기 레지스터 (len) ← ② CPU가 기록 wmb() 보장: ①② 완료 확인 디바이스가 addr/len을 읽을 준비 완료 CMD 레지스터 (DMA 시작 트리거) ← ③ wmb() 이후 CPU가 기록 → DMA 동작 시작 wmb() 없을 때의 문제 CMD 레지스터가 먼저 도달 → DMA 시작 → addr/len 미기록 상태에서 DMA 동작 → 메모리 오염 / 시스템 크래시 발생 writel_relaxed() + wmb(): 배치 쓰기 후 한 번 배리어 → 성능 최적화 writel(): 매번 내부 배리어 포함 → 순서 보장 단순화 (단 성능 비용)

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()acquirereleasepair로 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()r1r2 사이에 추가하거나, 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 (성공 시)블로킹 없이 완료 여부 확인
spin_lock과의 관계: 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 시나리오가 가능합니다.

  1. CPU 0이 ctx->done = 1 쓰기보다 waitqueue_active() 읽기를 먼저 수행해, 아직 비어 있는 대기열을 관찰합니다.
  2. CPU 1은 prepare_to_wait()ctx->done을 검사하지만, CPU 0의 store가 아직 보이지 않아 0으로 읽습니다.
  3. CPU 0은 이미 "대기자 없음"이라고 판단했으므로 wake_up()를 건너뜁니다.
  4. 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));
왜 before와 after가 둘 다 필요한가: 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(Non-Maskable Interrupt) 주의:
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이므로 변화가 없었다고 잘못 판단합니다. 값은 같지만 메모리 구조(포인터가 가리키는 객체)는 이미 교체된 상태입니다.

ABA 문제 시나리오 (락-프리 스택):
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_tspinlock을 내장합니다.

특성seqcount_tseqlock_t
spinlock 포함❌ 별도 spinlock 필요✅ 내장
Writer 직렬화외부 락으로 직렬화내장 spinlock으로 직렬화
Reader 대기재시도만 (블로킹 없음)재시도만 (블로킹 없음)
Writer APIwrite_seqcount_begin/end()write_seqlock/sequnlock()
Reader APIread_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 상호작용)

Seqcount/Seqlock: Writer-Reader 타임라인 시간 → Writer seq=0 초기 seq=1 write_seqcount_begin (홀수=쓰기 중) data.timestamp = ts data.counter = cnt seq=2 write_seqcount_end (짝수=완료) seq=3 두 번째 쓰기 시작 Reader A s=0 읽기 data 읽기 (seq=0) 짝수→쓰기 없음 ✅ 성공 Reader B s=1 읽기 data 읽기 (seq=1) 홀수→쓰기 중! ❌ retry s=2 읽기 data 읽기 (seq=2) 짝수→성공 확인 ✅ 성공 (재시도) 핵심 규칙 • seq 홀수 → 쓰기 진행 중 → 리더는 나중에 재시도 (데이터 일관성 없을 수 있음) • seq 짝수이고 시작 seq == 종료 seq → 쓰기 없었음 → 읽은 데이터 유효 • write_seqcount_begin/end 내부의 smp_wmb()가 데이터와 seq 사이의 메모리 순서를 보장

상태 플래그 패턴

/* 흔한 패턴: 완료 플래그로 데이터 발행 */

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) 패턴

포인터를 통해 구조체를 다른 스레드에 "발행"할 때 가장 흔하게 사용하는 패턴입니다. 포인터 자체는 원자적이지만, 포인터가 가리키는 데이터의 초기화 완료를 보장하려면 배리어가 필요합니다.

포인터 발행: smp_store_release vs RCU Publisher (쓰기 측) ① 구조체 초기화 p = kmalloc(sizeof(*p), GFP_KERNEL); p->field = value; ② 배리어 (필수!) smp_store_release(&global_ptr, p); /* release */ 또는: RCU 발행 (더 안전) rcu_assign_pointer(global_ptr, p); → smp_store_release() 내장 + RCU 시맨틱 추가 ❌ 버그: WRITE_ONCE(&global_ptr, p) 만 사용 — p->field 초기화 순서 미보장! Subscriber (읽기 측) ① 포인터 읽기 p = smp_load_acquire(&global_ptr); /* acquire */ ② 포인터 역참조 (안전) if (p) use(p->field); /* field 초기화 완료 보장 */ 또는: RCU 역참조 rcu_read_lock(); p = rcu_dereference(global_ptr); /* 데이터 의존성 */ ❌ 버그: READ_ONCE(&global_ptr) 후 바로 역참조 — 초기화 미완료 가능! 원칙: store_release & load_acquire 쌍이 포인터와 데이터 사이의 happens-before를 보장합니다.
/* 완전한 예시: 동적 설정 갱신 */

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가 포인터는 보지만 필드는 아직 이전 값처럼 보일 수 있습니다.
언제 이 패턴을 쓰는가: 파일시스템 superblock 보조 구조체, 드라이버 fast-path 테이블, 프로토콜 파서 캐시처럼 "초기화는 드물고 읽기는 매우 잦은" 데이터에 적합합니다. 갱신과 해제가 빈번해지면 이 패턴보다 RCU나 명시적 락을 검토하는 편이 좋습니다.

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);   /* 마킹된 접근 → 레이스 아님 */
KCSAN의 동작 원리: KCSAN은 watchpoint 기반으로 동작합니다. 메모리 접근 시 해당 주소에 watchpoint를 설정하고, 잠시 후 다른 CPU에서 같은 주소에 동기화 없이 접근하면 데이터 레이스로 보고합니다. 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: 잘못된 배리어 선택

MMIO 메모리 배리어: 버그 vs 올바른 사용 ❌ 버그 (smp_wmb 사용) writel_relaxed(addr, reg + ADDR); smp_wmb(); /* UP에서 no-op! */ writel_relaxed(cmd, reg + CMD); UP 커널: smp_wmb() → barrier() (컴파일러만) CPU MMIO 순서 미보장 → 하드웨어 오동작! ✅ 올바른 사용 (wmb 또는 writel) 방법 1: wmb() 사용 writel_relaxed(addr, reg + ADDR); wmb(); /* UP에서도 실제 CPU 배리어 */ writel_relaxed(cmd, reg + CMD); 방법 2: writel() 사용 (암시적 배리어) writel(addr, reg + ADDR); /* 배리어 내장 */ writel(cmd, reg + CMD); 원칙: SMP 배리어(smp_wmb/smp_rmb)는 NUMA/SMP 간 캐시 일관성용 — MMIO에는 wmb()/mb() 또는 writel() 사용 MMIO는 버스(PCIe/AHB)에서 순서 보장이 필요 → CPU 레벨 배리어 필요

실수 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배리어 전후 값 확인런타임

배리어 선택 플로우차트

어떤 배리어를 사용할까? ① 잠금(spinlock/mutex/rwlock) 안인가? ✅ 추가 배리어 불필요 아니오 ② 단일 변수 접근 보호만? READ_ONCE() WRITE_ONCE() 아니오 ③ MMIO 디바이스 / DMA 순서? mb()/wmb()/rmb() dma_wmb()/dma_rmb() 아니오 ④ Producer-Consumer 또는 플래그 패턴? (한 CPU가 발행, 다른 CPU가 소비) smp_store_release() + smp_load_acquire() 아니오 ⑤ atomic_inc/set_bit 후 순서 보장? (반환값 없는 atomic 연산) smp_mb__after_atomic() 또는 atomic_*_return() 아니오 ⑥ 양방향 완전 순서 보장 필요? (Store-Load 재배열 포함 차단) smp_mb() (가장 비용 높음, 최후 수단) 아니오 설계 재검토 또는 전문가 상담 LKMM litmus 테스트로 정확성 검증 선택 원칙 1. 상위 추상화 우선 사용 잠금 → RCU → acquire/release → wmb/rmb → smp_mb 2. 배리어는 반드시 쌍으로 writer wmb ↔ reader rmb store_release ↔ load_acquire 3. smp_* vs mb() 구분 CPU 간: smp_* 사용 MMIO/DMA: mb()/dma_* 사용 4. 비용 인식 낮음: READ/WRITE_ONCE, barrier() 중간: acquire/release, wmb/rmb 높음: smp_mb(), mb() (MFENCE) 5. 검증 필수 KCSAN으로 런타임 탐지 herd7/litmus로 정적 검증 ARM/RISC-V에서 실기 테스트 6. x86 ≠ 안전 TSO가 숨긴 버그가 ARM에서 드러나는 일이 빈번함 직접 배리어는 최후 수단!

핵심 원칙: 배리어는 항상 쌍(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 CPUsmp_mb(), smp_rmb(), smp_wmb()CPU 간 순서 보장lockless 알고리즘
Acquire/Releasesmp_load_acquire(), smp_store_release()단방향 순서 보장Producer-Consumer, 플래그
Full CPUmb(), rmb(), wmb()모든 CPU (UP 포함)MMIO, I/O 순서
DMAdma_wmb(), dma_rmb()CPU-디바이스 간DMA 디스크립터
프리미티브spin_lock(), mutex_lock(), etc.암시적 배리어 내장일반 동기화

메모리 배리어와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.