동기화 기법 (Synchronization)

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

전제 조건: 커널 아키텍처인터럽트(Interrupt) 문서를 먼저 읽으세요. 동기화 선택은 실행 문맥(프로세스/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. 대기/깨우기(Wakeup) 경로 검증
    조건 변경 순서와 wake_up 호출 지점을 함께 점검해 lost wakeup을 방지합니다.
  4. 디버깅 옵션 상시 활성화
    개발 커널에서 lockdep 계열 옵션을 켜고 경고를 즉시 수정합니다.
관련 표준: C11 Memory Model (ISO/IEC 9899:2011, 원자적 연산(Atomic Operation)/메모리 순서), LKMM (Linux Kernel Memory Model) — 커널 동기화 프리미티브의 이론적 기반이 되는 메모리 모델 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

동기화가 필요한 이유

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

커널의 동시성 원천

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

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

레이스 컨디션의 발생 원리

레이스 컨디션(Race Condition)은 두 개 이상의 실행 흐름이 공유 데이터에 비원자적(non-atomic)으로 접근할 때, 실행 순서(타이밍)에 따라 결과가 달라지는 결함입니다. 가장 전형적인 패턴은 Read-Modify-Write(RMW)입니다. 카운터를 1 증가시키는 counter++도 실제로는 (1) 메모리에서 읽기, (2) 레지스터(Register)에서 증가, (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
정의두 스레드(Thread)가 같은 메모리에 동시 접근하고, 하나 이상이 쓰기이며, 순서 보장(Ordering)이 없는 경우프로그램의 결과가 실행 순서(타이밍)에 의존하여 의도와 다르게 동작하는 경우
형식적 정의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)하는 시점 사이에 상태가 변하는 레이스 컨디션의 하위 유형입니다. 커널에서는 사용자 공간(User Space) 포인터 검증, 파일 권한 확인, 리소스 가용성 검사 등에서 자주 발생합니다.

/* 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) 조건 검사와 사용을 같은 잠금(Lock) 내에서 수행하세요. (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 (기아(Starvation))
최소 범위 (Minimal Scope)임계 구역을 가능한 짧게 유지불필요한 경합(Contention), 처리량(Throughput) 저하
/* 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이 발생합니다.

ℹ️

기술 문서: ticket lock→MCS→qspinlock 진화 과정, 32비트 상태 인코딩, queued_spin_lock_slowpath 3단계 분석, PV qspinlock, CNA NUMA 확장, 아키텍처별 원자적(Atomic) 명령어(x86/ARM64/RISC-V), PREEMPT_RT raw_spinlock 변환, lock contention 프로파일링(Profiling) 등 spinlock의 내부 구현을 깊이 다루는 Spinlock 문서를 참고하세요.

Spinlock API 요약

APIpreemptIRQBH용도
spin_lock()비활성화프로세스 컨텍스트 전용, IRQ와 공유 안 할 때
spin_lock_bh()비활성화비활성화softirq/tasklet과 공유할 때
spin_lock_irq()비활성화비활성화비활성화IRQ와 공유, IRQ 활성 확정 시
spin_lock_irqsave()비활성화비활성화비활성화IRQ와 공유, IRQ 상태 불확실 (가장 안전)
spin_trylock()비활성화비블로킹 시도 (실패 시 0 반환)
특성spinlock_traw_spinlock_t
일반 커널busy-waitbusy-wait (동일)
PREEMPT_RTrt_mutex 기반 sleeping lockbusy-wait (변환 없음)
사용 기준일반적 커널 자료구조 보호하드웨어, 스케줄러(Scheduler), 타이머(Timer) 등 핵심 경로만

Mutex

mutex는 프로세스 컨텍스트 전용 잠금입니다. 락을 획득할 수 없으면 태스크를 슬립(sleep) 시키므로 CPU를 낭비하지 않습니다. 내부적으로 3단계 획득 경로(Fast Path → Optimistic Spinning → Slow Path)를 구현하여, 경합이 짧으면 context switch 없이 처리합니다.

#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 핵심 규칙: 소유자만 해제 가능, 재귀 불가, 인터럽트 컨텍스트 불가. HANDOFF 메커니즘으로 starvation을 방지합니다.

ℹ️

상세 문서: struct mutex 필드 분석, owner 플래그 비트 인코딩, Optimistic Spinning(osq_lock) 내부, HANDOFF 메커니즘, mutex_unlock 경로, PI Chain 연결, PREEMPT_RT 동작 등 mutex의 내부 구현을 깊이 다루는 Mutex 문서를 참고하세요.

Reader-Writer Lock

읽기 작업이 쓰기보다 훨씬 빈번할 때 사용합니다. 여러 reader가 동시에 접근 가능하지만, writer는 독점 접근합니다. 커널은 spinlock 기반의 rwlock_t, sleeping lock 기반의 rw_semaphore, 그리고 극단적 읽기 최적화를 위한 percpu_rw_semaphore 세 가지를 제공합니다.

특성rwlock_trw_semaphorepercpu_rw_semaphore
내부 구현qrwlock (busy-wait)sleeping lock + osqper-CPU 카운터 + rw_semaphore
Sleep 허용불가가능가능
IRQ 컨텍스트가능불가불가
읽기 오버헤드(Overhead)atomic_add (캐시(Cache) 바운싱)atomic_add + osqthis_cpu_inc (거의 0)
쓰기 오버헤드qspinlock + reader drainosq + wait_listsynchronize_rcu + percpu sum
ℹ️

상세 문서: qrwlock cnts 비트 인코딩, Reader/Writer fast/slow path, rw_semaphore Optimistic Spinning, HANDOFF 메커니즘, Writer Starvation 방지, percpu_rw_semaphore per-CPU 카운터, downgrade_write 패턴, PREEMPT_RT rwbase_rt 변환 등은 Reader-Writer Lock 문서를 참고하세요.

Seqlock

seqlock은 읽기 측이 절대 차단되지 않는 동기화 메커니즘입니다. 쓰기 측이 sequence 카운터를 증가시키고, 읽기 측은 읽기 전후의 카운터를 비교하여 일관성을 검증합니다. 불일치 시 재시도합니다.

/* seqlock 기본 사용 패턴 */
seqlock_t my_seqlock;
seqlock_init(&my_seqlock);

/* Reader: 재시도 루프 */
unsigned seq;
do {
    seq = read_seqbegin(&my_seqlock);
    /* 데이터 읽기 (side-effect 금지!) */
} while (read_seqretry(&my_seqlock, seq));

/* Writer: spinlock 보호 */
write_seqlock(&my_seqlock);
/* 데이터 쓰기 */
write_sequnlock(&my_seqlock);
ℹ️

상세 문서: seqcount_t vs seqlock_t vs seqcount_latch_t 차이, read/write 경로 구현, retry 메커니즘, jiffies 보호 패턴, 통계 카운터(u64_stats_sync), seqcount_latch 이중 버퍼, seqcount_LOCKNAME_t 연관 잠금, 메모리 순서 보장 등은 Seqlock 문서를 참고하세요.

Wait Queue (대기 큐(Wait Queue))

wait queue는 커널의 조건 대기 메커니즘입니다. 특정 조건이 만족될 때까지 태스크를 슬립시키고, 조건이 충족되면 wake_up()으로 깨웁니다.

DECLARE_WAIT_QUEUE_HEAD(my_wq);

/* 대기: 조건이 참이 될 때까지 슬립 */
wait_event(my_wq, condition);
wait_event_interruptible(my_wq, condition);
wait_event_timeout(my_wq, condition, timeout);

/* 깨우기: 조건 변경 후 호출 */
wake_up(&my_wq);
wake_up_interruptible(&my_wq);
wake_up_all(&my_wq);
ℹ️

상세 문서: wait_queue_head_t/wait_queue_entry_t 구조, WQ_FLAG_* 플래그, exclusive/non-exclusive 웨이크업, thundering herd 방지, wait_event 매크로(Macro) 내부 구현, poll/select/epoll 연결, simple wait queue(swait), lost wakeup 방지 패턴 등은 Wait Queue 문서를 참고하세요.

Completion

completion은 일회성 완료 신호를 전달하는 동기화 메커니즘입니다. 한 스레드가 작업을 완료하면 complete()를 호출하고, 대기 중인 스레드가 wait_for_completion()에서 깨어납니다. wait queue 기반이지만, done 카운터로 신호 손실을 방지합니다.

DECLARE_COMPLETION(my_comp);

/* 대기 측 (슬립) */
wait_for_completion(&my_comp);

/* 완료 측 */
complete(&my_comp);         /* 1명 깨움 */
complete_all(&my_comp);     /* 전원 깨움 */

/* 재사용 시 */
reinit_completion(&my_comp); /* done=0 리셋 (init_completion 아님!) */
ℹ️

상세 문서: done 카운터 동작 원리, complete vs complete_all, 타임아웃/인터럽트 가능 대기, DECLARE_COMPLETION_ONSTACK, reinit_completion 재사용, 모듈 로딩·펌웨어(Firmware) 요청·워커 동기화·디바이스 프로빙 패턴 등은 Completion 문서를 참고하세요.

Semaphore (세마포어(Semaphore))

semaphore는 Dijkstra(1965)가 제안한 고전적 동기화 프리미티브입니다. count 값에 따라 counting semaphore(리소스 풀 관리)와 binary semaphore(상호 배제)로 사용됩니다. mutex와 달리 소유권 개념이 없어 다른 스레드가 해제할 수 있습니다.

DEFINE_SEMAPHORE(my_sem, 1);  /* count=1: binary semaphore */

down(&my_sem);           /* P 연산: count-- (0이면 슬립) */
/* critical section */
up(&my_sem);             /* V 연산: count++ (대기자 깨움) */

/* 인터럽트 가능 */
if (down_interruptible(&my_sem))
    return -ERESTARTSYS;
ℹ️

상세 문서: Dijkstra P/V 연산 이론, struct semaphore 내부, down/up 구현, counting semaphore 패턴, binary semaphore vs mutex 비교, rw_semaphore 구현(count 인코딩·Optimistic Spinning·HANDOFF), 레거시 마이그레이션 등은 Semaphore 문서를 참고하세요.

실전 구현 패턴 확장

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

구현 패턴 요약표

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

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

하드웨어 인터럽트가 데이터를 밀어 넣고, 사용자 공간의 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와 근본적으로 다릅니다. 따라서 디바이스 정책 테이블, 파일시스템(Filesystem) 메타데이터 캐시, 모듈 설정 테이블처럼 프로세스 컨텍스트에서 긴 순회가 필요하고 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 사용

패킷(Packet) 수, 바이트 수, 마지막 타임스탬프처럼 몇 개의 정수 필드를 일관된 순간값으로 읽고 싶을 때는 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 명령 슬롯, 하드웨어 암호화(Encryption) 엔진의 동시 요청 수 제한입니다. 이 경우 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()
조건부 대기 (이벤트 루프(Event Loop))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은 커널의 잠금 순서 위반, 교착(deadlock) 가능성, 부적절한 컨텍스트 사용을 런타임에 탐지하는 검증 도구입니다. CONFIG_PROVE_LOCKING으로 활성화하며, lock_class_key 기반의 의존성 그래프를 구축하여 순환(cycle)을 탐지합니다.

ℹ️

상세 문서: lock_class/lock_class_key 개념, 의존성 그래프 구축, 순환 탐지 알고리즘, lock_acquire/lock_release 후킹, IRQ 안전성 검증, wait_type 검사, 어노테이션(nest_lock, subclass), /proc/lockdep 인터페이스, 경고 메시지 해석, PROVE_LOCKING/LOCK_STAT 등은 Lockdep 문서를 참고하세요. 통합 디버깅 가이드는 동시성 디버깅을 참고하세요.

RT Mutex (우선순위 상속(Priority Inheritance))

rt_mutex는 우선순위 상속(Priority Inheritance)을 지원하는 mutex입니다. 낮은 우선순위(Priority) 태스크가 잠금을 보유하고 있을 때, 높은 우선순위 태스크가 대기하면 보유자의 우선순위를 일시적으로 상승시켜 우선순위 역전(Priority Inversion)을 방지합니다.

struct rt_mutex my_rtmutex;
rt_mutex_init(&my_rtmutex);

rt_mutex_lock(&my_rtmutex);    /* PI 지원 잠금 */
/* critical section */
rt_mutex_unlock(&my_rtmutex);
ℹ️

상세 문서: Priority Inversion 문제(Mars Pathfinder 사례), PI 프로토콜, struct rt_mutex/rt_mutex_waiter rb-tree, PI Chain 전파 알고리즘, Boosting/Deboosting, 데드락 감지, PREEMPT_RT spinlock→rt_mutex 변환, PI Futex 등은 RT Mutex 문서를 참고하세요.

Wait/Wound Mutex (ww_mutex)

ww_mutex는 데드락 회피를 위한 잠금 프리미티브입니다. 여러 잠금을 임의의 순서로 획득해야 하는 상황(GPU/DRM 서브시스템 등)에서, 트랜잭션(Transaction) ticket 번호를 기반으로 wound-wait 또는 wait-die 알고리즘을 적용하여 데드락을 자동으로 해소합니다.

DEFINE_WD_CLASS(my_class);  /* wound-wait 알고리즘 */
struct ww_acquire_ctx ctx;
struct ww_mutex lock_a, lock_b;

ww_acquire_init(&ctx, &my_class);
retry:
if (ww_mutex_lock(&lock_a, &ctx) == -EDEADLK) {
    ww_mutex_unlock(&lock_b);
    ww_mutex_lock_slow(&lock_a, &ctx);
    goto retry;
}
ww_mutex_lock(&lock_b, &ctx);
/* critical section */
ww_mutex_unlock(&lock_b);
ww_mutex_unlock(&lock_a);
ww_acquire_fini(&ctx);
ℹ️

상세 문서: Wound-Wait/Wait-Die 알고리즘 원리, ww_class/ww_acquire_ctx/ww_mutex 구조, ticket 번호 순서 결정, 상처(wound) 전파 경로, -EDEADLK 재시도 루프, DRM/GEM/TTM 실전 사용, 다중 잠금 획득 패턴 등은 Wait/Wound Mutex 문서를 참고하세요.

잠금 순서 규칙

규칙설명
일관된 순서여러 잠금 획득 시 항상 같은 순서 유지
중첩 잠금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라우팅(Routing)/인터페이스 변경
/* 잠금 순서 주석 패턴: 복잡한 서브시스템에서 필수 */
/* 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 경고는 "잠재적" 교착 상태(Deadlock)이므로 즉시 수정해야 합니다.

동기화 관련 주요 버그 사례

커널 개발에서 동기화 버그는 가장 디버깅하기 어려운 문제 중 하나입니다. 재현이 어렵고, 증상이 원인과 멀리 떨어져 나타나며, 특정 타이밍이나 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하는 것은 아니지만, 물리 메모리(Physical Memory)가 부족하면 다음 경로를 거쳐 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와 스핀락(Spinlock) 혼용 시 문제

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 콜백(Callback) 처리를 무기한 지연시킵니다.

/* 잘못된 코드: 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) 실제 사례

우선순위 역전은 높은 우선순위의 태스크가 낮은 우선순위 태스크가 보유한 잠금을 기다리는 동안, 중간 우선순위 태스크가 낮은 우선순위 태스크의 실행을 선점하여 간접적으로 높은 우선순위 태스크를 무기한 차단하는 현상입니다. 이는 실시간(Real-time) 시스템에서 치명적인 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 패치(Patch)셋(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 스로틀링 정책을 조정하세요.

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

동기화 메커니즘의 결함은 데이터 레이스, 데드락, 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로 보호되는 포인터를 직접 역참조(Dereference)하면 컴파일러 최적화(Compiler Optimization)에 의해 불완전한 데이터를 읽을 수 있음. 반드시 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 순서로 획득하는 경로와, 페이지 폴트(Page Fault)에서 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)
 * ======================================================
 */

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

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

프리미티브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: 사용자-커널 동기화 브리지(Bridge)

Futex(Fast Userspace Mutex)는 경합이 없을 때 커널 진입 없이 동기화하고, 경합 시에만 futex() 시스템 콜을 통해 커널의 대기 큐를 사용하는 하이브리드 메커니즘입니다. glibc의 pthread_mutex, sem_wait 등이 내부적으로 futex를 사용합니다.

참고: futex 시스템 콜 인터페이스, PI futex, robust futex, futex2/futex_waitv, NUMA-aware 해싱, glibc 내부 구현 등 상세 내용은 Futex 전용 페이지를 참고하세요.

Lock-free 프로그래밍 패턴

Lock-free 자료구조는 CAS(Compare-and-Swap) 기반으로 잠금 없이 동시 접근을 허용합니다. 커널에서는 llist(lock-less list), kfifo, ptr_ring, percpu_ref 등이 대표적인 lock-free 패턴입니다.

참고: CAS/ABA 문제, MPSC 큐, 시퀀스 카운터 래치, SPSC 패턴 등 상세 내용은 Lock-free 자료구조 전용 페이지를 참고하세요.

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

KCSAN(Kernel Concurrency Sanitizer)은 데이터 레이스를 컴파일 타임 계측으로 탐지합니다. CONFIG_KCSAN=y로 활성화하며, READ_ONCE()/WRITE_ONCE() 없이 공유 변수에 접근하는 패턴을 보고합니다.

참고: KCSAN 설정, 리포트 해석, 억제 방법 등 상세 내용은 동시성 디버깅메모리 배리어 페이지를 참고하세요.

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서로 다른 변수지만 같은 캐시 라인(Cache Line)에 위치perf c2c, cache miss 프로파일링____cacheline_aligned, 패딩(Padding)
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)되는 현상입니다. 실제로 데이터를 공유하지 않는데도 캐시 일관성(Cache Coherency) 프로토콜(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 확장성 근본 해결
네트워크 스택(Network Stack)단일 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 미초기화, 이중 해제(Double Free)낮음비활성화
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
커널 동기화 프리미티브 종합 관계도 — 실행 컨텍스트와 접근 패턴에 따른 선택 가이드

mutex Lock/Unlock 내부 코드 분석

mutex는 커널에서 가장 널리 쓰이는 sleeping lock입니다. 내부 구현은 Fast Path → Optimistic Spinning(Mid Path) → Slow Path 3단계로 나뉘며, 각 단계에서 점진적으로 비용이 증가합니다. 아래에서 kernel/locking/mutex.c의 핵심 경로를 소스 수준으로 추적합니다.

struct mutex 핵심 필드

struct mutexinclude/linux/mutex.h에 정의됩니다. 주요 필드는 다음과 같습니다.

struct mutex {
    atomic_long_t       owner;      /* 소유자 task_struct 포인터 + 플래그 비트 */
    raw_spinlock_t      wait_lock;  /* 대기 리스트 보호용 spinlock */
    struct list_head    wait_list;  /* 대기 중인 태스크 연결 리스트 */
#ifdef CONFIG_DEBUG_MUTEXES
    void                *magic;     /* 디버그 매직 넘버 */
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
    struct lockdep_map  dep_map;    /* lockdep 의존성 추적 */
#endif
};
코드 설명
  • 2행owner — 하위 3비트를 플래그로 사용합니다. MUTEX_FLAG_WAITERS(bit 0), MUTEX_FLAG_HANDOFF(bit 1), MUTEX_FLAG_PICKUP(bit 2)이며, 나머지 비트는 소유자 task_struct 포인터입니다.
  • 3행wait_lockwait_list 조작 시 경합을 방지하는 내부 spinlock입니다. 임계 구간이 매우 짧습니다.
  • 4행wait_list — slow path에서 슬립하는 태스크들의 FIFO 큐입니다. struct mutex_waiter가 연결됩니다.
  • 6행magic — CONFIG_DEBUG_MUTEXES 활성화 시 mutex 무결성 검증에 사용되는 값입니다.
  • 9행dep_map — lockdep가 잠금 의존성 그래프를 추적하는 데 사용하는 구조체입니다.

호출 체인(Call Chain)

mutex_lock()의 전체 호출 경로는 다음과 같습니다.

/* include/linux/mutex.h */
void mutex_lock(struct mutex *lock)
{
    might_sleep();  /* atomic 컨텍스트에서 호출하면 경고 */

    /* Fast path: owner가 0이면 현재 태스크로 cmpxchg */
    if (!__mutex_trylock_fast(lock))
        __mutex_lock_slowpath(lock);
}

/* kernel/locking/mutex.c — fast path */
static __always_inline bool __mutex_trylock_fast(struct mutex *lock)
{
    unsigned long curr = (unsigned long)current;
    unsigned long zero = 0UL;

    /* atomic_long_try_cmpxchg_acquire(&lock->owner, &zero, curr) */
    if (atomic_long_try_cmpxchg_acquire(&lock->owner, &zero, curr))
        return true;

    return false;
}

/* kernel/locking/mutex.c — slow path entry */
static noinline void __mutex_lock_slowpath(struct mutex *lock)
{
    __mutex_lock(lock, TASK_UNINTERRUPTIBLE, 0, NULL, _RET_IP_);
}

/* __mutex_lock() → __mutex_lock_common() 핵심 로직 */
static int __mutex_lock_common(struct mutex *lock,
        unsigned int state, unsigned int subclass,
        struct lockdep_map *nest_lock, unsigned long ip)
{
    /* 1단계: optimistic spinning (owner가 CPU에서 실행 중이면 대기) */
    if (mutex_optimistic_spin(lock, ww_ctx, NULL)) {
        /* spinning으로 락 획득 성공 → context switch 없이 반환 */
        return 0;
    }

    /* 2단계: slow path — wait_list에 진입 */
    raw_spin_lock(&lock->wait_lock);
    list_add_tail(&waiter.list, &lock->wait_list);
    __set_current_state(state);  /* TASK_UNINTERRUPTIBLE */
    raw_spin_unlock(&lock->wait_lock);

    schedule_preempt_disabled();  /* 슬립 → 소유자가 unlock 시 깨움 */

    /* 깨어난 후 다시 trylock 시도, 실패하면 재수면 */
    return 0;
}
코드 설명
  • 4행might_sleep()은 현재 컨텍스트가 슬립 가능한지 검사합니다. atomic 컨텍스트라면 "BUG: sleeping function called from invalid context" 경고를 출력합니다.
  • 7행Fast path — owner 필드가 0(미잠금)이면 cmpxchg로 현재 태스크 포인터를 원자적으로 기록합니다. 경합이 없으면 여기서 즉시 반환됩니다.
  • 8행Fast path 실패 시 slow path로 진입합니다. 이 함수는 noinline으로 선언되어 fast path의 인라인 코드 크기를 최소화합니다.
  • 18행atomic_long_try_cmpxchg_acquire는 acquire 의미론으로 CAS를 수행합니다. 성공 시 이후의 임계 구간 접근이 이 연산 이전으로 재배치되지 않음을 보장합니다.
  • 36행Optimistic spinning(mid path) — 현재 owner가 다른 CPU에서 실행 중이면, 슬립하지 않고 owner의 unlock을 busy-wait합니다. owner가 스케줄 아웃되면 spinning을 포기하고 slow path로 전환합니다.
  • 42행wait_lock spinlock으로 wait_list를 보호하면서 현재 태스크를 대기열 끝에 추가합니다.
  • 44행__set_current_state(TASK_UNINTERRUPTIBLE)로 태스크 상태를 설정한 후, schedule()로 CPU를 양보합니다.

mutex Fast/Slow Path 결정 흐름

mutex_lock() 3단계 획득 경로 mutex_lock(lock) owner == 0 ? (cmpxchg_acquire) 성공 Fast Path 완료 실패 Optimistic Spinning (Mid Path) owner가 CPU에서 실행 중이면 busy-wait spinning으로 획득 성공? 성공 Mid Path 완료 실패 Slow Path: wait_list에 추가 schedule() → sleep → owner unlock 시 wakeup HANDOFF: 대기 1번 태스크에게 소유권 직접 전달 (starvation 방지) 비용: ~수 ns 비용: ~수백 ns 비용: ~수 μs
mutex_lock() 3단계 획득 경로 — Fast Path(cmpxchg) → Mid Path(optimistic spinning) → Slow Path(sleep)

spinlock 내부 구현 분석

Linux 커널의 spinlock은 ticket spinlock에서 qspinlock(MCS 기반)으로 진화했습니다. qspinlock은 캐시 라인 바운싱을 줄여 대규모 SMP 시스템에서 확장성을 크게 개선합니다. 여기서는 kernel/locking/qspinlock.c의 핵심 구현을 분석합니다.

호출 체인

spin_lock()의 전체 호출 경로는 다음과 같습니다.

/* include/linux/spinlock.h */
static __always_inline void spin_lock(spinlock_t *lock)
{
    raw_spin_lock(&lock->rlock);
}

/* → preempt_disable() + do_raw_spin_lock() */
static __always_inline void do_raw_spin_lock(raw_spinlock_t *lock)
{
    arch_spin_lock(&lock->raw_lock);  /* → queued_spin_lock() */
}

/* include/asm-generic/qspinlock.h */
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
    int val = 0;

    /* Fast path: val==0 → locked=1, 획득 성공 */
    if (likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL)))
        return;

    /* Slow path: MCS 대기열 기반 경합 처리 */
    queued_spin_lock_slowpath(lock, val);
}
코드 설명
  • 4행raw_spin_lock()은 preempt를 비활성화하고 실제 아키텍처별 lock 함수를 호출합니다.
  • 10행arch_spin_lock()은 x86에서 queued_spin_lock()으로 매핑됩니다.
  • 19행Fast path — val이 0(미잠금)이면 _Q_LOCKED_VAL(1)로 원자적 교환합니다. 비경합 상황에서 단일 atomic 명령어로 완료됩니다.
  • 23행경합 시 slow path로 진입합니다. MCS 큐 노드를 할당하고 per-CPU 대기열에서 자기 차례를 기다립니다.

