LOCKDEP 사용법

여러 개의 락(lock)을 다루는 드라이버나 서브시스템을 작성하다 보면, 어떤 경로에서는 lock_a를 먼저 잡고 lock_b를 나중에 잡는데 다른 경로에서는 반대로 lock_b를 먼저 잡는 실수를 저지르기 쉽다. 이런 ABBA 데드락은 두 경로가 정확히 동시에 실행되는 타이밍이 맞아야만 실제로 커널이 멈추기 때문에, 개발 중에는 수십 번 테스트해도 안 걸리다가 운영 환경에서 특정 부하 패턴에서만 시스템 전체가 멎어버리는 식으로 터진다. 일단 멈추고 나면 sysrq+w로 대기 중인 태스크 스택을 덤프해서 원인을 역추적해야 하는데, 이미 시스템이 죽은 뒤에 원인을 찾는 것보다는 애초에 위험한 락 순서가 코드에 존재한다는 걸 미리 알아내는 편이 훨씬 낫다. 커널의 lockdep(lock dependency validator)이 하는 일이 정확히 이것이다 — 실제로 두 태스크가 동시에 걸리지 않아도, 관찰된 락 획득 순서만으로 수학적으로 데드락 가능성을 증명해 즉시 경고를 띄운다. 이 글에서는 lockdep을 커널에서 활성화하는 방법, 실제 경고가 뜨는 예제, 경고 메시지를 읽는 법, 오탐(false positive)을 다루는 어노테이션까지 lockdep 사용법을 정리한다.

lockdep 활성화하기

lockdep은 기본적으로 꺼져 있다. 상시 켜두기에는 오버헤드가 작지 않기 때문에 배포판 커널에는 대부분 비활성화되어 있고, 직접 커널을 빌드하거나 menuconfig로 옵션을 켜야 한다.

# 현재 실행 중인 커널에 lockdep이 켜져 있는지 확인
$ grep LOCKDEP /boot/config-$(uname -r)

# menuconfig 경로: Kernel hacking -> Lock Debugging (spinlocks, mutexes, etc...)
#                  -> Lock debugging: prove locking correctness

# 또는 scripts/config로 직접 옵션 활성화 후 재빌드
$ ./scripts/config --enable CONFIG_PROVE_LOCKING
$ ./scripts/config --enable CONFIG_DEBUG_LOCKDEP
$ make olddefconfig

CONFIG_PROVE_LOCKING이 핵심 옵션이며, 이걸 켜면 CONFIG_DEBUG_LOCK_ALLOC, CONFIG_DEBUG_SPINLOCK, CONFIG_DEBUG_MUTEXES, CONFIG_LOCKDEP가 함께 활성화된다. 실제 하드웨어보다는 QEMU 같은 가상 환경이나 별도 빌드 서버에서 커스텀 커널을 올려 테스트하는 게 일반적이다.

실전 예제 — ABBA 데드락 재현하기

간단한 커널 모듈로 lockdep이 어떻게 동작하는지 확인할 수 있다. 아래 모듈은 두 스핀락을 서로 다른 순서로 잡는 함수 두 개를 각각 호출한다. 실제로 두 함수가 동시에 실행되지 않아도, lockdep은 두 순서가 모두 관찰된 시점에 즉시 경고를 낸다.

#include <linux/module.h>
#include <linux/spinlock.h>

static DEFINE_SPINLOCK(lock_a);
static DEFINE_SPINLOCK(lock_b);

static void path_ab(void)
{
    spin_lock(&lock_a);
    spin_lock(&lock_b);   /* A -> B 순서 */
    spin_unlock(&lock_b);
    spin_unlock(&lock_a);
}

static void path_ba(void)
{
    spin_lock(&lock_b);
    spin_lock(&lock_a);   /* B -> A 순서: 순환 의존성 발생 */
    spin_unlock(&lock_a);
    spin_unlock(&lock_b);
}

static int __init lockdep_demo_init(void)
{
    path_ab();
    path_ba();   /* 이 시점에 lockdep이 경고를 출력한다 */
    return 0;
}

static void __exit lockdep_demo_exit(void) {}

module_init(lockdep_demo_init);
module_exit(lockdep_demo_exit);
MODULE_LICENSE("GPL");

이 모듈을 insmod하면 dmesg에 대략 다음과 같은 형태의 경고가 찍힌다.

$ sudo insmod lockdep_demo.ko
$ dmesg | tail -30

======================================================
WARNING: possible circular locking dependency detected
------------------------------------------------------
insmod/1234 is trying to acquire lock:
(lock_a){+.+.}, at: path_ba+0x1a/0x40 [lockdep_demo]

but task is already holding lock:
(lock_b){+.+.}, at: path_ba+0x10/0x40 [lockdep_demo]

which lock already depends on the new lock.

the existing dependency chain (in reverse order) is:

-> #1 (lock_b){+.+.}:
       path_ab+0x1a/0x40 [lockdep_demo]
       lockdep_demo_init+0x9/0x20 [lockdep_demo]

-> #0 (lock_a){+.+.}:
       path_ba+0x1a/0x40 [lockdep_demo]
       lockdep_demo_init+0xe/0x20 [lockdep_demo]

other info that might help us debug this:

