동기화 기법 (Synchronization)

커널 동기화 프리미티브인 spinlock/raw spinlock, mutex, rwlock/rwsem, semaphore, seqlock, wait queue, completion을 실행 문맥(프로세스/IRQ/softirq)과 슬립 가능 여부 기준으로 체계적으로 비교합니다. 또한 데드락, lost wakeup, sleep-in-atomic 같은 대표 실패 패턴을 피하는 설계 규칙과 lockdep 기반 디버깅 절차까지 상세히 설명합니다.

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

핵심 요약

  • 락 선택 기준 — 슬립 가능 여부와 실행 컨텍스트(IRQ 가능 여부)로 1차 분류합니다.
  • 이벤트 대기 도구 — 조건 반복 대기는 wait queue, 단발 완료 신호는 completion이 적합합니다.
  • 읽기 우세 패턴 — rwsem/seqlock/RCU 중 데이터 형태와 충돌 특성에 맞춰 선택합니다.
  • 버그 유형 — ABBA 데드락, sleep-in-atomic, lost wakeup이 대표 위험입니다.
  • 검증 도구 — lockdep, DEBUG_ATOMIC_SLEEP, stall 로그 분석으로 조기 탐지합니다.

단계별 이해

  1. 실행 문맥 먼저 분류
    프로세스/IRQ/softirq 여부를 먼저 정하고 "슬립 가능 여부"를 확정합니다.
  2. 자료 접근 형태 확인
    단일 변수인지, 복합 구조인지, 읽기 비율이 높은지에 따라 primitive 후보를 좁힙니다.
  3. 대기/깨우기 경로 검증
    조건 변경 순서와 wake_up 호출 지점을 함께 점검해 lost wakeup을 방지합니다.
  4. 디버깅 옵션 상시 활성화
    개발 커널에서 lockdep 계열 옵션을 켜고 경고를 즉시 수정합니다.
관련 표준: C11 Memory Model (ISO/IEC 9899:2011, 원자적 연산/메모리 순서), LKMM (Linux Kernel Memory Model) — 커널 동기화 프리미티브의 이론적 기반이 되는 메모리 모델 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

동기화가 필요한 이유

커널은 진정한 병렬 실행 환경입니다. SMP(Symmetric Multi-Processing, 대칭형 다중 처리) 시스템에서 여러 CPU가 동시에 같은 자료구조에 접근할 수 있고, 인터럽트나 선점으로 인해 단일 CPU에서도 레이스 컨디션이 발생합니다. Linux 커널은 다양한 동기화 프리미티브를 제공하여 critical section을 보호합니다.

커널의 동시성 원천

커널에서 동기화가 필요한 동시성은 다음 네 가지 원천에서 발생합니다. 각 원천의 특성에 따라 적합한 동기화 프리미티브가 달라지므로, 먼저 어떤 동시성 원천이 관여하는지 파악해야 합니다.

동시성 원천설명예시대표 보호 수단
SMP (True parallelism)여러 CPU가 물리적으로 동시에 같은 코드/데이터 접근CPU0, CPU1이 동시에 list_add() 호출spinlock, mutex, RCU
Preemption (선점)커널 선점(CONFIG_PREEMPT)이 활성화되면 프로세스 컨텍스트 실행 중 다른 태스크로 전환프로세스 A가 전역 변수 갱신 중 선점 → 프로세스 B가 같은 변수 접근preempt_disable(), spinlock, per-CPU 변수
Interrupt (인터럽트)하드웨어 인터럽트가 현재 실행 흐름을 중단하고 IRQ 핸들러 실행프로세스가 디바이스 버퍼 읽기 중 → IRQ 핸들러가 같은 버퍼에 쓰기spin_lock_irqsave(), local_irq_disable()
Softirq / Tasklet하위 반쪽(Bottom Half)이 인터럽트 반환 후 지연 처리네트워크 수신 softirq가 소켓 버퍼를 처리 중 다른 CPU의 softirq도 동시 실행spin_lock_bh(), per-CPU 데이터

레이스 컨디션의 발생 원리

레이스 컨디션(Race Condition)은 두 개 이상의 실행 흐름이 공유 데이터에 비원자적(non-atomic)으로 접근할 때, 실행 순서(타이밍)에 따라 결과가 달라지는 결함입니다. 가장 전형적인 패턴은 Read-Modify-Write(RMW)입니다. 카운터를 1 증가시키는 counter++도 실제로는 (1) 메모리에서 읽기, (2) 레지스터에서 증가, (3) 메모리에 쓰기의 3단계로 분해되며, 이 중간에 다른 CPU나 인터럽트가 개입하면 갱신이 손실됩니다.

Race Condition: counter++ (Read-Modify-Write) 시간 CPU 0 CPU 1 메모리 (counter) counter = 5 ① READ counter → reg0 = 5 ② READ counter → reg1 = 5 ③ reg0 = 5 + 1 = 6 ④ reg1 = 5 + 1 = 6 ⑤ WRITE counter = 6 ⑥ WRITE counter = 6 (덮어쓰기!) counter = 6 ✗ 기대값: counter = 7 (5+1+1) → 실제: 6 — 한 번의 증가가 소실됨 (lost update) 위험 구간
RMW 레이스 컨디션 — 두 CPU가 같은 counter를 읽고 수정하면 한쪽 갱신이 소실됩니다
/* 레이스 컨디션 예: counter++가 원자적이지 않은 이유 */
static int shared_counter = 0;

/* CPU 0 실행 */                   /* CPU 1 실행 (동시) */
/* ───────────────── */           /* ───────────────── */
shared_counter++;                   shared_counter++;
/* 어셈블리 분해: */               /* 어셈블리 분해: */
/* mov eax, [counter] */          /* mov eax, [counter] */
/* inc eax            */          /* inc eax            */
/* mov [counter], eax */          /* mov [counter], eax */

/* 해결 1: atomic 연산 (단일 변수에 적합) */
static atomic_t safe_counter = ATOMIC_INIT(0);
atomic_inc(&safe_counter);  /* lock prefix가 캐시 라인 독점 보장 */

/* 해결 2: spinlock (복합 자료구조에 적합) */
spin_lock(&my_lock);
shared_counter++;
spin_unlock(&my_lock);

Data Race vs Race Condition

Data RaceRace Condition은 종종 혼용되지만 정확히 다른 개념입니다.

구분Data RaceRace Condition
정의두 스레드가 같은 메모리에 동시 접근하고, 하나 이상이 쓰기이며, 순서 보장이 없는 경우프로그램의 결과가 실행 순서(타이밍)에 의존하여 의도와 다르게 동작하는 경우
형식적 정의C11/LKMM에서 명확히 정의 (UB)논리적/설계 결함 (형식 정의 어려움)
탐지 도구KCSAN, TSAN, sparse코드 리뷰, 모델 검사, lockdep
관계Data race는 race condition의 부분집합. Data race 없이도 race condition 가능 (예: TOCTOU)
/* Data Race: 동기화 없이 동시 접근 — UB (Undefined Behavior) */
int flag = 0;
/* Thread 1 */  flag = 1;
/* Thread 2 */  if (flag) { ... }   /* data race: 동기화 없음 */

/* 수정: READ_ONCE/WRITE_ONCE로 data race 제거 */
/* Thread 1 */  WRITE_ONCE(flag, 1);
/* Thread 2 */  if (READ_ONCE(flag)) { ... }

/* Race Condition (data race 아님): TOCTOU 패턴 */
spin_lock(&lock);
if (list_empty(&queue)) {    /* check */
    spin_unlock(&lock);
    /* 여기서 다른 CPU가 enqueue() 할 수 있음! */
    spin_lock(&lock);
    list_del(&queue);         /* use — 조건이 변했을 수 있음 */
}
spin_unlock(&lock);

TOCTOU (Time-of-Check to Time-of-Use)

TOCTOU는 조건을 검사(Check)한 시점과 그 결과를 사용(Use)하는 시점 사이에 상태가 변하는 레이스 컨디션의 하위 유형입니다. 커널에서는 사용자 공간 포인터 검증, 파일 권한 확인, 리소스 가용성 검사 등에서 자주 발생합니다.

/* TOCTOU 취약점: 사용자 공간 데이터 검증 후 재접근 */
char __user *ubuf;

/* 잘못된 패턴: */
if (access_ok(ubuf, len)) {           /* ← Check: 유효한 사용자 주소 */
    /* 다른 스레드가 mmap/munmap으로 매핑 변경 가능! */
    copy_from_user(kbuf, ubuf, len);   /* ← Use: 매핑이 변했을 수 있음 */
}

/* 올바른 패턴: copy_from_user()가 내부적으로 검증+복사를 원자적으로 수행 */
if (copy_from_user(kbuf, ubuf, len))
    return -EFAULT;  /* 검증+복사가 단일 연산으로 처리됨 */

/* 커널 내부 TOCTOU 패턴: 잠금 해제 후 재검사 */
spin_lock(&dev->lock);
if (dev->state == DEV_READY) {       /* Check */
    /* 올바른 사용: 잠금 보유 상태에서 즉시 사용 */
    start_transfer(dev);              /* Use — 잠금 내에서 원자적 */
}
spin_unlock(&dev->lock);
⚠️

TOCTOU 방지 원칙: (1) 조건 검사와 사용을 같은 잠금 내에서 수행하세요. (2) 사용자 공간 데이터는 커널 버퍼로 한 번만 복사하고 복사본에서 검증/사용하세요 (double-fetch 방지). (3) 파일 시스템 경로 검증은 AT_* 계열 시스템 콜(openat, fstatat)로 디렉터리 FD 기준 상대 경로를 사용하세요.

Critical Section의 설계 원칙

Critical Section(임계 구역)은 공유 자원에 접근하는 코드 영역으로, 한 번에 하나의 실행 흐름만 진입할 수 있어야 합니다. 올바른 critical section 설계의 네 가지 필수 조건은 다음과 같습니다.

조건설명위반 시 결과
상호 배제 (Mutual Exclusion)한 태스크가 임계 구역에 있으면 다른 태스크는 진입 불가Data corruption, lost update
진행 (Progress)임계 구역이 비어 있으면 대기 중인 태스크가 진입 가능Livelock (진행 불가)
유한 대기 (Bounded Waiting)요청 후 유한 시간 내에 진입 보장Starvation (기아)
최소 범위 (Minimal Scope)임계 구역을 가능한 짧게 유지불필요한 경합, 처리량 저하
/* Critical Section 최소 범위 원칙 */

/* 잘못된 패턴: 불필요하게 넓은 임계 구역 */
spin_lock(&lock);
buf = kmalloc(4096, GFP_ATOMIC);    /* 할당은 락 밖에서 가능 */
memset(buf, 0, 4096);               /* 초기화도 락 밖에서 가능 */
shared_list.data = buf;              /* 이것만 보호 필요 */
shared_list.count++;
spin_unlock(&lock);

/* 올바른 패턴: 임계 구역 최소화 */
buf = kmalloc(4096, GFP_KERNEL);    /* 락 밖: sleep 가능 */
if (!buf) return -ENOMEM;
memset(buf, 0, 4096);               /* 락 밖: 준비 작업 */

spin_lock(&lock);
shared_list.data = buf;              /* 최소 임계 구역 */
shared_list.count++;
spin_unlock(&lock);

Spinlock

spinlock은 가장 기본적인 커널 동기화 메커니즘입니다. 락을 획득할 수 없으면 CPU에서 busy-wait(spinning) 합니다. 슬립이 불가능한 인터럽트 컨텍스트에서 널리 쓰이는 대표 잠금이며, PREEMPT_RT 환경에서는 raw_spinlock_t와의 의미 차이를 함께 고려해야 합니다.

#include <linux/spinlock.h>

DEFINE_SPINLOCK(my_lock);

/* 프로세스 컨텍스트에서만 */
spin_lock(&my_lock);
/* critical section */
spin_unlock(&my_lock);

/* 인터럽트 비활성화 + 잠금 (IRQ handler와 공유 시) */
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* critical section */
spin_unlock_irqrestore(&my_lock, flags);

/* Bottom half 비활성화 + 잠금 */
spin_lock_bh(&my_lock);
/* critical section */
spin_unlock_bh(&my_lock);
⚠️

spinlock을 보유한 상태에서는 절대로 슬립하면 안 됩니다. kmalloc(GFP_KERNEL), mutex_lock(), copy_from_user() 등 슬립 가능한 함수를 호출하면 deadlock이 발생합니다.

spinlock_t vs raw_spinlock_t

일반 커널에서 spinlock_traw_spinlock_t는 동일하게 busy-wait합니다. 그러나 PREEMPT_RT 커널에서는 spinlock_tsleeping lock(rt_mutex 기반)으로 변환되어 우선순위 상속이 적용됩니다. 진정한 busy-wait이 필요한 경우(하드웨어 레지스터, 스케줄러 핵심 경로, NMI 핸들러)에만 raw_spinlock_t를 사용합니다.

특성spinlock_traw_spinlock_t
일반 커널busy-waitbusy-wait (동일)
PREEMPT_RTrt_mutex 기반 sleeping lockbusy-wait (변환 없음)
Sleep 허용 (RT)가능 (우선순위 상속)불가
IRQ 핸들러RT에서 불가가능
사용 기준일반적 커널 자료구조 보호하드웨어, 스케줄러, 타이머 등 핵심 경로만
개수 (커널 전체)~10,000개 이상~100개 미만

qspinlock: 커널의 실제 스핀락 구현

Linux 커널의 스핀락은 세 세대를 거쳐 진화했습니다. 현재 x86_64, ARM64 등 주요 아키텍처에서 기본으로 사용되는 qspinlock은 MCS 큐 기반의 NUMA-aware 스핀락입니다.

세대구현문제점
1세대: Test-and-Set단일 변수에 atomic xchg로 획득 시도모든 CPU가 같은 캐시 라인에서 spinning → 캐시 라인 바운싱 심각, 불공정(unfair)
2세대: Ticket Spinlocknext/owner 카운터 쌍으로 FIFO 순서 보장FIFO 공정성 확보했으나 여전히 모든 CPU가 같은 변수를 polling → NUMA에서 캐시 트래픽 O(N)
3세대: qspinlock (MCS)대기자 각각이 자신의 로컬 변수에서 spinning → 잠금 해제 시 다음 대기자만 통지캐시 트래픽 O(1), NUMA 친화적, 32비트 워드에 모든 상태 압축

qspinlock 32비트 상태 인코딩

qspinlock은 32비트 정수 하나에 잠금 상태, pending 비트, MCS 큐 tail을 모두 인코딩합니다. 이 설계는 대기자가 2명 이하일 때 MCS 큐 할당 없이 경량으로 동작하는 fast path를 가능하게 합니다.

/* include/asm-generic/qspinlock_types.h */
typedef struct qspinlock {
    union {
        atomic_t val;       /* 32-bit 전체 상태 */
        struct {
            u8 locked;      /* bit [0]:   잠금 보유 여부 (1=locked) */
            u8 pending;     /* bit [8]:   pending 대기자 존재 (1=있음) */
            u16 tail;       /* bit[16:31] MCS 큐 tail (cpu+1 인코딩) */
        };
    };
} arch_spinlock_t;

/* 상태 전이:
 *
 * [val = 0x0]         — 잠금 해제 (free)
 *       ↓ atomic_try_cmpxchg(0 → 1)
 * [locked=1]          — Fast path: 즉시 획득 (대기자 없음)
 *       ↓ 두 번째 대기자 도착
 * [locked=1,pending=1] — Pending: 두 번째 대기자가 locked 비트만 polling
 *       ↓ 세 번째+ 대기자 도착
 * [locked=1,pending=1,tail≠0] — MCS 큐 활성: 추가 대기자는 MCS 큐에 진입
 */
qspinlock 상태 전이 (32-bit 워드 인코딩) FREE val = 0x00000000 cmpxchg(0→1) LOCKED (보유자 1명) locked=1, pending=0, tail=0 두 번째 대기자 LOCKED + PENDING (대기자 1명) locked=1, pending=1, tail=0 세 번째+ 대기자 LOCKED + PENDING + MCS QUEUE locked=1, pending=1, tail=cpu_id unlock → 다음 대기자 통지 Fast Path (경합 없음) 단일 atomic_try_cmpxchg 1회 Pending Path (2명) MCS 큐 없이 locked 비트만 polling Slow Path (3명+) MCS 큐: 각자 로컬 변수에서 spin 핵심: 대기자가 각자 로컬 캐시 라인에서 spinning → 캐시 트래픽 O(1) Ticket spinlock의 O(N) 캐시 invalidation 문제 완전 해결
qspinlock 상태 전이 — 경합 수준에 따라 fast/pending/MCS 경로를 자동 선택

MCS 큐의 동작 원리

MCS(Mellor-Crummey and Scott) 큐는 각 대기 CPU가 자기 자신의 mcs_spinlock 노드에서 spinning합니다. 잠금 해제 시 보유자는 큐의 다음 노드(next 포인터)의 locked 필드를 설정하여 정확히 한 CPU만 깨웁니다. 이 방식은 대기 CPU가 아무리 많아도 캐시 라인 invalidation이 해제 1회당 1개만 발생합니다.

/* kernel/locking/qspinlock.c — MCS 큐 노드 (per-CPU) */
struct qnode {
    struct mcs_spinlock mcs;
};
static DEFINE_PER_CPU_ALIGNED(struct qnode, qnodes[4]);
/* 4개: 일반/softirq/hardirq/NMI 각 컨텍스트용 */

struct mcs_spinlock {
    struct mcs_spinlock *next;   /* 다음 대기자 노드 */
    int locked;                   /* 0=대기중, 1=차례 도달 */
    int count;                    /* 중첩 깊이 */
};