struct qspinlock 구조

struct qspinlock은 32비트 atomic_t val 하나로 모든 상태를 인코딩합니다.

struct qspinlock {
    union {
        atomic_t val;           /* 전체 32비트 상태 */
        struct {
            u8 locked;          /* [7:0] locked 바이트 */
            u8 pending;         /* [15:8] pending 비트 */
        };
        struct {
            u16 locked_pending;  /* [15:0] locked + pending */
            u16 tail;            /* [31:16] MCS 대기열 tail (cpu+idx) */
        };
    };
};
코드 설명
  • 3행val — 전체 32비트를 원자적으로 조작합니다. 비경합 시 fast path에서 cmpxchg 대상입니다.
  • 5행locked — 1이면 락 보유 중입니다. unlock 시 이 바이트만 0으로 기록하여 빠르게 해제합니다.
  • 6행pending — 두 번째 경합자가 설정합니다. pending 비트가 있으면 세 번째부터는 MCS 큐로 진입합니다.
  • 10행tail — MCS 대기열의 꼬리를 가리킵니다. CPU 번호와 컨텍스트 인덱스를 인코딩합니다.

qspinlock 상태 전이도

qspinlock 상태 전이 Unlocked val = 0x00000000 Locked (비경합) locked=1, pending=0, tail=0 cmpxchg(0→1) Locked+Pending locked=1, pending=1, tail=0 2번째 경합자 Locked+Pending+Tail locked=1, pending=1, tail≠0 (MCS 큐 활성) 3번째 이후 → MCS 노드에서 local spinning 3번째+ MCS Queue Node 각 CPU는 자기 노드의 locked 필드를 local spinning — 캐시 라인 바운싱 없음 Unlock: smp_store_release(&lock->locked, 0) locked 바이트만 0으로 기록 → 다음 대기자가 획득 Ticket Lock: next/owner 카운터 — 모든 CPU가 같은 캐시 라인 경합 QSpinlock: MCS 큐 — 각 CPU가 로컬 변수에서 spinning → O(1) 캐시 트래픽
qspinlock 상태 전이 — 경합 수준에 따라 fast path(cmpxchg) → pending 비트 → MCS 큐로 단계적 전환