Possible unsafe locking scenario:

       CPU0                    CPU1
       ----                    ----
  lock(lock_a);
                                lock(lock_b);
                                lock(lock_a);
  lock(lock_b);

  *** DEADLOCK ***

경고 메시지 읽는 법

lockdep 경고는 길지만 구조는 일정하다.

  • trying to acquire lock: 지금 막 획득을 시도하는 락과 그 위치
  • already holding lock: 현재 태스크가 이미 잡고 있는 락
  • the existing dependency chain: lockdep이 지금까지 관찰한 락 획득 순서(A → B 관계) 기록. #0, #1 순서로 어떤 경로에서 어떤 순서로 락이 잡혔는지 보여준다
  • Possible unsafe locking scenario: 두 CPU/태스크가 정확히 어떻게 겹치면 실제 데드락이 되는지를 도식으로 요약

락 이름 옆에 붙는 {+.+.} 같은 표기는 이 락이 어떤 문맥(하드IRQ/소프트IRQ, 인터럽트 활성/비활성)에서 잡혔는지를 나타내는 usage state다. 순수한 락 순서 역전뿐 아니라, 인터럽트 컨텍스트에서 안전한 락을 인터럽트가 켜진 상태에서 잡는 것 같은 IRQ 안전성 위반도 같은 방식으로 잡아낸다.

커스텀 락 클래스와 lockdep_assert_held

lockdep은 락 하나하나가 아니라 락 클래스(lock class) 단위로 순서를 추적한다. 예를 들어 배열 형태로 여러 개의 스핀락을 런타임에 초기화하면, 초기화 코드 위치가 같기 때문에 서로 다른 락 인스턴스인데도 동일한 클래스로 묶여 실제로는 안전한 중첩(nested) 락 사용이 순환 의존성처럼 오탐될 수 있다.

/* 계층적으로 안전하게 중첩되는 락인데 같은 클래스로 묶여 오탐이 나는 경우 */
spin_lock(&obj->locks[0]);
spin_lock_nested(&obj->locks[1], SINGLE_DEPTH_NESTING);

spin_lock_nested()/mutex_lock_nested()처럼 nesting depth를 명시하는 변형 함수를 쓰거나, lockdep_set_class()로 특정 락 인스턴스에 별도 클래스를 부여해 lockdep이 서로 다른 락으로 인식하게 만들 수 있다. 다만 실제로 계층 구조가 항상 같은 순서로 지켜지는지 직접 꼼꼼히 확인한 뒤에 적용해야 하며, 확신 없이 오탐을 지우는 용도로 남용하면 진짜 버그를 숨기게 된다.

반대로 특정 함수가 반드시 어떤 락을 잡은 상태에서만 호출돼야 한다는 불변조건을 코드에 남기고 싶다면 lockdep_assert_held()를 쓴다.

void update_internal_state(struct my_dev *dev)
{
    lockdep_assert_held(&dev->lock);
    /* dev->lock이 잡혀있지 않은 채 호출되면 lockdep이 즉시 경고한다 */
}

상태 확인과 한계 파악하기

lockdep이 추적 중인 락 클래스 수와 통계는 /proc/lockdep_stats에서 확인할 수 있다.

$ grep "lock-classes" /proc/lockdep_stats
 lock-classes:                         1234 [max: 8191]

기본 MAX_LOCKDEP_KEYS는 8191개이며, 모듈을 로드/언로드할 때마다 락 클래스가 계속 늘어나 이 한도에 가까워진다면 락을 컴파일 타임 초기화 매크로 대신 spin_lock_init()으로 런타임에 초기화하지 않고 있거나, 락 클래스가 새는(leak) 코드가 있다는 신호다. 어떤 클래스가 계속 쌓이는지는 grep "BD" /proc/lockdep로 추적할 수 있다.

주의사항

  • lockdep은 정적 분석(static analysis)이 아니라 런타임 관찰 기반이다. 문제가 되는 두 락 순서가 실제로 코드 경로상에서 한 번이라도 실행돼야 경고가 뜨므로, 거의 실행되지 않는 에러 처리 경로의 락 순서 문제는 놓칠 수 있다.
  • 오버헤드가 크기 때문에 프로덕션 커널에는 보통 비활성화하고, 개발/CI/스테이징 환경에서만 켜서 회귀 테스트에 활용하는 것이 일반적이다.
  • 경고가 떴다고 바로 lockdep_set_class()로 억누르기보다는, 정말 안전한 중첩인지 락 순서를 직접 추적해서 검증한 뒤 억제해야 한다.
  • 모듈 로드/언로드를 반복하는 개발 환경에서는 락 클래스 누수로 MAX_LOCKDEP_KEYS를 초과해 lockdep 자체가 비활성화될 수 있다. 이 경우 /proc/lockdep_stats로 먼저 상태를 확인한다.

마무리

lockdep은 락 순서 실수를 실제 데드락이 터지기 전에, 그것도 재현 조건이 갖춰지지 않아도 순서 관찰만으로 잡아낸다는 점에서 커널/드라이버 개발에서 강력한 도구다. 개발 단계에서 CONFIG_PROVE_LOCKING을 켜둔 커널로 코드를 한 번이라도 모든 경로를 타도록 테스트해보는 습관을 들이면, 운영 환경에서 재현조차 어려운 락 순서 버그를 사전에 걸러낼 수 있다.

참고

답글 남기기