/* MCS 대기 루프 핵심 (간략화) */
void mcs_spin_lock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
    node->locked = 0;
    node->next = NULL;

    /* 큐 끝에 자신을 연결 (atomic xchg) */
    struct mcs_spinlock *prev = xchg(lock, node);
    if (prev) {
        /* 이전 대기자가 있으면 그 뒤에 연결 */
        WRITE_ONCE(prev->next, node);
        /* 자신의 locked 필드에서 spinning (로컬 캐시 라인!) */
        arch_mcs_spin_lock_contended(&node->locked);
    }
}

/* MCS 해제: 다음 대기자의 locked만 설정 → 캐시 invalidation 1회 */
void mcs_spin_unlock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
    struct mcs_spinlock *next = READ_ONCE(node->next);
    if (next)
        arch_mcs_spin_unlock_contended(&next->locked);
}

Spinlock API 전체 정리

APIpreemptIRQBH용도
spin_lock()비활성화프로세스 컨텍스트 전용, IRQ와 공유 안 할 때
spin_lock_bh()비활성화비활성화softirq/tasklet과 공유할 때
spin_lock_irq()비활성화비활성화비활성화IRQ와 공유, IRQ가 활성화 상태임을 알 때
spin_lock_irqsave()비활성화비활성화비활성화IRQ와 공유, IRQ 상태 불확실할 때 (가장 안전)
spin_trylock()비활성화비블로킹 시도 (실패 시 0 반환)
spin_is_locked()상태 확인만 (잠금 없이), 디버깅/assert용
💡

spin_lock_irq() vs spin_lock_irqsave() 선택: 함수 진입 시점에 인터럽트가 반드시 활성화되어 있다고 확신하면 spin_lock_irq()가 약간 더 빠릅니다 (flags 저장/복원 생략). 그러나 호출 경로가 복잡하거나 라이브러리 함수처럼 여러 컨텍스트에서 호출될 수 있으면 spin_lock_irqsave()가 안전합니다. 의심스러우면 항상 irqsave를 사용하세요.

Spinlock 경합 프로파일링 (/proc/lock_stat)

CONFIG_LOCK_STAT=y를 활성화하면 /proc/lock_stat에서 모든 잠금의 경합 통계를 확인할 수 있습니다. 어떤 잠금이 가장 많이 경합하고, 대기 시간이 얼마나 되는지 정량적으로 분석할 수 있습니다.

# /proc/lock_stat 출력 형식
cat /proc/lock_stat

# 주요 컬럼:
# con-bounces   — 경합 발생 횟수 (contention bounces)
# contentions   — 경합으로 대기한 총 횟수
# waittime-min  — 최소 대기 시간 (ns)
# waittime-max  — 최대 대기 시간 (ns)
# waittime-total — 총 대기 시간 (ns)
# waittime-avg  — 평균 대기 시간 (ns)
# acq-bounces   — 획득 시 캐시 바운스 횟수
# acquisitions  — 총 획득 횟수
# holdtime-*    — 잠금 보유 시간 통계

# 경합이 가장 심한 잠금 상위 10개 정렬
sort -k2 -rn /proc/lock_stat | head -20

# 통계 리셋
echo 0 > /proc/lock_stat

# perf로 잠금 경합 프로파일링
perf lock record -a -- sleep 10
perf lock report

Mutex

mutex는 프로세스 컨텍스트 전용 잠금입니다. 락을 획득할 수 없으면 태스크를 슬립(sleep) 시키므로 CPU를 낭비하지 않습니다.

#include <linux/mutex.h>

DEFINE_MUTEX(my_mutex);

mutex_lock(&my_mutex);           /* 획득 (슬립 가능) */
/* critical section — can sleep here */
mutex_unlock(&my_mutex);

/* 시그널 인터럽트 가능 잠금 */
if (mutex_lock_interruptible(&my_mutex))
    return -ERESTARTSYS;

/* 비블로킹 시도 */
if (!mutex_trylock(&my_mutex))
    return -EBUSY;

Mutex 내부 구조와 3단계 획득 경로

커널의 mutex는 단순한 sleeping lock이 아닙니다. 성능을 위해 세 단계 획득 경로를 구현합니다: (1) Fast Path — 경합 없으면 단일 atomic 연산으로 즉시 획득, (2) Mid Path (Optimistic Spinning) — 보유자가 현재 CPU에서 실행 중이면 잠시 busy-wait, (3) Slow Path — 보유자가 sleep 상태면 자신도 sleep.

/* include/linux/mutex.h */
struct mutex {
    atomic_long_t       owner;     /* 보유자 task_struct 포인터 + 플래그 */
    raw spinlock_t      wait_lock; /* 대기 리스트 보호 */
    struct optimistic_spin_queue osq; /* optimistic spinning 큐 */
    struct list_head    wait_list; /* 대기 태스크 리스트 */
};

/* owner 필드의 하위 비트 플래그 */
/* bit 0: MUTEX_FLAG_WAITERS   — 대기자 존재 */
/* bit 1: MUTEX_FLAG_HANDOFF   — 특정 대기자에게 직접 전달 */
/* bit 2: MUTEX_FLAG_PICKUP    — 전달 대기 중 */
/* 상위 비트: task_struct 포인터 (정렬 보장으로 하위 비트 사용 가능) */
Mutex 3단계 획득 경로 mutex_lock() 호출 atomic_try_cmpxchg(owner, 0, current) 성공 획득! Fast Path ~수 ns 실패 (경합) 보유자가 현재 다른 CPU에서 실행 중? (owner→on_cpu == 1 && !need_resched()) Yes Optimistic Spin Mid Path ~수 μs (보유자 완료 대기) 보유자 해제 시 즉시 획득 No (sleep 상태) wait_list에 추가 → schedule() TASK_UNINTERRUPTIBLE로 전환 Slow Path: context switch 비용 (~수 μs) mutex_unlock() → wake_up → 획득 Optimistic Spinning 조건 1. 보유자가 다른 CPU에서 실행 중 2. need_resched() == false 3. OSQ 큐에서 자기 차례일 때
Mutex의 3단계 획득 경로 — 경합이 짧으면 context switch를 회피하여 성능 향상

Optimistic Spinning은 커널 3.15에서 도입된 핵심 최적화입니다. mutex 보유자가 다른 CPU에서 현재 실행 중이라면, 곧 mutex를 해제할 가능성이 높으므로 context switch 비용을 감수하는 대신 잠시 busy-wait합니다. 이 최적화는 critical section이 짧은 경우(수 μs 이내) 극적인 성능 향상을 제공합니다. osq_lock()(Optimistic Spin Queue)으로 대기자 간 순서를 관리하여 여러 CPU가 동시에 spinning하는 것을 방지합니다.

⚠️

Mutex 핵심 규칙:

  • 소유권: mutex를 획득한 태스크만 해제할 수 있습니다 (위반 시 lockdep 경고)
  • 재귀 불가: 같은 태스크가 같은 mutex를 두 번 획득하면 deadlock (재귀 잠금이 필요하면 설계 재검토)
  • 인터럽트 컨텍스트 불가: IRQ/softirq에서 mutex_lock() 호출 금지 (sleep 발생)
  • HANDOFF 메커니즘: 대기자가 2번 이상 깨어나도 획득에 실패하면 MUTEX_FLAG_HANDOFF를 설정하여 보유자가 해제 시 해당 대기자에게 직접 전달 — starvation 방지

Mutex 디버깅 (CONFIG_DEBUG_MUTEXES)

/* CONFIG_DEBUG_MUTEXES=y 활성화 시 추가 검증: */
/* 1. 소유권 확인: unlock 시 현재 태스크가 보유자인지 검증 */
/* 2. 재초기화 검사: 보유 중인 mutex를 다시 초기화하면 경고 */
/* 3. 메모리 해제 감지: 보유 중인 mutex가 kfree()되면 경고 */
/* 4. 태스크 종료 감지: mutex를 보유한 채 exit()하면 경고 */

/* 디버그 검증 매크로 */
mutex_is_locked(&my_mutex);            /* 잠금 상태 확인 */
lockdep_assert_held(&my_mutex);        /* 보유 중인지 assert */
lockdep_assert_not_held(&my_mutex);    /* 미보유 assert */

/* mutex_lock_killable(): SIGKILL만 응답하는 변종 */
/* 사용자 공간 요청이 아닌 커널 내부 대기에 적합 */
int ret = mutex_lock_killable(&my_mutex);
if (ret)
    return ret;  /* SIGKILL로 중단됨 */

Reader-Writer Lock

읽기 작업이 쓰기보다 훨씬 빈번할 때 사용합니다. 여러 reader가 동시에 접근 가능하지만, writer는 독점 접근합니다.

/* Spinlock 기반 rwlock */
DEFINE_RWLOCK(my_rwlock);

read_lock(&my_rwlock);
/* read-only access */
read_unlock(&my_rwlock);

write_lock(&my_rwlock);
/* exclusive access */
write_unlock(&my_rwlock);

/* Mutex 기반 rw_semaphore (슬립 가능) */
DECLARE_RWSEM(my_rwsem);

down_read(&my_rwsem);   /* shared read */
up_read(&my_rwsem);

down_write(&my_rwsem);  /* exclusive write */
up_write(&my_rwsem);

rwlock vs rwsem 선택 기준

rwlock_t는 spinlock 기반으로 reader와 writer 모두 busy-wait하며 슬립하지 않습니다. 반면 rw_semaphore는 mutex 기반으로 대기 태스크를 슬립시켜 CPU를 절약합니다. 선택 기준은 실행 컨텍스트와 critical section 길이에 따라 달라집니다.

특성rwlock_trw_semaphore (rwsem)
내부 구현spinlock 기반 busy-waitmutex 기반 sleeping lock
인터럽트 컨텍스트가능 (read/write_lock_irqsave)불가 (sleep 발생)
Sleep 허용불가가능
Critical section 길이매우 짧아야 함 (수십 ns)길어도 됨 (I/O 포함 가능)
Writer starvation위험 (reader가 계속 진입 가능)writer 우선 대기 지원
대표 사용처IRQ handler 공유 리스트VFS inode 보호, mm 서브시스템
⚠️

Writer Starvation 경고: rwlock_t는 새 reader가 지속적으로 진입하면 writer가 무기한 대기하는 writer starvation이 발생할 수 있습니다. 읽기 비율이 90% 이상이고 writer 지연이 허용되지 않는다면 seqlock 또는 RCU를 고려하세요. rw_semaphore는 커널 5.4 이후 writer 우선 정책을 기본으로 채택하여 starvation이 완화되었습니다.

읽기-쓰기 잠금 선택 의사결정 트리 sleep 가능? (프로세스 컨텍스트?) No (IRQ/BH) rwlock_t Yes writer 우선 보장 필요? Yes seqlock No 읽기 압도적 + 포인터 교체? Yes RCU 고려 No rw_semaphore (rwsem)
읽기-쓰기 잠금 선택 의사결정 트리 — sleep 가능 여부와 writer 우선순위로 1차 분류

Seqlock

seqlock은 writer 우선 reader-writer 락입니다. reader는 잠금 없이 읽되, 시퀀스 번호를 검사하여 읽는 동안 writer가 수정했는지 확인합니다. 충돌 시 reader가 재시도합니다.

DEFINE_SEQLOCK(my_seq);

/* Writer (exclusive) */
write_seqlock(&my_seq);
/* modify shared data */
write_sequnlock(&my_seq);

/* Reader (lock-free, retry on conflict) */
unsigned int seq;
do {
    seq = read_seqbegin(&my_seq);
    /* read shared data into local vars */
} while (read_seqretry(&my_seq, seq));

Seqlock 동작 원리와 제약사항

Writer 우선 동작: writer가 write_seqlock()을 호출하면 시퀀스 번호를 홀수로 증가시킵니다. 완료 후 write_sequnlock()이 다시 짝수로 만듭니다. reader는 시작과 끝의 시퀀스 번호를 비교하여 쓰기 충돌 여부를 판단합니다. 충돌이 감지되면 reader가 전체 읽기를 재시도합니다.

⚠️

Seqlock 사용 제약:

  • 포인터/가변 길이 데이터 불가: reader가 읽는 도중 writer가 구조체를 해제하면 Use-After-Free가 발생합니다. seqlock은 고정 크기 정수, 타임스탬프, 카운터에만 적합합니다.
  • Reader retry 비용: 쓰기 충돌 시 reader 전체가 재시도되므로, writer가 빈번하면 reader가 무한 루프에 빠질 수 있습니다. writer 빈도가 낮고 reader가 빠르게 완료되는 경우에 적합합니다.
  • Writer는 spinlock 기반: writer도 기다려야 하므로 sleep 불가, IRQ 핸들러에서는 write_seqlock_irqsave() 사용이 필요합니다.

대표 사용처: timekeeper(jiffies, xtime 갱신), vDSO clock(사용자 공간에서 gettimeofday 고속 처리), 네트워크 라우팅 테이블 시퀀스 번호.

Wait Queue (대기 큐)

Wait queue는 커널에서 이벤트 기반 대기를 구현하는 핵심 메커니즘입니다. 특정 조건이 만족될 때까지 태스크를 슬립 상태(TASK_INTERRUPTIBLE 또는 TASK_UNINTERRUPTIBLE)로 전환하고, 조건이 충족되면 깨웁니다. 커널 전역에서 I/O 완료 대기, 버퍼 가용 대기, 디바이스 준비 대기 등에 광범위하게 사용됩니다.

자료구조와 초기화

Wait queue는 wait_queue_head_t(대기 큐 헤드)와 wait_queue_entry_t(개별 대기 엔트리)로 구성됩니다.

#include <linux/wait.h>

/* 정적 초기화 */
DECLARE_WAIT_QUEUE_HEAD(my_wq);

/* 동적 초기화 */
struct wait_queue_head my_wq;
init_waitqueue_head(&my_wq);

/* 내부 구조 (include/linux/wait.h) */
struct wait_queue_head {
    spinlock_t      lock;       /* 대기 큐 보호용 spinlock */
    struct list_head head;      /* 대기 엔트리 연결 리스트 */
};

struct wait_queue_entry {
    unsigned int     flags;      /* WQ_FLAG_EXCLUSIVE 등 */
    void            *private;    /* 보통 current (task_struct *) */
    wait_queue_func_t func;     /* 깨우기 콜백 (기본: default_wake_function) */
    struct list_head entry;      /* 대기 큐 연결 */
};

wait_event 매크로 계열

wait_event 매크로는 조건(condition)이 참이 될 때까지 자동으로 슬립/깨우기/재검사를 처리합니다. 수동으로 schedule()을 호출하는 것보다 안전하고 간결합니다.

/* 기본: TASK_UNINTERRUPTIBLE — 시그널에 의해 깨어나지 않음 */
wait_event(wq, condition);

/* TASK_INTERRUPTIBLE — 시그널 수신 시 -ERESTARTSYS 반환 */
int ret = wait_event_interruptible(wq, condition);
if (ret)
    return -ERESTARTSYS;  /* 시그널에 의해 깨어남 */

/* 타임아웃: 조건 충족 시 남은 jiffies, 타임아웃 시 0 반환 */
unsigned long remaining;
remaining = wait_event_timeout(wq, condition, msecs_to_jiffies(5000));
if (!remaining) {
    pr_warn("timeout waiting for condition\\n");
    return -ETIMEDOUT;
}

/* interruptible + timeout 조합 */
remaining = wait_event_interruptible_timeout(wq, condition,
                                              msecs_to_jiffies(3000));
if (remaining == 0)
    return -ETIMEDOUT;       /* 타임아웃 */
if (remaining < 0)
    return -ERESTARTSYS;     /* 시그널 */

/* TASK_KILLABLE — SIGKILL만 응답 (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE) */
ret = wait_event_killable(wq, condition);
💡

wait_event 선택 가이드: 사용자 공간 요청 처리 경로에서는 wait_event_interruptible을 사용하세요 (Ctrl+C 응답 필수). 커널 내부 동기화(kthread 간 통신 등)에서는 wait_event이 적합합니다. 하드웨어 대기에는 항상 wait_event_timeout 계열을 사용하여 무한 대기를 방지하세요.

wake_up 계열 함수

조건을 변경한 후 반드시 대기 큐의 waiter들을 깨워야 합니다.

/* 모든 waiter 깨우기 */
wake_up(&wq);                /* TASK_NORMAL (INTERRUPTIBLE + UNINTERRUPTIBLE) */
wake_up_all(&wq);            /* 모든 waiter (exclusive 포함) */

/* TASK_INTERRUPTIBLE waiter만 깨우기 */
wake_up_interruptible(&wq);
wake_up_interruptible_all(&wq);

/* 주의: 조건 변경과 wake_up 사이의 순서가 중요! */
/* 올바른 패턴: */
shared_flag = 1;             /* ① 조건을 먼저 변경 */
smp_wmb();                    /* ② 메모리 배리어 (필요 시) */
wake_up(&wq);                /* ③ 그 다음 깨우기 */
⚠️

wake_up()은 exclusive waiter를 하나만 깨우고 non-exclusive waiter는 모두 깨웁니다. 모든 waiter를 깨우려면 wake_up_all()을 사용하세요. wake_up_interruptible()TASK_INTERRUPTIBLE 상태의 waiter만 깨우므로, wait_event()(TASK_UNINTERRUPTIBLE)로 대기 중인 태스크는 깨우지 않습니다.

실전 예제: 디바이스 드라이버에서의 Wait Queue

#include <linux/wait.h>
#include <linux/sched.h>
#include <linux/string.h>

struct my_device {
    wait_queue_head_t  read_wq;       /* 읽기 대기 큐 */
    wait_queue_head_t  write_wq;      /* 쓰기 대기 큐 */
    spinlock_t         lock;
    char               buf[256];
    size_t             data_len;      /* 0이면 데이터 없음 */
    size_t             buf_space;     /* 쓰기 가능 공간 */
    bool               disconnected;
};

