동기화 기법 (Synchronization)
커널 동기화 프리미티브인 spinlock/raw spinlock, mutex, rwlock/rwsem, semaphore, seqlock, wait queue, completion을 실행 문맥(프로세스/IRQ/softirq)과 슬립 가능 여부 기준으로 체계적으로 비교합니다. 또한 데드락, lost wakeup, sleep-in-atomic 같은 대표 실패 패턴을 피하는 설계 규칙과 lockdep 기반 디버깅 절차까지 상세히 설명합니다.
핵심 요약
- 락 선택 기준 — 슬립 가능 여부와 실행 컨텍스트(IRQ 가능 여부)로 1차 분류합니다.
- 이벤트 대기 도구 — 조건 반복 대기는 wait queue, 단발 완료 신호는 completion이 적합합니다.
- 읽기 우세 패턴 — rwsem/seqlock/RCU 중 데이터 형태와 충돌 특성에 맞춰 선택합니다.
- 버그 유형 — ABBA 데드락, sleep-in-atomic, lost wakeup이 대표 위험입니다.
- 검증 도구 — lockdep, DEBUG_ATOMIC_SLEEP, stall 로그 분석으로 조기 탐지합니다.
단계별 이해
- 실행 문맥 먼저 분류
프로세스/IRQ/softirq 여부를 먼저 정하고 "슬립 가능 여부"를 확정합니다. - 자료 접근 형태 확인
단일 변수인지, 복합 구조인지, 읽기 비율이 높은지에 따라 primitive 후보를 좁힙니다. - 대기/깨우기 경로 검증
조건 변경 순서와 wake_up 호출 지점을 함께 점검해 lost wakeup을 방지합니다. - 디버깅 옵션 상시 활성화
개발 커널에서 lockdep 계열 옵션을 켜고 경고를 즉시 수정합니다.
동기화가 필요한 이유
커널은 진정한 병렬 실행 환경입니다. 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나 인터럽트가 개입하면 갱신이 손실됩니다.
/* 레이스 컨디션 예: 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 Race와 Race Condition은 종종 혼용되지만 정확히 다른 개념입니다.
| 구분 | Data Race | Race 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_t와 raw_spinlock_t는 동일하게 busy-wait합니다. 그러나 PREEMPT_RT 커널에서는 spinlock_t가 sleeping lock(rt_mutex 기반)으로 변환되어 우선순위 상속이 적용됩니다. 진정한 busy-wait이 필요한 경우(하드웨어 레지스터, 스케줄러 핵심 경로, NMI 핸들러)에만 raw_spinlock_t를 사용합니다.
| 특성 | spinlock_t | raw_spinlock_t |
|---|---|---|
| 일반 커널 | busy-wait | busy-wait (동일) |
| PREEMPT_RT | rt_mutex 기반 sleeping lock | busy-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 Spinlock | next/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 큐에 진입
*/
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 전체 정리
| API | preempt | IRQ | BH | 용도 |
|---|---|---|---|---|
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 포인터 (정렬 보장으로 하위 비트 사용 가능) */
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_t | rw_semaphore (rwsem) |
|---|---|---|
| 내부 구현 | spinlock 기반 busy-wait | mutex 기반 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이 완화되었습니다.
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를 제거합니다.
수동 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 내부 동작
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()은 done을 UINT_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 Queue | Completion |
|---|---|---|
| 용도 | 조건 기반 반복 대기 | 일회성 이벤트 알림 |
| 조건 검사 | 매크로가 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 path | mutex + spinlock | 재구성 경로는 sleep 허용, fast path는 짧게 보호 | 모든 경로를 mutex 하나로 직렬화 |
| 읽기 다수의 설정 테이블 | rw_semaphore | reader 병렬성 확보, writer는 원자적 교체 | write lock 안에서 메모리 할당/사용자 복사 |
| 작은 구조체의 일관된 스냅샷 | seqcount/seqlock | reader 무잠금, 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 queue만으로는 부족한가: 버퍼의
head/tail갱신은 여전히 상호 배제가 필요합니다. wait queue는 "잠들고 깨우는 메커니즘"이지 자료구조 보호 수단이 아닙니다. - 왜 mutex가 아닌가: IRQ 핸들러는 sleep하지 못합니다.
mutex_lock()경합 시 스케줄링이 필요하므로 인터럽트 문맥에 부적합합니다. - 왜
copy_to_user()를 락 밖으로 뺐는가: 사용자 페이지 fault 처리로 sleep할 수 있기 때문입니다. 이 작업을 spinlock 안에서 수행하면 sleep-in-atomic 버그가 납니다. - 왜
wake_up_interruptible()를 락 해제 뒤 호출하는가: 깨워진 태스크가 곧바로 경쟁에 참여하므로, 가능하면 락을 먼저 풀어 불필요한 경합을 줄이는 편이 유리합니다.
자주 나오는 오해: "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;
}
- 중요한 설계 포인트: 새 설정 blob은
mutex_lock()전에 미리 할당합니다. 그래야 실제 lock 보유 시간은 "포인터 교체 + 상태 검증" 정도로 짧아집니다. - fast path가 보는 값은 짧게 교체:
queue_depth처럼 IRQ/softirq가 직접 보는 값만 spinlock으로 보호합니다. 느린 설정 검증 로직 전체를 여기에 넣으면 안 됩니다. - 락 역할을 문서화해야 함:
state_lock은 생명주기/설정,tx_lock은 제출 큐 보호라는 계약이 주석과 helper 이름에 드러나야 이후 유지보수가 안전해집니다.
예제 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;
}
- reader 병렬성: 여러 reader가 동시에
down_read()로 진입할 수 있으므로 조회 처리량이 좋아집니다. - writer 설계의 핵심: 새 규칙 파싱, 문자열 검증, 메모리 할당은 전부 write lock 전에 마칩니다. write lock 안에서는 리스트 교체와 generation 증가만 수행해야 합니다.
- 언제 RCU로 넘어가야 하는가: reader 비율이 극단적으로 높고 조회 경로가 더 짧아야 한다면
rw_semaphore보다 RCU가 적합할 수 있습니다. 다만 reader가 sleep 가능한 긴 순회를 해야 한다면 rwsem이 더 단순하고 안전합니다.
예제 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));
}
- reader 비용이 매우 낮음: 락을 잡지 않고 단순히 시퀀스 번호를 확인한 뒤, 충돌 시에만 다시 읽습니다.
- writer가 길어지면 오히려 손해: writer가 오래 걸리면 reader 재시도가 급증합니다. 따라서 seqcount는 짧은 writer 임계구역에만 적합합니다.
- 포인터 금지: reader가 구조체 안의 포인터를 따라가야 한다면 seqcount 대신 RCU나 rwsem을 고려해야 합니다.
예제 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;
}
- 세마포어의 진짜 장점: 슬롯 수를 자연스럽게 모델링합니다.
down()이 성공했다는 것은 "슬롯 하나를 확보했다"는 뜻이고,up()은 "슬롯 하나가 반납되었다"는 뜻입니다. - 세마포어만으로는 불충분: 어떤 태그가 비어 있는지는 여전히 비트맵/리스트로 관리해야 하므로
spinlock이 함께 필요합니다. - 타임아웃과 abort: 실제 드라이버는 명령 타임아웃 시 하드웨어 abort 후 슬롯을 반납해야 합니다. 이 경로를 빼먹으면 세마포어 카운터는 줄어든 채 복구되지 않아 결국 제출이 모두 멈춥니다.
예제 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;
}
- 왜 reader가 빠른가: reader는 잠금 경합이나 캐시라인 바운싱 없이 현재 포인터만 읽고 순회합니다.
- 왜 writer에 mutex가 필요한가: 동시에 두 writer가 서로 다른 새 버전을 게시하면 최신 규칙셋이 유실될 수 있으므로 writer 직렬화가 필요합니다.
- 가장 흔한 버그:
rcu_read_unlock()이후에rule포인터를 계속 붙잡고 사용하는 것입니다. unlock 이후에는 객체가 해제될 수 있으므로 필요한 값은 임계구역 안에서 복사하거나 refcount를 잡아야 합니다.
RCU를 언제 선택할 것인가: reader가 짧고 압도적으로 많으며, 업데이트를 "부분 수정"보다 "새 버전 교체"로 표현하기 쉽다면 RCU가 매우 강력합니다. 반대로 reader가 길고 sleep 가능 작업을 해야 하거나 writer가 객체를 세밀하게 수정해야 한다면 RCU보다 rw_semaphore가 더 단순한 경우가 많습니다.
동기화 프리미티브 비교
동기화 프리미티브 선택 가이드
| 조건 | 권장 프리미티브 | 이유 |
|---|---|---|
| IRQ/BH 컨텍스트에서 사용 | spinlock_t + irqsave | sleep 불가, preemption 비활성화 필요 |
| Process context, 짧은 임계구역 | spinlock_t | 경량, 캐시 친화적 |
| Process context, sleep 가능 | mutex | 소유권 추적, lockdep 완전 지원 |
| 읽기 비율 ≥ 80% | rwlock_t / rw_semaphore | 병렬 읽기로 처리량 향상 |
| 읽기가 90%+ (포인터 기반) | RCU | read-side 무비용, 확장성 최대 |
| 단발성 이벤트 알림 | completion | wait_for_completion + complete() |
| 조건부 대기 (이벤트 루프) | wait_queue | wait_event_interruptible 패턴 |
| N개 리소스 풀 제한 | semaphore | 카운팅 세마포어, 소유권 불필요시 |
| 쓰기 빈도 낮고 데이터 작음 | seqlock | reader 무잠금, writer 우선 |
| RT 커널 (PREEMPT_RT) | mutex / local_lock_t | spinlock → sleeping lock 자동 변환 |
| 프리미티브 | 슬립 | 인터럽트 | 재귀 | 용도 |
|---|---|---|---|---|
spinlock | 불가 | 가능 | 불가 | 짧은 critical section, IRQ handler |
mutex | 가능 | 불가 | 불가 | 긴 critical section, 프로세스 컨텍스트 |
rw_semaphore | 가능 | 불가 | 불가 | 읽기 다수, 쓰기 소수 |
seqlock | reader 불가 | 가능 | 불가 | writer 우선, 간단한 데이터 |
RCU | reader 불가 | 가능 | 가능 | 읽기 최적화, 포인터 교체 |
atomic | N/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)에서는 체인 전체로 우선순위가 전파됩니다.
Wait/Wound Mutex (ww_mutex)
여러 잠금을 동시에 획득해야 할 때 교착 상태를 자동으로 회피합니다. GPU 드라이버(DRM/GEM)에서 버퍼 객체 잠금에 사용됩니다.
Wait/Wound 알고리즘
ww_mutex는 wound-wait 알고리즘을 사용합니다. 각 컨텍스트는 ww_acquire_init() 호출 시 64비트 카운터(stamp)를 부여받습니다. 낮은 stamp = older(선착순 우선), 높은 stamp = younger(나중 진입)입니다.
- Younger → Older 잠금 시도: younger 컨텍스트가 older가 보유한 잠금을 기다리면
-EDEADLK를 즉시 반환합니다 (wound). older가 먼저 진행합니다. - Older → Younger 잠금 시도: older 컨텍스트는 younger가 보유한 잠금을 기다립니다 (wait). 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이 즉시 탐지합니다.
| 서브시스템 | 잠금 순서 (상위 → 하위) | 비고 |
|---|---|---|
| VFS | sb_lock → inode i_rwsem → page lock → mmap_lock | 파일시스템 전반 |
| mm (메모리 관리) | mmap_lock → anon_vma lock → page table lock | page fault 경로 |
| 드라이버 패턴 | 디바이스 글로벌 lock → 인스턴스 lock | probe/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_ALLOC과 CONFIG_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의 순환을 탐지합니다. 잠금 획득 순서를 방향 그래프로 기록하고, 새로운 잠금 의존성이 추가될 때마다 순환이 생기는지 검사합니다.
/* 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_LOCKING과 CONFIG_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합니다:
- Direct reclaim 진입: 여유 메모리 부족 →
__alloc_pages_slowpath()→try_to_free_pages() - Zone reclaim: LRU 리스트에서 page 회수 시도 → dirty page → writeback 요청
- 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_count와 might_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 경고를 출력합니다.
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_t | busy-wait, preempt 비활성화 | rt_mutex 기반 sleeping lock |
raw_spinlock_t | busy-wait, preempt 비활성화 | 변환 없음 (진짜 spinlock 유지) |
mutex | sleeping lock | rt_mutex 기반 (우선순위 상속 추가) |
rw_semaphore | sleeping rw lock | rt_mutex 기반 변환 |
local_lock_t | preempt_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_us와 sched_rt_period_us로 RT 스로틀링 정책을 조정하세요.
동기화 관련 주요 취약점 사례
동기화 메커니즘의 결함은 데이터 레이스, 데드락, Use-After-Free 등 다양한 형태로 나타나며, 재현이 어렵고 탐지가 늦어 오랜 기간 잠복하는 특성이 있습니다. 커널에서 실제로 발생한 주요 동기화 버그 사례를 분석합니다.
futex 서브시스템 취약점
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;
}
...
}
Netfilter의 xt_compat_target_from_user()에서 스택 버퍼 범위 밖 쓰기가 가능합니다. 이 취약점 자체는 동기화 문제가 아니지만, 익스플로잇 과정에서 msg_msg 구조체의 잠금 메커니즘을 조작하여 커널 힙 레이아웃을 제어하는 기법이 사용됩니다. 동기화 프리미티브가 악용될 수 있음을 보여주는 사례입니다.
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이 발견한 주요 버그 패턴:
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 기반 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 | 라우팅 테이블, 프로세스 리스트, dcache | read-side 비용 0 | write-side 복잡, 메모리 회수 지연 |
| Atomic RMW | atomic_inc, cmpxchg, xchg | 참조 카운터, 플래그, 통계 | 단순, 하드웨어 보장 | 단일 변수만 보호 |
| Per-CPU 변수 | CPU별 독립 변수, 합산은 read 시 | 통계 카운터, slab 캐시, softnet_data | 경합 완전 제거 | 글로벌 합산 비용, 정밀도 지연 |
| Seqcount | 시퀀스 번호로 일관성 검증 | 시간 관리(timekeeper), vDSO | reader 무잠금 | 포인터 불가, 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 wait | spinlock 전환 또는 optimistic spinning 확인 |
| Reader starvation | reader가 많아 writer가 진입 불가 | writer waittime-max 급증 | seqlock, RCU 전환 |
캐시 라인 바운싱과 False Sharing
False Sharing은 서로 다른 변수가 같은 캐시 라인(보통 64바이트)에 위치하여, 한 CPU가 자기 변수를 수정하면 다른 CPU의 캐시 라인이 무효화(invalidation)되는 현상입니다. 실제로 데이터를 공유하지 않는데도 캐시 일관성 프로토콜(MESI)에 의해 성능이 크게 저하됩니다.
/* 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) 잠금은 복잡도와 오버헤드를 증가시킵니다. 커널은 서브시스템 특성에 맞춰 다양한 그래뉼러리티 전략을 사용합니다.
커널의 잠금 세분화 사례
| 서브시스템 | 과거 (Coarse) | 현재 (Fine/Lock-free) | 개선 효과 |
|---|---|---|---|
| 전체 커널 | BKL (Big Kernel Lock, 2.0~2.6) | 서브시스템별 개별 잠금 | SMP 확장성 근본 해결 |
| 네트워크 스택 | 단일 socket lock | per-bucket hash lock + RCU | 소켓 수 N에 비례하는 확장성 |
| VFS dcache | 글로벌 dcache_lock | per-bucket hash lock + RCU + seqcount | NUMA 64코어에서 10배+ 향상 |
| 메모리 할당 | 글로벌 zone lock | per-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_LOCKING | lockdep + 더 정밀한 순환 탐지 | 매우 높음 | 비활성화 |
CONFIG_DEBUG_LOCK_ALLOC | 잠금 할당/해제 추적 | 중간 | 비활성화 |
CONFIG_DEBUG_ATOMIC_SLEEP | atomic 컨텍스트에서 sleep 호출 | 낮음 | 비활성화 |
CONFIG_DEBUG_MUTEXES | mutex 소유권/재초기화 위반 | 낮음 | 비활성화 |
CONFIG_DEBUG_SPINLOCK | spinlock 미초기화, 이중 해제 | 낮음 | 비활성화 |
CONFIG_KCSAN | data race (동기화 없는 동시 접근) | 중간 (2-5×) | 비활성화 |
CONFIG_PROVE_RCU | RCU 사용 규칙 위반 | 중간 | 비활성화 |
CONFIG_LOCK_STAT | 잠금 경합 통계 수집 | 낮음 | 선택적 |
CONFIG_DETECT_HUNG_TASK | 120초 이상 sleep 태스크 탐지 | 매우 낮음 | 활성화 가능 |
CONFIG_SOFTLOCKUP_DETECTOR | CPU를 장기간 독점하는 코드 탐지 | 매우 낮음 | 활성화 권장 |
증상별 진단 가이드
| 증상 | 의심 원인 | 진단 도구 | 확인 방법 |
|---|---|---|---|
| 시스템 완전 멈춤 (hard hang) | 데드락, 무한 spinlock 대기 | SysRq-L (show locks), NMI watchdog | lockdep 로그, dmesg | grep -i deadlock |
| "BUG: soft lockup" 커널 메시지 | spinlock 장기 보유, 무한 루프 | softlockup detector | 스택 트레이스에서 spin_lock 호출 경로 확인 |
| "BUG: sleeping function called" | atomic 컨텍스트에서 sleep | CONFIG_DEBUG_ATOMIC_SLEEP | 스택에서 spinlock 보유 함수 → sleep 함수 경로 |
| "WARNING: possible circular locking" | ABBA 잠금 순서 역전 | lockdep | CPU0/CPU1 잠금 순서 비교 |
| 데이터 손상 (random corruption) | data race, 보호 누락 | KCSAN, sparse | 공유 변수 접근 경로의 잠금 확인 |
| "rcu_sched self-detected stall" | RCU read-side CS 장기 보유 | RCU stall detector | grace period 완료를 차단하는 CPU 식별 |
| 성능 저하 (CPU 사용률 높지만 처리량 낮음) | 잠금 경합, false sharing | perf lock, perf c2c, /proc/lock_stat | contention 상위 잠금 분석 |
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% 이상을 개발 단계에서 잡을 수 있습니다.
동기화 프리미티브 전체 관계도
커널의 모든 동기화 프리미티브가 어떤 상황에서 사용되는지, 서로 어떤 관계인지를 종합적으로 정리합니다.
관련 문서
동기화와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.