Mutex (뮤텍스)
커널의 핵심 sleeping lock인 mutex의 내부 구현을 심층 분석합니다. struct mutex의 owner 필드 플래그 비트 인코딩, Fast Path(단일 CAS 즉시 획득)·Mid Path(Optimistic Spinning과 osq_lock MCS 큐)·Slow Path(슬립(Sleep)과 웨이크업) 3단계 경로를 추적하고, HANDOFF 메커니즘에 의한 starvation 방지, mutex_waiter 대기 리스트 관리, mutex_unlock 경로 분석을 다룹니다. Priority Inversion과 PI Chain 연결, PREEMPT_RT에서의 mutex 변환, CONFIG_DEBUG_MUTEXES와 lockdep 디버깅(Debugging), 실전 사용 패턴과 안티패턴까지 커널 소스 기반으로 분석합니다.
핵심 요약
- Sleeping Lock -- 락을 얻을 수 없으면 태스크(Task)를 슬립 상태로 전환하여 CPU를 양보(Yield)합니다. 프로세스(Process) 컨텍스트에서만 사용 가능합니다.
- 3단계 획득 -- Fast Path(단일 CAS로 즉시 획득), Mid Path(osq_lock 기반 optimistic spinning), Slow Path(wait_list에 추가 후 sleep).
- owner 인코딩 -- owner 필드 상위 비트에 task_struct 포인터, 하위 3비트에 WAITERS/HANDOFF/PICKUP 플래그를 저장합니다.
- HANDOFF 방지 -- 대기자가 2번 연속 깨어났는데도 획득 실패하면 HANDOFF 플래그를 설정하여 소유권을 직접 이전합니다.
- 단일 소유자 -- 한 번에 하나의 태스크만 mutex를 보유할 수 있으며, 반드시 소유자가 직접 unlock해야 합니다.
단계별 이해
- Sleeping Lock 개념 파악
spinlock(busy-wait)과 달리, mutex는 대기 중 CPU를 양보하는 sleeping lock입니다. 임계 영역(Critical Section)이 길거나 슬립 가능 함수를 호출해야 할 때 사용합니다. - struct mutex 구조 이해
owner, wait_lock, wait_list, osq 필드의 역할과 상호 관계를 파악합니다. - 3단계 경로 추적
Fast Path(CAS), Mid Path(optimistic spin), Slow Path(sleep) 각각의 진입 조건과 동작을 이해합니다. - HANDOFF와 공정성(Fairness)
starvation을 방지하는 HANDOFF 메커니즘이 어떻게 동작하는지 파악합니다. - 실전 패턴과 디버깅
올바른 사용 패턴, 흔한 실수, lockdep을 활용한 디버깅 방법을 익힙니다.
이론적 배경: Sleeping Lock과 상호 배제(Mutual Exclusion)
mutex(mutual exclusion)는 이름 그대로 상호 배제를 위한 가장 기본적인 sleeping lock입니다. spinlock이 락을 얻을 때까지 CPU에서 루프를 도는 busy-wait 방식인 반면, mutex는 락이 이미 점유되어 있으면 현재 태스크를 TASK_UNINTERRUPTIBLE 또는 TASK_INTERRUPTIBLE 상태로 전환하고 스케줄러(Scheduler)에 CPU를 양보합니다.
Sleeping Lock의 원리
sleeping lock의 핵심은 대기 비용을 CPU 사이클에서 컨텍스트 스위치 오버헤드(Overhead)로 교환하는 것입니다. 컨텍스트 스위치는 수천 사이클이 소요되지만, 임계 영역이 긴 경우(I/O 대기, 메모리 할당 등) CPU를 유용한 작업에 할당할 수 있어 시스템 전체 처리량(Throughput)이 향상됩니다.
/* 비용 모델 비교 */
spinlock 대기: busy-wait 루프, 매 반복 ~5 cycles (PAUSE/WFE)
임계 영역 보유 시간 동안 CPU 100% 소모
mutex 대기: sleep + wakeup ~5,000-10,000 cycles (컨텍스트 스위치 2회)
대기 중 CPU 0% 소모 (다른 태스크 실행)
/* 손익 분기점 */
임계 영역 < ~1,000 cycles → spinlock 유리 (전환 비용이 대기 비용보다 큼)
임계 영역 > ~1,000 cycles → mutex 유리 (CPU 양보가 이득)
/* Linux mutex: 최적화를 통해 두 영역 모두 커버 */
- Fast Path: 경합 없으면 CAS 한 번으로 획득 (~수 cycles)
- Mid Path: 짧은 경합은 optimistic spinning으로 (~spinlock 수준)
- Slow Path: 긴 경합만 실제 sleep
Linux Mutex의 계약 조건
커널 mutex는 일반적인 세마포어(Semaphore)와 달리 엄격한 규칙을 강제합니다. 이 규칙 덕분에 공격적인 최적화가 가능합니다.
| 규칙 | 내용 | 세마포어와 차이 |
|---|---|---|
| 단일 소유자 | 한 번에 하나의 태스크만 mutex를 보유 | 세마포어는 count > 1 가능 |
| 소유자만 해제 | lock한 태스크가 반드시 unlock해야 함 | 세마포어는 다른 태스크가 up() 가능 |
| 재귀 불가 | 같은 태스크가 두 번 lock하면 데드락 | 일부 세마포어는 재귀 허용 |
| 프로세스 컨텍스트 | IRQ/softirq에서 사용 불가 (sleep 가능해야 함) | 동일 |
| 보유 중 exit 불가 | mutex 보유 상태에서 태스크 종료 금지 | 동일 |
| 초기화 필수 | API로만 초기화 (memset/memcpy 금지) | 동일 |
task_struct 포인터를 저장할 수 있고, 이를 통해 optimistic spinning에서 "owner가 현재 CPU에서 실행 중인가?"를 확인하여 spinning 여부를 결정합니다. 세마포어는 이 규칙이 없어 이 최적화가 불가능합니다.
Mutex vs Spinlock 선택 기준
mutex와 spinlock은 모두 상호 배제를 제공하지만 완전히 다른 대기 전략을 사용합니다. 올바른 선택을 위한 상세 비교입니다.
| 기준 | Spinlock | Mutex |
|---|---|---|
| 대기 방식 | Busy-wait (CPU 루프) | Sleep (스케줄러 양보) |
| 사용 컨텍스트 | IRQ, softirq, 프로세스 | 프로세스 컨텍스트만 |
| 임계 영역 제약 | 슬립 불가 (GFP_ATOMIC만) | 슬립 가능 (GFP_KERNEL, I/O 등) |
| 선점(Preemption) | 보유 중 선점 비활성화 | 보유 중 선점 허용 |
| 소유자 추적 | 없음 (qspinlock은 tail로 간접) | owner 필드에 task_struct 포인터 |
| Priority Inversion | 대응 없음 | rt_mutex로 PI 지원 가능 |
| 최적 보유 시간 | 수십~수백 cycles | 수백 cycles ~ 수 ms |
| PREEMPT_RT | spinlock_t → rt_mutex로 변환 | 이미 sleeping lock |
| 메모리 크기 | 4 bytes (qspinlock) | 32~40 bytes (아키텍처 의존) |
struct mutex 필드 분석
struct mutex는 include/linux/mutex.h에 정의되어 있으며, 커널 빌드 설정에 따라 디버깅용 필드가 추가됩니다.
/* include/linux/mutex.h */
struct mutex {
atomic_long_t owner; /* task_struct* | flags (하위 3비트) */
raw_spinlock_t wait_lock; /* wait_list 보호용 spinlock */
struct list_head wait_list; /* mutex_waiter 연결 리스트 (FIFO) */
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
struct optimistic_spin_queue osq; /* MCS-like 스핀 큐 */
#endif
#ifdef CONFIG_DEBUG_MUTEXES
const char *name; /* 디버깅용 이름 */
void *magic; /* 매직 넘버 (corruption 탐지) */
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map; /* lockdep 의존성 추적 */
#endif
};
| 필드 | 타입 | 크기 | 역할 |
|---|---|---|---|
owner | atomic_long_t | 8B (64bit) | 현재 소유자 task_struct 포인터 + 하위 3비트 플래그 |
wait_lock | raw_spinlock_t | 4B | wait_list 조작 보호, IRQ에서도 안전한 raw spinlock |
wait_list | list_head | 16B | 대기 태스크들의 FIFO 연결 리스트(Linked List) |
osq | optimistic_spin_queue | 4B | MCS 기반 optimistic spinner 큐의 tail |
struct mutex는 디버깅 옵션 없이 약 32바이트(64비트 아키텍처)입니다. spinlock_t(4바이트)의 8배이지만, sleeping lock의 대기 리스트와 소유자 추적에 필요한 최소 크기입니다. 수천 개의 mutex를 할당하는 서브시스템에서는 메모리 풋프린트를 고려해야 합니다.
owner 필드와 플래그 비트 인코딩
owner 필드는 mutex의 핵심입니다. 64비트 atomic_long_t 하나에 소유자 정보와 상태 플래그를 동시에 저장합니다.
/* kernel/locking/mutex.c */
#define MUTEX_FLAG_WAITERS 0x01 /* wait_list에 대기자 존재 */
#define MUTEX_FLAG_HANDOFF 0x02 /* 소유권 직접 이전 요청 */
#define MUTEX_FLAG_PICKUP 0x04 /* HANDOFF 소유권 수령 대기 */
#define MUTEX_FLAGS 0x07 /* 모든 플래그 마스크 */
/* owner 필드 구조 (64비트):
*
* bit 63 3 2 1 0
* | task_struct* (정렬 보장) |PU|HO|WA|
*
* task_struct는 최소 L1_CACHE_BYTES 정렬되므로
* 하위 3비트가 항상 0 → 플래그 저장에 활용
*/
static inline struct task_struct *__mutex_owner(struct mutex *lock)
{
return (struct task_struct *)
(atomic_long_read(&lock->owner) & ~MUTEX_FLAGS);
}
static inline unsigned long __mutex_flags(struct mutex *lock)
{
return atomic_long_read(&lock->owner) & MUTEX_FLAGS;
}
플래그별 의미
| 플래그 | 비트 | 설정 시점 | 해제 시점 | 의미 |
|---|---|---|---|---|
MUTEX_FLAG_WAITERS | 0 | slow path 진입 시 | 마지막 대기자가 획득하거나 떠날 때 | unlock 시 wake_up 필요함을 알림 |
MUTEX_FLAG_HANDOFF | 1 | 대기자가 2번 깨어났는데 획득 실패 시 | HANDOFF 완료 시 | spinner에게 양보 강제 |
MUTEX_FLAG_PICKUP | 2 | unlock에서 HANDOFF된 waiter에게 소유권 설정 시 | waiter가 소유권 수령 시 | 특정 waiter만 획득 가능 |
task_struct가 최소 8바이트 정렬(실제로는 L1_CACHE_BYTES, 64바이트 정렬)됨을 전제합니다. 이 정렬이 깨지면 task_struct 포인터와 플래그가 충돌하여 심각한 버그가 발생합니다.
API 전체 레퍼런스
커널은 다양한 mutex API를 제공합니다. 각 함수의 동작과 사용 조건을 정리합니다.
초기화
/* 정적 초기화 */
DEFINE_MUTEX(my_mutex);
/* 동적 초기화 */
struct mutex lock;
mutex_init(&lock);
/* mutex_init 내부:
* - owner = 0 (unlocked, no flags)
* - wait_list 초기화
* - wait_lock 초기화
* - osq 초기화
* - lockdep: __mutex_init()으로 dep_map 등록
*/
잠금(Lock) 획득
| 함수 | 설명 | 반환값 |
|---|---|---|
mutex_lock(lock) | TASK_UNINTERRUPTIBLE로 대기, 시그널(Signal) 무시 | void |
mutex_lock_interruptible(lock) | TASK_INTERRUPTIBLE로 대기, 시그널 수신 시 -EINTR | 0 또는 -EINTR |
mutex_lock_killable(lock) | TASK_KILLABLE로 대기, SIGKILL 시 -EINTR | 0 또는 -EINTR |
mutex_lock_io(lock) | I/O 대기로 계정 (io_schedule) | void |
mutex_trylock(lock) | non-blocking, 즉시 성공/실패 | 1(성공) 또는 0(실패) |
mutex_lock_nested(lock, subclass) | lockdep subclass 지정 (중첩 잠금 구분) | void |
잠금 해제
/* 해제 — 반드시 lock한 태스크에서 호출 */
mutex_unlock(&lock);
/* 상태 확인 */
bool locked = mutex_is_locked(&lock); /* owner != 0 */
/* lockdep assertion — 현재 태스크가 보유 중인지 검증 */
lockdep_assert_held(&lock);
/* 파괴 (디버깅 빌드에서 사용 후 poison) */
mutex_destroy(&lock);
mutex_trylock()은 IRQ 컨텍스트에서도 호출할 수 있지만, 이는 "lock을 가져올 수 없으면 즉시 포기"하는 경우에만 사용해야 합니다. IRQ에서 mutex_lock()을 호출하면 BUG_ON이 발생합니다.
Fast Path: 경합 없는 즉시 획득
mutex_lock()의 fast path는 mutex가 완전히 해제된 상태(owner == 0, 플래그 없음)에서 단일 CAS로 소유권을 획득하는 경로입니다. 경합이 없는 일반적인 경우에 최소 오버헤드를 제공합니다.
/* kernel/locking/mutex.c — mutex_lock() 진입점 (간략화) */
void __sched mutex_lock(struct mutex *lock)
{
might_sleep(); /* 디버그: 슬립 불가 컨텍스트 검출 */
/* Fast Path: CAS(0 → current) */
if (!__mutex_trylock_fast(lock))
__mutex_lock_slowpath(lock);
}
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)
* 성공: owner를 0에서 current로 변경, acquire 배리어
* 실패: zero에 현재 owner 값 저장
*/
if (atomic_long_try_cmpxchg_acquire(&lock->owner, &zero, curr))
return true;
return false;
}
아키텍처별 CAS 구현
| 아키텍처 | CAS 명령어 | Acquire 배리어 | 비고 |
|---|---|---|---|
| x86-64 | LOCK CMPXCHG | 암묵적 (LOCK 접두사) | TSO 모델, 추가 배리어 불필요 |
| ARM64 | CASA (LSE) / LDAXR+STXR | acquire suffix (-A) | LSE 원자적(Atomic) 명령어가 더 효율적 |
| RISC-V | LR.W.AQ + SC.W | .aq suffix | LL/SC 방식, SC 실패 시 재시도 |
Mid Path: Optimistic Spinning (osq_lock)
Fast Path에서 CAS가 실패하면(mutex가 이미 점유), 바로 sleep하는 대신 optimistic spinning을 시도합니다. 이 최적화의 핵심 아이디어는 "현재 소유자가 다른 CPU에서 실행 중이라면, 곧 unlock할 가능성이 높으므로 잠깐 spin하는 것이 sleep보다 효율적"이라는 것입니다.
Mid Path 진입 조건
optimistic spinning은 모든 경우에 시도되지 않습니다. 다음 조건을 모두 만족해야 합니다.
/* kernel/locking/mutex.c — __mutex_lock_common() 내부 (간략화) */
static noinline int __mutex_lock_common(...)
{
/* Mid Path 진입 조건 검사 */
if (__mutex_trylock(lock) ||
mutex_optimistic_spin(lock, ww_ctx, NULL)) {
/* CAS 또는 spinning으로 획득 성공 */
return 0;
}
/* Mid Path 실패 → Slow Path */
...
}
static bool mutex_optimistic_spin(struct mutex *lock, ...)
{
/* 조건 1: CONFIG_MUTEX_SPIN_ON_OWNER 활성화 */
/* 조건 2: 선점이 비활성화 가능해야 함 */
/* 조건 3: HANDOFF 플래그가 설정되지 않았어야 함 */
if (!osq_lock(&lock->osq))
goto fail;
for (;;) {
struct task_struct *owner;
/* owner가 CPU에서 실행 중인지 확인 */
owner = __mutex_owner(lock);
if (owner && !owner_on_cpu(owner))
break; /* owner가 sleep/preempt → spinning 중단 */
/* owner가 없으면(방금 unlock됨) 획득 시도 */
if (__mutex_trylock(lock)) {
osq_unlock(&lock->osq);
return true;
}
/* 재스케줄 필요하면 spinning 중단 */
if (need_resched())
break;
cpu_relax(); /* PAUSE/WFE/YIELD */
}
osq_unlock(&lock->osq);
fail:
return false;
}
Spinning 중단 조건
| 조건 | 이유 | 다음 동작 |
|---|---|---|
owner_on_cpu(owner) == false | owner가 선점당하거나 sleep한 상태 — 곧 unlock될 가능성 낮음 | osq_unlock → Slow Path |
need_resched() == true | 더 높은 우선순위(Priority) 태스크가 대기 중 | osq_unlock → Slow Path → schedule() |
MUTEX_FLAG_HANDOFF 설정됨 | 특정 대기자에게 소유권을 넘겨야 함 | spinning 포기 |
osq_lock() 실패 | 다른 spinner가 이미 큐에서 대기 중 | 곧바로 Slow Path |
OSQ (Optimistic Spin Queue) 내부 구현
OSQ는 MCS lock의 변형으로, optimistic spinning 단계에서 여러 spinner가 동시에 owner의 상태를 폴링(Polling)하는 것을 방지합니다. 각 spinner는 자신만의 로컬 변수에서 spin하여 캐시 라인(Cache Line) 경합을 최소화합니다.
/* include/linux/osq_lock.h */
struct optimistic_spin_queue {
atomic_t tail; /* 큐 tail, 0 = empty, >0 = CPU+1 인코딩 */
};
/* kernel/locking/osq_lock.c */
struct optimistic_spin_node {
struct optimistic_spin_node *next; /* 다음 노드 */
struct optimistic_spin_node *prev; /* 이전 노드 */
int locked; /* 0=spinning, 1=acquired */
int cpu; /* 노드 소유 CPU 번호 */
};
/* Per-CPU 노드 — 동적 할당 불필요 */
static DEFINE_PER_CPU_SHARED_ALIGNED(
struct optimistic_spin_node, osq_node);
bool osq_lock(struct optimistic_spin_queue *lock)
{
struct optimistic_spin_node *node = this_cpu_ptr(&osq_node);
struct optimistic_spin_node *prev, *next;
int curr = encode_cpu(smp_processor_id());
int old;
node->locked = 0;
node->next = NULL;
node->cpu = curr;
/* tail에 자신을 등록, 이전 tail 값 획득 */
old = atomic_xchg(&lock->tail, curr);
if (old == OSQ_UNLOCKED_VAL)
return true; /* 큐가 비어있었음 → 즉시 획득 */
/* 이전 노드에 자신을 연결 */
prev = decode_cpu(old);
WRITE_ONCE(prev->next, node);
/* 로컬 locked 변수에서 spin (MCS 핵심!) */
while (!READ_ONCE(node->locked)) {
if (need_resched() || vcpu_is_preempted(prev->cpu))
goto unqueue;
cpu_relax();
}
return true;
unqueue:
/* 큐에서 자신을 제거 (prev→next를 next로 연결) */
...
return false;
}
Slow Path: 슬립과 웨이크업
Fast Path와 Mid Path 모두 실패하면 Slow Path에 진입합니다. 태스크를 wait_list에 추가하고 schedule()을 호출하여 sleep합니다.
/* kernel/locking/mutex.c — slow path (간략화) */
static noinline int
__mutex_lock_common(struct mutex *lock, unsigned int state, ...)
{
struct mutex_waiter waiter;
bool first = false;
int ret;
/* ... Fast Path + Mid Path 실패 ... */
raw_spin_lock(&lock->wait_lock);
/* WAITERS 플래그 설정 (unlock이 wake를 해야 함을 알림) */
__mutex_set_flag(lock, MUTEX_FLAG_WAITERS);
/* wait_list에 추가 (FIFO 순서) */
__mutex_add_waiter(lock, &waiter, &lock->wait_list);
waiter.task = current;
set_current_state(state); /* TASK_UNINTERRUPTIBLE 등 */
for (;;) {
/* 한 번 더 trylock 시도 (unlock 직후 race window) */
if (__mutex_trylock(lock))
goto acquired;
/* 시그널 체크 (interruptible 모드일 때) */
if (signal_pending_state(state, current)) {
ret = -EINTR;
goto err;
}
raw_spin_unlock(&lock->wait_lock);
schedule_preempt_disabled(); /* CPU 양보, sleep */
/* 깨어남 — first waiter인지 확인 */
first = __mutex_waiter_is_first(lock, &waiter);
set_current_state(state);
/* first waiter면 optimistic spinning 한 번 더 시도 */
if (first && mutex_optimistic_spin(lock, ww_ctx, &waiter))
break;
raw_spin_lock(&lock->wait_lock);
}
acquired:
__set_current_state(TASK_RUNNING);
__mutex_remove_waiter(lock, &waiter);
if (list_empty(&lock->wait_list))
__mutex_clear_flag(lock, MUTEX_FLAGS);
raw_spin_unlock(&lock->wait_lock);
return 0;
}
HANDOFF 메커니즘: Starvation 방지
optimistic spinning은 성능에 매우 효과적이지만 공정성 문제를 야기합니다. spinner가 sleep 중인 waiter보다 항상 먼저 mutex를 획득하면, waiter는 영구적으로 starvation에 빠질 수 있습니다. HANDOFF 메커니즘이 이 문제를 해결합니다.
HANDOFF 발동 조건
/* Slow Path 내부 — first waiter가 깨어난 후 */
/* waiter가 깨어났는데 trylock 실패 → 누군가 먼저 가져감 */
if (first) {
/* 이전에도 실패한 적 있으면 HANDOFF 설정 */
if (woken) {
/* 2번째 실패: HANDOFF 플래그 설정 */
__mutex_set_flag(lock, MUTEX_FLAG_HANDOFF);
}
woken = true;
}
HANDOFF 동작 과정
| 단계 | 동작 | 상태 변화 |
|---|---|---|
| 1 | first waiter가 깨어남 | trylock 시도 |
| 2 | spinner에게 빼앗김 (1차 실패) | woken = true |
| 3 | 다시 깨어남, 다시 실패 (2차 실패) | MUTEX_FLAG_HANDOFF 설정 |
| 4 | 현재 owner가 unlock 시 HANDOFF 감지 | owner를 waiter의 task|PICKUP으로 설정 |
| 5 | spinner는 PICKUP 감지 → spinning 포기 | spinner가 Slow Path로 이동 |
| 6 | 지정된 waiter가 PICKUP 확인 후 획득 | PICKUP 클리어, 정상 소유 |
mutex_waiter 구조와 대기 리스트
struct mutex_waiter는 mutex의 slow path에서 대기 중인 태스크를 추적하는 스택 할당 구조체(Struct)입니다.
/* kernel/locking/mutex.c */
struct mutex_waiter {
struct list_head list; /* mutex->wait_list 연결 */
struct task_struct *task; /* 대기 중인 태스크 */
struct ww_acquire_ctx *ww_ctx; /* wound-wait context (ww_mutex) */
#ifdef CONFIG_DEBUG_MUTEXES
void *magic; /* 매직 넘버 */
#endif
};
/* wait_list 관리 — FIFO 순서 */
static void
__mutex_add_waiter(struct mutex *lock,
struct mutex_waiter *waiter,
struct list_head *list)
{
/* list_add_tail: FIFO 순서로 뒤에 추가 */
list_add_tail(&waiter->list, list);
/* 첫 번째 waiter면 WAITERS 플래그 설정 */
if (__mutex_waiter_is_first(lock, waiter))
__mutex_set_flag(lock, MUTEX_FLAG_WAITERS);
}
mutex_waiter는 __mutex_lock_common()의 스택에 할당됩니다. 태스크가 sleep하면 스택 프레임(Stack Frame)이 유지되므로 별도의 힙 할당이 필요 없습니다. 이는 메모리 할당 실패 가능성을 제거하여 mutex_lock()이 항상 성공(대기)할 수 있게 합니다.
mutex_unlock() 경로 분석
mutex_unlock()도 lock과 마찬가지로 fast/slow 두 경로를 가집니다.
/* kernel/locking/mutex.c */
void __sched mutex_unlock(struct mutex *lock)
{
#ifndef CONFIG_DEBUG_MUTEXES
/* Fast Unlock: CAS(current → 0)
* 성공 조건: 플래그 비트가 모두 0 (대기자 없음) */
if (__mutex_unlock_fast(lock))
return;
#endif
__mutex_unlock_slowpath(lock, _RET_IP_);
}
static __always_inline bool
__mutex_unlock_fast(struct mutex *lock)
{
unsigned long curr = (unsigned long)current;
/* CAS(current → 0): 플래그 없이 순수 current일 때만 성공
* → release 배리어로 임계 영역 메모리 연산 완료 보장 */
return atomic_long_try_cmpxchg_release(&lock->owner, &curr, 0UL);
}
static noinline void
__mutex_unlock_slowpath(struct mutex *lock, unsigned long ip)
{
struct task_struct *next = NULL;
unsigned long owner;
raw_spin_lock(&lock->wait_lock);
if (!list_empty(&lock->wait_list)) {
struct mutex_waiter *waiter =
list_first_entry(&lock->wait_list,
struct mutex_waiter, list);
next = waiter->task;
/* HANDOFF 처리: owner를 직접 waiter로 설정 */
if (__mutex_flags(lock) & MUTEX_FLAG_HANDOFF) {
__mutex_set_flag(lock, MUTEX_FLAG_PICKUP);
atomic_long_set(&lock->owner,
(unsigned long)next | MUTEX_FLAG_PICKUP);
} else {
/* 일반 해제: owner를 0|WAITERS로 설정 */
atomic_long_set(&lock->owner, MUTEX_FLAG_WAITERS);
}
} else {
atomic_long_set(&lock->owner, 0);
}
raw_spin_unlock(&lock->wait_lock);
/* 대기자 깨우기 */
if (next)
wake_up_process(next);
}
Priority Inversion과 PI Chain 연결
일반 struct mutex는 Priority Inversion을 처리하지 않습니다. 낮은 우선순위 태스크가 mutex를 보유하고 있을 때, 높은 우선순위 태스크가 해당 mutex를 기다리면 중간 우선순위 태스크에 의해 무기한 지연(Latency)될 수 있습니다.
Priority Inversion 문제
/* Priority Inversion 시나리오 */
태스크 우선순위: H(높음) > M(중간) > L(낮음)
1. L이 mutex 획득
2. H가 mutex_lock() 호출 → L을 기다림 (sleep)
3. M이 깨어남 → L보다 높은 우선순위로 L을 선점
4. M이 오래 실행 → L은 실행 불가 → mutex unlock 불가
5. H는 간접적으로 M에 의해 차단됨 (Priority Inversion!)
/* 해결: rt_mutex (Priority Inheritance) */
1. L이 rt_mutex 획득
2. H가 rt_mutex_lock() 호출 → L의 우선순위를 H로 부스팅
3. L이 H의 우선순위로 실행 → M보다 먼저 스케줄됨
4. L이 빠르게 unlock → H가 획득
5. L의 우선순위 복원
일반 mutex vs rt_mutex
| 특성 | mutex | rt_mutex |
|---|---|---|
| PI 지원 | 없음 | Priority Inheritance |
| 오버헤드 | 최소 (CAS + osq) | PI chain 관리 추가 |
| 사용 사례 | 일반 커널 코드 | RT 태스크, futex PI |
| PREEMPT_RT | rt_mutex 기반으로 변환 | 네이티브 |
| 구조체 크기 | ~32 bytes | ~64 bytes |
| 체인 지원 | 없음 | 다중 lock PI chain 전파 |
struct mutex로 충분합니다. rt_mutex는 RT 스케줄링 정책(SCHED_FIFO/RR)을 사용하는 실시간(Real-time) 태스크 간의 동기화, 또는 PREEMPT_RT 커널에서 자동으로 사용됩니다. 더 자세한 내용은 rt_mutex 문서를 참고하세요.
PREEMPT_RT에서의 Mutex
PREEMPT_RT 패치(Patch)에서 struct mutex는 내부적으로 rt_mutex 기반으로 대체되어 Priority Inheritance를 지원하게 됩니다. 이미 sleeping lock이므로 API 변화는 없지만, optimistic spinning이 비활성화되고 PI chain 관리 비용이 추가됩니다.
- Optimistic spinning이 비활성화됩니다 (모든 경합이 PI chain을 통해 해결)
- mutex 보유 중에도 선점이 가능합니다 (결정론적 지연 시간)
- PI chain 전파로 인해 lock/unlock 비용이 약간 증가합니다
spinlock_t → rt_mutex 변환 관계, raw_spinlock_t 예외, PREEMPT_RT의 전체 잠금 변환 메커니즘은 rt_mutex — PREEMPT_RT 통합에서 상세히 다룹니다.
실전 사용 패턴
기본 보호 패턴
struct my_device {
struct mutex lock; /* data 필드 보호 */
int data;
struct list_head list;
};
int device_read(struct my_device *dev, int *out)
{
mutex_lock(&dev->lock);
*out = dev->data;
mutex_unlock(&dev->lock);
return 0;
}
int device_write(struct my_device *dev, int val)
{
mutex_lock(&dev->lock);
dev->data = val;
mutex_unlock(&dev->lock);
return 0;
}
시그널 대응 패턴
int device_ioctl(struct my_device *dev, unsigned int cmd, ...)
{
int ret;
/* 사용자 프로세스가 Ctrl+C로 취소할 수 있는 대기 */
ret = mutex_lock_interruptible(&dev->lock);
if (ret)
return ret; /* -EINTR: 시그널로 중단됨 */
/* 임계 영역 */
...
mutex_unlock(&dev->lock);
return 0;
}
Non-blocking 시도 패턴
/* 주기적 작업에서 lock이 가능할 때만 처리 */
void periodic_cleanup(struct my_device *dev)
{
if (!mutex_trylock(&dev->lock))
return; /* 바쁘면 다음 기회에 */
do_cleanup(dev);
mutex_unlock(&dev->lock);
}
Scoped Guard 패턴 (6.4+)
/* cleanup.h 기반 자동 unlock (커널 6.4 이상) */
int device_complex_op(struct my_device *dev)
{
/* guard가 스코프를 벗어나면 자동으로 mutex_unlock */
guard(mutex)(&dev->lock);
if (some_check_fails())
return -EINVAL; /* 자동 unlock */
if (another_check())
return -ENODEV; /* 자동 unlock */
do_work(dev);
return 0; /* 자동 unlock */
}
중첩 잠금과 lockdep subclass
/* 같은 lock class의 두 mutex를 동시에 보유해야 할 때 */
void transfer(struct account *from, struct account *to)
{
/* 항상 포인터 주소 순서로 잠금 (ABBA 방지) */
if (from < to) {
mutex_lock(&from->lock);
mutex_lock_nested(&to->lock, SINGLE_DEPTH_NESTING);
} else {
mutex_lock(&to->lock);
mutex_lock_nested(&from->lock, SINGLE_DEPTH_NESTING);
}
/* 전송 작업 */
from->balance -= amount;
to->balance += amount;
mutex_unlock(&to->lock);
mutex_unlock(&from->lock);
}
안티패턴과 흔한 실수
IRQ 컨텍스트에서 mutex_lock
/* BUG: IRQ 핸들러에서 mutex_lock → schedule() 호출 → 커널 패닉 */
irqreturn_t my_irq_handler(int irq, void *data)
{
struct my_device *dev = data;
mutex_lock(&dev->lock); /* BUG_ON(in_interrupt()) */
...
}
/* 해결: spinlock 사용하거나, 하반부로 작업 위임 */
irqreturn_t my_irq_handler(int irq, void *data)
{
struct my_device *dev = data;
spin_lock(&dev->irq_lock); /* OK: busy-wait */
queue_work(dev->wq, &dev->work); /* mutex는 workqueue에서 */
spin_unlock(&dev->irq_lock);
return IRQ_HANDLED;
}
재귀적 잠금
/* BUG: 같은 mutex를 두 번 lock → 데드락 */
void foo(struct my_device *dev)
{
mutex_lock(&dev->lock);
bar(dev); /* bar()가 다시 mutex_lock을 호출하면 데드락! */
mutex_unlock(&dev->lock);
}
void bar(struct my_device *dev)
{
mutex_lock(&dev->lock); /* 데드락! foo()가 이미 보유 */
...
}
/* 해결: lock 보유 여부를 호출 규약으로 명확히 하거나,
* __bar_locked() 내부 함수를 분리 */
void bar(struct my_device *dev)
{
mutex_lock(&dev->lock);
__bar_locked(dev);
mutex_unlock(&dev->lock);
}
void foo(struct my_device *dev)
{
mutex_lock(&dev->lock);
__bar_locked(dev); /* lock 보유 상태에서 내부 함수 호출 */
mutex_unlock(&dev->lock);
}
ABBA 데드락
/* BUG: CPU0: lock A → lock B, CPU1: lock B → lock A → 데드락 */
void thread1(void)
{
mutex_lock(&A);
mutex_lock(&B); /* CPU1이 B를 보유하고 A를 기다림 → 교착 */
...
}
void thread2(void)
{
mutex_lock(&B);
mutex_lock(&A); /* CPU0이 A를 보유하고 B를 기다림 → 교착 */
...
}
/* 해결: 항상 일관된 순서 (A → B) */
장시간 보유
/* 나쁜 예: 대용량 I/O를 mutex 보유 상태에서 수행 */
mutex_lock(&lock);
vfs_read(file, buf, 1024 * 1024); /* 수 ms ~ 수십 ms */
process_data(buf);
mutex_unlock(&lock);
/* 개선: I/O를 lock 밖으로 이동 */
vfs_read(file, buf, 1024 * 1024); /* lock 없이 I/O */
mutex_lock(&lock);
process_data(buf); /* 짧은 데이터 처리만 */
mutex_unlock(&lock);
디버깅: CONFIG_DEBUG_MUTEXES와 lockdep
디버깅 설정
mutex 전용 디버그 옵션은 CONFIG_DEBUG_MUTEXES=y (소유자 검증, magic 필드 검사)입니다. CONFIG_PROVE_LOCKING, CONFIG_LOCK_STAT, CONFIG_DEBUG_LOCK_ALLOC 등 공통 잠금 디버깅 설정은 lockdep — 커널 설정을 참고하세요.
CONFIG_DEBUG_MUTEXES 검사 항목
| 검사 | 위반 시 | 메시지 |
|---|---|---|
| 소유자가 아닌 태스크가 unlock | BUG() | "mutex not owned by task" |
| 이미 보유 중인 mutex를 다시 lock | 데드락 탐지 | "recursive locking detected" |
| 초기화되지 않은 mutex 사용 | BUG() | "bad mutex magic" |
| 해제된 mutex 사용 (mutex_destroy 후) | BUG() | "bad mutex magic" |
| IRQ 컨텍스트에서 mutex_lock | BUG() | "scheduling while atomic" |
lockdep 활용
mutex에 lockdep_assert_held() 어노테이션을 사용하여 함수 진입 시 잠금 보유 여부를 검증할 수 있습니다. ABBA 순환 의존성, 재귀 잠금 등의 경고 형식과 해석 방법은 lockdep 경고 메시지 해석을 참고하세요.
/* lockdep annotation: 함수 진입 시 lock 보유 검증 */
void do_critical_work(struct my_device *dev)
{
lockdep_assert_held(&dev->lock);
/* dev->lock을 보유하지 않았으면 WARNING */
...
}
lock contention 프로파일링(Profiling)
CONFIG_LOCK_STAT=y 활성화 시 /proc/lock_stat에서 mutex 경합 통계를 확인할 수 있습니다. 필드 설명(con-bounces, contentions, waittime 등)과 상세 사용법은 lockdep의 /proc/lock_stat 섹션을 참고하세요. perf lock record와 perf lock report로도 경합을 분석할 수 있습니다.
성능 특성과 벤치마크
경합 없는 경우 (Uncontended)
| 연산 | x86-64 (cycles) | ARM64 (cycles) | 비고 |
|---|---|---|---|
mutex_lock (fast) | ~15-20 | ~20-30 | 단일 CAS (acquire) |
mutex_unlock (fast) | ~10-15 | ~15-25 | 단일 CAS (release) |
lock + unlock 합계 | ~25-35 | ~35-55 | spinlock과 유사 |
경합 상황 (Contended)
| 시나리오 | 비용 | 경로 |
|---|---|---|
| 짧은 경합 (owner on CPU) | ~100-500 cycles | Mid Path: osq spinning |
| 중간 경합 (owner preempted) | ~5,000-15,000 cycles | Slow Path: sleep + wakeup |
| 긴 대기 (여러 waiter) | 수만 cycles 이상 | Slow Path + 다수 wakeup |
Optimistic Spinning 효과
Ingo Molnar의 원래 패치(2013)에 따르면, optimistic spinning 도입으로 경합이 있는 워크로드에서 mutex 성능이 수십 퍼센트 향상되었습니다. 특히 파일시스템(Filesystem)의 i_mutex처럼 짧은 임계 영역에서 여러 스레드(Thread)가 경합하는 경우 효과가 큽니다.
/* osq spinning 도입 전후 비교 (개략적) */
워크로드: dbench, 32 threads, ext4
Before (순수 sleep) After (osq spinning)
throughput: 4,200 MB/s 5,600 MB/s (+33%)
avg latency: 23.4 us 14.1 us (-40%)
context switches: 1,240,000/s 380,000/s (-69%)
Mutex 대안 선택 가이드
mutex가 항상 최선의 선택은 아닙니다. 상황에 따라 더 적합한 동기화 프리미티브가 있습니다.
| 상황 | 권장 프리미티브 | 이유 |
|---|---|---|
| 읽기 빈번, 쓰기 드묾 | rw_semaphore | 여러 reader 동시 접근 가능 |
| 읽기만 하는 경로 (락 필요 없음) | RCU | 읽기 경로 무잠금, 쓰기 시 grace period |
| 카운터 증감만 | atomic 연산 | 단일 변수에 lock 불필요 |
| CPU별 독립 데이터 | Per-CPU 변수 | 경합 자체를 제거 |
| 완료 대기 | completion | 일회성 이벤트 알림에 특화 |
| PI 필요 | rt_mutex | Priority Inheritance 내장 |
| 다중 lock 교착 방지 | ww_mutex | Wound-Wait 알고리즘으로 데드락 회피 |
| IRQ에서 보호 필요 | spinlock | sleep 불가 컨텍스트 |
| 읽기-시퀀스 번호 기반 | seqlock | writer 우선, reader 재시도 |
관련 커널 설정 옵션
| 옵션 | 기본값 | 설명 |
|---|---|---|
CONFIG_MUTEX_SPIN_ON_OWNER | y (SMP) | optimistic spinning 활성화. SMP + SCHEDULER 조건 |
CONFIG_DEBUG_MUTEXES | n | mutex 규칙 위반 런타임 검출 (magic 필드, 소유자 검증) |
CONFIG_PREEMPT_RT | n | mutex를 rt_mutex 기반으로 변환 (PI 지원) |
CONFIG_PROVE_LOCKING, CONFIG_LOCK_STAT, CONFIG_DEBUG_LOCK_ALLOC 등 공통 잠금 디버깅 옵션의 전체 목록과 권장 설정은 lockdep — 커널 설정을 참고하세요.
/* mutex_init() 확장 — DEBUG_MUTEXES 활성 시 */
#ifdef CONFIG_DEBUG_MUTEXES
void __mutex_init(struct mutex *lock, const char *name,
struct lock_class_key *key)
{
atomic_long_set(&lock->owner, 0);
raw_spin_lock_init(&lock->wait_lock);
INIT_LIST_HEAD(&lock->wait_list);
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
osq_lock_init(&lock->osq);
#endif
lock->magic = lock; /* 자기 참조 매직 넘버 */
lockdep_init_map(&lock->dep_map, name, key, 0);
}
void mutex_destroy(struct mutex *lock)
{
DEBUG_LOCKS_WARN_ON(mutex_is_locked(lock));
lock->magic = NULL; /* poison: 이후 사용 시 BUG */
}
#endif
Wound-Wait Mutex (ww_mutex) 개요
ww_mutex는 여러 mutex를 동시에 획득해야 할 때 데드락을 회피하는 특수 mutex입니다. GPU 드라이버(DRM/TTM)에서 버퍼(Buffer) 객체 잠금에 주로 사용됩니다.
/* Wound-Wait 알고리즘 */
/* 두 트랜잭션 T1(오래된), T2(최근)가 같은 mutex를 요청 시:
*
* T1이 T2가 보유한 lock 요청 → T2를 "wound" (T2가 양보)
* T2가 T1이 보유한 lock 요청 → T2가 "wait" (T2가 대기)
*
* 결과: 항상 나이가 많은 트랜잭션이 우선 → 데드락 불가능
*/
struct ww_acquire_ctx ctx;
ww_acquire_init(&ctx, &my_class);
/* 여러 ww_mutex를 순서 없이 획득 시도 */
ret = ww_mutex_lock(&obj_a->lock, &ctx);
if (ret == -EDEADLK) {
/* 양보 필요: 보유한 lock 해제 후 재시도 */
ww_mutex_unlock(&obj_b->lock);
ww_mutex_lock_slow(&obj_a->lock, &ctx);
/* obj_b 다시 획득 */
}
ww_acquire_done(&ctx);
/* ... 작업 수행 ... */
ww_mutex_unlock(&obj_a->lock);
ww_mutex_unlock(&obj_b->lock);
ww_acquire_fini(&ctx);
아키텍처별 구현 차이
mutex의 핵심 연산인 CAS(Compare-And-Swap)는 아키텍처마다 다른 명령어로 구현됩니다.
x86-64: LOCK CMPXCHG
/* x86-64: TSO 메모리 모델 — 추가 배리어 불필요 */
/* mutex_lock fast path 생성 코드 (개략) */
/* LOCK CMPXCHG [owner], current
* - LOCK 접두사가 acquire + release 모두 제공
* - 성공 시 ZF=1, 실패 시 ZF=0 + RAX=현재값
*/
/* mutex_unlock fast path */
/* LOCK CMPXCHG [owner], 0
* - release semantics 자동 제공
*/
ARM64: LSE / LL/SC
/* ARM64: Weak 메모리 모델 — 명시적 배리어 필요 */
/* LSE (Large System Extensions, ARMv8.1+) */
/* CASA Xs, Xt, [Xn] — Compare-and-Swap with Acquire */
/* LL/SC fallback (ARMv8.0) */
/* LDAXR Xt, [Xn] — Load-Acquire Exclusive
* STXR Ws, Xt, [Xn] — Store Exclusive (release는 별도)
* CBNZ Ws, retry — SC 실패 시 재시도
*/
/* osq spinning에서: WFE로 전력 절약
* - cpu_relax() → WFE (Wait For Event)
* - osq_unlock이 SEV (Send Event) 발행
*/
RISC-V: LR/SC + AMO
/* RISC-V: Weak 메모리 모델 (RVWMO) */
/* LR.D.AQ rd, (rs1) — Load-Reserved with Acquire
* SC.D rd, rs2, (rs1) — Store-Conditional
* BNEZ rd, retry — SC 실패 시 재시도
*
* .aq (acquire) / .rl (release) suffix로 순서 지정
*/
/* AMO (Atomic Memory Operations) — Zamo 확장
* AMOSWAP.D.AQ rd, rs2, (rs1) — CAS 대용 가능
*/
Mutex 구현의 역사적 진화
Linux 커널 mutex는 여러 단계의 최적화를 거쳐 현재의 형태에 이르렀습니다.
| 버전 | 년도 | 변경 | 효과 |
|---|---|---|---|
| 2.6.16 | 2006 | mutex 도입 (Ingo Molnar) semaphore 대체 시작 | owner 추적 가능, 디버깅 향상, 구조체 최적화 |
| 3.0 | 2011 | Adaptive spinning (Peter Zijlstra) | 경합 시 즉시 sleep 대신 owner 폴링 |
| 3.15 | 2014 | osq_lock MCS-like 큐 도입 | spinner 직렬화, 캐시(Cache) 경합 감소 |
| 4.6 | 2016 | owner 필드에 플래그 비트 인코딩 | WAITERS/HANDOFF/PICKUP 상태 압축 |
| 4.8 | 2016 | HANDOFF 메커니즘 추가 | spinner에 의한 waiter starvation 방지 |
| 5.15 | 2021 | PREEMPT_RT 메인라인 시작 | mutex → rt_mutex 기반 변환 지원 |
| 6.4 | 2023 | cleanup.h guard 매크로(Macro) | guard(mutex) 자동 unlock 패턴 |
__mutex_trylock 내부 로직
slow path와 mid path에서 반복적으로 호출되는 __mutex_trylock()의 정확한 동작을 분석합니다. 이 함수는 __mutex_trylock_fast()보다 복잡하며, 플래그 비트를 고려합니다.
/* kernel/locking/mutex.c */
static inline bool __mutex_trylock(struct mutex *lock)
{
unsigned long owner, curr = (unsigned long)current;
owner = atomic_long_read(&lock->owner);
for (;;) {
unsigned long flags = __owner_flags(owner);
unsigned long task = owner & ~MUTEX_FLAGS;
if (task) {
/* 이미 다른 태스크가 소유 중 */
if (flags & MUTEX_FLAG_PICKUP)
return false; /* HANDOFF 대상만 획득 가능 */
return false;
}
/* task == NULL: 소유자 없음, 플래그만 있을 수 있음 */
/* CAS: (0|flags) → (current|flags)
* 플래그를 유지하면서 소유자만 설정 */
if (atomic_long_try_cmpxchg_acquire(
&lock->owner, &owner, curr | flags))
return true;
/* CAS 실패: owner가 변경됨, 재시도 */
}
}
__mutex_trylock()은 CAS 시 기존 플래그를 보존합니다. 예를 들어 WAITERS 플래그가 설정된 상태에서 trylock이 성공하면, owner는 current | MUTEX_FLAG_WAITERS가 됩니다. 이렇게 해야 unlock 시 wait_list를 확인합니다.
메모리 순서 보장(Ordering)
mutex의 lock과 unlock은 각각 acquire와 release 시맨틱을 제공합니다. 이는 임계 영역 내의 메모리 접근이 lock 밖으로 재배치(Relocation)되지 않음을 보장합니다.
/* 메모리 순서 보장 모델 */
mutex_lock(): acquire barrier
↓ (임계 영역 내 모든 load/store가 이 아래로)
--- critical section ---
↑ (임계 영역 내 모든 load/store가 이 위로)
mutex_unlock(): release barrier
/* 구체적으로: */
CPU 0: CPU 1:
mutex_lock(&m); mutex_lock(&m);
WRITE(data, 42); →→→ READ(data); // 반드시 42
mutex_unlock(&m); mutex_unlock(&m);
/* acquire/release 쌍이 happens-before 관계를 형성:
* CPU 0의 unlock(release) → CPU 1의 lock(acquire)
* → CPU 0의 모든 이전 쓰기가 CPU 1에게 가시적 */
smp_mb())는 불필요합니다. mutex의 lock/unlock이 이미 필요한 배리어를 제공합니다. 불필요한 배리어 추가는 성능 저하를 유발합니다.
주요 서브시스템의 Mutex 활용
커널의 주요 서브시스템에서 mutex가 어떻게 사용되는지 살펴봅니다.
VFS: inode->i_rwsem (구 i_mutex)
/* 과거 i_mutex → 현재 i_rwsem으로 변경되었지만,
* VFS에서 mutex 사용 패턴의 대표적 사례 */
/* 파일 생성 시: 부모 디렉토리 inode를 보호 */
inode_lock(dir); /* i_rwsem write lock */
error = vfs_create(mnt_userns, dir, dentry, mode, true);
inode_unlock(dir);
/* 파일 크기 변경 */
inode_lock(inode);
error = do_truncate(mnt_userns, dentry, length, ...);
inode_unlock(inode);
MM: mmap_lock
/* mm_struct의 mmap_lock (rw_semaphore이지만 mutex 패턴 참고) */
/* 페이지 폴트 처리 (읽기) */
mmap_read_lock(mm);
vma = find_vma(mm, address);
handle_mm_fault(vma, address, flags, regs);
mmap_read_unlock(mm);
/* mmap()/munmap() (쓰기) */
mmap_write_lock(mm);
ret = do_mmap(file, addr, len, prot, flags, pgoff, ...);
mmap_write_unlock(mm);
디바이스 드라이버
/* 전형적인 캐릭터 디바이스 드라이버 */
struct my_char_dev {
struct cdev cdev;
struct mutex lock; /* 디바이스 상태 보호 */
u8 buffer[4096];
size_t data_len;
};
static ssize_t my_read(struct file *f, char __user *buf,
size_t count, loff_t *off)
{
struct my_char_dev *dev = f->private_data;
int ret;
ret = mutex_lock_interruptible(&dev->lock);
if (ret)
return ret;
if (copy_to_user(buf, dev->buffer, dev->data_len)) {
mutex_unlock(&dev->lock);
return -EFAULT;
}
mutex_unlock(&dev->lock);
return dev->data_len;
}
전체 흐름 통합 다이어그램
mutex_lock()부터 mutex_unlock()까지의 전체 흐름을 하나의 다이어그램으로 정리합니다.
Mutex 변형 비교 종합표
Linux 커널에서 제공하는 mutex 계열 동기화 프리미티브를 종합 비교합니다.
| 특성 | mutex | rt_mutex | ww_mutex | semaphore | rw_semaphore |
|---|---|---|---|---|---|
| 소유자 추적 | O | O | O | X | writer만 |
| PI 지원 | X | O | X | X | X |
| 데드락 회피 | X | X | O (Wound-Wait) | X | X |
| Optimistic Spin | O | O | O | X | O (writer) |
| 동시 접근 | 1 | 1 | 1 | N | N reader / 1 writer |
| IRQ 안전 | X | X | X | X | X |
| 크기 (64bit) | ~32B | ~64B | ~48B | ~24B | ~40B |
| PREEMPT_RT | → rt_mutex | 동일 | → rt 기반 | 동일 | 동일 |
mutex_lock() 소스 코드 완전 분석
mutex_lock()의 내부 진입점(Entry Point)인 __mutex_lock_common()은 커널 잠금 코드에서 가장 정교하게 최적화된 함수 중 하나입니다. 이 함수는 Fast Path CAS 실패 후 호출되며, optimistic spinning과 slowpath sleep을 조율합니다. v6.x 커널 기준으로 약 200줄에 달하는 이 함수의 전체 흐름을 단계별로 추적합니다.
핵심 구조를 이해하려면 함수의 제어 흐름이 3개의 명확한 단계로 분리된다는 점을 파악해야 합니다. 첫째, __mutex_trylock()으로 재시도하는 fast re-check, 둘째, mutex_optimistic_spin()을 통한 MCS 기반 spinning, 셋째, wait_list에 자신을 추가하고 schedule()로 슬립하는 slowpath입니다.
/* kernel/locking/mutex.c — __mutex_lock_common() 전체 흐름 (v6.8 기준) */
static __always_inline int __sched
__mutex_lock_common(struct mutex *lock, unsigned int state,
unsigned int subclass,
struct lockdep_map *nest_lock,
unsigned long ip,
struct ww_acquire_ctx *ww_ctx,
const bool use_ww_ctx)
{
struct mutex_waiter waiter;
int ret;
/* ① lockdep 검증 */
mutex_acquire_nest(&lock->dep_map, subclass, 0, nest_lock, ip);
/* ② Fast re-check: CAS 재시도 */
if (__mutex_trylock(lock) ||
mutex_optimistic_spin(lock, ww_ctx, NULL)) {
/* 획득 성공 — lock_acquired() 호출 후 리턴 */
lock_acquired(&lock->dep_map, ip);
if (use_ww_ctx && ww_ctx)
__ww_mutex_check_waiters(lock, ww_ctx);
return 0;
}
/* ③ Slow Path 진입: wait_lock 획득 */
raw_spin_lock(&lock->wait_lock);
/* 마지막 trylock 시도 (wait_lock 보호 하에) */
if (__mutex_trylock(lock)) {
goto skip_wait;
}
/* ④ 대기 리스트 등록 */
__mutex_add_waiter(lock, &waiter, &lock->wait_list);
waiter.task = current;
/* WAITERS 플래그 설정: spinner에게 대기자 존재를 알림 */
__mutex_set_flag(lock, MUTEX_FLAG_WAITERS);
lock_contended(&lock->dep_map, ip);
set_current_state(state);
/* ⑤ sleep 루프 */
for (;;) {
if (__mutex_trylock(lock))
goto acquired;
/* 시그널 확인 (interruptible/killable) */
if (signal_pending_state(state, current)) {
ret = -EINTR;
goto err;
}
raw_spin_unlock(&lock->wait_lock);
schedule_preempt_disabled(); /* 실제 sleep */
/* 깨어남: HANDOFF 대기자는 첫 번째로 시도 */
first_waiter = __mutex_waiter_is_first(lock, &waiter);
set_current_state(state);
/* 2번 연속 깨어났는데 실패 → HANDOFF 설정 */
if (first_waiter && __mutex_waiter_needs_handoff(&waiter))
__mutex_set_flag(lock, MUTEX_FLAG_HANDOFF);
raw_spin_lock(&lock->wait_lock);
}
acquired:
__set_current_state(TASK_RUNNING);
__mutex_remove_waiter(lock, &waiter);
skip_wait:
lock_acquired(&lock->dep_map, ip);
raw_spin_unlock(&lock->wait_lock);
return 0;
err:
__set_current_state(TASK_RUNNING);
__mutex_remove_waiter(lock, &waiter);
raw_spin_unlock(&lock->wait_lock);
mutex_release(&lock->dep_map, ip);
return ret;
}
이 코드에서 주목할 핵심 패턴이 있습니다. wait_lock은 raw_spinlock_t로, wait_list 조작을 직렬화합니다. sleep 루프에서는 raw_spin_unlock → schedule → raw_spin_lock 순서를 따르며, schedule 호출 전에 반드시 spinlock을 해제하여 다른 CPU가 mutex_unlock()에서 waiter를 깨울 수 있게 합니다.
__mutex_trylock()은 CAS 기반이지만, __mutex_trylock_fast()와 달리 owner 필드의 플래그 비트를 보존합니다. owner & ~MUTEX_FLAGS가 0이면(소유자 없음) 현재 task_struct 포인터를 OR 연산으로 설정하되, 기존 플래그(WAITERS, HANDOFF, PICKUP)는 유지합니다.
__mutex_trylock의 플래그 보존 CAS
__mutex_trylock()은 단순 CAS가 아니라 owner 필드의 하위 3비트 플래그를 보존하면서 소유자를 설정하는 복합 연산입니다. HANDOFF가 설정된 경우 첫 번째 대기자만 획득할 수 있도록 추가 검증을 수행합니다.
/* kernel/locking/mutex.c — __mutex_trylock() */
static bool __mutex_trylock(struct mutex *lock)
{
unsigned long owner, curr = (unsigned long)current;
owner = atomic_long_read(&lock->owner);
for (;;) {
unsigned long flags = __owner_flags(owner);
unsigned long task = owner & ~MUTEX_FLAGS;
/* 이미 다른 소유자가 있으면 실패 */
if (task) {
/* HANDOFF+PICKUP인데 자신이 대상자이면 획득 가능 */
if (flags & MUTEX_FLAG_PICKUP) {
if (task != curr)
return false;
flags &= ~MUTEX_FLAG_PICKUP;
} else {
return false;
}
}
/* CAS: owner를 (0|flags) → (current|flags)로 변경
* 플래그 비트(WAITERS 등)는 보존 */
if (atomic_long_try_cmpxchg_acquire(
&lock->owner, &owner, curr | flags))
return true;
}
}
__mutex_trylock()의 CAS 루프는 ABA 문제를 걱정할 필요가 없습니다. owner 필드는 task_struct 포인터(4KB 정렬)와 3비트 플래그의 조합이므로, 같은 값이 재사용될 확률은 사실상 0입니다. 그러나 루프가 지속적으로 실패하면 spinning보다 sleep이 효율적이므로, 호출자(sleep 루프)가 schedule()로 양보합니다.
mutex_unlock() 소스 상세
mutex_unlock()은 mutex_lock()보다 단순하지만, slowpath에서 waiter 깨우기(Wakeup)의 세부 사항은 중요합니다. Fast Path는 단일 atomic 연산으로 owner를 클리어하고, 대기자가 있으면 slowpath로 진입하여 wake_q 메커니즘을 통해 배치 웨이크업을 수행합니다.
/* kernel/locking/mutex.c — mutex_unlock() 전체 경로 */
void __sched mutex_unlock(struct mutex *lock)
{
/* lockdep: 소유권 해제 기록 */
mutex_release(&lock->dep_map, _RET_IP_);
/* Fast Path: WAITERS/HANDOFF 플래그 없으면 즉시 해제 */
if (__mutex_unlock_fast(lock))
return;
/* Slow Path: 대기자 존재 */
__mutex_unlock_slowpath(lock, _RET_IP_);
}
static __always_inline bool __mutex_unlock_fast(struct mutex *lock)
{
unsigned long curr = (unsigned long)current;
/* CAS: owner == current(플래그 없음) → 0
* 플래그가 설정된 경우 CAS 실패 → slowpath */
return atomic_long_try_cmpxchg_release(
&lock->owner, &curr, 0UL);
}
Fast Path의 CAS가 실패하는 경우는 owner 필드에 MUTEX_FLAG_WAITERS 또는 MUTEX_FLAG_HANDOFF가 설정되어 있을 때입니다. 이 경우 단순히 0으로 클리어하면 대기자를 깨우지 못하므로, slowpath가 반드시 필요합니다.
/* kernel/locking/mutex.c — __mutex_unlock_slowpath() */
static noinline void __mutex_unlock_slowpath(
struct mutex *lock, unsigned long ip)
{
struct task_struct *next = NULL;
DEFINE_WAKE_Q(wake_q);
unsigned long owner;
raw_spin_lock(&lock->wait_lock);
if (!list_empty(&lock->wait_list)) {
/* 첫 번째 대기자 가져오기 */
struct mutex_waiter *waiter =
list_first_entry(&lock->wait_list,
struct mutex_waiter, list);
next = waiter->task;
/* HANDOFF인 경우: owner를 직접 waiter로 이전 */
if (__mutex_waiter_is_handoff(waiter)) {
/* owner = next | MUTEX_FLAG_PICKUP
* waiter가 깨어나면 PICKUP 보고 즉시 획득 */
__mutex_handoff(lock, next);
} else {
/* 일반 해제: owner 클리어 */
__mutex_unlock_common_slowpath(lock);
}
wake_q_add(&wake_q, next);
} else {
/* 대기자 없음: 단순 해제 */
__mutex_unlock_common_slowpath(lock);
}
raw_spin_unlock(&lock->wait_lock);
wake_up_q(&wake_q); /* 배치 웨이크업 */
}
wake_q 배치 웨이크업
wake_q는 여러 태스크를 한 번에 깨우기 위한 큐입니다. mutex_unlock()에서는 보통 하나의 waiter만 깨우지만, wake_q를 사용하는 이유는 raw_spin_unlock 이후에 실제 웨이크업을 수행하여 spinlock 보유 시간을 최소화하기 위함입니다.
| 단계 | 동작 | lock->wait_lock |
|---|---|---|
| 1 | raw_spin_lock(&lock->wait_lock) | 보유 |
| 2 | 첫 번째 waiter 확인, HANDOFF 처리 | 보유 |
| 3 | wake_q_add(&wake_q, next) | 보유 |
| 4 | raw_spin_unlock(&lock->wait_lock) | 해제 |
| 5 | wake_up_q(&wake_q) — 실제 try_to_wake_up() 호출 | 해제 |
__mutex_handoff()는 owner를 next | MUTEX_FLAG_PICKUP으로 설정합니다. 깨어난 waiter는 __mutex_trylock()에서 PICKUP 플래그를 발견하고, 자신의 task_struct와 일치하면 즉시 획득합니다. 이렇게 하면 다른 spinner가 가로채는 것을 방지합니다.
mutex_unlock()은 반드시 mutex_lock()을 호출한 태스크에서 호출해야 합니다. CONFIG_DEBUG_MUTEXES 활성화 시 소유자 불일치가 감지되면 DEBUG_LOCKS_WARN_ON()이 발생합니다. 소유자 규칙을 위반하는 설계는 근본적으로 재구성해야 합니다.
cleanup.h 가드와 scoped mutex
Linux 6.4부터 도입된 cleanup.h 프레임워크는 GCC/Clang의 __attribute__((__cleanup__))를 활용하여 스코프 기반 자동 unlock을 제공합니다. C 언어에 RAII(Resource Acquisition Is Initialization) 패턴을 구현한 것으로, mutex의 unlock 누락 버그를 구조적으로 방지합니다.
전통적인 mutex 사용에서 가장 빈번한 버그는 에러 경로에서 mutex_unlock()을 빠뜨리는 것입니다. 복잡한 함수에서 여러 goto 레이블을 관리하거나, 조기 return 경로마다 unlock을 삽입해야 하는 부담을 guard(mutex)가 완전히 제거합니다.
/* include/linux/cleanup.h — 핵심 매크로 정의 */
/* CLASS 매크로: 타입별 생성자/소멸자 등록 */
DEFINE_CLASS(mutex,
struct mutex *, /* 관리 대상 타입 */
mutex_unlock(_T), /* 소멸자: 스코프 종료 시 호출 */
mutex_lock(_T), _T, /* 생성자: 스코프 진입 시 호출 */
struct mutex *_T /* 인자 */
);
/* guard() 매크로 확장 */
guard(mutex)(&my_lock);
/* 내부적으로 다음과 같이 확장:
* struct mutex *__guard_mutex_ptr
* __attribute__((__cleanup__(mutex_unlock_cleanup)))
* = mutex_lock_return(&my_lock);
*/
/* scoped_guard: 블록 범위 가드 */
scoped_guard(mutex, &my_lock) {
/* 이 블록이 끝나면 자동 unlock */
data->value = new_value;
data->timestamp = ktime_get();
}
guard(mutex) 실전 패턴
/* 패턴 1: 전통적 방식 — 에러 경로마다 unlock 필요 */
static int old_style(struct device *dev)
{
int ret;
mutex_lock(&dev->lock);
ret = prepare_something(dev);
if (ret)
goto out_unlock; /* unlock 누락 위험! */
ret = do_operation(dev);
if (ret)
goto out_unlock;
ret = finalize(dev);
out_unlock:
mutex_unlock(&dev->lock);
return ret;
}
/* 패턴 2: guard(mutex) — 스코프 종료 시 자동 unlock */
static int new_style(struct device *dev)
{
guard(mutex)(&dev->lock); /* 함수 끝에서 자동 unlock */
int ret = prepare_something(dev);
if (ret)
return ret; /* 자동 unlock! */
ret = do_operation(dev);
if (ret)
return ret; /* 자동 unlock! */
return finalize(dev); /* 자동 unlock! */
}
/* 패턴 3: scoped_guard — 블록 범위 제한 */
static ssize_t read_with_scoped(struct device *dev,
char *buf, size_t len)
{
size_t count;
scoped_guard(mutex, &dev->lock) {
count = min(len, dev->data_len);
memcpy(buf, dev->buffer, count);
}
/* 여기서는 이미 unlock된 상태 */
return count;
}
/* 패턴 4: scoped_guard + interruptible */
DEFINE_CLASS(mutex_intr,
struct mutex *,
if (_T) mutex_unlock(_T),
mutex_lock_interruptible(_T) ? NULL : _T,
struct mutex *_T
);
scoped_guard(mutex_intr, &lock) {
/* 시그널로 중단되면 블록을 건너뜀 */
do_work();
}
| 매크로 | 동작 | 사용 시나리오 |
|---|---|---|
guard(mutex)(&lock) | 함수 끝까지 lock 유지, 스코프 종료 시 unlock | 함수 전체가 임계 영역인 경우 |
scoped_guard(mutex, &lock) { } | 블록 내에서만 lock 유지 | 임계 영역을 블록으로 제한할 때 |
guard(mutex_intr)(&lock) | interruptible lock + 자동 unlock | 시그널 처리가 필요한 경우 |
CLASS(mutex, ptr)(&lock) | 명시적 변수명으로 guard 생성 | 가드 변수를 직접 참조할 때 |
guard() 매크로는 6.4 커널에서 도입되었으며, 6.5~6.8에서 커널 전반으로 확산되고 있습니다. 새로운 드라이버와 서브시스템 코드에서는 guard(mutex) 사용이 적극 권장됩니다. 다만, scoped_guard 블록 안에서 goto로 블록 밖으로 점프하면 소멸자가 호출되지 않으므로 주의해야 합니다.
Contention 프로파일링 실전
mutex contention은 시스템 성능의 주요 병목(Bottleneck)입니다. 커널은 perf lock, /proc/lock_stat, BPF 기반 트레이싱 등 다양한 도구를 제공하며, 각 도구는 서로 다른 수준의 세밀도와 오버헤드를 가집니다. 실전에서는 이들을 조합하여 경합 지점을 빠르게 식별하고 최적화 방향을 결정합니다.
perf lock contention
perf lock contention은 커널 6.2부터 BPF 기반으로 동작하며, 잠금 경합(Lock Contention)을 실시간으로 분석합니다. tracepoint 기반보다 오버헤드가 낮고 프로덕션 환경에서도 사용할 수 있습니다.
# perf lock contention — BPF 기반 실시간 분석
$ perf lock contention -ab sleep 5
contended total wait max wait avg wait type caller
1842 45.2ms 12.3ms 24.5us mutex ext4_fill_super+0x1a3
924 18.7ms 5.1ms 20.2us mutex do_mmap+0x8f
456 8.3ms 2.8ms 18.2us mutex i915_gem_object_lock+0x42
# 스택 트레이스 포함
$ perf lock contention -abs sleep 10
# 특정 프로세스만 추적
$ perf lock contention -abp 1234 sleep 5
# 호출 스택 깊이 조절
$ perf lock contention -ab --stack-depth 8 sleep 5
/proc/lock_stat
CONFIG_LOCK_STAT=y를 활성화하면 /proc/lock_stat에서 모든 잠금의 경합 통계를 확인할 수 있습니다. 오버헤드가 상당하므로 프로덕션보다는 개발/테스트 환경에 적합합니다.
# lock_stat 활성화/초기화
$ echo 1 > /proc/sys/kernel/lock_stat
$ echo 0 > /proc/lock_stat # 카운터 리셋
# 워크로드 실행 후 분석
$ cat /proc/lock_stat | head -30
lock_stat version 0.4
------------------------------------------------------
class name con-bounces contentions waittime-min ...
------------------------------------------------------
&sb->s_type->...: 12483 8921 0.42 ...
&mm->mmap_lock: 5621 3244 0.31 ...
# 상위 경합 잠금만 추출
$ sort -k 4 -rn /proc/lock_stat | head -20
BPF/bpftrace 트레이싱
# bpftrace: mutex 경합 히트맵 (대기 시간 분포)
$ bpftrace -e '
tracepoint:lock:contention_begin /args->flags == 0x2/ {
@start[tid] = nsecs;
}
tracepoint:lock:contention_end /args->flags == 0x2/ {
if (@start[tid]) {
@wait_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
}'
# 특정 mutex 주소 추적
$ bpftrace -e '
kprobe:mutex_lock {
@addr[arg0] = count();
}
END {
print(@addr, 10); /* 상위 10개 mutex */
}'
# mutex 보유 시간 측정 (lock→unlock)
$ bpftrace -e '
kprobe:mutex_lock { @lock_time[tid] = nsecs; }
kprobe:mutex_unlock /@lock_time[tid]/ {
@hold_us = hist((nsecs - @lock_time[tid]) / 1000);
delete(@lock_time[tid]);
}'
| 도구 | 오버헤드 | 커널 설정 | 장점 | 단점 |
|---|---|---|---|---|
perf lock contention | <2% | CONFIG_BPF | 프로덕션 안전, 스택 포함 | 커널 6.2+ 필요 |
bpftrace | ~5% | CONFIG_BPF_EVENTS | 유연한 커스텀 분석 | 스크립트 작성 필요 |
/proc/lock_stat | ~15% | CONFIG_LOCK_STAT | 포괄적 통계 | 프로덕션 부적합 |
lockdep | ~30% | CONFIG_PROVE_LOCKING | 데드락 탐지 | 성능 분석 아님 |
ftrace mutex events | ~3% | TRACE_EVENTS | 시계열 분석 | 대용량 데이터 |
벤치마크: mutex vs spinlock vs rwsem
mutex, spinlock, rw_semaphore의 성능 특성은 경합 수준, 임계 영역 길이, CPU 수에 따라 극적으로 달라집니다. locktorture 모듈과 custom 벤치마크를 사용한 측정 결과를 분석합니다. 모든 수치는 x86-64 환경(Intel Xeon, 48 코어)에서 측정되었으며, 임계 영역 내에서 변수 1개를 증가시키는 단순 워크로드 기준입니다.
Uncontended (경합 없음, 단일 스레드)
| 프리미티브 | lock+unlock 평균 | 주요 비용 |
|---|---|---|
| spinlock | ~15 ns | LOCK CMPXCHG (atomic RMW) |
| mutex (fast path) | ~22 ns | LOCK CMPXCHG + might_sleep 체크 |
| rw_semaphore (read) | ~25 ns | atomic_long_add + might_sleep |
| rw_semaphore (write) | ~28 ns | LOCK CMPXCHG + reader 카운터 체크 |
경합이 없는 상황에서는 spinlock이 가장 빠릅니다. mutex의 fast path도 단일 CAS이지만 might_sleep() 체크와 lockdep 오버헤드로 약 7ns 추가됩니다. 이 차이는 초당 수백만 회 호출되는 hot path에서만 유의미합니다.
Contended (경합, 다중 스레드)
| 스레드 수 | spinlock (ops/s) | mutex (ops/s) | rwsem-read (ops/s) |
|---|---|---|---|
| 1 | 66M | 45M | 40M |
| 2 | 14M | 18M | 38M |
| 4 | 5.2M | 12M | 36M |
| 8 | 2.1M | 8.5M | 34M |
| 16 | 0.9M | 5.8M | 32M |
| 32 | 0.4M | 3.2M | 28M |
| 48 | 0.2M | 1.8M | 24M |
경합이 증가하면 spinlock의 성능이 급격히 저하됩니다. busy-wait이 CPU를 소비하므로 스레드 수가 증가할수록 캐시 라인 바운싱이 심해지기 때문입니다. mutex는 optimistic spinning(MCS 기반)이 캐시 라인 바운싱을 줄이고, spinning 실패 시 sleep하여 CPU를 양보하므로 경합 상황에서 spinlock보다 처리량이 높습니다.
임계 영역 길이에 따른 최적 선택
| 임계 영역 길이 | 최적 프리미티브 | 이유 |
|---|---|---|
| <100 ns | spinlock | context switch 비용이 임계 영역보다 큼 |
| 100 ns ~ 10 us | mutex | optimistic spinning이 sleep보다 효율적 |
| 10 us ~ 1 ms | mutex | sleep하여 CPU 양보가 전체 처리량 향상 |
| >1 ms | mutex_interruptible | 긴 대기에 시그널 취소 가능성 필요 |
| I/O 포함 | mutex_lock_io | I/O 대기 시간을 iowait으로 정확히 계정 |
CONFIG_LOCK_TORTURE_TEST)는 극단적 경합 상황을 인위적으로 생성하므로, 실제 워크로드의 경합 패턴과 다를 수 있습니다. 벤치마크 결과는 참고용이며, 실제 시스템에서는 perf lock contention으로 워크로드별 프로파일링을 수행해야 합니다.
LKMM 관점의 mutex 메모리 순서
Linux Kernel Memory Model(LKMM)은 커널 동기화 프리미티브의 메모리 순서 보장을 형식적으로 정의합니다. mutex는 LKMM에서 acquire/release 쌍으로 모델링되며, 이는 임계 영역 내의 모든 메모리 접근이 lock/unlock 경계 밖으로 재배치되지 않음을 형식적으로 보장합니다.
실제 구현에서 mutex_lock()은 atomic_long_try_cmpxchg_acquire()를 사용하여 acquire 시맨틱을, mutex_unlock()은 atomic_long_try_cmpxchg_release()를 사용하여 release 시맨틱을 제공합니다. 이 두 배리어가 결합하여 임계 영역의 모든 load/store가 가시적(visible)임을 보장합니다.
Litmus 테스트로 보는 mutex 보장
/* LKMM litmus test: mutex는 happens-before를 보장 */
C mutex-ordering
/*
* 초기 상태:
* x = 0; y = 0;
* mutex M은 unlocked
*/
P0(int *x, int *y, mutex_t *M) {
mutex_lock(M); /* acquire */
WRITE_ONCE(*x, 1);
WRITE_ONCE(*y, 1);
mutex_unlock(M); /* release */
}
P1(int *x, int *y, mutex_t *M) {
mutex_lock(M); /* acquire */
r0 = READ_ONCE(*y);
r1 = READ_ONCE(*x);
mutex_unlock(M); /* release */
}
/* exists (1:r0=1 /\ 1:r1=0) -- 불가능!
* P1이 y=1을 관찰했다면 반드시 x=1도 관찰해야 함
* → release(P0 unlock) → acquire(P1 lock) 순서가 보장 */
형식적 순서 규칙
| 관계 | LKMM 표현 | 의미 |
|---|---|---|
| mutex_lock → 임계 영역 | acquire → po | lock 이후의 모든 접근은 lock 이전으로 재배치 불가 |
| 임계 영역 → mutex_unlock | po → release | unlock 이전의 모든 접근은 unlock 이후로 재배치 불가 |
| unlock → lock (다른 CPU) | release → acquire (sw) | P0의 unlock과 P1의 lock 사이에 synchronizes-with 관계 |
| 전이적 가시성 | hb (happens-before) | 위 관계의 전이적 닫힘: P0의 모든 선행 쓰기가 P1에게 가시적 |
LKMM에서 mutex의 메모리 순서 보장은 두 가지 핵심 속성으로 요약됩니다. 첫째, 상호 배제(mutual exclusion) — 동일 mutex의 임계 영역은 겹치지 않습니다. 둘째, 순서 보장(ordering) — 한 임계 영역의 모든 쓰기는 다음 임계 영역에서 가시적입니다. 이 두 속성이 결합하여 mutex는 순차적 일관성(sequential consistency)보다 약하지만 실용적으로 충분한 동기화를 제공합니다.
tools/memory-model/ 디렉토리에서 herd7 도구를 사용하여 LKMM litmus 테스트를 직접 실행할 수 있습니다: herd7 -conf linux-kernel.cfg litmus-tests/locking/mutex-ordering.litmus
서브시스템 사례: mmap_lock 추적
mmap_lock은 Linux 커널에서 가장 경합이 심한 잠금 중 하나입니다. mm_struct의 가상 메모리(Virtual Memory) 영역(VMA) 리스트를 보호하며, 페이지 폴트(Page Fault), mmap/munmap 시스템 콜(System Call), /proc/pid/maps 읽기 등 다양한 경로에서 접근합니다. 원래 mmap_sem이라는 세마포어였지만, v5.8에서 mmap_lock으로 이름이 변경되고 rw_semaphore로 구현됩니다.
mmap_lock의 경합은 특히 멀티스레드 애플리케이션에서 심각합니다. 페이지 폴트마다 read lock을 획득해야 하고, mmap/munmap은 write lock이 필요하므로, 메모리 집약적 워크로드에서 병목이 됩니다. 커널 개발자들은 이를 완화하기 위해 per-VMA lock, speculative page fault 등의 기술을 지속적으로 도입하고 있습니다.
mmap_lock API와 래퍼
/* include/linux/mmap_lock.h — mmap_lock 래퍼 */
/* 이름 변경 히스토리:
* v5.8 이전: down_read(&mm->mmap_sem) / up_read(&mm->mmap_sem)
* v5.8 이후: mmap_read_lock(mm) / mmap_read_unlock(mm) */
static inline void mmap_read_lock(struct mm_struct *mm)
{
down_read(&mm->mmap_lock);
__mmap_lock_trace_start_locking(mm, false);
}
static inline void mmap_write_lock(struct mm_struct *mm)
{
down_write(&mm->mmap_lock);
__mmap_lock_trace_start_locking(mm, true);
}
/* v6.4+: lock_mm_and_find_vma — 페이지 폴트 최적화 */
struct vm_area_struct *lock_mm_and_find_vma(
struct mm_struct *mm,
unsigned long addr,
struct pt_regs *regs)
{
/* 먼저 per-VMA lock 시도 (v6.4+) */
vma = lock_vma_under_rcu(mm, addr);
if (vma)
return vma; /* mmap_lock 없이 성공! */
/* 실패: mmap_lock read lock으로 폴백 */
mmap_read_lock(mm);
vma = find_vma(mm, addr);
return vma;
}
mmap_lock tracepoint 활용
# mmap_lock tracepoint 확인
$ ls /sys/kernel/debug/tracing/events/mmap_lock/
mmap_lock_acquire_returned mmap_lock_start_locking
# mmap_lock 경합 추적
$ echo 1 > /sys/kernel/debug/tracing/events/mmap_lock/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
bash-1234 [003] .... 1234.567890: mmap_lock_start_locking: mm=0xffff... write=1
bash-1234 [003] .... 1234.567920: mmap_lock_acquire_returned: mm=0xffff... write=1 success=1
# bpftrace로 mmap_lock 보유 시간 측정
$ bpftrace -e '
tracepoint:mmap_lock:mmap_lock_start_locking {
@start[tid] = nsecs;
}
tracepoint:mmap_lock:mmap_lock_acquire_returned /@start[tid]/ {
@wait_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
| 커널 버전 | 최적화 | 효과 |
|---|---|---|
| v5.8 | mmap_sem → mmap_lock 리네이밍 | tracepoint 추가, API 정리 |
| v6.1 | Maple Tree로 VMA 관리 구조 변경 | RCU 기반 VMA lookup 가능 |
| v6.4 | per-VMA lock (lock_vma_under_rcu) | 페이지 폴트에서 mmap_lock 우회, 75% 경합 감소 |
| v6.7+ | speculative page fault 확장 | anonymous page에 대해 mmap_lock 없는 폴트 처리 |
rw_semaphore입니다. 각 VMA마다 별도의 lock을 두어 서로 다른 VMA에 대한 페이지 폴트가 병렬로 처리됩니다. 이 설계는 "하나의 거대한 잠금 대신 세밀한 잠금"이라는 동기화의 핵심 원칙을 보여줍니다. perf lock contention으로 mmap_lock 경합을 측정하면 per-VMA lock 도입 전후의 차이를 극적으로 확인할 수 있습니다.
커널 버전별 진화
Linux 커널의 mutex 구현은 v2.6.16에서 처음 도입된 이후 지속적으로 진화해왔습니다. 단순한 sleeping lock에서 시작하여 adaptive spinning, osq_lock, HANDOFF, 그리고 최근의 cleanup.h guard까지, 각 단계는 실제 워크로드의 성능 문제를 해결하기 위한 것이었습니다.
이 진화 과정을 이해하면 왜 현재의 mutex가 이렇게 복잡한 구조를 가지게 되었는지, 그리고 각 최적화가 어떤 문제를 해결하는지를 명확히 파악할 수 있습니다.
| 버전 | 변경 | 해결한 문제 | 커밋/패치 시리즈 |
|---|---|---|---|
| v2.6.16 | struct mutex 도입 | semaphore(count=1)의 lockdep 불가 | Ingo Molnar 패치 시리즈 |
| v3.0 | Adaptive spinning | 불필요한 context switch | Peter Zijlstra |
| v3.15 | osq_lock (MCS) | cacheline bouncing during spin | Jason Low, Davidlohr Bueso |
| v4.6 | owner + flags encoding | lock 상태 조회의 atomicity | Peter Zijlstra |
| v5.0 | HANDOFF | spinner에 의한 sleeper starvation | Peter Zijlstra |
| v6.4 | cleanup.h guard | 에러 경로 unlock 누락 | Peter Zijlstra, Kent Overstreet |
CONFIG_MUTEX_SPIN_ON_OWNER 등으로 선택적 비활성화가 가능합니다.
보안: 타이밍 사이드채널
잠금 경합은 의도치 않게 타이밍 사이드채널을 형성할 수 있습니다. 공격자가 mutex_lock()의 대기 시간을 측정하면 다른 프로세스가 해당 자원을 사용하고 있는지, 얼마나 오래 임계 영역에 머무는지를 추론할 수 있습니다. 이는 특히 가상화(Virtualization) 환경과 컨테이너(Container)에서 격리(isolation)를 위협합니다.
공격 벡터
mutex 기반 타이밍 공격의 주요 벡터는 세 가지입니다. 첫째, 경합 탐지(contention detection) — 특정 시스템 콜의 latency 변화로 다른 프로세스의 활동을 추론합니다. 둘째, 임계 영역 길이 추론 — mutex 대기 시간으로 보호된 데이터의 크기나 처리 복잡도를 추정합니다. 셋째, 스케줄링 기반 추론 — optimistic spinning vs sleep 경로의 차이로 소유자의 CPU 상태를 파악합니다.
| 공격 벡터 | 측정 대상 | 추론 가능 정보 | 위험도 |
|---|---|---|---|
| 경합 탐지 | 시스템 콜 latency | 다른 VM/컨테이너의 자원 접근 패턴 | 중간 |
| 임계 영역 길이 | mutex 대기 시간 | 보호된 데이터 크기, 암호 키 길이 | 높음 |
| spinning vs sleep | 응답 시간 분포 | 소유자의 CPU affinity, NUMA 노드 | 낮음 |
| HANDOFF 트리거 | 2번째 wakeup 후 지연 | 경합 패턴, 스케줄링 우선순위 | 낮음 |
완화 기법
/* 완화 기법 1: 일정 시간(constant-time) 임계 영역
* 민감한 데이터를 다루는 경우, 데이터 크기에 관계없이
* 항상 동일한 시간이 걸리도록 구현 */
static int crypto_check_key(const u8 *key, size_t len)
{
guard(mutex)(&crypto_lock);
/* BAD: 조기 반환은 키 길이를 노출
* if (len != expected_len) return -EINVAL;
*/
/* GOOD: 항상 전체 비교 수행 */
int result = crypto_memneq(key, expected_key, MAX_KEY_LEN);
return result ? -EINVAL : 0;
}
/* 완화 기법 2: 격리된 잠금 사용
* 보안 경계를 넘는 공유 잠금을 피하고,
* 컨텍스트별 별도 잠금을 사용 */
/* BAD: 모든 사용자가 하나의 mutex 공유 */
static DEFINE_MUTEX(global_crypto_lock);
/* GOOD: 사용자별 별도 mutex */
struct crypto_ctx {
struct mutex lock; /* 사용자별 독립 잠금 */
u8 key[32];
};
/* 완화 기법 3: 잡음(noise) 추가 — 극히 예외적 경우에만 */
static void sensitive_operation(void)
{
guard(mutex)(&sensitive_lock);
do_actual_work();
/* 타이밍 잡음: 의도적 지연 (성능 비용 큼, 신중히 사용) */
if (IS_ENABLED(CONFIG_SECURITY_TIMING_MITIGATIONS))
udelay(get_random_u32_below(100));
}
특히 컨테이너 환경에서 주의할 점은 cgroup 관련 잠금입니다. cgroup_mutex는 모든 cgroup 계층에서 공유되므로, 한 컨테이너의 cgroup 조작이 다른 컨테이너의 cgroup 작업을 지연시킬 수 있습니다. 이는 사이드채널뿐만 아니라 DoS(서비스 거부) 벡터이기도 합니다. 커널 개발자들은 cgroup 잠금의 세밀화를 지속적으로 추진하고 있습니다.
실전 디버깅 시나리오
mutex 관련 버그는 데드락, hung task, priority inversion, unlock 누락 등 다양한 형태로 나타납니다. 각 시나리오별로 증상 식별, 진단 도구 활용, 원인 분석, 수정 방법을 구체적으로 다룹니다.
시나리오 1: hung_task 경고
태스크가 TASK_UNINTERRUPTIBLE 상태로 CONFIG_DEFAULT_HUNG_TASK_TIMEOUT(기본 120초) 이상 머무르면 hung_task 경고가 발생합니다. mutex_lock()은 기본적으로 UNINTERRUPTIBLE이므로, 장시간 경합이나 소유자의 비정상 동작 시 이 경고가 나타납니다.
/* hung_task 경고 예시 */
INFO: task kworker/0:1:1234 blocked for more than 120 seconds.
Not tainted 6.8.0 #1
"echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.
task:kworker/0:1 state:D stack:13456 pid: 1234 ppid: 2
Call Trace:
<TASK>
__schedule+0x3e8/0x1150
schedule+0x5c/0xd0
schedule_preempt_disabled+0x15/0x30
__mutex_lock.constprop.0+0x3e8/0x750
__mutex_lock_slowpath+0x13/0x20
mutex_lock+0x3e/0x50
my_driver_write+0x45/0x120 ← 여기서 블록
vfs_write+0x1a2/0x520
...
# 진단 절차
# 1. 블록된 태스크와 대기 중인 mutex 확인
$ cat /proc/1234/stack
[<0>] __mutex_lock.constprop.0+0x3e8/0x750
[<0>] my_driver_write+0x45/0x120
# 2. mutex 소유자 확인 (crash 또는 /proc/lock_stat)
$ cat /proc/1234/wchan
__mutex_lock_slowpath
# 3. lockdep로 의존성 확인
$ cat /proc/lockdep_chains | grep my_driver
# 4. 모든 D 상태 태스크 조회
$ ps aux | awk '$8 ~ /D/ { print }'
# 5. SysRq로 전체 태스크 상태 덤프
$ echo w > /proc/sysrq-trigger # blocked tasks
$ echo t > /proc/sysrq-trigger # all tasks
시나리오 2: lockdep ABBA 데드락 감지
/* lockdep 데드락 경고 예시 */
======================================================
WARNING: possible circular locking dependency detected
6.8.0 #1 Not tainted
------------------------------------------------------
process_a/5678 is trying to acquire lock:
ffff88810a2b3c40 (&dev->lock_b){+.+.}-{3:3}, at: func_x+0x30
but task is already holding lock:
ffff88810a2b3c00 (&dev->lock_a){+.+.}-{3:3}, at: func_x+0x18
which lock already depends on the new lock.
the existing dependency chain (in reverse order) is:
-> #1 (&dev->lock_b){+.+.}-{3:3}:
lock_acquire+0xd4/0x2c0
__mutex_lock+0x9c/0x750
func_y+0x18/0x60 ← lock_b 먼저 획득
-> #0 (&dev->lock_a){+.+.}-{3:3}:
lock_acquire+0xd4/0x2c0
__mutex_lock+0x9c/0x750
func_y+0x30/0x60 ← 그 다음 lock_a 획득 시도
/* 분석:
* func_x: lock_a → lock_b (A→B)
* func_y: lock_b → lock_a (B→A)
* → ABBA 데드락 가능!
*
* 해결: 잠금 순서 통일 — 항상 lock_a를 먼저 획득
*/
시나리오 3: crash dump에서 mutex 상태 분석
# crash 도구로 mutex 상태 분석
# 1. mutex 구조체 확인
crash> struct mutex ffff88810a2b3c00
struct mutex {
owner = {
counter = 0xffff888107a52b03 ← task + flags
},
wait_lock = {
raw_lock = { val = { counter = 0 } }
},
osq = {
tail = { counter = 0 }
},
wait_list = {
next = 0xffffc900012c7d58, ← 대기자 있음!
prev = 0xffffc900012c7d58
}
}
# 2. owner 디코딩
# owner = 0xffff888107a52b03
# task_struct = owner & ~0x7 = 0xffff888107a52b00
# flags = owner & 0x7 = 0x3 = WAITERS(0x1) | HANDOFF(0x2)
crash> task_struct.comm 0xffff888107a52b00
comm = "my_daemon\0..." ← 현재 소유자
crash> bt 0xffff888107a52b00
PID: 9876 TASK: ffff888107a52b00 CPU: 2 COMMAND: "my_daemon"
#0 [ffffc9000234fc80] __schedule at ffffffff81e1b8c5
#1 [ffffc9000234fd10] schedule at ffffffff81e1bc90
#2 [ffffc9000234fd28] io_schedule at ffffffff81e1bd50 ← I/O 대기 중!
...
# 3. 대기자 확인
crash> list mutex_waiter.list -s mutex_waiter.task -H 0xffff88810a2b3c10
ffff88810a2b3c10
task = 0xffff888108b64500 ← 대기 중인 태스크
시나리오 4: Priority Inversion 진단
Priority Inversion은 고우선순위 태스크가 mutex를 기다리는데, 소유자인 저우선순위 태스크가 중간 우선순위 태스크에 의해 선점당해 unlock하지 못하는 상황입니다. 일반 mutex는 PI(Priority Inheritance)를 지원하지 않으므로, 실시간 요구가 있는 시스템에서는 rt_mutex를 사용해야 합니다.
/* Priority Inversion 시나리오 */
/* 태스크 우선순위: T_high > T_mid > T_low */
/* T_low: mutex 획득 후 실행 중 */
mutex_lock(&shared_lock);
/* ... critical section ... */
/* T_high: mutex 대기 — T_low가 unlock해야 함 */
mutex_lock(&shared_lock); /* 블록! */
/* T_mid: T_low를 선점 — T_low가 unlock 불가! */
/* → T_high는 T_mid가 끝날 때까지 기다려야 함 */
/* → 실질적으로 T_mid의 우선순위로 동작! */
/* 해결: rt_mutex 사용 → T_low의 우선순위를 T_high로 부스트 */
struct rt_mutex shared_rt_lock;
rt_mutex_init(&shared_rt_lock);
/* PREEMPT_RT 커널에서는 mutex_lock()이 자동으로
* rt_mutex 기반으로 동작하므로 PI가 자동 적용됨 */
| 디버깅 체크 | 명령어/방법 | 확인 대상 |
|---|---|---|
| D 상태 태스크 목록 | ps aux | awk '$8~/D/' | 블록된 태스크와 명령어 |
| 태스크 스택 확인 | cat /proc/PID/stack | 어떤 함수에서 블록되었는지 |
| lockdep 순환 확인 | /proc/lockdep_chains | 잠금 의존성 체인 |
| mutex owner 식별 | crash: struct mutex ADDR | 소유자 task_struct + 플래그 |
| 경합 통계 | perf lock contention -abs | 경합 횟수, 대기 시간, 스택 |
| 전체 태스크 덤프(Dump) | echo t > /proc/sysrq-trigger | 시스템 전체 태스크 상태 |
| RCU stall 연관 | dmesg | grep "rcu.*stall" | mutex 블록이 RCU stall을 유발하는지 |
CONFIG_PROVE_LOCKING=y — 개발/테스트 빌드에서 데드락을 사전 감지합니다. (2) CONFIG_DEBUG_MUTEXES=y — 소유자 불일치, 이중 해제(Double Free) 등을 런타임에 감지합니다. (3) lockdep_assert_held() — 잠금 보유 필수 함수에 어노테이션을 추가하여 문서화와 검증을 동시에 달성합니다.
Mutex 코드 리뷰 체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 | 프로세스 컨텍스트에서만 호출하는가? | IRQ/softirq에서 mutex_lock 호출 금지 |
| 2 | 소유자가 직접 unlock하는가? | 다른 태스크/스레드에서 unlock 금지 |
| 3 | 재귀 잠금이 없는가? | 같은 mutex를 두 번 lock하면 데드락 |
| 4 | 다중 mutex 순서가 일관적인가? | ABBA 패턴 방지, lockdep으로 검증 |
| 5 | 사용자 대면 API에 _interruptible 사용? | Ctrl+C로 취소 가능하게 |
| 6 | 에러 경로에서 unlock을 빠뜨리지 않았는가? | goto 레이블, 조기 return 확인 |
| 7 | 임계 영역이 불필요하게 길지 않은가? | I/O, 할당을 lock 밖으로 |
| 8 | lockdep_assert_held() 추가했는가? | lock 보유 필수 함수에 어노테이션 |
| 9 | guard(mutex) 사용을 고려했는가? | 6.4+ 커널에서 자동 unlock |
| 10 | mutex가 정말 필요한가? | atomic, RCU, per-CPU, spinlock 등 대안 검토 |
참고 자료
커널 공식 문서
- Generic Mutex Subsystem — mutex 설계 문서: optimistic spinning, osq_lock, 3단계 fastpath/midpath/slowpath
- Lock types and their rules — mutex와 다른 잠금 유형의 비교, PREEMPT_RT 변환
- Runtime locking correctness validator — mutex_lock_nested()와 lockdep 서브클래스
- Lock Statistics — /proc/lock_stat으로 mutex contention 프로파일링
- RT-mutex implementation design — PREEMPT_RT에서 mutex가 rt_mutex로 변환되는 과정
LWN.net 심층 기사
- The mutex API (2013) — 커널 mutex의 설계 철학과 API 변천사
- Optimistic spinning for mutexes (2013) — adaptive mutex spinning 도입 배경
- Lockless patterns: an introduction to compare-and-swap (2014) — mutex fastpath의 CAS 최적화 이해
- MCS locks and qspinlocks (2015) — osq_lock(Optimistic Spin Queue)의 MCS 기반 구현
- Lockdep: how to read its cryptic output (2013) — mutex 관련 lockdep 경고 해석
학술 자료 및 외부 참고
- Paul McKenney — "Is Parallel Programming Hard?" — Chapter 9: Locking (mutex vs spinlock 성능 비교)
- 커널 소스:
kernel/locking/mutex.c,include/linux/mutex.h,kernel/locking/osq_lock.c - 커밋 로그:
git log --oneline kernel/locking/mutex.c— mutex HANDOFF 메커니즘 도입 이력 추적
관련 문서
mutex와 관련된 다른 동기화 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.