/* 읽기: 데이터가 올 때까지 대기 */
static ssize_t my_read(struct file *filp, char __user *ubuf,
                       size_t count, loff_t *ppos)
{
    struct my_device *dev = filp->private_data;
    int ret;
    char kbuf[256];

    /* non-blocking 모드 처리 */
    if ((filp->f_flags & O_NONBLOCK) && !dev->data_len)
        return -EAGAIN;

    /* 데이터가 준비되거나 연결 해제될 때까지 대기 */
    ret = wait_event_interruptible(dev->read_wq,
                dev->data_len > 0 || dev->disconnected);
    if (ret)
        return -ERESTARTSYS;

    if (dev->disconnected)
        return -ENODEV;

    spin_lock(&dev->lock);
    count = min(count, dev->data_len);
    count = min(count, sizeof(kbuf));
    memcpy(kbuf, dev->buf, count);
    dev->data_len = 0;
    spin_unlock(&dev->lock);

    if (copy_to_user(ubuf, kbuf, count))
        return -EFAULT;

    return count;
}

/* 인터럽트 핸들러: 데이터 수신 시 대기자 깨우기 */
static irqreturn_t my_irq_handler(int irq, void *data)
{
    struct my_device *dev = data;

    spin_lock(&dev->lock);
    dev->data_len = read_hw_fifo(dev->buf, sizeof(dev->buf));
    spin_unlock(&dev->lock);

    wake_up_interruptible(&dev->read_wq);  /* 대기자 깨우기 */

    return IRQ_HANDLED;
}

Exclusive Wait (Thundering Herd 방지)

다수의 태스크가 같은 이벤트를 기다릴 때 wake_up()으로 모두 깨우면 Thundering Herd 문제가 발생합니다. 하나의 태스크만 실제로 작업을 수행하고 나머지는 다시 슬립하므로 CPU를 낭비합니다. WQ_FLAG_EXCLUSIVE 플래그로 exclusive waiter를 등록하면 wake_up()이 exclusive waiter를 하나만 깨웁니다.

/* exclusive waiter 등록 (수동 방식) */
DEFINE_WAIT(wait);
prepare_to_wait_exclusive(&wq, &wait, TASK_INTERRUPTIBLE);

while (!condition) {
    schedule();
    prepare_to_wait_exclusive(&wq, &wait, TASK_INTERRUPTIBLE);
}
finish_wait(&wq, &wait);

/* exclusive waiter가 있을 때의 wake_up 동작: */
/*   wake_up()         → non-exclusive 전부 + exclusive 1개 */
/*   wake_up_all()     → exclusive 포함 전부 깨움 */
/*   wake_up_nr(&wq, n) → non-exclusive 전부 + exclusive n개 */

/* 대표적 사용 예: accept() 시스템 콜 */
/* 여러 스레드가 listen socket에서 accept() 대기 시 */
/* exclusive wait으로 연결 1개당 스레드 1개만 깨움 */

wake_up() 내부 로직과 Thundering Herd 비교

wake_up()은 대기 큐 리스트를 순회하면서 non-exclusive waiter를 전부 깨운 뒤, exclusive waiter를 처음 만나면 1개만 깨우고 순회를 중단합니다. 이 동작으로 N개의 waiter 중 N-1개의 불필요한 context switch를 제거합니다.

Thundering Herd: non-exclusive vs exclusive Non-Exclusive (문제) W1 SLEEP W2 SLEEP W3 SLEEP W4 SLEEP wake_up() W1 RUN W2 RUN W3 RUN W4 RUN W1만 작업 수행 → 나머지 3개 다시 슬립 3번의 불필요한 context switch 낭비 Exclusive (해결) E1 SLEEP E2 SLEEP E3 SLEEP E4 wake_up() E1 RUN E2 SLEEP E3 SLEEP E1만 작업 → 완료 후 다음 wake_up에 E2 context switch 낭비 0회 핵심: N개 exclusive waiter → wake_up() 1회당 1개만 깨움 → N-1개 불필요한 context switch 제거 wake_up_nr(&wq, n): exclusive n개 깨우기 | wake_up_all(): 전부 깨우기
Thundering Herd 문제와 exclusive waiter로 해결하는 과정

수동 Wait Queue 사용 패턴

wait_event 매크로로 충분하지 않은 복잡한 경우(조건 검사 중 락 획득/해제, 커스텀 깨우기 로직 등)에는 수동으로 wait queue를 조작합니다.

/* 수동 패턴: 정확한 순서가 중요! */
DEFINE_WAIT(wait);

for (;;) {
    /* ① 대기 큐에 등록 + 태스크 상태 변경 */
    prepare_to_wait(&wq, &wait, TASK_INTERRUPTIBLE);

    /* ② 조건 검사 (락 보호 하에) */
    spin_lock(&lock);
    if (condition_met) {
        spin_unlock(&lock);
        break;
    }
    spin_unlock(&lock);

    /* ③ 시그널 확인 (interruptible인 경우) */
    if (signal_pending(current)) {
        ret = -ERESTARTSYS;
        break;
    }

    /* ④ 슬립 — wake_up()이 호출될 때까지 */
    schedule();
}
/* ⑤ 대기 큐에서 제거 + 태스크 상태 TASK_RUNNING 복구 */
finish_wait(&wq, &wait);

/* 주의: ①과 ② 사이에서 wake_up()이 호출될 수 있지만 */
/* prepare_to_wait()이 상태를 설정하므로 깨우기를 놓치지 않음 */
/* (lost wakeup 방지가 이 패턴의 핵심) */

복합 조건 대기와 Spurious Wakeup 대응

복잡한 조건(OR/AND 조합)은 wait_event_interruptible()의 condition 표현식 안에 직접 작성합니다. Spurious wakeup(실제로 조건이 충족되지 않았는데 깨어나는 경우)은 while 루프로 재확인하여 대응합니다.

/* 복합 조건: 데이터 준비 OR 에러 발생 OR 연결 종료 */
ret = wait_event_interruptible(dev->wq,
        dev->data_ready || dev->error || dev->disconnected);
if (ret)
    return -ERESTARTSYS;

/* wake_up 후 반드시 조건을 재확인 — spurious wakeup 대응 */
if (dev->error)
    return dev->error;
if (dev->disconnected)
    return -ENODEV;

/* AND 조건: 두 가지가 모두 준비될 때까지 대기 */
ret = wait_event_interruptible(dev->wq,
        dev->rx_ready && dev->tx_ready);

/* Spurious wakeup 대응 패턴 (수동 방식에서) */
DEFINE_WAIT(wait);
for (;;) {
    prepare_to_wait(&wq, &wait, TASK_INTERRUPTIBLE);
    if (condition_met)    /* 항상 루프 내부에서 재확인 */
        break;
    if (signal_pending(current)) {
        ret = -ERESTARTSYS;
        break;
    }
    schedule();           /* spurious wakeup 후 다시 루프 진입 */
}
finish_wait(&wq, &wait);
☢️

Lost Wakeup 버그: 조건 검사와 schedule() 사이에서 다른 CPU가 조건을 변경하고 wake_up()을 호출하면, 대기 큐에 아직 등록되지 않아 깨우기를 놓칠 수 있습니다. 반드시 prepare_to_wait()으로 대기 큐 등록 후에 조건을 검사하세요. wait_event 매크로를 사용하면 이 순서를 자동으로 보장합니다.

Wait Queue와 poll/select/epoll

사용자 공간의 poll()/select()/epoll() 시스템 콜은 내부적으로 wait queue를 사용합니다. 드라이버의 poll 파일 오퍼레이션은 poll_wait()로 대기 큐를 VFS poll 테이블에 등록합니다.

static __poll_t my_poll(struct file *filp,
                        struct poll_table_struct *pt)
{
    struct my_device *dev = filp->private_data;
    __poll_t mask = 0;

    /* 대기 큐를 poll 테이블에 등록 (슬립하지 않음!) */
    poll_wait(filp, &dev->read_wq, pt);
    poll_wait(filp, &dev->write_wq, pt);

    /* 현재 상태 확인 */
    spin_lock(&dev->lock);
    if (dev->data_len > 0)
        mask |= EPOLLIN | EPOLLRDNORM;   /* 읽기 가능 */
    if (dev->buf_space > 0)
        mask |= EPOLLOUT | EPOLLWRNORM;  /* 쓰기 가능 */
    if (dev->disconnected)
        mask |= EPOLLHUP;                 /* 연결 종료 */
    spin_unlock(&dev->lock);

    return mask;
}

/* 상태 변경 시 대기자(및 epoll)에게 알림 */
wake_up_interruptible(&dev->read_wq);  /* epoll에도 자동 전달됨 */

Wait Queue 내부 동작

Wait Queue 동작 흐름 Task A: wait_event() prepare_to_wait() condition == false? Yes schedule() → SLEEP wake_up wait_queue_head_t spinlock + list_head → entry A (Task A) → entry B (Task B) → entry C (excl.) Task B: 조건 변경 condition = true wake_up(&wq) Task A: RUNNING 복귀 finish_wait(): 큐에서 제거 + RUNNING
wait_event / wake_up의 내부 동작 흐름

Completion

Completion은 wait queue를 기반으로 구축된 일회성 이벤트 알림 메커니즘입니다. "작업이 완료되었다"라는 단순한 시그널링에 최적화되어 있으며, wait queue보다 간결한 API를 제공합니다. 커널 스레드 시작/종료 동기화, 펌웨어 로딩 완료, DMA 전송 완료, 디바이스 프로브 완료 등에 널리 사용됩니다.

초기화와 내부 구조

#include <linux/completion.h>

/* 정적 초기화 (done = 0, 미완료 상태) */
DECLARE_COMPLETION(my_comp);

/* 동적 초기화 */
struct completion my_comp;
init_completion(&my_comp);

/* 재사용을 위한 재초기화 (done 카운터만 0으로 리셋) */
reinit_completion(&my_comp);

/* 내부 구조 (include/linux/completion.h) */
struct completion {
    unsigned int          done;   /* 완료 카운터 (0=미완료, >0=완료) */
    struct swait_queue_head wait;  /* simple wait queue */
};
ℹ️

Completion은 내부적으로 swait_queue_head(simple wait queue)를 사용합니다. 일반 wait queue와 달리 커스텀 콜백이 없고 FIFO 순서로만 깨우므로 오버헤드가 더 낮습니다. done 카운터 덕분에 complete()wait_for_completion()보다 먼저 호출되어도 정상 동작합니다 (lost wakeup 없음).

대기 API

/* 기본: TASK_UNINTERRUPTIBLE (무한 대기) */
wait_for_completion(&my_comp);

/* 타임아웃: 남은 jiffies 반환 (0이면 타임아웃) */
unsigned long remaining;
remaining = wait_for_completion_timeout(&my_comp,
                                         msecs_to_jiffies(5000));
if (!remaining) {
    pr_err("operation timed out\\n");
    return -ETIMEDOUT;
}

/* 시그널 인터럽트 가능: -ERESTARTSYS 반환 시 시그널 수신 */
int ret = wait_for_completion_interruptible(&my_comp);
if (ret)
    return -ERESTARTSYS;

/* interruptible + timeout 조합 */
long result = wait_for_completion_interruptible_timeout(
                    &my_comp, msecs_to_jiffies(3000));
if (result == 0)
    return -ETIMEDOUT;
if (result < 0)
    return -ERESTARTSYS;

/* SIGKILL만 응답 (TASK_KILLABLE) */
ret = wait_for_completion_killable(&my_comp);

/* 비블로킹 완료 확인 (슬립하지 않음) */
if (try_wait_for_completion(&my_comp)) {
    /* 이미 완료됨 */
} else {
    /* 아직 미완료 */
}

/* 완료 여부 확인만 (done 카운터 소비하지 않음) */
if (completion_done(&my_comp))
    pr_info("already completed\\n");

완료 알림 (시그널링)

/* 대기자 1개만 깨우기 (done 카운터 1 증가) */
complete(&my_comp);

/* 모든 대기자 깨우기 (done = UINT_MAX로 설정) */
complete_all(&my_comp);

/* complete()와 complete_all()의 차이: */
/*   complete()    → done++, waiter 1개 깨움 */
/*                   반복 호출로 여러 waiter를 순차적으로 깨울 수 있음 */
/*   complete_all() → done = UINT_MAX, 모든 waiter 깨움 */
/*                    이후 wait_for_completion()은 즉시 반환 */
/*                    재사용 시 반드시 reinit_completion() 필요 */
☢️

complete_all() 재사용 버그: complete_all()doneUINT_MAX로 설정합니다. reinit_completion() 없이 재사용하면 이후의 wait_for_completion()즉시 반환되어 동기화가 깨집니다.

/* BUG: complete_all() 후 reinit_completion() 없이 재사용 */
complete_all(&comp);          /* done = UINT_MAX */
/* ... 다음 이벤트 준비 ... */
/* reinit_completion() 빠뜨림! */
start_async_operation();
wait_for_completion(&comp);   /* BUG: done > 0이므로 즉시 반환 — 대기 없음! */

/* 올바른 재사용 패턴 */
complete_all(&comp);
reinit_completion(&comp);     /* done = 0으로 리셋 */
start_async_operation();
wait_for_completion(&comp);   /* 정상: 비동기 작업 완료까지 대기 */

실전 사용 예제

/* 예제 1: 커널 스레드 시작 동기화 */
struct my_context {
    struct completion started;
    struct completion stopped;
    bool should_stop;
    /* ... */
};

static int my_kthread(void *data)
{
    struct my_context *ctx = data;

    /* 초기화 완료 후 생성자에게 알림 */
    complete(&ctx->started);

    while (!ctx->should_stop) {
        /* ... 작업 수행 ... */
    }

    /* 종료 알림 */
    complete(&ctx->stopped);
    return 0;
}

/* 생성자 */
init_completion(&ctx->started);
init_completion(&ctx->stopped);
kthread_run(my_kthread, ctx, "my-worker");
wait_for_completion(&ctx->started);  /* 스레드 초기화 완료 대기 */

/* 종료 요청 */
ctx->should_stop = true;
wait_for_completion(&ctx->stopped);  /* 스레드 종료 대기 */

/* ─────────────────────────────────── */
/* 예제 2: DMA 전송 완료 대기 */
struct dma_op {
    struct completion done;
    dma_addr_t addr;
    int status;
};

static void dma_callback(void *param)
{
    struct dma_op *op = param;
    op->status = 0;
    complete(&op->done);  /* DMA 완료 알림 */
}

/* DMA 시작 */
reinit_completion(&op->done);
start_dma_transfer(op->addr, dma_callback, op);

/* 완료 대기 (타임아웃 포함) */
if (!wait_for_completion_timeout(&op->done,
                                  msecs_to_jiffies(1000))) {
    pr_err("DMA transfer timed out\\n");
    abort_dma_transfer(op);
    return -ETIMEDOUT;
}

/* ─────────────────────────────────── */
/* 예제 3: 펌웨어 로딩 완료 대기 */
static int my_probe(struct platform_device *pdev)
{
    struct completion fw_done;
    init_completion(&fw_done);

    /* 비동기 펌웨어 요청 */
    request_firmware_nowait(THIS_MODULE, true,
        "my_fw.bin", &pdev->dev, GFP_KERNEL,
        &fw_done, fw_loaded_callback);

    /* 최대 30초 대기 */
    long wait_ret = wait_for_completion_interruptible_timeout(
            &fw_done, msecs_to_jiffies(30000));
    if (wait_ret == 0) {
        dev_err(&pdev->dev, "firmware load timed out\\n");
        return -ETIMEDOUT;
    }
    if (wait_ret < 0)
        return -ERESTARTSYS;
    return 0;
}

Wait Queue vs Completion 비교

항목Wait QueueCompletion
용도조건 기반 반복 대기일회성 이벤트 알림
조건 검사매크로가 condition을 반복 검사내부 done 카운터 자동 관리
재사용별도 처리 불필요reinit_completion() 필요
Lost wakeup올바른 패턴 필수done 카운터로 자동 방지
Exclusive 대기지원 (WQ_FLAG_EXCLUSIVE)미지원
poll/epoll 연동가능 (poll_wait())불가
대표 사용처디바이스 I/O, 소켓, 프로세스 대기kthread 동기화, DMA, 펌웨어 로딩

Semaphore (세마포어)

세마포어(Semaphore)는 정수 카운터를 기반으로 하는 동기화 프리미티브입니다. Mutex가 "소유권 개념(1개 락)"인 반면, 카운팅 세마포어는 동시에 허용되는 접근 수를 N개로 제한할 수 있습니다. 이진 세마포어(초기값 1)는 기능상 Mutex와 유사하지만 소유권이 없어 해제 주체 제약이 없습니다.

⚠️

커널 세마포어 사용 감소 추세: 커널 내부에서는 Mutex와 Wait Queue가 세마포어보다 권장됩니다. 세마포어는 소유권 추적이 없어 lockdep 검증이 어렵습니다. 새 코드 작성 시에는 mutex 또는 completion을 우선 검토하세요.

#include <linux/semaphore.h>

/* 세마포어 초기화 */
struct semaphore sem;
sema_init(&sem, 1);          /* 이진 세마포어 (초기값 1) */
sema_init(&sem, 3);          /* 카운팅 세마포어 (동시 3개 허용) */

/* 또는 정적 초기화 */
static DEFINE_SEMAPHORE(my_sem);  /* 초기값 1 */

/* P 연산 (Acquire / down) */
down(&sem);               /* 블로킹 (인터럽트 불가) */
down_interruptible(&sem); /* 블로킹 (시그널로 중단 가능) — 권장 */
down_killable(&sem);      /* 블로킹 (SIGKILL만 중단 가능) */
down_trylock(&sem);       /* 비블로킹 — 실패 시 1 반환 */
down_timeout(&sem, jiffies); /* 타임아웃 대기 */

