#커널 동기화
커널 동기화란 용어를 자주 들어보셨나요? 리눅스 커널에 대한 개념을 담고 있는 블로그에서 많이 다루고 있는 개념이죠. 그 중에서 가장 많이 볼 수 있는 스핀락에 대해서 다루겠습니다.
일단 spin_lock이 사용되는 코드를 살펴보겠습니다. 아래 코드를 보면 spin_lock() 함수를 볼 수 있는데 이 코드를 전처리 파일에서 확인하면 다른 형태로 구현된 것을 확인할 수 있습니다.
[kernel/fs/jbd2/commit.c]
void jbd2_journal_commit_transaction(journal_t *journal) {
//snip
spin_lock(&commit_transaction->t_handle_lock);
위 코드를 전처리 코드에서 확인하면, spin_lock() 함수는 실제 arch_spin_lock() 함수임을 알 수 있습니다.
spin_lock() -> _raw_spin_lock() -> __raw_spin_lock() -> do_raw_spin_lock() -> arch_spin_lock()
static inline __attribute__((always_inline)) __attribute__((no_instrument_function)) void spin_lock(spinlock_t *lock)
{
_raw_spin_lock(&lock->rlock);
}
void __attribute__((section(".spinlock.text"))) _raw_spin_lock(raw_spinlock_t *lock)
{
__raw_spin_lock(lock);
}
static inline __attribute__((always_inline)) __attribute__((no_instrument_function)) void __raw_spin_lock(raw_spinlock_t *lock)
{
do { preempt_count_add(1); __asm__ __volatile__("": : :"memory"); } while (0);
do { } while (0);
do_raw_spin_lock(lock);
}
static inline __attribute__((always_inline)) __attribute__((no_instrument_function)) void do_raw_spin_lock(raw_spinlock_t *lock)
{
(void)0;
arch_spin_lock(&lock->raw_lock);
}
스핀락을 제대로 파악하기 위해서는 arch_spin_lock() 함수를 제대로 분석해야 합니다. 그런데 아래 코드를 잠깐 보면 평소에 볼 수 없는 인라인 어셈블리 코드를 확인할 수 있습니다.
static inline __attribute__((always_inline)) __attribute__((no_instrument_function)) void arch_spin_lock(arch_spinlock_t *lock)
{
unsigned long tmp;
u32 newval;
arch_spinlock_t lockval;
prefetchw(&lock->slock);
__asm__ __volatile__(
"1: ldrex %0, [%3]n"
" add %1, %0, %4n"
" strex %2, %1, [%3]n"
" teq %2, #0n"
" bne 1b"
: "=&r" (lockval), "=&r" (newval), "=&r" (tmp)
: "r" (&lock->slock), "I" (1 << 16) //<<--[2]
: "cc");
while (lockval.tickets.next != lockval.tickets.owner) {
__asm__ __volatile__ ("wfe" : : : "memory");
lockval.tickets.owner = (*(volatile typeof(lock->tickets.owner) *)&(lock->tickets.owner));
}
__asm__ __volatile__ ("dmb " "ish" : : : "memory");
}
큰 마음 먹고 리눅스 커널 코드를 분석하다가 인라인 어셈블리 코드가 나타날 때 포기할 때가 많습니다. 그런데 인라인 어셈블리 코드는 생각보다 해석하기 쉽습니다. 겁먹지 마세요.
어셈블 코드 이후에 보이는 선언부를 일단 확인하면, 각 %에 해당하는 변수를 아래와 같이 확인할 수 있어요.
%0: &r" (lockval),
%1: "=&r" (newval),
%2: "=&r" (tmp)
%3:"r" (&lock->slock),
%4: "I" (1 << TICKET_SHIFT)
그리고 아래 코드는 업데이트될 변수들이고,
"=&r" (lockval), "=&r" (newval), "=&r" (tmp)
이 값들은 인풋으로 쓰일 변수들입니다.
: "r" (&lock->slock), "I" (1 << 16)
위에서 설명한 내용을 조금 더 쉽게 아래 주석문으로 표현할 수 있습니다. 어떤 인라인 어셈블리 코드를 만나도 이 규칙만 제대로 적용하면 어렵지 않게 코드 분석을 할 수 있습니다.
prefetchw(&lock->slock);
__asm__ __volatile__(
"1: ldrex %0, [%3]n"
" add %1, %0, %4n"
" strex %2, %1, [%3]n"
" teq %2, #0n"
" bne 1b"
: "=&r" (lockval), "=&r" (newval), "=&r" (tmp) //<<-- 인풋
: "r" (&lock->slock), "I" (1 << 16) //<<-- 아웃풋
: "cc");
위 인라인 어셈블리 코드를 C 언어로 바꿔서 더 쉽게 표현할 수 있습니다. 위 동작을 요약하면 아래와 같습니다.
1> lock.tickets.next 값을 1만큼 증가
2>입력되는 lock.tickets.next값이 lock.tickets.owner와 같아 질 때까지 기다림
조금 더 깊히 있는 코드 분석을 하겠습니다.
[1]번 코드는 만약 arch_spin_lock() 함수에 첫 진입하기 직전에,
next가 owner보다 1보다 크면(next+1 = owner) 이미 spinlock을 잡고 있다고 간주합니다.
이럴 때는 [1] while 루프를 돌면서 기존에 spinlock을 잡고 있는 누군가가 spinlock을 해제(owner을 1만큼 증가)
할 때 까지 기다립니다.
보통 spinlock을 다른 프로그램에서 획득하지 않았으면 spinlock을 획득하고 바로 이 루프를 빠져 나옵니다. 좀 어렵죠?
static inline __attribute__((always_inline)) __attribute__((no_instrument_function)) void arch_spin_lock(arch_spinlock_t *lock)
{
unsigned long tmp;
u32 newval;
arch_spinlock_t lockval;
lockval = &lock->slock;
newval = lockval.tickets.next + 1;
newval = &(lock.tickets.next);
while (lockval.tickets.next != lockval.tickets.owner) { //<<--[1]
__asm__ __volatile__ ("wfe" : : : "memory");
lockval.tickets.owner = (*(volatile typeof(lock->tickets.owner) *)&(lock->tickets.owner));
}
스핀락 획득과 릴리즈 과정을 정리하면 아래와 같아요.
1. &lock->tickets.next 1만큼 증가시키고 arch_spin_lock() 함수 빠져 나옴.
2. arch_spin_unlock() 호출로 &lock->tickets.owner 1만큼 증가
스핀락을 해제하는 핵심 코드는 arch_spin_unlock인데 구현부는 간단합니다.
lock->tickets.owner를 +1 만큼 증가시키죠.
static inline void arch_spin_unlock(arch_spinlock_t *lock)
{
asm volatile(
" stlrh %w1, %0n"
: "=Q" (lock->owner)
: "r" (lock->owner + 1)
: "memory");
}
스핀락 코드를 잠깐 살펴봤는데 뮤텍스와 다르게 상당히 코드 복잡도가 낮습니다. tickets.next과 tickets.owner 두 값으로 스핀락을 획득하는 순서를 콘트롤하죠. spin_lock 함수가 처음 실행되는 시점에 tickets.owner 값을 로컬 변수에 저장하고 전역 tickets.next 값이 업데이트 될 때까지 Busy-wait합니다. 여기서 중요한 포인트는 tickets.owner 값을 로컬 변수 즉 스택 메모리 공간에 저장해서 제어한다는 점입니다. 모든 프로세스들은 각각 스택 공간에서 돌기 때문에 ticket 스핀락을 획득한 순서를 정확하게 파악할 수 있습니다.
낮은 복잡도에 정확하기 까지 합니다. 이렇게 스핀락 함수는 빠른 시간 내에 실행돼야 하는 인터럽트 서비스 루틴에서 공유 데이터를 보호하기 위해 씁니다.
스핀락 데드락이 생기면 어떻게 될 까요. 만약 어떤 프로세스가 스핀락을 획득하려고 하는데 이미 다른 모듈이 스핀락을 획득하고 해제하지 않으면 계속 while 루프 내에 돌면서 기다립니다. 이를 다른 말로 Busy-wait라고도 합니다. 그래서 어떤 프로세스가 스핀락을 획득하고 슬립에 빠지거나 무한 루프를 돌면 다른 프로세스들은 스핀락을 얻기 위해 계속 기다립니다. 대부분 시스템에서 이런 상황에서 결국 와치독 리셋으로 크래시가 발생합니다.
스핀락을 여러 방식으로 설명할 수 있는데 실제 소스 코드를 열어서 어셈블리 코드를 분석하는 게 가장 효율적으로 스핀락을 이해하는 길인 것 같습니다.
다음 시간에는 어셈블리 코드를 상세하게 분석하면서 스핀락을 어떻게 획득하는지 점검하겠습니다.
"혹시 궁금한 점이 있으면 댓글로 질문 남겨주세요. 아는 한 성실히 답변 올려드리겠습니다!"
Thanks,
Austin Kim(austindh.kim@gmail.com)
#Reference(커널 동기화)
커널 동기화 기본 개념 소개
레이스 발생 동작 확인
커널 동기화 기법 소개
스핀락
뮤텍스란
커널 동기화 디버깅
# Reference: For more information on 'Linux Kernel';
디버깅을 통해 배우는 리눅스 커널의 구조와 원리. 1
디버깅을 통해 배우는 리눅스 커널의 구조와 원리. 2

최근 덧글