rwlock/rwsem 내부 코드 비교

읽기 우세 워크로드에서는 rwlock_t(spinning)와 struct rw_semaphore(sleeping)를 선택할 수 있습니다. 두 프리미티브의 내부 구현을 비교하여 설계 차이를 이해합니다.

struct rw_semaphore 핵심 필드

struct rw_semaphore {
    atomic_long_t count;     /* reader/writer 상태 인코딩 */
    atomic_long_t owner;     /* writer 소유자 + 플래그 */
    struct optimistic_spin_queue osq; /* MCS 기반 optimistic spinning 큐 */
    raw_spinlock_t wait_lock; /* 대기 리스트 보호 */
    struct list_head wait_list; /* 대기 중인 reader/writer 목록 */
};
코드 설명
  • 2행count — 비트 인코딩으로 reader 수와 writer 상태를 동시에 추적합니다. 상위 비트는 RWSEM_WRITER_LOCKED(bit 0), RWSEM_FLAG_WAITERS(bit 1), RWSEM_FLAG_HANDOFF(bit 2)이며, 상위 비트에 reader 카운트가 저장됩니다.
  • 3행owner — write lock 보유자의 task_struct 포인터입니다. optimistic spinning에서 소유자의 실행 상태를 확인하는 데 사용됩니다.
  • 4행osq — MCS 기반 optimistic spinning 큐입니다. 대기 중인 writer들이 자기 노드에서 local spinning하여 캐시 라인 바운싱을 줄입니다.
  • 5-6행wait_lockwait_list는 mutex와 유사하게 slow path 대기열을 관리합니다.