/* V 연산 (Release / up) */
up(&sem);                 /* 카운터 증가 + 대기 중인 프로세스 깨움 */

/* down_interruptible 사용 패턴 */
if (down_interruptible(&sem))
    return -ERESTARTSYS;  /* 시그널로 중단됨 */
/* critical section */
up(&sem);

이진 vs 카운팅 세마포어

특성이진 세마포어 (초기값 1)카운팅 세마포어 (초기값 N)
동시 접근1개N개
주요 용도상호 배제 (Mutex 대체)리소스 풀 제한
소유권없음 (어떤 태스크도 up() 가능)없음
lockdep 지원제한적제한적
커널 권장도Mutex 우선 권장적합한 경우 사용 가능

생산자-소비자 패턴

카운팅 세마포어의 대표적 사용 사례는 버퍼 크기를 제한하는 생산자-소비자 패턴입니다:

/* 링 버퍼 + 세마포어로 구현하는 생산자-소비자 */
#define BUF_SIZE 16

static DEFINE_SEMAPHORE(empty_slots); /* 빈 슬롯 수 (초기값 BUF_SIZE) */
static struct semaphore filled_slots; /* 채워진 슬롯 수 (초기값 0) */
static DEFINE_SPINLOCK(buf_lock);

static int __init producer_consumer_init(void)
{
    sema_init(&empty_slots, BUF_SIZE); /* 16개 빈 슬롯 */
    sema_init(&filled_slots, 0);     /* 처음엔 아무것도 없음 */
    return 0;
}

/* 생산자: 빈 슬롯 확보 후 데이터 채움 */
int produce(struct data *item)
{
    if (down_interruptible(&empty_slots))
        return -ERESTARTSYS;
    spin_lock(&buf_lock);
    buffer_put(item);
    spin_unlock(&buf_lock);
    up(&filled_slots);  /* 소비자에게 알림 */
    return 0;
}

/* 소비자: 채워진 슬롯 대기 후 읽음 */
int consume(struct data *item)
{
    if (down_interruptible(&filled_slots))
        return -ERESTARTSYS;
    spin_lock(&buf_lock);
    buffer_get(item);
    spin_unlock(&buf_lock);
    up(&empty_slots);   /* 생산자에게 슬롯 반환 */
    return 0;
}
💡

세마포어 vs Mutex vs Completion 선택:

  • 상호 배제만 필요mutex (소유권 추적, lockdep 지원)
  • 단발성 완료 알림completion (wait_for_completion)
  • N개 리소스 풀 제한semaphore (카운팅)
  • 업 주체와 다운 주체가 다름semaphore (소유권 없음)

실전 구현 패턴 확장

개별 프리미티브의 API를 아는 것과, 실제 커널 경로에서 여러 프리미티브를 어떻게 조합해야 하는지를 아는 것은 별개의 문제입니다. 실전 코드에서는 거의 항상 "하나의 락"이 아니라 짧은 임계구역용 락 + 슬립 가능한 대기 수단 + 상태 플래그 + 해제 후 후처리가 함께 등장합니다. 아래 패턴들은 드라이버, VFS, 메모리 관리, 네트워크 경로에서 반복적으로 나타나는 설계 골격을 문맥별로 정리한 것입니다.

구현 패턴 요약표

시나리오권장 조합핵심 이유대표 실수
IRQ 생산자 + 프로세스 소비자spin_lock_irqsave() + wait_event_interruptible()IRQ는 sleep 불가, 프로세스는 조건 대기가 필요copy_to_user()를 spinlock 안에서 호출
긴 제어 경로 + 짧은 fast pathmutex + spinlock재구성 경로는 sleep 허용, fast path는 짧게 보호모든 경로를 mutex 하나로 직렬화
읽기 다수의 설정 테이블rw_semaphorereader 병렬성 확보, writer는 원자적 교체write lock 안에서 메모리 할당/사용자 복사
작은 구조체의 일관된 스냅샷seqcount/seqlockreader 무잠금, writer만 직렬화포인터·가변 길이 데이터를 넣음
제한된 하드웨어 슬롯 풀semaphore + spinlock동시 제출 수 제한, 완료 경로가 슬롯 반환 가능소유권이 필요한 mutex로 대체하려 함
읽기 99% 이상의 포인터 게시mutex + RCU업데이트는 느리지만 조회는 거의 무비용rcu_read_unlock() 뒤 포인터를 계속 사용

예제 1: IRQ 생산자 + 프로세스 소비자 링 버퍼

하드웨어 인터럽트가 데이터를 밀어 넣고, 사용자 공간의 read()가 이를 소비하는 경로는 동기화 설계의 전형적인 시험대입니다. 인터럽트 핸들러는 절대로 sleep할 수 없으므로 mutex나 wait queue 대기를 사용할 수 없습니다. 반대로 사용자 공간의 read()는 데이터가 없을 때 잠들어야 CPU를 낭비하지 않습니다.

따라서 가장 안정적인 설계는 다음처럼 역할을 분리하는 것입니다. (1) IRQ 경로는 spin_lock_irqsave()로 아주 짧게 링 버퍼 인덱스만 갱신하고, (2) 프로세스 경로는 wait_event_interruptible()로 데이터 도착을 기다리며, (3) 사용자 복사 같은 슬립 가능 작업은 락을 풀고 나서 수행합니다. 즉, 버퍼 메타데이터 보호대기/깨우기를 분리해야 합니다.

#define RX_RING_SIZE 256

struct my_irq_dev {
    spinlock_t          rx_lock;
    wait_queue_head_t   read_wq;
    atomic_t            queued;
    unsigned int        head;
    unsigned int        tail;
    bool                unplugged;
    u8                  ring[RX_RING_SIZE];
};

static bool mydev_can_read(struct my_irq_dev *dev)
{
    return atomic_read(&dev->queued) > 0 ||
           READ_ONCE(dev->unplugged);
}

static irqreturn_t mydev_irq_handler(int irq, void *data)
{
    struct my_irq_dev *dev = data;
    unsigned long flags;
    unsigned int next;
    u8 byte = readb(mydev_rx_reg(dev));

    spin_lock_irqsave(&dev->rx_lock, flags);
    next = (dev->head + 1) & (RX_RING_SIZE - 1);
    if (next != dev->tail) {
        dev->ring[dev->head] = byte;
        dev->head = next;
        atomic_inc(&dev->queued);
    }
    spin_unlock_irqrestore(&dev->rx_lock, flags);

    /* 상태를 먼저 기록한 뒤 waiter를 깨운다. */
    wake_up_interruptible(&dev->read_wq);
    return IRQ_HANDLED;
}

static ssize_t mydev_read(struct file *file, char __user *buf,
                          size_t len, loff_t *ppos)
{
    struct my_irq_dev *dev = file->private_data;
    unsigned long flags;
    u8 byte;
    int ret;

    if (!len)
        return 0;

    ret = wait_event_interruptible(dev->read_wq,
                                         mydev_can_read(dev));
    if (ret)
        return ret;

    if (READ_ONCE(dev->unplugged))
        return -ENODEV;

    spin_lock_irqsave(&dev->rx_lock, flags);
    if (!atomic_read(&dev->queued)) {
        spin_unlock_irqrestore(&dev->rx_lock, flags);
        return -EAGAIN;
    }

    byte = dev->ring[dev->tail];
    dev->tail = (dev->tail + 1) & (RX_RING_SIZE - 1);
    atomic_dec(&dev->queued);
    spin_unlock_irqrestore(&dev->rx_lock, flags);

    if (copy_to_user(buf, &byte, 1))
        return -EFAULT;

    return 1;
}
⚠️

자주 나오는 오해: "wait_event_interruptible()의 condition이 참이면 이미 데이터가 보장되니 락 없이 꺼내도 된다"는 생각은 틀립니다. condition은 힌트일 뿐이며, 실제 dequeue는 반드시 다시 락을 잡고 검증해야 합니다. 깨어난 뒤 다른 태스크가 먼저 데이터를 소비했을 수 있기 때문입니다.

예제 2: 긴 제어 경로와 짧은 fast path를 분리하는 mutex + spinlock

실제 드라이버는 "설정 변경", "장치 재초기화", "큐 깊이 변경" 같은 느린 제어 경로와, IRQ/softirq 또는 빠른 I/O 제출 같은 짧은 fast path를 동시에 가집니다. 이 둘을 하나의 잠금으로 묶어 버리면 설계가 급격히 나빠집니다. fast path까지 sleep lock에 묶이면 처리량이 떨어지고, 반대로 제어 경로까지 spinlock에 묶으면 메모리 할당이나 디바이스 정지 같은 긴 작업을 할 수 없습니다.

가장 널리 쓰이는 해법은 상태 전이와 메모리 할당은 mutex, 짧은 큐 조작은 spinlock으로 분할하는 것입니다. 핵심은 두 락의 책임을 명확히 나누고, 가능하면 긴 경로가 fast path 락을 오래 잡지 않도록 "새 상태를 미리 준비한 뒤 짧게 교체"하는 구조를 만드는 데 있습니다.

struct my_split_dev {
    struct mutex        state_lock;
    spinlock_t          tx_lock;
    struct list_head    pending_tx;
    void                *cfg_blob;
    u32                 queue_depth;
    bool                running;
};

static int mydev_reload_config(struct my_split_dev *dev,
                                    const void *src, size_t len)
{
    unsigned long flags;
    void *new_blob;
    void *old_blob;

    new_blob = kmemdup(src, len, GFP_KERNEL);
    if (!new_blob)
        return -ENOMEM;

    mutex_lock(&dev->state_lock);
    if (!dev->running) {
        mutex_unlock(&dev->state_lock);
        kfree(new_blob);
        return -ENODEV;
    }

    old_blob = dev->cfg_blob;
    dev->cfg_blob = new_blob;

    /* fast path가 보는 큐 관련 수치만 짧게 갱신 */
    spin_lock_irqsave(&dev->tx_lock, flags);
    dev->queue_depth = mydev_calc_depth(new_blob);
    spin_unlock_irqrestore(&dev->tx_lock, flags);
    mutex_unlock(&dev->state_lock);

    kfree(old_blob);
    return 0;
}

static netdev_tx_t mydev_xmit(struct sk_buff *skb,
                                 struct net_device *ndev)
{
    struct my_split_dev *dev = netdev_priv(ndev);
    unsigned long flags;
    struct tx_desc *desc;

    desc = tx_desc_alloc(skb, GFP_ATOMIC);
    if (!desc)
        return NETDEV_TX_BUSY;

    spin_lock_irqsave(&dev->tx_lock, flags);
    if (!dev->running) {
        spin_unlock_irqrestore(&dev->tx_lock, flags);
        tx_desc_free(desc);
        return NETDEV_TX_BUSY;
    }
    list_add_tail(&desc->node, &dev->pending_tx);
    spin_unlock_irqrestore(&dev->tx_lock, flags);

    mydev_kick_tx(dev);
    return NETDEV_TX_OK;
}

예제 3: 읽기 많은 설정 테이블에 rw_semaphore 적용

rw_semaphore는 "reader도 sleep 가능"한 점에서 rwlock_t와 근본적으로 다릅니다. 따라서 디바이스 정책 테이블, 파일시스템 메타데이터 캐시, 모듈 설정 테이블처럼 프로세스 컨텍스트에서 긴 순회가 필요하고 reader가 다수인 자료구조에 적합합니다.

이 구조의 핵심은 writer가 무거운 준비 작업을 lock 밖에서 끝낸 뒤, write lock 안에서는 포인터 교체만 짧게 수행하는 것입니다. 그렇지 않으면 많은 reader가 장시간 막히고, rwsem의 장점이 거의 사라집니다.

struct policy_rule {
    struct list_head node;
    char name[32];
    u32 action;
};

struct policy_db {
    struct rw_semaphore rwsem;
    struct list_head rules;
    u64 generation;
};

static int policy_lookup(struct policy_db *db,
                               const char *name, u32 *action)
{
    struct policy_rule *rule;
    int ret = -ENOENT;

    down_read(&db->rwsem);
    list_for_each_entry(rule, &db->rules, node) {
        if (!strcmp(rule->name, name)) {
            *action = rule->action;
            ret = 0;
            break;
        }
    }
    up_read(&db->rwsem);
    return ret;
}

static int policy_reload(struct policy_db *db,
                               struct list_head *new_rules)
{
    LIST_HEAD(old_rules);

    /* new_rules는 lock 밖에서 이미 파싱/할당 완료했다고 가정 */
    down_write(&db->rwsem);
    list_splice_init(&db->rules, &old_rules);
    list_splice_init(new_rules, &db->rules);
    db->generation++;
    up_write(&db->rwsem);

    policy_free_list(&old_rules);
    return 0;
}

예제 4: 작은 통계 구조체 스냅샷에 seqcount 사용

패킷 수, 바이트 수, 마지막 타임스탬프처럼 몇 개의 정수 필드를 일관된 순간값으로 읽고 싶을 때는 spinlock으로 reader까지 모두 막는 것이 과한 경우가 많습니다. 이때 writer만 직렬화하고 reader는 재시도만 하는 seqcount가 매우 효과적입니다.

중요한 제약은 두 가지입니다. 첫째, seqcount_t 자체는 writer 직렬화를 제공하지 않으므로 writer를 감싸는 별도의 락이 필요합니다. 둘째, reader가 포인터나 가변 길이 버퍼를 다루면 재시도 중 use-after-free 위험이 생기므로 이런 데이터에는 부적합합니다.

#include <linux/seqlock.h>

struct my_stats {
    spinlock_t             lock;
    seqcount_spinlock_t    seq;
    u64                    packets;
    u64                    bytes;
    u32                    drops;
};

static void my_stats_init(struct my_stats *s)
{
    spin_lock_init(&s->lock);
    seqcount_spinlock_init(&s->seq, &s->lock);
}

static void my_stats_account(struct my_stats *s, u32 len, bool drop)
{
    spin_lock(&s->lock);
    write_seqcount_begin(&s->seq);
    s->packets++;
    s->bytes += len;
    if (drop)
        s->drops++;
    write_seqcount_end(&s->seq);
    spin_unlock(&s->lock);
}

struct stats_snapshot {
    u64 packets;
    u64 bytes;
    u32 drops;
};

static void my_stats_read(struct my_stats *s,
                               struct stats_snapshot *snap)
{
    unsigned int seq;

    do {
        seq = read_seqcount_begin(&s->seq);
        snap->packets = s->packets;
        snap->bytes   = s->bytes;
        snap->drops   = s->drops;
    } while (read_seqcount_retry(&s->seq, seq));
}

예제 5: 제한된 하드웨어 제출 슬롯에 카운팅 세마포어 적용

카운팅 세마포어는 "동시에 N개까지만 진행 가능"한 자원 풀에서 특히 강합니다. 대표적인 예가 NVMe 태그, HBA 명령 슬롯, 하드웨어 암호화 엔진의 동시 요청 수 제한입니다. 이 경우 submit 경로가 슬롯을 빌리고, 완료 인터럽트가 슬롯을 반납합니다. 즉 획득한 주체와 반환하는 주체가 다를 수 있습니다.

이 점 때문에 mutex는 잘 맞지 않습니다. mutex는 소유권 추적과 동일 소유자 해제를 전제로 하지만, 세마포어는 단순히 카운터를 조정하므로 "제출 경로에서 down, 완료 경로에서 up" 같은 구조를 자연스럽게 표현할 수 있습니다.

#define HW_DEPTH 32

struct my_hba {
    struct semaphore slots;
    spinlock_t       lock;
    DECLARE_BITMAP(inuse, HW_DEPTH);
};

static int my_hba_init(struct my_hba *hba)
{
    sema_init(&hba->slots, HW_DEPTH);
    spin_lock_init(&hba->lock);
    bitmap_zero(hba->inuse, HW_DEPTH);
    return 0;
}

static int my_submit_cmd(struct my_hba *hba, struct my_cmd *cmd)
{
    unsigned long flags;
    int tag;

    if (down_killable(&hba->slots))
        return -EINTR;

    spin_lock_irqsave(&hba->lock, flags);
    tag = find_first_zero_bit(hba->inuse, HW_DEPTH);
    if (tag >= HW_DEPTH) {
        spin_unlock_irqrestore(&hba->lock, flags);
        up(&hba->slots);
        return -EBUSY;
    }
    __set_bit(tag, hba->inuse);
    spin_unlock_irqrestore(&hba->lock, flags);

    cmd->tag = tag;
    my_hw_submit(hba, cmd);
    return 0;
}

static irqreturn_t my_hba_complete_irq(int irq, void *data)
{
    struct my_hba *hba = data;
    unsigned long flags;
    int tag = my_hw_fetch_done_tag(hba);

    spin_lock_irqsave(&hba->lock, flags);
    __clear_bit(tag, hba->inuse);
    spin_unlock_irqrestore(&hba->lock, flags);

    /* 완료 경로가 슬롯을 반환한다. */
    up(&hba->slots);
    return IRQ_HANDLED;
}

예제 6: 읽기 우세 포인터 게시를 위한 mutex + RCU

읽기 경로가 압도적으로 많고, reader가 짧으며, 업데이트는 "새 버전을 만들어 통째로 교체"하는 형태라면 rw_semaphore조차 reader에게는 비싸게 느껴질 수 있습니다. 이때 가장 강력한 패턴이 writer는 mutex로 새 버전을 준비하고, reader는 RCU로 현재 버전을 잠금 없이 참조하는 구조입니다.

