동기화 기법 (Synchronization)
커널 동기화 프리미티브인 spinlock/raw spinlock, mutex, rwlock/rwsem, semaphore, seqlock, wait queue, completion을 실행 문맥(프로세스(Process)/IRQ/softirq)과 슬립(Sleep) 가능 여부 기준으로 체계적으로 비교합니다. 또한 데드락, lost wakeup, sleep-in-atomic 같은 대표 실패 패턴을 피하는 설계 규칙과 lockdep 기반 디버깅(Debugging) 절차까지 상세히 설명합니다.
핵심 요약
- 락 선택 기준 — 슬립 가능 여부와 실행 컨텍스트(IRQ 가능 여부)로 1차 분류합니다.
- 이벤트 대기 도구 — 조건 반복 대기는 wait queue, 단발 완료 신호는 completion이 적합합니다.
- 읽기 우세 패턴 — rwsem/seqlock/RCU 중 데이터 형태와 충돌 특성에 맞춰 선택합니다.
- 버그 유형 — ABBA 데드락, sleep-in-atomic, lost wakeup이 대표 위험입니다.
- 검증 도구 — lockdep, DEBUG_ATOMIC_SLEEP, stall 로그 분석으로 조기 탐지합니다.
단계별 이해
- 실행 문맥 먼저 분류
프로세스/IRQ/softirq 여부를 먼저 정하고 "슬립 가능 여부"를 확정합니다. - 자료 접근 형태 확인
단일 변수인지, 복합 구조인지, 읽기 비율이 높은지에 따라 primitive 후보를 좁힙니다. - 대기/깨우기(Wakeup) 경로 검증
조건 변경 순서와 wake_up 호출 지점을 함께 점검해 lost wakeup을 방지합니다. - 디버깅 옵션 상시 활성화
개발 커널에서 lockdep 계열 옵션을 켜고 경고를 즉시 수정합니다.
동기화가 필요한 이유
커널은 진정한 병렬 실행 환경입니다. 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나 인터럽트가 개입하면 갱신이 손실됩니다.
/* 레이스 컨디션 예: 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 |
|---|---|---|
| 정의 | 두 스레드(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 요약
| 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 반환) |
| 특성 | spinlock_t | raw_spinlock_t |
|---|---|---|
| 일반 커널 | busy-wait | busy-wait (동일) |
| PREEMPT_RT | rt_mutex 기반 sleeping lock | busy-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_t | rw_semaphore | percpu_rw_semaphore |
|---|---|---|---|
| 내부 구현 | qrwlock (busy-wait) | sleeping lock + osq | per-CPU 카운터 + rw_semaphore |
| Sleep 허용 | 불가 | 가능 | 가능 |
| IRQ 컨텍스트 | 가능 | 불가 | 불가 |
| 읽기 오버헤드(Overhead) | atomic_add (캐시(Cache) 바운싱) | atomic_add + osq | this_cpu_inc (거의 0) |
| 쓰기 오버헤드 | qspinlock + reader drain | osq + wait_list | synchronize_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 path | mutex + spinlock | 재구성 경로는 sleep 허용, fast path는 짧게 보호 | 모든 경로를 mutex 하나로 직렬화(Serialization) |
| 읽기 다수의 설정 테이블 | rw_semaphore | reader 병렬성 확보, writer는 원자적 교체 | write lock 안에서 메모리 할당/사용자 복사 |
| 작은 구조체(Struct)의 일관된 스냅샷 | seqcount/seqlock | reader 무잠금, 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 queue만으로는 부족한가: 버퍼의
head/tail갱신은 여전히 상호 배제가 필요합니다. wait queue는 "잠들고 깨우는 메커니즘"이지 자료구조 보호 수단이 아닙니다. - 왜 mutex가 아닌가: IRQ 핸들러는 sleep하지 못합니다.
mutex_lock()경합 시 스케줄링이 필요하므로 인터럽트 문맥에 부적합합니다. - 왜
copy_to_user()를 락 밖으로 뺐는가: 사용자 페이지(Page) 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와 근본적으로 다릅니다. 따라서 디바이스 정책 테이블, 파일시스템(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;
}
- reader 병렬성: 여러 reader가 동시에
down_read()로 진입할 수 있으므로 조회 처리량이 좋아집니다. - writer 설계의 핵심: 새 규칙 파싱, 문자열 검증, 메모리 할당은 전부 write lock 전에 마칩니다. write lock 안에서는 리스트 교체와 generation 증가만 수행해야 합니다.
- 언제 RCU로 넘어가야 하는가: reader 비율이 극단적으로 높고 조회 경로가 더 짧아야 한다면
rw_semaphore보다 RCU가 적합할 수 있습니다. 다만 reader가 sleep 가능한 긴 순회를 해야 한다면 rwsem이 더 단순하고 안전합니다.
예제 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));
}
- reader 비용이 매우 낮음: 락을 잡지 않고 단순히 시퀀스 번호를 확인한 뒤, 충돌 시에만 다시 읽습니다.
- writer가 길어지면 오히려 손해: writer가 오래 걸리면 reader 재시도가 급증합니다. 따라서 seqcount는 짧은 writer 임계구역에만 적합합니다.
- 포인터 금지: reader가 구조체 안의 포인터를 따라가야 한다면 seqcount 대신 RCU나 rwsem을 고려해야 합니다.
예제 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;
}
- 세마포어의 진짜 장점: 슬롯 수를 자연스럽게 모델링합니다.
down()이 성공했다는 것은 "슬롯 하나를 확보했다"는 뜻이고,up()은 "슬롯 하나가 반납되었다"는 뜻입니다. - 세마포어만으로는 불충분: 어떤 태그가 비어 있는지는 여전히 비트맵(Bitmap)/리스트로 관리해야 하므로
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는 잠금 경합(Lock Contention)이나 캐시라인 바운싱 없이 현재 포인터만 읽고 순회합니다.
- 왜 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() |
| 조건부 대기 (이벤트 루프(Event Loop)) | 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은 커널의 잠금 순서 위반, 교착(deadlock) 가능성, 부적절한 컨텍스트 사용을 런타임에 탐지하는 검증 도구입니다. CONFIG_PROVE_LOCKING으로 활성화하며, lock_class_key 기반의 의존성 그래프를 구축하여 순환(cycle)을 탐지합니다.
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이 즉시 탐지합니다.
| 서브시스템 | 잠금 순서 (상위 → 하위) | 비고 |
|---|---|---|
| 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 | 라우팅(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_ALLOC과 CONFIG_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의 순환을 탐지합니다. 잠금 획득 순서를 방향 그래프로 기록하고, 새로운 잠금 의존성이 추가될 때마다 순환이 생기는지 검사합니다.
/* 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하는 것은 아니지만, 물리 메모리(Physical Memory)가 부족하면 다음 경로를 거쳐 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와 스핀락(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 경고를 출력합니다.
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_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 스로틀링 정책을 조정하세요.
동기화 관련 주요 취약점(Vulnerability) 사례
동기화 메커니즘의 결함은 데이터 레이스, 데드락, 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로 보호되는 포인터를 직접 역참조(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이 발견한 주요 버그 패턴:
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를 사용합니다.
Lock-free 프로그래밍 패턴
Lock-free 자료구조는 CAS(Compare-and-Swap) 기반으로 잠금 없이 동시 접근을 허용합니다. 커널에서는 llist(lock-less list), kfifo, ptr_ring, percpu_ref 등이 대표적인 lock-free 패턴입니다.
KCSAN: 커널 동시성 새니타이저
KCSAN(Kernel Concurrency Sanitizer)은 데이터 레이스를 컴파일 타임 계측으로 탐지합니다. CONFIG_KCSAN=y로 활성화하며, READ_ONCE()/WRITE_ONCE() 없이 공유 변수에 접근하는 패턴을 보고합니다.
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 wait | spinlock 전환 또는 optimistic spinning 확인 |
| Reader starvation | reader가 많아 writer가 진입 불가 | writer waittime-max 급증 | seqlock, RCU 전환 |
캐시 라인 바운싱과 False Sharing
False Sharing은 서로 다른 변수가 같은 캐시 라인(보통 64바이트)에 위치하여, 한 CPU가 자기 변수를 수정하면 다른 CPU의 캐시 라인이 무효화(invalidation)되는 현상입니다. 실제로 데이터를 공유하지 않는데도 캐시 일관성(Cache Coherency) 프로토콜(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 확장성 근본 해결 |
| 네트워크 스택(Network Stack) | 단일 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 미초기화, 이중 해제(Double Free) | 낮음 | 비활성화 |
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% 이상을 개발 단계에서 잡을 수 있습니다.
동기화 프리미티브 전체 관계도
커널의 모든 동기화 프리미티브가 어떤 상황에서 사용되는지, 서로 어떤 관계인지를 종합적으로 정리합니다.
mutex Lock/Unlock 내부 코드 분석
mutex는 커널에서 가장 널리 쓰이는 sleeping lock입니다. 내부 구현은 Fast Path → Optimistic Spinning(Mid Path) → Slow Path 3단계로 나뉘며, 각 단계에서 점진적으로 비용이 증가합니다. 아래에서 kernel/locking/mutex.c의 핵심 경로를 소스 수준으로 추적합니다.
struct mutex 핵심 필드
struct mutex는 include/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_lock—wait_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_lockspinlock으로wait_list를 보호하면서 현재 태스크를 대기열 끝에 추가합니다. - 44행
__set_current_state(TASK_UNINTERRUPTIBLE)로 태스크 상태를 설정한 후,schedule()로 CPU를 양보합니다.
mutex Fast/Slow Path 결정 흐름
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 상태 전이도
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_lock과wait_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_t | rw_semaphore |
|---|---|---|
| 대기 방식 | busy-wait (spinning) | sleeping (schedule) |
| 사용 컨텍스트 | IRQ/softirq/프로세스 | 프로세스 컨텍스트만 |
| reader 동시성 | 다수 reader 동시 진입 | 다수 reader 동시 진입 |
| writer starvation | 가능 (reader 우선) | 방지 (HANDOFF 메커니즘) |
| optimistic spinning | 없음 | 있음 (OSQ) |
| lockdep 추적 | 지원 | 지원 |
| PREEMPT_RT | rt_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-CPUrcu_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_seq와gp_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) 탐지 흐름
Lock 비교 종합 및 코드 패턴
커널에서 제공하는 주요 동기화 프리미티브를 성능, 컨텍스트 제약, 확장성 측면에서 종합 비교합니다.
성능/제약 비교표
| 프리미티브 | 비경합 비용 | 경합 시 대기 | IRQ 컨텍스트 | 슬립 가능 | reader 동시성 | 주요 사용 시나리오 |
|---|---|---|---|---|---|---|
spinlock_t | ~10-20 cycles | busy-wait | 사용 가능 | 불가 | 불가 (배타) | 짧은 임계구간, IRQ 경로 |
mutex | ~20-30 cycles | spinning → sleep | 사용 불가 | 가능 | 불가 (배타) | 긴 임계구간, I/O 포함 |
rw_semaphore | ~30-40 cycles | spinning → sleep | 사용 불가 | 가능 | 가능 | 읽기 우세, writer 공정성 필요 |
rwlock_t | ~15-25 cycles | busy-wait | 사용 가능 | 불가 | 가능 | 짧은 읽기 우세, IRQ 경로 |
| RCU | ~0-5 cycles (읽기) | 읽기: 대기 없음 | 사용 가능 | 불가 (읽기) | 무제한 | 읽기 극도 우세, 포인터 기반 |
seqlock | ~10-15 cycles | reader: 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행Spinlock —
spin_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의 읽기가 부수 효과 없어야 합니다(포인터 역참조 불가).
선택 가이드
락 선택 판단 흐름:
- IRQ/softirq 컨텍스트인가? →
spinlock_t(또는raw_spinlock_t) - 임계구간에서 슬립 가능한 함수를 호출하는가? →
mutex - 읽기:쓰기 비율이 10:1 이상이고 포인터 기반인가? → RCU
- 읽기:쓰기 비율이 높고 writer 공정성이 필요한가? →
rw_semaphore - 작은 데이터이고 writer가 절대 차단되면 안 되는가? →
seqlock - 단순 카운터/플래그인가? →
atomic_t(락 불필요)
참고 자료
커널 공식 문서
- Lock types and their rules — 커널 잠금 유형별 규칙과 PREEMPT_RT에서의 변환 규칙
- Generic Mutex Subsystem — mutex 설계 문서: optimistic spinning, osq_lock, 3단계 경로
- Locking lessons — Linus Torvalds의 spinlock 사용 가이드
- RT-mutex implementation design — Priority Inheritance chain 설계
- Wound/Wait Deadlock-Proof Mutex Design — ww_mutex 데드락 회피 설계
- Runtime locking correctness validator — lockdep 설계와 동작 원리
- Sequence counters and sequential locks — seqcount/seqlock API 가이드
- RCU concepts — RCU 전체 문서 색인
- atomic_t — Pair Ordering and Semantics — 원자적 연산의 메모리 순서 의미론
- this_cpu operations — Per-CPU 연산 API 가이드
LWN.net 심층 기사
- Lockless patterns: an introduction to compare-and-swap (2014) — CAS 기반 락 프리 알고리즘 입문
- What is RCU, Fundamentally? (2007) — Paul McKenney의 RCU 3부작 첫 번째
- MCS locks and qspinlocks (2015) — ticket spinlock에서 qspinlock으로의 전환
- The mutex API (2013) — 커널 mutex의 설계 철학과 API 변천사
- MCS lock and qspinlock (2014) — MCS 기반 qspinlock의 scalability 분석
- Concurrency bugs should fear the big bad data-race detector (2019) — KCSAN 소개
- Lockdep: how to read its cryptic output (2013) — lockdep 출력 해석 튜토리얼
학술 자료 및 외부 참고
- Paul McKenney — "Is Parallel Programming Hard, And, If So, What Can You Do About It?" — 병렬 프로그래밍 교과서 (무료)
- C++ Memory Model — C/C++ 메모리 모델과 리눅스 커널 LKMM 비교 참고
- memory-barriers.txt — David Howells & Paul McKenney의 메모리 배리어 완전 가이드
관련 문서
동기화와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.