down_read() 호출 체인

/* kernel/locking/rwsem.c */
void down_read(struct rw_semaphore *sem)
{
    might_sleep();
    rwsem_acquire_read(&sem->dep_map, 0, 0, _RET_IP_);
    LOCK_CONTENDED(sem, __down_read_trylock, __down_read);
}

/* Fast path: count에 RWSEM_READER_BIAS 원자적 덧셈 */
static inline void __down_read(struct rw_semaphore *sem)
{
    long tmp = atomic_long_add_return_acquire(RWSEM_READER_BIAS, &sem->count);

    /* writer가 없고 waiter도 없으면 즉시 성공 */
    if (unlikely(tmp < 0))
        rwsem_down_read_slowpath(sem, tmp, TASK_UNINTERRUPTIBLE);
}

/* Slow path: 대기열에서 writer 완료를 기다림 */
static struct rw_semaphore *
rwsem_down_read_slowpath(struct rw_semaphore *sem,
        long count, unsigned int state)
{
    /* writer가 활성이면 대기 리스트에 추가 후 슬립 */
    raw_spin_lock_irq(&sem->wait_lock);
    list_add_tail(&waiter.list, &sem->wait_list);
    /* writer unlock 시 대기 중인 reader들을 일괄 깨움 */
    schedule();
    raw_spin_unlock_irq(&sem->wait_lock);
    return sem;
}
코드 설명
  • 6행LOCK_CONTENDED 매크로는 먼저 trylock을 시도하고, 실패 시 실제 lock 함수를 호출합니다. lockdep 통계 수집에도 사용됩니다.
  • 12행Fast path — RWSEM_READER_BIAS(0x100)를 count에 더합니다. writer가 없으면 결과가 양수이므로 즉시 성공합니다.
  • 15-16행tmp < 0이면 writer가 활성이거나 대기 중입니다. slow path에서 대기열에 진입하여 writer 완료를 기다립니다.
  • 25행IRQ를 비활성화한 채로 wait_lock을 잡고 대기열을 조작합니다. 이는 IRQ 핸들러에서의 간섭을 방지합니다.