핵심 아이디어는 reader가 절대 자료구조를 수정하지 않고, writer는 기존 객체를 건드리지 않은 채 새 객체를 만든 뒤 rcu_assign_pointer()로 게시한다는 점입니다. 이전 객체의 해제는 즉시 하면 안 되고, 모든 reader가 빠져나간 뒤에야 가능하므로 call_rcu()kfree_rcu()가 필요합니다.

struct ruleset {
    struct list_head rules;
    struct rcu_head  rcu;
};

struct policy_engine {
    struct mutex      update_lock;
    struct ruleset __rcu *active;
};

static int policy_match_packet(struct policy_engine *engine,
                                     struct packet *pkt)
{
    struct ruleset *ruleset;
    struct rule *rule;
    int verdict = NF_DROP;

    rcu_read_lock();
    ruleset = rcu_dereference(engine->active);
    if (ruleset) {
        list_for_each_entry_rcu(rule, &ruleset->rules, node) {
            if (rule_matches(rule, pkt)) {
                verdict = rule->verdict;
                break;
            }
        }
    }
    rcu_read_unlock();
    return verdict;
}

static int policy_replace_ruleset(struct policy_engine *engine,
                                        struct ruleset *new_ruleset)
{
    struct ruleset *old_ruleset;

    mutex_lock(&engine->update_lock);
    old_ruleset = rcu_dereference_protected(
            engine->active, lockdep_is_held(&engine->update_lock));
    rcu_assign_pointer(engine->active, new_ruleset);
    mutex_unlock(&engine->update_lock);

    if (old_ruleset)
        call_rcu(&old_ruleset->rcu, ruleset_free_rcu);
    return 0;
}
ℹ️

RCU를 언제 선택할 것인가: reader가 짧고 압도적으로 많으며, 업데이트를 "부분 수정"보다 "새 버전 교체"로 표현하기 쉽다면 RCU가 매우 강력합니다. 반대로 reader가 길고 sleep 가능 작업을 해야 하거나 writer가 객체를 세밀하게 수정해야 한다면 RCU보다 rw_semaphore가 더 단순한 경우가 많습니다.

동기화 프리미티브 비교

동기화 메커니즘 선택 가이드 인터럽트 컨텍스트인가? Yes spinlock_irqsave No 짧은 critical section? Yes spinlock No mutex
동기화 메커니즘 선택 흐름

동기화 프리미티브 선택 가이드

조건권장 프리미티브이유
IRQ/BH 컨텍스트에서 사용spinlock_t + irqsavesleep 불가, preemption 비활성화 필요
Process context, 짧은 임계구역spinlock_t경량, 캐시 친화적
Process context, sleep 가능mutex소유권 추적, lockdep 완전 지원
읽기 비율 ≥ 80%rwlock_t / rw_semaphore병렬 읽기로 처리량 향상
읽기가 90%+ (포인터 기반)RCUread-side 무비용, 확장성 최대
단발성 이벤트 알림completionwait_for_completion + complete()
조건부 대기 (이벤트 루프)wait_queuewait_event_interruptible 패턴
N개 리소스 풀 제한semaphore카운팅 세마포어, 소유권 불필요시
쓰기 빈도 낮고 데이터 작음seqlockreader 무잠금, writer 우선
RT 커널 (PREEMPT_RT)mutex / local_lock_tspinlock → sleeping lock 자동 변환
프리미티브슬립인터럽트재귀용도
spinlock불가가능불가짧은 critical section, IRQ handler
mutex가능불가불가긴 critical section, 프로세스 컨텍스트
rw_semaphore가능불가불가읽기 다수, 쓰기 소수
seqlockreader 불가가능불가writer 우선, 간단한 데이터
RCUreader 불가가능가능읽기 최적화, 포인터 교체
atomicN/A가능N/A카운터, 단일 변수
wait_queue가능불가N/A조건 기반 이벤트 대기
completion가능불가N/A일회성 완료 알림
💡

lockdep은 커널의 런타임 잠금 의존성 검사 도구입니다. CONFIG_LOCKDEP을 활성화하면 잠금 순서 위반(잠재적 deadlock)을 자동으로 감지하여 경고합니다.

lockdep: 잠금 의존성 검증

lockdep은 런타임에 잠금 획득 순서를 추적하여 잠재적 교착 상태를 탐지합니다:

/* lockdep이 감지하는 문제들 */

/* 1. AB-BA 교착 (lock ordering violation) */
/* CPU 0: lock(A) → lock(B) */
/* CPU 1: lock(B) → lock(A)  → DEADLOCK */

/* 2. 재귀 잠금 */
/* lock(A) → lock(A)  → DEADLOCK */

/* 3. IRQ 안전성 위반 */
/* 프로세스: lock(A) */
/* IRQ handler: lock(A)  → DEADLOCK */
/* → lock_irqsave(A) 사용해야 함 */

/* lockdep_assert 매크로 (디버깅 보조) */
lockdep_assert_held(&my_lock);       /* 잠금 보유 확인 */
lockdep_assert_not_held(&my_lock);   /* 잠금 미보유 확인 */
lockdep_assert_held_write(&my_rwsem); /* 쓰기 잠금 확인 */

lockdep_assert_held() 실전 패턴

lockdep_assert_held()는 함수 진입 시점에 호출자가 반드시 특정 잠금을 보유하고 있어야 함을 강제합니다. API 계약(contract)을 코드로 표현하는 가장 효과적인 방법입니다.

/* 함수 초입에 삽입: 호출자가 my_lock을 보유했는지 강제 검증 */
static void __update_state(struct my_obj *obj)
{
    lockdep_assert_held(&obj->lock);  /* 호출자가 lock 보유 필수 */

    /* lock 보유가 보장된 상태에서 내부 상태 수정 */
    obj->state = UPDATED;
}

/* 올바른 호출: lock 보유 후 호출 */
spin_lock(&obj->lock);
__update_state(obj);   /* 정상: lockdep_assert_held 통과 */
spin_unlock(&obj->lock);

/* 잘못된 호출: lock 미보유 상태에서 호출 (개발 중 즉시 탐지) */
__update_state(obj);   /* BUG: lockdep이 경고 출력 */

/* lockdep false positive 처리: 같은 타입 lock을 여러 개 중첩 획득 */
/* (예: 디렉터리 트리 순회) — lockdep에게 계층 구분 알리기 */
spin_lock(&parent->lock);
spin_lock_nested(&child->lock, SINGLE_DEPTH_NESTING);

/* 동적 할당 lock: lockdep_set_class()로 클래스 명시 등록 */
static struct lock_class_key reader_lock_key;
spin_lock_init(&obj->rlock);
lockdep_set_class(&obj->rlock, &reader_lock_key);
ℹ️

CONFIG_LOCKDEP 성능 오버헤드: lockdep은 잠금 획득/해제마다 의존성 그래프를 갱신하므로 상당한 CPU와 메모리 오버헤드가 발생합니다. 개발/CI 커널에서만 활성화하고 프로덕션 커널에서는 반드시 비활성화해야 합니다. CONFIG_PROVE_LOCKING은 lockdep보다 오버헤드가 더 높으나 더 정밀한 탐지를 제공합니다.

RT Mutex (우선순위 상속)

RT mutex는 우선순위 역전(priority inversion) 문제를 해결합니다. 낮은 우선순위 태스크가 잠금을 보유 중이면, 대기 중인 높은 우선순위 태스크의 우선순위를 일시적으로 상속합니다:

#include <linux/rtmutex.h>

DEFINE_RT_MUTEX(my_rt_mutex);

rt_mutex_lock(&my_rt_mutex);
/* critical section */
rt_mutex_unlock(&my_rt_mutex);

/* PREEMPT_RT 커널에서는 일반 mutex/spinlock도 */
/* 내부적으로 rt_mutex 기반으로 동작합니다. */

PI waiter 리스트와 우선순위 전파

rt_mutex는 대기 태스크를 우선순위 순 정렬 리스트(rb-tree)로 관리합니다. 잠금 보유자의 우선순위는 대기자 중 최고 우선순위로 즉시 상승합니다. 중첩 잠금 체인(A→B→C)에서는 체인 전체로 우선순위가 전파됩니다.

Priority Inversion vs Priority Inheritance 일반 mutex — 우선순위 역전 발생 시간→ L: mutex 획득 H(RT): mutex 요청 M: L 선점! H 차단 L: mutex 해제 H: 실행 재개 문제: H(RT)가 M(중간)보다 늦게 실행됨 — 우선순위 역전! rt_mutex — 우선순위 상속으로 역전 방지 L: rt_mutex 획득 H 요청 → L 우선순위 ↑H M: 선점 불가 (L이 더 높음) L: 빠르게 해제 H: 즉시 실행! 해결: H 요청 즉시 L의 우선순위가 H로 상승 → M이 선점 불가 → H가 즉시 진행
일반 mutex vs rt_mutex 타임라인 — 우선순위 상속으로 역전 방지

Wait/Wound Mutex (ww_mutex)

여러 잠금을 동시에 획득해야 할 때 교착 상태를 자동으로 회피합니다. GPU 드라이버(DRM/GEM)에서 버퍼 객체 잠금에 사용됩니다.

Wait/Wound 알고리즘

ww_mutexwound-wait 알고리즘을 사용합니다. 각 컨텍스트는 ww_acquire_init() 호출 시 64비트 카운터(stamp)를 부여받습니다. 낮은 stamp = older(선착순 우선), 높은 stamp = younger(나중 진입)입니다.

완전한 EDEADLK 재시도 루프:

#include <linux/ww_mutex.h>

static DEFINE_WW_CLASS(my_ww_class);

int acquire_two_locks(struct obj *a, struct obj *b)
{
    struct ww_acquire_ctx ctx;
    int ret;

    ww_acquire_init(&ctx, &my_ww_class);

retry:
    ret = ww_mutex_lock(&a->lock, &ctx);
    if (ret)
        goto err_a;

    ret = ww_mutex_lock(&b->lock, &ctx);
    if (ret) {
        ww_mutex_unlock(&a->lock);
        if (ret == -EDEADLK) {
            /* contended lock(b)을 먼저 획득 후 재시도 */
            ww_mutex_lock_slow(&b->lock, &ctx);
            ww_mutex_unlock(&b->lock);
            goto retry;
        }
        goto err_b;
    }

    ww_acquire_done(&ctx);
    /* ... critical section: a와 b 모두 보유 ... */
    ww_mutex_unlock(&b->lock);
    ww_mutex_unlock(&a->lock);
    ww_acquire_fini(&ctx);
    return 0;

err_b:
err_a:
    ww_acquire_fini(&ctx);
    return ret;
}

기본 사용 예제

#include <linux/ww_mutex.h>

static DEFINE_WW_CLASS(my_ww_class);

struct ww_acquire_ctx ctx;
ww_acquire_init(&ctx, &my_ww_class);

/* 여러 잠금 획득 시도 */
ret = ww_mutex_lock(&obj_a->lock, &ctx);
ret = ww_mutex_lock(&obj_b->lock, &ctx);
if (ret == -EDEADLK) {
    /* 교착 감지: 잠금 해제 후 contended 잠금 먼저 획득 */
    ww_mutex_unlock(&obj_a->lock);
    ww_mutex_lock_slow(&obj_b->lock, &ctx);
    /* obj_a 재시도 */
}

ww_acquire_done(&ctx);
/* ... critical section ... */
ww_mutex_unlock(&obj_b->lock);
ww_mutex_unlock(&obj_a->lock);
ww_acquire_fini(&ctx);

잠금 순서 규칙

규칙설명
일관된 순서여러 잠금 획득 시 항상 같은 순서 유지
중첩 잠금spin_lock_nested(&lock, SINGLE_DEPTH_NESTING)
IRQ 안전IRQ handler와 공유하는 잠금은 _irqsave 사용
잠금 계층상위 → 하위 순서 (예: inode lock → page lock)
최소 범위critical section을 가능한 짧게 유지

실제 서브시스템 잠금 계층 사례

커널 주요 서브시스템은 명시적인 잠금 순서 규칙을 문서화하고 있습니다. 이 순서를 어기면 lockdep이 즉시 탐지합니다.

서브시스템잠금 순서 (상위 → 하위)비고
VFSsb_lockinode i_rwsempage lockmmap_lock파일시스템 전반
mm (메모리 관리)mmap_lockanon_vma lockpage table lockpage fault 경로
드라이버 패턴디바이스 글로벌 lock → 인스턴스 lockprobe/remove 시 주의
네트워크rtnl_lock → 인터페이스 lock → 소켓 lock라우팅/인터페이스 변경
/* 잠금 순서 주석 패턴: 복잡한 서브시스템에서 필수 */
/* Lock order: sb_lock → i_rwsem → page_lock → mmap_lock */
static int do_file_operation(struct inode *inode)
{
    down_read(&inode->i_rwsem);    /* ① inode 잠금 */
    /* 이 함수 내에서 mmap_lock은 획득 불가 (순서 역전) */
    up_read(&inode->i_rwsem);
    return 0;
}

/* 드라이버 패턴: 글로벌 → 인스턴스 */
/* Lock order: drv->global_lock → dev->instance_lock */
spin_lock(&drv->global_lock);       /* ① 글로벌 먼저 */
spin_lock(&dev->instance_lock);     /* ② 인스턴스 나중 */
spin_unlock(&dev->instance_lock);
spin_unlock(&drv->global_lock);
⚠️

CONFIG_DEBUG_LOCK_ALLOCCONFIG_PROVE_LOCKING을 개발 커널에서 반드시 활성화하세요. lockdep의 오버헤드는 개발 시에만 존재하며, 프로덕션에서는 비활성화합니다. lockdep 경고는 "잠재적" 교착 상태이므로 즉시 수정해야 합니다.

동기화 관련 주요 버그 사례

커널 개발에서 동기화 버그는 가장 디버깅하기 어려운 문제 중 하나입니다. 재현이 어렵고, 증상이 원인과 멀리 떨어져 나타나며, 특정 타이밍이나 CPU 수에서만 발생하기도 합니다. 이 섹션에서는 실제로 발생했던 주요 동기화 버그 패턴과 그 탐지/예방 방법을 살펴봅니다.

ABBA 데드락 패턴과 실제 사례

ABBA 데드락은 두 개 이상의 잠금을 서로 다른 순서로 획득할 때 발생하는 교착 상태입니다. CPU 0이 Lock A를 획득한 후 Lock B를 기다리고, 동시에 CPU 1이 Lock B를 획득한 후 Lock A를 기다리면 두 CPU 모두 영원히 진행할 수 없습니다.

☢️

실제 사례 — inode lock과 mmap_lock 순서 역전: mm/ 서브시스템에서 page fault 경로는 mmap_lock을 먼저 획득한 후 파일 시스템의 inode lock을 획득하지만, 일부 ioctl 경로에서는 inode lock을 먼저 획득한 후 사용자 공간 버퍼 접근 시 mmap_lock이 필요해지는 상황이 발생했습니다. 이 순서 역전은 수천 개의 동시 접근이 있는 프로덕션 환경에서만 교착 상태를 유발했습니다.

lockdep은 실제 데드락이 발생하기 전에 lock dependency graph의 순환을 탐지합니다. 잠금 획득 순서를 방향 그래프로 기록하고, 새로운 잠금 의존성이 추가될 때마다 순환이 생기는지 검사합니다.

ABBA 락 의존성 순환 그래프 Lock A (inode) Lock B (mmap_lock) CPU0: A 보유 → B 시도 CPU1: B 보유 → A 시도 DEADLOCK 순환: CPU0(A→B)와 CPU1(B→A)이 서로 상대방의 잠금을 기다려 교착 상태
ABBA 락 의존성 순환 — lockdep이 이 순환 그래프를 런타임에 탐지
/* lockdep이 출력하는 전형적인 ABBA 데드락 경고 메시지 */

/*
 ======================================================
 WARNING: possible circular locking dependency detected
 6.8.0-rc1 #1 Not tainted
 ------------------------------------------------------
 process_A/1234 is trying to acquire lock:
 ffff888012345678 (&mm->mmap_lock){++++}-{3:3}, at: do_page_fault+0x1a2/0x520

 but task is already holding lock:
 ffff888087654321 (&inode->i_rwsem){++++}-{3:3}, at: ext4_ioctl+0x15c/0x1100

 which lock already depends on the new lock.

 the existing dependency chain (in reverse order) is:

 -> #1 (&inode->i_rwsem){++++}-{3:3}:
        lock_acquire+0xd1/0x2d0
        down_read+0x3e/0x160
        ext4_map_blocks+0x8c/0x620
        filemap_fault+0x28f/0x8a0

 -> #0 (&mm->mmap_lock){++++}-{3:3}:
        lock_acquire+0xd1/0x2d0
        down_read+0x3e/0x160
        do_page_fault+0x1a2/0x520

 other info that might help us debug this:
  Possible unsafe locking scenario:

        CPU0                    CPU1
        ----                    ----
   lock(&inode->i_rwsem);
                                lock(&mm->mmap_lock);
                                lock(&inode->i_rwsem);
   lock(&mm->mmap_lock);

                    *** DEADLOCK ***
 */

lockdep의 핵심은 lock_class_key입니다. 같은 타입의 모든 잠금 인스턴스는 하나의 클래스로 묶이며, 클래스 간의 의존성만 추적합니다. 동일 클래스의 잠금을 중첩 획득해야 하는 경우 (예: 디렉터리 트리에서 부모 inode → 자식 inode 순서) lock nesting subclass를 사용하여 lockdep에게 구분을 알려야 합니다.

/* lock_class_key: 잠금 클래스 정의 */
static struct lock_class_key my_lock_key;

/* 동적 초기화 시 잠금 클래스 등록 */
spin_lock_init(&obj->lock);
lockdep_set_class(&obj->lock, &my_lock_key);