rwlock_t vs rw_semaphore 비교

특성rwlock_trw_semaphore
대기 방식busy-wait (spinning)sleeping (schedule)
사용 컨텍스트IRQ/softirq/프로세스프로세스 컨텍스트만
reader 동시성다수 reader 동시 진입다수 reader 동시 진입
writer starvation가능 (reader 우선)방지 (HANDOFF 메커니즘)
optimistic spinning없음있음 (OSQ)
lockdep 추적지원지원
PREEMPT_RTrt_mutex 기반으로 변환변환 없음 (이미 sleeping)
주요 사용처짧은 임계구간, IRQ 경로VFS inode, mm mmap_lock

RCU 내부 코드 분석

RCU(Read-Copy-Update)는 락 없이 읽기 경로를 보호하는 커널 최적의 읽기 우세 동기화 기법입니다. 읽기 측은 사실상 비용이 없고, 갱신 측만 유예 기간(Grace Period) 완료를 대기합니다. kernel/rcu/tree.c의 핵심 경로를 분석합니다.

rcu_read_lock() 읽기 경로

/* include/linux/rcupdate.h */
static __always_inline void rcu_read_lock(void)
{
    __rcu_read_lock();
    rcu_lock_acquire(&rcu_lock_map);  /* lockdep 추적 */
}

/* non-preemptible 커널 (CONFIG_PREEMPT_RCU=n) */
static __always_inline void __rcu_read_lock(void)
{
    preempt_disable();  /* 그것이 전부 — 거의 비용 없음 */
}

/* preemptible 커널 (CONFIG_PREEMPT_RCU=y) */
void __rcu_read_lock(void)
{
    current->rcu_read_lock_nesting++;
    if (unlikely(!READ_ONCE(current->rcu_read_unlock_special.s)))
        return;
    barrier();  /* 컴파일러 배리어만 */
}
코드 설명
  • 4행__rcu_read_lock()은 커널 선점 모드에 따라 구현이 달라집니다.
  • 11행Non-preemptible 커널에서는 preempt_disable()만 호출합니다. 이는 단순히 선점 카운터를 증가시키는 것으로, atomic 연산도 메모리 배리어도 없습니다.
  • 17행Preemptible 커널에서는 per-task 카운터 rcu_read_lock_nesting을 증가시킵니다. RCU 유예 기간이 이 카운터를 확인하여 reader가 임계 구간에 있는지 판별합니다.

synchronize_rcu() 갱신 경로

/* kernel/rcu/tree.c */
void synchronize_rcu(void)
{
    /* 최적화: 다른 CPU가 없으면 즉시 반환 */
    if (rcu_blocking_is_gp())
        return;

    /* 유예 기간 완료 대기 — call_rcu + wait */
    wait_rcu_gp(call_rcu_hurry);
}