/* nesting subclass: 같은 타입 잠금의 중첩 획득을 허용 */
/* 예: 부모 inode lock (subclass 0) → 자식 inode lock (subclass 1) */
mutex_lock(&parent->i_mutex);                          /* subclass 0 (기본) */
mutex_lock_nested(&child->i_mutex, I_MUTEX_CHILD);    /* subclass 1 */

/* 커널에서 정의된 inode mutex subclass 상수들 */
enum inode_i_mutex_lock_class {
    I_MUTEX_NORMAL,     /* 일반 파일/디렉터리 */
    I_MUTEX_PARENT,     /* 부모 디렉터리 (rename 등) */
    I_MUTEX_CHILD,      /* 자식 디렉터리 */
    I_MUTEX_XATTR,      /* 확장 속성 */
    I_MUTEX_NONDIR2,    /* 두 번째 비디렉터리 */
    I_MUTEX_PARENT2,    /* 두 번째 부모 (cross-dir rename) */
};
💡

잠금 순서 규칙 문서화: 복잡한 서브시스템에서는 잠금 순서를 소스 코드 주석이나 Documentation/에 명시적으로 기록하세요. 예를 들어 VFS의 Documentation/filesystems/directory-locking.rst는 디렉터리 연산에서의 inode lock 획득 순서를 상세히 규정하고 있습니다. CONFIG_PROVE_LOCKINGCONFIG_DEBUG_LOCK_ALLOC은 개발 커널에서 항상 활성화하여 잠재적 ABBA 패턴을 조기에 발견하세요.

Sleep-in-atomic 컨텍스트 버그

atomic 컨텍스트(spinlock 보유, 인터럽트 비활성화, preemption 비활성화)에서 sleep 가능 함수를 호출하면 시스템이 교착 상태에 빠지거나 스케줄러가 손상됩니다. 이 버그는 코드 리뷰만으로는 놓치기 쉬우며, 특정 실행 경로에서만 발생하기도 합니다.

☢️

전형적 패턴 — spinlock 내 GFP_KERNEL 할당: spin_lock()으로 잠금을 획득한 상태에서 kmalloc(GFP_KERNEL)을 호출하면, 메모리 부족 시 커널이 직접 회수(direct reclaim)를 시도하고 이는 I/O 대기를 포함하므로 sleep합니다. spinlock은 선점을 비활성화하므로 다른 태스크가 CPU를 점유할 수 없어 데드락이 발생합니다.

/* 잘못된 코드: spinlock 보유 중 sleep 가능 함수 호출 */
spin_lock(&my_lock);

/* BUG: GFP_KERNEL은 sleep 가능 — atomic 컨텍스트에서 금지! */
buf = kmalloc(4096, GFP_KERNEL);

/* BUG: copy_from_user()는 page fault로 sleep 가능 */
copy_from_user(buf, ubuf, len);

/* BUG: mutex_lock()은 contention 시 sleep */
mutex_lock(&other_mutex);

spin_unlock(&my_lock);

/* 올바른 코드: atomic 컨텍스트에서는 GFP_ATOMIC 사용 */
spin_lock(&my_lock);
buf = kmalloc(4096, GFP_ATOMIC);  /* sleep하지 않음, 실패 가능 */
if (!buf) {
    spin_unlock(&my_lock);
    return -ENOMEM;
}
/* ... */
spin_unlock(&my_lock);

GFP_KERNEL이 sleep을 유발하는 정확한 경로

GFP_KERNEL이 항상 sleep하는 것은 아니지만, 물리 메모리가 부족하면 다음 경로를 거쳐 sleep합니다:

  1. Direct reclaim 진입: 여유 메모리 부족 → __alloc_pages_slowpath()try_to_free_pages()
  2. Zone reclaim: LRU 리스트에서 page 회수 시도 → dirty page → writeback 요청
  3. I/O 대기: writeback 완료를 위해 block layer에서 I/O 완료 대기 → schedule() 호출 → sleep 발생

copy_from_user()도 sleep할 수 있습니다. 사용자 페이지가 swap-out된 상태라면 page fault 핸들러가 swap device에서 읽어야 하므로 I/O 대기가 발생합니다.

/* in_atomic() = (preempt_count() != 0)
 * preempt_count 비트 필드 구조 상세: preempt-count.html 참조
 * → spinlock 보유 / softirq / hardirq / NMI 중 하나라도 활성이면 true */

/* 사용 예: sleep 가능 여부 동적 판단 */
void *my_alloc(size_t size)
{
    if (in_atomic())
        return kmalloc(size, GFP_ATOMIC);
    return kmalloc(size, GFP_KERNEL);
}

/* irqs_disabled()도 함께 확인 — IRQ 비활성화 상태 */
if (in_atomic() || irqs_disabled())
    pr_warn("atomic context detected\\n");

might_sleep() 매크로는 디버그 빌드에서 현재 컨텍스트가 atomic인 경우 경고를 출력합니다. 많은 커널 API 내부에 이미 삽입되어 있으며, 커스텀 sleep 가능 함수에도 추가하는 것이 좋습니다. (preempt_countmight_sleep/in_atomic의 심화 분석은 preempt_count 문서를 참조하세요.)

/* might_sleep() — 디버그 빌드에서 atomic 컨텍스트 검사 */
void my_blocking_function(void)
{
    /* 이 함수가 sleep할 수 있음을 선언 */
    might_sleep();

    /* ... sleep 가능한 작업 수행 ... */
    mutex_lock(&some_mutex);
    /* ... */
    mutex_unlock(&some_mutex);
}

/* might_sleep()의 디버그 빌드 구현 (kernel/sched/core.c) */
/*
 * CONFIG_DEBUG_ATOMIC_SLEEP 활성화 시:
 *   - preempt_count() != 0 이면 경고 (spinlock, BH, IRQ disabled 등)
 *   - in_atomic() 검사
 *   - 스택 트레이스 출력
 *
 * 커널 로그 출력 예:
 * BUG: sleeping function called from invalid context at kernel/locking/mutex.c:580
 * in_atomic(): 1, irqs_disabled(): 0, non_block: 0, pid: 1234, name: my_process
 * preempt_count: 1 (preempt_disable)
 * Call Trace:
 *   dump_stack+0x6d/0x88
 *   ___might_sleep+0x100/0x170
 *   mutex_lock+0x1c/0x40
 *   my_blocking_function+0x28/0x60
 *   my_spinlock_holder+0x44/0x80   <-- 여기서 spinlock 보유 중
 */
💡

CONFIG_DEBUG_ATOMIC_SLEEP 활성화: 개발 커널에서 CONFIG_DEBUG_ATOMIC_SLEEP=y를 설정하면 might_sleep()이 실제로 검사를 수행합니다. 프로덕션 커널에서는 이 옵션이 비활성화되어 might_sleep()은 빈 매크로로 컴파일됩니다. 또한 CONFIG_PROVE_LOCKING은 lockdep 기반으로 sleep-in-atomic을 더욱 정밀하게 탐지합니다.

RCU와 스핀락 혼용 시 문제

RCU(Read-Copy-Update)는 reader 측의 오버헤드를 극도로 낮추는 동기화 메커니즘이지만, 잘못된 사용 패턴은 미묘하고 치명적인 버그를 유발합니다. 특히 non-preemptible RCU(CONFIG_PREEMPT_NONE, CONFIG_PREEMPT_VOLUNTARY)에서 rcu_read_lock() 구간은 선점이 비활성화되므로 sleep 가능 함수를 호출할 수 없습니다.

☢️

rcu_read_lock() 내에서의 sleep: non-preemptible RCU 구성에서 rcu_read_lock()rcu_read_unlock() 사이에서 sleep하면 grace period가 완료되지 않아 메모리 누수 또는 use-after-free가 발생합니다. rcu_read_lock()은 preempt_disable()의 래퍼이므로, sleep은 다른 태스크의 실행을 방해하여 RCU 콜백 처리를 무기한 지연시킵니다.

/* 잘못된 코드: rcu_dereference() 없이 RCU 보호 포인터 직접 접근 */
struct my_data __rcu *global_ptr;

rcu_read_lock();
/* BUG: 컴파일러가 포인터 읽기를 재배치하거나 */
/*       추측 실행으로 인한 stale 값 참조 가능 */
struct my_data *p = global_ptr;       /* 잘못됨! */
do_something(p->field);
rcu_read_unlock();

/* 올바른 코드: rcu_dereference()로 접근 */
rcu_read_lock();
struct my_data *p = rcu_dereference(global_ptr);
if (p)
    do_something(p->field);
rcu_read_unlock();

/* rcu_dereference()는 READ_ONCE() + 의존성 배리어를 포함하여 */
/* 컴파일러와 CPU의 재배치를 방지합니다 */

sleep이 필요한 RCU read-side critical section에서는 일반 RCU 대신 SRCU(Sleepable RCU)를 사용해야 합니다. SRCU는 reader 측에서 sleep을 허용하지만, 도메인별로 별도의 srcu_struct를 관리해야 하며 오버헤드가 더 높습니다.

/* SRCU: sleep 가능한 RCU read-side critical section */
#include <linux/srcu.h>

DEFINE_SRCU(my_srcu);

/* reader: sleep 가능 */
int idx = srcu_read_lock(&my_srcu);
/* sleep 가능한 작업 수행 가능 */
mutex_lock(&some_mutex);
/* ... */
mutex_unlock(&some_mutex);
srcu_read_unlock(&my_srcu, idx);

/* updater: grace period 대기 */
synchronize_srcu(&my_srcu);

/* 일반 RCU를 써야 할 곳에서 SRCU를 쓰지 않은 버그 패턴: */
/*   1. notifier chain에서 sleep 가능 콜백 등록 시 */
/*   2. 파일시스템 콜백에서 I/O 대기가 필요한 경우 */
/*   3. 네트워크 필터 훅에서 사용자 공간 통신이 필요한 경우 */

Grace Period 타이밍과 RCU Stall

RCU grace period는 모든 CPU가 최소 한 번 퀴에센트 상태(스케줄링 포인트 통과)를 지날 때까지 걸리는 시간입니다. 실제 소요 시간은 수 밀리초 ~ 수십 밀리초이며, CPU 수, NOHZ(tickless) 여부, 시스템 부하에 따라 달라집니다. grace period가 21초 이상 완료되지 않으면 커널이 rcu_sched_stall 경고를 출력합니다.

RCU Grace Period 타임라인 시간 CPU0 rcu_read_lock CS CPU1 rcu_read_lock CS (더 긴 reader) CPU2 rcu_read_lock CS Updater list_del_rcu + kfree_rcu ← Grace Period 대기 (수~수십 ms) → 모든 CS 종료 kfree 실행 t0 t_gp_end t_free RCU Stall 경고 grace period > 21초 시 rcu_sched_stall 출력
RCU grace period 타임라인 — reader CS가 모두 종료된 후에야 kfree 실행 보장
ℹ️

PREEMPT_RT에서 RCU 변환: PREEMPT_RT 커널에서는 rcu_read_lock()preempt_disable() 대신 rcu_read_lock_notrace()로 구현되어 선점 가능(preemptible)합니다. 이로 인해 RT 커널의 RCU read-side critical section 내에서 제한적으로 sleeping이 허용됩니다. 단, spin_lock()은 RT에서 sleeping lock이므로 RCU read-side 안에서 사용 가능하지만, raw_spin_lock()은 여전히 불가합니다.

sparse 정적 분석 도구는 __rcu 어노테이션을 통해 RCU 보호 포인터의 잘못된 사용을 컴파일 타임에 탐지합니다.

/* sparse __rcu 어노테이션으로 정적 분석 */
struct my_struct {
    struct data __rcu *rcu_ptr;    /* RCU 보호 포인터로 표시 */
    struct data *normal_ptr;       /* 일반 포인터 */
};

/* sparse 검사 실행: */
/* make C=1 CF="-D__CHECK_ENDIAN__" drivers/my_driver.o */

/* sparse가 경고하는 패턴들: */
/*   - __rcu 포인터를 rcu_dereference() 없이 직접 읽기 */
/*   - rcu_dereference()로 읽은 값을 __rcu 포인터에 대입 */
/*   - rcu_assign_pointer() 없이 __rcu 포인터에 직접 쓰기 */
/*   - 잘못된 컨텍스트에서 __rcu 포인터 접근 */
💡

RCU 사용 체크리스트: (1) reader 측에서 sleep이 필요하면 SRCU를 사용하세요. (2) RCU 보호 포인터는 반드시 rcu_dereference() 계열 매크로로 접근하세요. (3) 포인터 갱신은 반드시 rcu_assign_pointer()를 사용하세요. (4) 모든 RCU 보호 포인터에 __rcu sparse 어노테이션을 붙이고 make C=1로 정적 분석을 수행하세요. (5) CONFIG_PROVE_RCU=y를 활성화하여 런타임에 잘못된 RCU 사용을 탐지하세요.

우선순위 역전 (Priority Inversion) 실제 사례

우선순위 역전은 높은 우선순위의 태스크가 낮은 우선순위 태스크가 보유한 잠금을 기다리는 동안, 중간 우선순위 태스크가 낮은 우선순위 태스크의 실행을 선점하여 간접적으로 높은 우선순위 태스크를 무기한 차단하는 현상입니다. 이는 실시간 시스템에서 치명적인 deadline miss를 유발합니다.

☢️

우선순위 역전 시나리오: 낮은 우선순위 태스크 L이 mutex를 보유한 상태에서, 높은 우선순위 태스크 H가 같은 mutex를 요청합니다. H는 L이 mutex를 해제할 때까지 대기하지만, 중간 우선순위 태스크 M이 L을 선점하여 L의 실행을 지연시킵니다. 결과적으로 H는 M보다 낮은 우선순위로 실행되는 것과 같은 효과가 발생합니다. Mars Pathfinder (1997)의 시스템 리셋이 이 문제로 발생한 대표적 사례입니다.

/* 일반 mutex: 우선순위 역전 가능 */
DEFINE_MUTEX(shared_resource);

/* 태스크 L (낮은 우선순위, nice=19) */
mutex_lock(&shared_resource);
/* ... 긴 작업 수행 중 ... */
/* 태스크 M (중간 우선순위)이 L을 선점! */
/* 태스크 H (RT 우선순위)가 mutex 대기 — 무기한 지연됨 */
mutex_unlock(&shared_resource);

/* rt_mutex: 우선순위 상속(Priority Inheritance)으로 역전 방지 */
#include <linux/rtmutex.h>

DEFINE_RT_MUTEX(rt_shared_resource);

/* 태스크 L이 rt_mutex를 보유하고 있을 때 */
/* 태스크 H가 rt_mutex를 요청하면: */
/*   → L의 우선순위가 H의 우선순위로 일시적 상승 */
/*   → M이 L을 선점할 수 없음 */
/*   → L이 빠르게 critical section 완료 후 mutex 해제 */
/*   → L의 우선순위 원래대로 복구, H가 진행 */

rt_mutex_lock(&rt_shared_resource);
/* critical section — L의 우선순위가 대기자 중 최고로 상승됨 */
rt_mutex_unlock(&rt_shared_resource);

PREEMPT_RT 패치셋(RT 커널)에서는 커널의 동기화 동작이 근본적으로 변경됩니다. 일반 spinlock_t가 내부적으로 rt_mutex 기반의 sleeping lock으로 변환되어, 우선순위 상속이 자동으로 적용됩니다. 이는 실시간 응답성을 크게 향상시키지만 기존 코드에 영향을 미칩니다.

/* PREEMPT_RT에서의 spinlock 변환 */

/* 일반 커널: spinlock_t = raw spinlock (busy-wait, preempt 비활성화) */
/* PREEMPT_RT: spinlock_t = rt_mutex 기반 sleeping lock */

/* 따라서 PREEMPT_RT에서 spinlock_t는: */
/*   - sleep 가능 (우선순위 상속 적용) */
/*   - 인터럽트 컨텍스트에서 사용 불가! */
/*   - preemption을 비활성화하지 않음 */

/* 진짜 busy-wait이 필요한 경우 raw_spinlock_t 사용 */
static DEFINE_RAW_SPINLOCK(hw_lock);

/* raw_spinlock_t는 PREEMPT_RT에서도 진짜 spinlock */
/* 하드웨어 레지스터 접근, 인터럽트 핸들러 등에서 사용 */
raw_spin_lock_irqsave(&hw_lock, flags);
/* 하드웨어 레지스터 접근 — 매우 짧은 critical section */
raw_spin_unlock_irqrestore(&hw_lock, flags);

/* PREEMPT_RT 호환 코드 작성 가이드라인: */
/*   1. spinlock_t: 일반적인 커널 자료구조 보호 (RT에서 sleep 가능) */
/*   2. raw_spinlock_t: 하드웨어, 스케줄러, 타이머 등 핵심 경로만 */
/*   3. local_lock_t: per-CPU 데이터 보호 (RT 호환) */
/*   4. spin_lock 보유 중 sleep 가능 함수 호출이 RT에서 허용됨 */
/*      (단, raw_spin_lock 보유 중에는 여전히 불가) */

local_lock_t와 PREEMPT_RT 동기화 변환 요약

local_lock_t는 per-CPU 데이터 보호를 위한 RT 호환 메커니즘입니다. 일반 커널에서는 preempt_disable()과 동일하게 동작하고, PREEMPT_RT에서는 sleeping lock으로 변환되어 우선순위 상속이 적용됩니다.

#include <linux/local_lock.h>

struct my_percpu_data {
    local_lock_t  lock;     /* per-CPU 보호 (RT 호환) */
    int           counter;
};

DEFINE_PER_CPU(struct my_percpu_data, my_data) = {
    .lock = INIT_LOCAL_LOCK(lock),
};