/* call_rcu(): 콜백 등록 (비동기) */
void call_rcu(struct rcu_head *head, rcu_callback_t func)
{
    /* 콜백을 per-CPU rcu_data의 콜백 리스트에 추가 */
    __call_rcu_common(head, func, false);
}

/* rcu_gp_kthread(): 유예 기간 상태 머신 (per-flavor kthread) */
static int rcu_gp_kthread(void *unused)
{
    for (;;) {
        /* 1. 새 유예 기간 시작 대기 */
        swait_event_idle_exclusive(rnp->gp_wq,
            READ_ONCE(rcu_state.gp_flags) & RCU_GP_FLAG_INIT);

        /* 2. 유예 기간 초기화: gp_seq 증가, 모든 CPU에 qs 요청 */
        rcu_gp_init();

        /* 3. 모든 CPU의 quiescent state 보고 대기 */
        rcu_gp_fqs_loop();

        /* 4. 유예 기간 정리: 콜백 실행 허용 */
        rcu_gp_cleanup();
    }
}
코드 설명
  • 5-6행단일 CPU 시스템에서는 선점 비활성화만으로 유예 기간이 보장되므로, 별도의 대기 없이 즉시 반환합니다.
  • 9행wait_rcu_gp()call_rcu()로 콜백을 등록한 후 해당 콜백이 호출될 때까지 대기합니다. synchronize_rcu()의 동기 버전을 구현합니다.
  • 13-16행call_rcu()는 비동기 API입니다. 콜백을 per-CPU rcu_data의 리스트에 추가하고 즉시 반환합니다. 유예 기간 완료 후 softirq에서 콜백이 실행됩니다.
  • 27행rcu_gp_init()gp_seq를 증가시키고 모든 rcu_node에 quiescent state 보고를 요청합니다.
  • 30행rcu_gp_fqs_loop()는 모든 CPU가 quiescent state(컨텍스트 스위치 또는 idle)를 통과할 때까지 반복합니다. 느린 CPU에 대해 FQS(Force Quiescent State)를 전송할 수 있습니다.
  • 33행rcu_gp_cleanup()은 유예 기간을 마무리하고, 등록된 콜백들의 실행을 허용합니다.

RCU 핵심 자료구조

/* kernel/rcu/tree.h */
struct rcu_data {            /* per-CPU */
    unsigned long gp_seq;     /* 이 CPU가 인지한 유예 기간 번호 */
    unsigned long gp_seq_needed; /* 필요한 유예 기간 번호 */
    bool          cpu_no_qs;  /* quiescent state 미보고 */
    struct rcu_segcblist cblist; /* segmented 콜백 리스트 */
    struct rcu_node *mynode;   /* 소속 rcu_node */
    int           cpu;        /* CPU 번호 */
};

struct rcu_node {             /* 트리 계층 노드 */
    raw_spinlock_t lock;      /* 이 노드 보호 */
    unsigned long  gp_seq;    /* 유예 기간 번호 */
    unsigned long  qsmask;    /* qs 미보고 CPU 비트마스크 */
    unsigned long  qsmaskinit; /* 초기 비트마스크 */
    struct rcu_node *parent;   /* 상위 노드 */
    int            grplo;     /* 관리 CPU 범위 시작 */
    int            grphi;     /* 관리 CPU 범위 끝 */
};
코드 설명
  • 2행rcu_data는 per-CPU 구조체로, 각 CPU의 RCU 상태와 콜백을 관리합니다.
  • 3-4행gp_seqgp_seq_needed의 비교로 이 CPU가 현재 유예 기간에 참여해야 하는지 판단합니다.
  • 6행cblist는 4개 세그먼트(DONE, WAIT, NEXT_READY, NEXT)로 분류된 콜백 리스트입니다. 유예 기간 진행에 따라 세그먼트가 이동합니다.
  • 11행rcu_node는 대규모 SMP에서 확장성을 위한 트리 구조입니다. leaf 노드는 CPU 그룹을, 상위 노드는 하위 노드를 집약합니다.
  • 14행qsmask — 아직 quiescent state를 보고하지 않은 CPU(또는 하위 노드)의 비트마스크입니다. 모두 0이 되면 이 노드의 모든 CPU가 유예 기간을 통과한 것입니다.

유예 기간(Grace Period) 탐지 흐름

RCU 유예 기간(Grace Period) 탐지 흐름 시간 → GP 시작 gp_seq++ GP 종료 콜백 실행 CPU 0 RCU read QS CPU 1 RCU read QS CPU 2 RCU read QS CPU 3 긴 RCU read (마지막 통과) QS FQS (Force QS) rcu_node 트리: qsmask 비트 클리어 전파 leaf → root: 모든 qsmask=0 → 유예 기간 완료 콜백 실행 (softirq/kthread) kfree_rcu(), call_rcu() 등록 콜백 RCU 읽기 구간 Quiescent State (컨텍스트 스위치/idle) FQS: 강제 QS 요청 (느린 CPU 대상)
RCU 유예 기간 탐지 — 모든 CPU가 quiescent state를 통과하면 유예 기간이 종료되고 콜백이 실행됨