/* per-CPU 데이터 접근 */
local_lock(&my_data.lock);
this_cpu_inc(my_data.counter);
local_unlock(&my_data.lock);
동기화 원형일반 커널 동작PREEMPT_RT 변환
spinlock_tbusy-wait, preempt 비활성화rt_mutex 기반 sleeping lock
raw_spinlock_tbusy-wait, preempt 비활성화변환 없음 (진짜 spinlock 유지)
mutexsleeping lockrt_mutex 기반 (우선순위 상속 추가)
rw_semaphoresleeping rw lockrt_mutex 기반 변환
local_lock_tpreempt_disable()per-CPU sleeping lock (PI 적용)
rcu_read_lock()preempt_disable()preemptible (선점 허용)
IRQ handler하드웨어 IRQ 컨텍스트kthread로 스레드화 (threaded IRQ)
💡

실시간 시스템 개발 시 주의점: (1) 실시간 태스크 간 공유 자원은 반드시 rt_mutex 또는 PREEMPT_RT 환경의 spinlock_t(자동 PI 적용)를 사용하세요. (2) raw_spinlock_t는 critical section이 수 마이크로초 이하인 경우에만 사용하며, 절대로 긴 작업에 사용하지 마세요. (3) PREEMPT_RT 커널에서는 spin_lock()이 sleep할 수 있으므로, 인터럽트 핸들러에서는 raw_spin_lock()만 사용하세요. (4) cyclictest, rt-tests 도구로 latency를 측정하여 우선순위 역전이 발생하지 않는지 검증하세요. (5) /proc/sys/kernel/sched_rt_runtime_ussched_rt_period_us로 RT 스로틀링 정책을 조정하세요.

동기화 관련 주요 취약점 사례

동기화 메커니즘의 결함은 데이터 레이스, 데드락, Use-After-Free 등 다양한 형태로 나타나며, 재현이 어렵고 탐지가 늦어 오랜 기간 잠복하는 특성이 있습니다. 커널에서 실제로 발생한 주요 동기화 버그 사례를 분석합니다.

futex 서브시스템 취약점

CVE-2014-3153 (Towelroot) — futex requeue Priority Inheritance UAF (CVSS 7.8):

futex_requeue()에서 PI(Priority Inheritance) futex의 waiter를 non-PI futex로 requeue할 때, rt_mutex 소유권 전이 과정에서 Use-After-Free가 발생합니다. Android 루팅 도구 "Towelroot"로 악용되어 2014년 대부분의 Android 기기에 영향을 미쳤습니다.

/* CVE-2014-3153: futex PI requeue 결함 */

/*
 * futex_requeue()의 정상 동작:
 *   futex A에서 대기 중인 waiter를 futex B로 이동
 *
 * 취약점:
 *   1. FUTEX_CMP_REQUEUE로 PI futex waiter를 non-PI futex로 이동
 *   2. rt_mutex의 top_waiter 변경 → 이전 waiter의 task_struct 참조 유지
 *   3. 이전 waiter가 종료되어 task_struct 해제
 *   4. rt_mutex가 해제된 task_struct 접근 → UAF
 *
 * 수정: PI와 non-PI futex 간 requeue를 명시적으로 금지
 *       FUTEX_CMP_REQUEUE_PI만 PI futex 간 requeue 허용
 */

/* kernel/futex.c — 수정 코드 */
static int futex_requeue(..., int requeue_pi) {
    /* PI futex → non-PI futex requeue 차단 */
    if (requeue_pi) {
        /* FUTEX_CMP_REQUEUE_PI: 양쪽 모두 PI여야 함 */
        if (!futex_cmpxchg_enabled)
            return -ENOSYS;
    }
    ...
}
CVE-2021-22555 — Netfilter 스택 버퍼 OOB 쓰기 (잠금 우회):

Netfilter의 xt_compat_target_from_user()에서 스택 버퍼 범위 밖 쓰기가 가능합니다. 이 취약점 자체는 동기화 문제가 아니지만, 익스플로잇 과정에서 msg_msg 구조체의 잠금 메커니즘을 조작하여 커널 힙 레이아웃을 제어하는 기법이 사용됩니다. 동기화 프리미티브가 악용될 수 있음을 보여주는 사례입니다.

RCU 관련 버그 패턴

RCU 사용 시 반복되는 버그 패턴:

1. Grace Period 이전 해제: kfree()를 직접 호출하는 대신 kfree_rcu()call_rcu()를 사용해야 합니다. RCU read-side critical section에서 접근 중인 객체를 즉시 해제하면 UAF 발생
2. rcu_dereference() 누락: RCU로 보호되는 포인터를 직접 역참조하면 컴파일러 최적화에 의해 불완전한 데이터를 읽을 수 있음. 반드시 rcu_dereference() 사용
3. RCU read-side에서 sleep: 일반 RCU(rcu_read_lock()) 내에서는 sleep 불가. sleep이 필요하면 srcu_read_lock()(SRCU) 사용
4. RCU Stall: RCU 콜백이 장기간 실행되거나 read-side critical section이 지나치게 길면 RCU stall 발생 → softlockup이나 시스템 정지

/* RCU 올바른 사용 vs 잘못된 사용 */

/* 잘못된 패턴: 즉시 해제 → UAF 가능 */
spin_lock(&list_lock);
list_del_rcu(&entry->list);
spin_unlock(&list_lock);
kfree(entry);  /* BUG: 다른 CPU의 rcu_read_lock() 구간에서 접근 중일 수 있음 */

/* 올바른 패턴: RCU grace period 대기 후 해제 */
spin_lock(&list_lock);
list_del_rcu(&entry->list);
spin_unlock(&list_lock);
kfree_rcu(entry, rcu_head);  /* grace period 후 자동 해제 */

/* 또는 명시적 콜백 */
call_rcu(&entry->rcu_head, my_rcu_free_callback);

/* 포인터 역참조: rcu_dereference() 필수 */
rcu_read_lock();
p = rcu_dereference(global_ptr);  /* 올바른 역참조 */
/* p = global_ptr;  ← BUG: 컴파일러 최적화로 불완전한 데이터 읽기 가능 */
if (p)
    do_something(p);
rcu_read_unlock();

Lockdep이 탐지한 실제 커널 버그들

lockdep의 실전 활용:

lockdep은 커널 개발에서 가장 강력한 동기화 버그 탐지 도구입니다. 실제로 lockdep이 발견한 주요 버그 패턴:

inode lock + mmap_lock 순서 역전: 파일시스템 코드에서 inode lock → mmap_lock 순서로 획득하는 경로와, 페이지 폴트에서 mmap_lock → inode lock 순서로 획득하는 경로가 공존하여 ABBA 데드락 발생 (ext4, XFS 등에서 반복 발견)
IRQ-safe / IRQ-unsafe 혼용: 같은 lock을 프로세스 컨텍스트에서 spin_lock()으로, IRQ 핸들러에서 spin_lock()으로 사용하여 데드락 → lockdep이 경고
nested lock 미표기: 같은 유형의 lock을 여러 개 동시에 잡을 때 spin_lock_nested()를 사용하지 않으면 lockdep이 false positive 경고 → lockdep_set_class()로 해결

/* lockdep 활성화 및 디버깅 */
CONFIG_LOCKDEP=y
CONFIG_PROVE_LOCKING=y       /* 잠금 순서 검증 */
CONFIG_LOCK_STAT=y           /* 잠금 통계 수집 */
CONFIG_DEBUG_LOCK_ALLOC=y    /* 잠금 할당 디버깅 */

/* lockdep 경고 예시 */
/*
 * ======================================================
 * WARNING: possible circular locking dependency detected
 * ------------------------------------------------------
 * task/1234 is trying to acquire lock:
 *  (&inode->i_rwsem){++++}-{3:3}, at: ext4_file_write_iter
 *
 * but task already holds lock:
 *  (&mm->mmap_lock){++++}-{3:3}, at: do_page_fault
 *
 * Chain: mmap_lock -> i_rwsem (ABBA with i_rwsem -> mmap_lock)
 * ======================================================
 */

동기화 프리미티브와 메모리 순서 보장

각 동기화 프리미티브는 메모리 배리어를 내재적으로 포함합니다. 어떤 배리어가 보장되는지 이해하면 불필요한 명시적 배리어 추가를 피할 수 있습니다.

프리미티브Acquire 배리어Release 배리어전체 배리어
spin_lock()✅ (lock 획득 시)✅ (lock 해제 시)
mutex_lock()✅ (unlock 시)
rcu_read_lock()컴파일러 배리어컴파일러 배리어
synchronize_rcu()
smp_mb()
atomic_set()
atomic_set_release()
complete()
wait_for_completion()
ℹ️

실전 규칙: spin_lock()/mutex_lock()으로 보호된 임계구역 내부에서는 별도의 smp_mb()가 불필요합니다. lock/unlock 자체가 acquire/release 배리어를 제공하기 때문입니다. 메모리 배리어가 별도로 필요한 경우는 잠금 없는 경로(lock-free)에서만 검토하세요.

상세 내용은 메모리 배리어 문서를 참고하세요.

Futex: 사용자-커널 동기화 브리지

Futex(Fast Userspace Mutex)는 경합이 없는 경우 시스템 콜 없이 순수 사용자 공간에서 동작하고, 경합 발생 시에만 커널에 진입하여 태스크를 sleep/wakeup하는 하이브리드 동기화 메커니즘입니다. pthread_mutex_lock(), sem_wait(), std::mutex 등 사용자 공간 동기화 라이브러리의 핵심 기반입니다.

Futex 동작 원리

Futex의 핵심 아이디어는 사용자 공간의 정수 변수(futex word)와 커널의 대기 큐를 결합하는 것입니다. 경합이 없으면 atomic compare-and-swap만으로 잠금을 획득하고, 경합 시에만 futex() 시스템 콜로 커널에 진입합니다.

Futex: 사용자-커널 하이브리드 동기화 사용자 공간 (User Space) 커널 공간 (Kernel Space) ① atomic CAS(futex_word, 0, tid) 성공! 시스템 콜 없이 획득 Fast Path: ~20ns (L1 cache hit) ② CAS 실패 → futex_word에 WAITER 표시 경합 감지 ③ syscall(SYS_futex, FUTEX_WAIT, ...) ④ futex_hash → 해시 버킷의 대기 큐에 추가 ⑤ schedule() → TASK_INTERRUPTIBLE ⑥ FUTEX_WAKE: 해시 버킷에서 waiter 탐색 ⑦ wake_up_q() → 대기자 RUNNING 전환 Slow Path: ~수 μs (시스템 콜 + context switch) 핵심: 경합률 5% 미만이면 95%의 잠금이 시스템 콜 없이 처리 → 전통적 커널 뮤텍스 대비 ~10배 빠름
Futex 동작 — 경합 없으면 사용자 공간에서 완료, 경합 시에만 커널 진입
/* 사용자 공간에서의 futex 기반 mutex 구현 (glibc pthread_mutex 간략화) */

/* futex word 상태:
 *   0 = unlocked
 *   1 = locked, no waiters
 *   2 = locked, waiters present
 */
static int futex_word = 0;

void futex_lock(void)
{
    int c;

    /* Fast path: CAS(0 → 1), 경합 없으면 즉시 획득 */
    if ((c = cmpxchg(&futex_word, 0, 1)) == 0)
        return;  /* 시스템 콜 없이 획득 완료! */

    /* Slow path: 경합 발생 */
    do {
        /* waiter 존재를 표시 (2로 설정) */
        if (c == 2 || cmpxchg(&futex_word, 1, 2) != 0)
            /* 커널에서 대기: futex_word가 2가 아닐 때까지 sleep */
            syscall(SYS_futex, &futex_word, FUTEX_WAIT, 2, NULL);
    } while ((c = cmpxchg(&futex_word, 0, 2)) != 0);
}

void futex_unlock(void)
{
    /* atomic_dec: 1→0이면 대기자 없음, 즉시 완료 */
    if (atomic_dec_return(&futex_word) != 0) {
        /* 대기자 있음: futex_word = 0으로 리셋 후 커널에 깨우기 요청 */
        futex_word = 0;
        syscall(SYS_futex, &futex_word, FUTEX_WAKE, 1);
    }
}

커널 내부 Futex 처리

커널의 futex 서브시스템(kernel/futex/)은 해시 테이블로 대기 큐를 관리합니다. futex word의 물리 주소를 키로 해시하여 대기자를 찾습니다. 이 설계 덕분에 mmap()으로 공유 메모리에 배치한 futex는 프로세스 간 동기화에도 사용할 수 있습니다.

/* kernel/futex/core.c — futex 해시 키 생성 */
union futex_key {
    struct {
        u64 i_seq;              /* inode sequence number */
        unsigned long pgoff;     /* page offset */
        unsigned int offset;     /* 페이지 내 오프셋 */
    } shared;                   /* 공유 매핑 (프로세스 간) */
    struct {
        union {
            struct mm_struct *mm;
            u64 __tmp;
        };
        unsigned long address;   /* 가상 주소 */
        unsigned int offset;
    } private;                  /* 프라이빗 매핑 (프로세스 내) */
};

/* 해시 버킷 수: CPU 수에 비례하여 동적 결정 */
/* 각 버킷은 spinlock + linked list로 구성 */
struct futex_hash_bucket {
    atomic_t         waiters;        /* 대기자 수 (최적화) */
    spinlock_t       lock;           /* 버킷 보호 */
    struct plist_head chain;          /* 우선순위 정렬 대기 리스트 */
};

/* FUTEX 연산 종류 */
/* FUTEX_WAIT      — futex_word == expected이면 sleep */
/* FUTEX_WAKE      — 대기자 n개 깨우기 */
/* FUTEX_REQUEUE   — 한 futex에서 다른 futex로 대기자 이동 */
/* FUTEX_WAIT_BITSET — 비트마스크 기반 선택적 깨우기 */
/* FUTEX_LOCK_PI   — Priority Inheritance futex */
/* FUTEX_UNLOCK_PI — PI futex 해제 */
ℹ️

futex2 (커널 5.16+): 새로운 futex_waitv() 시스템 콜은 여러 futex를 동시에 대기할 수 있습니다. Windows의 WaitForMultipleObjects()와 유사한 기능으로, Proton/Wine의 Windows 게임 호환성을 위해 도입되었습니다. 또한 다양한 크기(8/16/32/64비트) futex word를 지원합니다.

Lock-free 프로그래밍 패턴

Lock-free 알고리즘은 잠금 없이 atomic 연산과 메모리 배리어만으로 동시성을 관리합니다. 잠금 기반 알고리즘 대비 확장성이 우수하지만, 올바른 구현이 매우 어렵습니다. 커널은 RCU, atomic 연산, per-CPU 변수 등 검증된 lock-free 패턴을 제공하므로, 새로운 lock-free 구조를 직접 구현하기보다 기존 인프라를 활용하는 것이 권장됩니다.

커널의 주요 Lock-free 패턴

패턴핵심 기법커널 사용처장점제약
RCU (Read-Copy-Update)reader 무잠금, 포인터 교체 + grace period라우팅 테이블, 프로세스 리스트, dcacheread-side 비용 0write-side 복잡, 메모리 회수 지연
Atomic RMWatomic_inc, cmpxchg, xchg참조 카운터, 플래그, 통계단순, 하드웨어 보장단일 변수만 보호
Per-CPU 변수CPU별 독립 변수, 합산은 read 시통계 카운터, slab 캐시, softnet_data경합 완전 제거글로벌 합산 비용, 정밀도 지연
Seqcount시퀀스 번호로 일관성 검증시간 관리(timekeeper), vDSOreader 무잠금포인터 불가, reader retry
LLIST (Lock-less list)CAS 기반 단방향 연결 리스트IRQ→process 작업 전달, per-CPU 큐IRQ-safe 추가/제거단방향만, 순회 제한

Compare-and-Swap (CAS) 패턴

cmpxchg()는 lock-free 프로그래밍의 핵심 연산입니다. "기대값과 실제값이 같으면 새 값으로 교체하고, 다르면 실패"하는 원자적 연산입니다. 실패 시 최신 값을 얻어 재시도하는 CAS 루프가 기본 패턴입니다.

/* CAS 루프 패턴: atomic하게 값 갱신 */
static atomic_t my_state = ATOMIC_INIT(0);

int atomic_add_unless_zero(atomic_t *v, int a)
{
    int old, new_val;
    old = atomic_read(v);
    do {
        if (old == 0)
            return 0;  /* 0이면 변경하지 않음 */
        new_val = old + a;
    } while (!atomic_try_cmpxchg(v, &old, new_val));
    /* cmpxchg 실패 시 old에 최신 값이 자동 갱신됨 */
    return 1;
}

/* ABA 문제: CAS의 고전적 함정 */
/* 값이 A→B→A로 변했는데 CAS는 "변하지 않았다"고 판단 */
/* 커널 해결법: */
/*   - RCU: grace period가 ABA 방지 역할 */
/*   - 포인터+세대 번호: 상위 비트에 세대 카운터 포함 */
/*   - cmpxchg_double(): 128비트 atomic CAS (x86_64) */

Lock-less List (llist)

llist는 CAS 기반의 IRQ-safe 단방향 연결 리스트입니다. 인터럽트 핸들러에서 작업을 큐에 추가하고, 프로세스 컨텍스트에서 일괄 처리하는 패턴에 최적화되어 있습니다.

#include <linux/llist.h>

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

static LLIST_HEAD(pending_work);

/* 추가: IRQ 컨텍스트에서도 안전 (lock-free, atomic CAS) */
static irqreturn_t my_irq(int irq, void *data)
{
    struct work_item *item = kmalloc(sizeof(*item), GFP_ATOMIC);
    if (item) {
        item->data = read_hw_data();
        llist_add(&item->node, &pending_work);
    }
    return IRQ_HANDLED;
}

/* 일괄 처리: 프로세스 컨텍스트에서 전체 리스트를 원자적으로 분리 */
static void process_work(void)
{
    struct llist_node *batch;
    struct work_item *item;

    /* 전체 리스트를 atomic으로 분리 (xchg(head, NULL)) */
    batch = llist_del_all(&pending_work);
    /* 순서 역전: LIFO → FIFO (선택적) */
    batch = llist_reverse_order(batch);

    llist_for_each_entry(item, batch, node) {
        handle_data(item->data);
        kfree(item);
    }
}

KCSAN: 커널 동시성 새니타이저

KCSAN(Kernel Concurrency Sanitizer)은 런타임에 data race를 탐지하는 도구입니다. 컴파일러 인스트루멘테이션을 통해 모든 메모리 접근을 감시하고, 동기화 없이 동시에 같은 주소에 접근하는 경우(하나 이상이 쓰기)를 보고합니다. Google이 개발하여 커널 5.8에서 도입되었습니다.

KCSAN 동작 원리

KCSAN은 워치포인트(watchpoint) 기반으로 동작합니다. 메모리 접근 시 해당 주소에 워치포인트를 설정하고, 짧은 지연(delay) 후 다른 CPU에서 같은 주소에 대한 충돌 접근이 있었는지 확인합니다. 충돌이 감지되면 두 접근 지점의 스택 트레이스를 함께 보고합니다.

/* KCSAN 활성화 커널 설정 */
CONFIG_KCSAN=y
CONFIG_KCSAN_STRICT=y           /* 엄격 모드: 모든 data race 보고 */
CONFIG_KCSAN_REPORT_ONCE_IN_MS=0  /* 동일 레이스 중복 보고 */

/* KCSAN 보고 예시 */
/*
 * ==================================================================
 * BUG: KCSAN: data-race in process_one_work / __queue_work
 *
 * write to 0xffff888012345678 of 4 bytes by task 1234 on cpu 0:
 *  process_one_work+0x1a2/0x520
 *  worker_thread+0x50/0x3b0
 *
 * read to 0xffff888012345678 of 4 bytes by task 5678 on cpu 1:
 *  __queue_work+0x8c/0x620
 *  queue_work_on+0x2c/0x40
 *
 * value changed: 0x00000001 -> 0x00000002
 *
 * Reported by Kernel Concurrency Sanitizer on:
 * CPU: 0 PID: 1234 Comm: kworker/0:1 Not tainted 6.8.0
 * ==================================================================
 */

/* KCSAN 어노테이션: 의도적 data race 표시 (false positive 억제) */
/* READ_ONCE / WRITE_ONCE: KCSAN에게 알려진 접근으로 표시 */
int val = READ_ONCE(shared_var);     /* KCSAN은 이를 동기화된 접근으로 인식 */
WRITE_ONCE(shared_var, new_val);

/* data_race(): 의도적 benign race 표시 */
/* 예: 통계 카운터의 정확한 값이 중요하지 않은 경우 */
data_race(stats->approx_count++);

/* ASSERT_EXCLUSIVE_WRITER / ASSERT_EXCLUSIVE_ACCESS */
/* 현재 시점에 다른 writer/접근이 없어야 함을 선언 */
ASSERT_EXCLUSIVE_WRITER(obj->field);  /* 다른 writer 없음을 assert */
ASSERT_EXCLUSIVE_ACCESS(obj->field);  /* 다른 접근 자체가 없음을 assert */
💡

KCSAN vs lockdep: lockdep은 잠금 순서 위반(잠재적 데드락)을 탐지하고, KCSAN은 데이터 레이스(동기화 없는 동시 접근)를 탐지합니다. 두 도구는 상호 보완적이므로 개발 커널에서 함께 활성화하는 것이 이상적입니다. KCSAN의 성능 오버헤드는 약 2-5배로, lockdep(5-10배)보다 가볍습니다.

Lock Contention 분석과 프로파일링

동기화 성능 문제의 대부분은 잠금 경합(lock contention)에서 비롯됩니다. 특정 잠금을 너무 많은 CPU가 동시에 요청하면 대부분의 시간을 spinning이나 sleeping에 소비하게 됩니다. 커널은 이를 진단하기 위한 다양한 프로파일링 도구를 제공합니다.

perf lock: 잠금 프로파일링

# perf lock: 잠금 경합 이벤트 수집
perf lock record -a -- sleep 10
perf lock report

# 출력 예시:
# Name            acquired  contended  avg wait  total wait
# rtnl_mutex          1847        312    4.2 us     1.31 ms
# &sb->s_type->...    9421         87    1.8 us     0.16 ms
# dcache_lock        45123         23    0.9 us     0.02 ms

# perf lock contention: 경합 지점의 스택 트레이스 (커널 5.18+)
perf lock contention -a -- sleep 10

# 출력: 어떤 코드 경로에서 경합이 발생하는지 정확히 표시
# contended   total wait   max wait    avg wait   type   caller
#       312      1.31ms     45.2us      4.2us     mutex  rtnl_lock+0x12
#        87      0.16ms     12.1us      1.8us     rwsem  iterate_dir+0x2c

# ftrace 기반 잠금 이벤트 추적
echo 1 > /sys/kernel/debug/tracing/events/lock/enable
echo 1 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace_pipe

# bpftrace로 특정 잠금의 대기 시간 히스토그램
bpftrace -e '
kprobe:mutex_lock { @start[tid] = nsecs; }
kretprobe:mutex_lock /@start[tid]/ {
    @wait_ns = hist(nsecs - @start[tid]);
    delete(@start[tid]);
}
'

경합 패턴과 해결 전략

경합 패턴증상진단 방법해결 전략
Hot lock하나의 글로벌 잠금에 모든 CPU가 경합/proc/lock_stat 상위 contentions잠금 분할(hash lock, per-CPU lock)
Long hold time잠금 보유 시간이 길어 대기자 누적holdtime-max/avg 분석임계 구역 최소화, 잠금 밖에서 준비 작업
False sharing서로 다른 변수지만 같은 캐시 라인에 위치perf c2c, cache miss 프로파일링____cacheline_aligned, 패딩
Lock convoy짧은 CS인데 sleep lock 사용 → context switch 연쇄높은 contended 비율, 낮은 avg waitspinlock 전환 또는 optimistic spinning 확인
Reader starvationreader가 많아 writer가 진입 불가writer waittime-max 급증seqlock, RCU 전환

캐시 라인 바운싱과 False Sharing

False Sharing은 서로 다른 변수가 같은 캐시 라인(보통 64바이트)에 위치하여, 한 CPU가 자기 변수를 수정하면 다른 CPU의 캐시 라인이 무효화(invalidation)되는 현상입니다. 실제로 데이터를 공유하지 않는데도 캐시 일관성 프로토콜(MESI)에 의해 성능이 크게 저하됩니다.

False Sharing: 같은 캐시 라인에 다른 변수 64-byte 캐시 라인 var_A (CPU0) var_B (CPU1) 나머지 (미사용) CPU 0 L1 Cache var_A 수정 CPU 1 L1 Cache var_B 수정 Invalidate! (MESI: M→I) Invalidate! 왕복 바운싱 CPU0이 var_A만 수정해도 CPU1의 캐시 라인 전체가 무효화 → var_B도 재로드 필요 해결: ____cacheline_aligned 로 변수 분리 var_A padding 캐시 라인 1 var_B padding 캐시 라인 2
False Sharing — 같은 캐시 라인에 있는 독립 변수가 서로의 캐시를 무효화
/* False Sharing 발생 예 */
struct bad_struct {
    atomic_t cpu0_counter;   /* CPU 0이 주로 갱신 */
    atomic_t cpu1_counter;   /* CPU 1이 주로 갱신 */
    /* 같은 64바이트 캐시 라인에 위치 → false sharing! */
};

/* 해결: 캐시 라인 정렬로 분리 */
struct good_struct {
    atomic_t cpu0_counter ____cacheline_aligned;  /* 자체 캐시 라인 */
    atomic_t cpu1_counter ____cacheline_aligned;  /* 자체 캐시 라인 */
};

/* 또는 명시적 패딩 */
struct padded_struct {
    atomic_t cpu0_counter;
    char __pad[L1_CACHE_BYTES - sizeof(atomic_t)];
    atomic_t cpu1_counter;
};

/* Per-CPU 변수는 자동으로 캐시 라인 정렬됨 */
DEFINE_PER_CPU(atomic_t, per_cpu_counter);

/* perf c2c: false sharing 탐지 도구 */
/* $ perf c2c record -a -- sleep 10 */
/* $ perf c2c report --stdio */
/* "Shared Data Cache Line Table"에서 */
/* 높은 HITM(Hit Modified) 비율 = false sharing 의심 지점 */

Lock 그래뉼러리티와 확장성 패턴

잠금의 그래뉼러리티(granularity)는 하나의 잠금이 보호하는 데이터 범위를 의미합니다. 너무 거친(coarse) 잠금은 경합을 유발하고, 너무 세밀한(fine) 잠금은 복잡도와 오버헤드를 증가시킵니다. 커널은 서브시스템 특성에 맞춰 다양한 그래뉼러리티 전략을 사용합니다.

Lock Granularity: Coarse → Fine → Per-CPU Coarse-grained (BKL 시절) 단일 글로벌 잠금 데이터 A 데이터 B 데이터 C 데이터 D 경합 높음, 확장성 ✗ 구현 단순 Fine-grained (해시 잠금) Lock[0] 데이터 A 데이터 B Lock[1] 데이터 C 데이터 D 경합 분산, 확장성 ○ 잠금 순서 관리 필요 Per-CPU (경합 제거) CPU0 Local CPU1 Local 경합 없음, 확장성 ✓ 글로벌 합산 비용 처리량 vs CPU 수 CPU 수 → 처리량 → Per-CPU Fine Coarse
Lock Granularity — 세밀할수록 확장성 향상, Per-CPU가 이상적이지만 글로벌 합산 비용 존재

커널의 잠금 세분화 사례

서브시스템과거 (Coarse)현재 (Fine/Lock-free)개선 효과
전체 커널BKL (Big Kernel Lock, 2.0~2.6)서브시스템별 개별 잠금SMP 확장성 근본 해결
네트워크 스택단일 socket lockper-bucket hash lock + RCU소켓 수 N에 비례하는 확장성
VFS dcache글로벌 dcache_lockper-bucket hash lock + RCU + seqcountNUMA 64코어에서 10배+ 향상
메모리 할당글로벌 zone lockper-CPU page set + SLUB per-CPU cache할당/해제 거의 무경합
프로세스 리스트tasklist_lock (글로벌)RCU + per-task spinlock프로세스 순회 무잠금
파일 시스템BKL → 글로벌 lock_super()per-inode rwsem + per-page lock병렬 I/O 가능
/* 잠금 분할 패턴: 글로벌 잠금 → 해시 버킷 잠금 */

/* Before: 단일 글로벌 잠금 (경합 심함) */
static DEFINE_SPINLOCK(global_lock);
static struct hlist_head table[1024];

void lookup_v1(u32 key)
{
    spin_lock(&global_lock);
    /* 어떤 버킷이든 이 잠금 필요 → 모든 CPU가 경합 */
    __lookup(&table[hash(key)], key);
    spin_unlock(&global_lock);
}

/* After: per-bucket 잠금 (경합 분산) */
struct hash_bucket {
    spinlock_t         lock;
    struct hlist_head  head;
} ____cacheline_aligned;  /* false sharing 방지 */

static struct hash_bucket table[1024];

void lookup_v2(u32 key)
{
    struct hash_bucket *bkt = &table[hash(key) & 0x3FF];
    spin_lock(&bkt->lock);
    /* 다른 버킷은 동시 접근 가능 → 경합 1/1024로 감소 */
    __lookup(&bkt->head, key);
    spin_unlock(&bkt->lock);
}

/* 궁극: RCU 기반 (읽기 무잠금) */
void lookup_v3(u32 key)
{
    rcu_read_lock();
    /* 잠금 없이 해시 테이블 탐색 */
    __lookup_rcu(&table[hash(key) & 0x3FF].head, key);
    rcu_read_unlock();
}

동기화 디버깅 종합 체크리스트

동기화 버그는 간헐적이고 재현이 어려우므로, 개발 초기부터 예방적 디버깅 옵션을 활성화하는 것이 핵심입니다. 아래 체크리스트는 커널 개발 시 반드시 확인해야 할 동기화 디버깅 항목을 정리합니다.

필수 커널 디버그 옵션

CONFIG 옵션탐지 대상오버헤드프로덕션
CONFIG_LOCKDEP잠금 순서 위반 (잠재적 데드락)높음 (5-10×)비활성화
CONFIG_PROVE_LOCKINGlockdep + 더 정밀한 순환 탐지매우 높음비활성화
CONFIG_DEBUG_LOCK_ALLOC잠금 할당/해제 추적중간비활성화
CONFIG_DEBUG_ATOMIC_SLEEPatomic 컨텍스트에서 sleep 호출낮음비활성화
CONFIG_DEBUG_MUTEXESmutex 소유권/재초기화 위반낮음비활성화
CONFIG_DEBUG_SPINLOCKspinlock 미초기화, 이중 해제낮음비활성화
CONFIG_KCSANdata race (동기화 없는 동시 접근)중간 (2-5×)비활성화
CONFIG_PROVE_RCURCU 사용 규칙 위반중간비활성화
CONFIG_LOCK_STAT잠금 경합 통계 수집낮음선택적
CONFIG_DETECT_HUNG_TASK120초 이상 sleep 태스크 탐지매우 낮음활성화 가능
CONFIG_SOFTLOCKUP_DETECTORCPU를 장기간 독점하는 코드 탐지매우 낮음활성화 권장

증상별 진단 가이드

증상의심 원인진단 도구확인 방법
시스템 완전 멈춤 (hard hang)데드락, 무한 spinlock 대기SysRq-L (show locks), NMI watchdoglockdep 로그, dmesg | grep -i deadlock
"BUG: soft lockup" 커널 메시지spinlock 장기 보유, 무한 루프softlockup detector스택 트레이스에서 spin_lock 호출 경로 확인
"BUG: sleeping function called"atomic 컨텍스트에서 sleepCONFIG_DEBUG_ATOMIC_SLEEP스택에서 spinlock 보유 함수 → sleep 함수 경로
"WARNING: possible circular locking"ABBA 잠금 순서 역전lockdepCPU0/CPU1 잠금 순서 비교
데이터 손상 (random corruption)data race, 보호 누락KCSAN, sparse공유 변수 접근 경로의 잠금 확인
"rcu_sched self-detected stall"RCU read-side CS 장기 보유RCU stall detectorgrace period 완료를 차단하는 CPU 식별
성능 저하 (CPU 사용률 높지만 처리량 낮음)잠금 경합, false sharingperf lock, perf c2c, /proc/lock_statcontention 상위 잠금 분석

SysRq 키를 이용한 긴급 잠금 진단

# 시스템이 응답하지 않을 때 SysRq 키로 디버깅
# (시리얼 콘솔 또는 /proc/sysrq-trigger 사용)

# SysRq-D: 모든 잠금 보유 상태 출력 (CONFIG_LOCKDEP 필요)
echo d > /proc/sysrq-trigger

# SysRq-L: 모든 CPU의 현재 스택 트레이스 (어디서 멈췄는지 확인)
echo l > /proc/sysrq-trigger

# SysRq-T: 모든 태스크의 상태와 스택 출력
echo t > /proc/sysrq-trigger

# SysRq-W: blocked 상태 태스크만 출력 (TASK_UNINTERRUPTIBLE)
echo w > /proc/sysrq-trigger

# /proc/lockdep: 잠금 의존성 그래프 덤프
cat /proc/lockdep

# /proc/lockdep_chains: 관찰된 잠금 획득 체인 목록
cat /proc/lockdep_chains

# /proc/lockdep_stats: lockdep 통계 (관찰된 잠금 클래스 수 등)
cat /proc/lockdep_stats

# lockdep 한계: 최대 8191개의 잠금 클래스만 추적 가능
# "BUG: MAX_LOCKDEP_KEYS too low!" 경고 시 커널 빌드 옵션 조정 필요
💡

동기화 디버깅 우선순위: 개발 커널에서 최소한 다음 3가지를 항상 활성화하세요: (1) CONFIG_PROVE_LOCKING=y — 데드락 예방의 핵심, (2) CONFIG_DEBUG_ATOMIC_SLEEP=y — sleep-in-atomic 즉시 탐지, (3) CONFIG_KCSAN=y — data race 자동 발견. 이 세 가지만으로도 동기화 버그의 80% 이상을 개발 단계에서 잡을 수 있습니다.

동기화 프리미티브 전체 관계도

커널의 모든 동기화 프리미티브가 어떤 상황에서 사용되는지, 서로 어떤 관계인지를 종합적으로 정리합니다.

커널 동기화 프리미티브 종합 관계도 Busy-Wait 계열 (Sleep 불가) Sleeping 계열 (프로세스 컨텍스트) raw_spinlock_t spinlock_t RT: → rt_mutex rwlock_t seqlock atomic_t per-CPU 변수 RCU (read-side) mutex 소유권 추적 rt_mutex 우선순위 상속 rw_semaphore ww_mutex semaphore SRCU wait_queue completion futex (User↔Kernel 브리지) 선택 결정 트리 IRQ/BH 컨텍스트? Yes No 단일 변수? Yes atomic_t No spinlock_irqsave 읽기 우세? Yes No RCU / rwsem / seqlock mutex 이벤트 대기: 조건 반복 → wait_queue | 일회성 → completion PREEMPT_RT: spinlock_t → rt_mutex(sleeping) | raw_spinlock_t → 진짜 busy-wait | 모든 IRQ → threaded
커널 동기화 프리미티브 종합 관계도 — 실행 컨텍스트와 접근 패턴에 따른 선택 가이드

동기화와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.