Lock 비교 종합 및 코드 패턴

커널에서 제공하는 주요 동기화 프리미티브를 성능, 컨텍스트 제약, 확장성 측면에서 종합 비교합니다.

성능/제약 비교표

프리미티브비경합 비용경합 시 대기IRQ 컨텍스트슬립 가능reader 동시성주요 사용 시나리오
spinlock_t~10-20 cyclesbusy-wait사용 가능불가불가 (배타)짧은 임계구간, IRQ 경로
mutex~20-30 cyclesspinning → sleep사용 불가가능불가 (배타)긴 임계구간, I/O 포함
rw_semaphore~30-40 cyclesspinning → sleep사용 불가가능가능읽기 우세, writer 공정성 필요
rwlock_t~15-25 cyclesbusy-wait사용 가능불가가능짧은 읽기 우세, IRQ 경로
RCU~0-5 cycles (읽기)읽기: 대기 없음사용 가능불가 (읽기)무제한읽기 극도 우세, 포인터 기반
seqlock~10-15 cyclesreader: retry사용 가능불가가능 (retry)작은 데이터, writer 우선

프리미티브별 코드 패턴

/* ─── 1. Spinlock: IRQ 경로에서 짧은 임계구간 ─── */
DEFINE_SPINLOCK(dev_lock);
unsigned long flags;
spin_lock_irqsave(&dev_lock, flags);
dev->reg = new_val;                  /* 레지스터 갱신 */
spin_unlock_irqrestore(&dev_lock, flags);

/* ─── 2. Mutex: I/O를 포함하는 긴 경로 ─── */
DEFINE_MUTEX(file_lock);
mutex_lock(&file_lock);
buf = kmalloc(sz, GFP_KERNEL);      /* 슬립 가능한 할당 */
vfs_read(fp, buf, sz, &pos);          /* 파일 I/O */
mutex_unlock(&file_lock);

/* ─── 3. rwsem: 읽기 우세 + writer 공정성 ─── */
DECLARE_RWSEM(data_sem);
down_read(&data_sem);
val = shared_data->field;            /* reader: 다수 동시 */
up_read(&data_sem);

down_write(&data_sem);
shared_data->field = new_val;        /* writer: 배타적 */
up_write(&data_sem);

/* ─── 4. RCU: 읽기 극도 우세, 포인터 교체 ─── */
/* 읽기 측 */
rcu_read_lock();
p = rcu_dereference(global_ptr);
val = p->field;                      /* 락 없이 읽기 */
rcu_read_unlock();

/* 갱신 측 */
new = kmalloc(sizeof(*new), GFP_KERNEL);
*new = *old;
new->field = new_val;
rcu_assign_pointer(global_ptr, new);
synchronize_rcu();                    /* 기존 reader 완료 대기 */
kfree(old);

/* ─── 5. Seqlock: 작은 데이터, writer 우선 ─── */
seqlock_t seq;
/* 읽기 측: retry 루프 */
unsigned seq_val;
do {
    seq_val = read_seqbegin(&seq);
    ts = shared_time;                /* 읽기 */
} while (read_seqretry(&seq, seq_val));

/* 쓰기 측 */
write_seqlock(&seq);
shared_time = new_time;
write_sequnlock(&seq);
코드 설명
  • 1-6행Spinlockspin_lock_irqsave로 IRQ를 비활성화하고 잠금을 획득합니다. 레지스터 접근처럼 매우 짧은 임계구간에 적합합니다. 임계구간에서 슬립 함수 호출은 금지됩니다.
  • 8-13행Mutex — 슬립 가능한 경로에서 사용합니다. kmalloc(GFP_KERNEL)이나 파일 I/O처럼 블로킹될 수 있는 호출을 포함할 때 필수입니다.
  • 15-23행rwsem — reader 다수가 동시에 down_read()를 보유할 수 있습니다. writer는 down_write()로 배타적 접근을 얻으며, HANDOFF로 starvation을 방지합니다.
  • 25-36행RCU — 읽기 측은 rcu_read_lock()만으로 보호되며 실질적으로 비용이 없습니다. 갱신 측은 새 객체를 할당 → 포인터 교체 → 유예 기간 대기 → 구 객체 해제 순서를 따릅니다.
  • 38-48행Seqlock — reader는 sequence number를 확인하며 retry 루프를 돕니다. writer는 절대 차단되지 않아 jiffies 같은 작은 시간 데이터에 적합합니다. reader의 읽기가 부수 효과 없어야 합니다(포인터 역참조 불가).

선택 가이드

💡

락 선택 판단 흐름:

  1. IRQ/softirq 컨텍스트인가? → spinlock_t (또는 raw_spinlock_t)
  2. 임계구간에서 슬립 가능한 함수를 호출하는가? → mutex
  3. 읽기:쓰기 비율이 10:1 이상이고 포인터 기반인가? → RCU
  4. 읽기:쓰기 비율이 높고 writer 공정성이 필요한가? → rw_semaphore
  5. 작은 데이터이고 writer가 절대 차단되면 안 되는가? → seqlock
  6. 단순 카운터/플래그인가? → atomic_t (락 불필요)

참고 자료

커널 동기화에 대한 공식 문서, 심층 기사, 학술 자료를 아래에 정리했습니다.

커널 공식 문서

LWN.net 심층 기사

학술 자료 및 외부 참고

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