[OSTEP] 28. 락(Locks): 병행성 제어의 기초

[OSTEP] 28. 락(Locks): 병행성 제어의 기초

안녕하세요, pingu52입니다.

이전 장에서 병행 프로그램이 가진 근본적인 문제, 즉 원자적으로 실행되어야 할 명령어들이 중간에 중단되면서 발생하는 경쟁 상태(Race Condition)를 보았습니다. 이번 장에서는 이 문제를 해결하기 위한 가장 직접적인 도구인 락(Lock) 을 다룹니다.

프로그래머는 임계 영역(Critical Section) 주위에 락을 배치하여, 해당 구간이 마치 하나의 원자적 동작처럼 실행되도록 보장합니다.


1. 락이란 무엇인가 (The Basic Idea)#

락은 임계 영역에 오직 하나의 스레드만 진입하도록 강제하는 도구입니다.

lock_t mutex;
lock(&mutex);
balance = balance + 1; // 임계 영역
unlock(&mutex);

락 변수(mutex)는 현재 락이 사용 가능(available, unlocked) 한지, 아니면 누군가 획득(acquired, held) 했는지를 나타내는 상태를 가집니다.

  • lock(): 락 획득을 시도합니다. 누군가 락을 가지고 있다면 락이 해제될 때까지 반환하지 않고 대기합니다.
  • unlock(): 락을 반환합니다. 대기 중인 스레드가 있다면 그 중 하나가 락을 얻을 수 있도록 진행됩니다.

이 단순한 규칙만으로도 프로그래머는 임계 영역의 동시 진입을 막고, 결과가 타이밍에 따라 바뀌는 문제를 통제할 수 있습니다.


2. 락의 평가 기준 (Evaluating Locks)#

우리가 만든 락이 좋은지 나쁜지는 어떻게 판단할까요. 보통 다음 세 가지 기준을 봅니다.

  1. 상호 배제 (Mutual Exclusion)
    임계 영역에 다수의 스레드가 동시에 들어가는 것을 확실히 막아주는가

  2. 공정성 (Fairness)
    락을 기다리는 스레드들이 굶주리지(Starvation) 않고 언젠가는 락을 얻을 수 있는가

  3. 성능 (Performance)
    락 사용으로 발생하는 오버헤드는 얼마인가
    경쟁이 없을 때와 경쟁이 심할 때를 나누어 고려해야 합니다

이 장의 흐름은 결국 다음 질문으로 이어집니다.

  • 상호 배제는 어떻게 보장할까
  • 공정성은 어떻게 확보할까
  • 경쟁이 심할 때 CPU 낭비를 어떻게 줄일까

3. 초기 시도들: 하드웨어의 도움 없이?#

3.1 인터럽트 제어 (Controlling Interrupts)#

초기 단일 프로세서 시스템에서는 임계 영역 진입 전에 인터럽트를 끄는 방식이 사용되기도 했습니다.

void lock() {
DisableInterrupts();
}
void unlock() {
EnableInterrupts();
}
  • 장점: 단순하고 확실하게 상호 배제를 보장합니다.
  • 단점:
    • 멀티프로세서에서는 동작하지 않습니다. 한 CPU에서 인터럽트를 꺼도 다른 CPU는 계속 실행됩니다.
    • 사용자 프로그램에 인터럽트 제어 권한을 주는 것은 매우 위험합니다. 무한 루프에 빠지면 시스템이 복구 불가능해질 수 있습니다.
    • 중요한 인터럽트(예: 디스크 I/O 완료)를 지연시키거나 놓치면 시스템 전체 지연이 커집니다.

정리하면, 인터럽트 제어는 특정 상황에서만 제한적으로 의미가 있고 일반적인 사용자 레벨 락의 해법으로는 부적절합니다.

3.2 소프트웨어만 사용하기 (A Failed Attempt)#

단순한 플래그 변수(flag)로 락을 구현하려는 시도는 실패합니다.

while (flag == 1) {
; // spin
}
flag = 1;

문제는 flag를 확인하고 값을 설정하는 과정이 원자적이지 않다는 점입니다. 두 스레드가 동시에 flag를 0으로 읽고 동시에 flag = 1을 수행하면, 결국 둘 다 임계 영역에 진입할 수 있습니다. 즉, 상호 배제를 보장하지 못합니다.


4. 하드웨어의 도움: 원자적 명령어#

제대로 된 락을 만들기 위해 하드웨어는 원자적 명령어(Atomic Instruction) 를 지원하기 시작했습니다. 핵심은 간단합니다.

  • 락 획득 시점의 경쟁은 결국 메모리의 특정 값 하나를 두고 발생합니다.
  • 이 값을 읽고 갱신하는 과정을 원자적으로 묶어야 경쟁을 올바르게 처리할 수 있습니다.

4.1 Test-And-Set (Atomic Exchange)#

Test-And-Set은 메모리의 이전 값을 반환하면서 동시에 새로운 값을 씁니다. 이 과정 전체가 원자적으로 수행됩니다.

이를 이용해 가장 단순한 스핀 락(Spin Lock) 을 만들 수 있습니다.

void lock(lock_t *lock) {
while (TestAndSet(&lock->flag, 1) == 1) {
; // spin-wait
}
}

이 락은 상호 배제를 보장하지만, 다음 한계가 있습니다.

  • 공정성 부족: 운이 나쁜 스레드는 계속 밀려 기아 상태가 생길 수 있습니다.
  • CPU 낭비: 락을 기다리는 동안 계속 루프를 돌며 CPU 시간을 소모합니다.
  • 단일 CPU에서 특히 비효율적: 스핀이 타임 슬라이스를 태우면, 정작 락 보유자가 실행될 기회가 줄어 더 느려질 수 있습니다.

4.2 Compare-And-Swap (CAS)#

Compare-And-Swap은 기대값(expected)과 메모리 값이 일치할 때만 새로운 값으로 갱신합니다. 비교와 갱신이 원자적으로 수행됩니다.

  • Test-And-Set과 마찬가지로 락 구현에 사용할 수 있고
  • 더 일반적으로는 락 프리(lock-free) 자료구조 등에도 활용됩니다

4.3 Load-Linked / Store-Conditional (LL/SC)#

MIPS, ARM 등에서 사용하는 방식입니다.

  • Load-Linked로 값을 읽고
  • Store-Conditional로 값을 쓰되
  • 그 사이 해당 주소에 다른 쓰기(write)가 없었을 때만 저장이 성공합니다

결국 목적은 동일합니다. 경쟁 구간을 원자적으로 다루기 위한 하드웨어 지원입니다.

4.4 Fetch-And-Add (Ticket Lock)#

Fetch-And-Add는 값을 원자적으로 1 증가시키고 이전 값을 반환합니다. 이를 이용하면 티켓 락(Ticket Lock) 을 만들 수 있습니다.

int myturn = FetchAndAdd(&lock->ticket);
while (lock->turn != myturn) {
; // spin
}

티켓 락은 먼저 온 스레드가 먼저 락을 얻는 선입선출(FIFO)을 보장하여 공정성 문제를 크게 완화합니다.

다만 여전히 스핀 방식이므로 경쟁이 심하면 CPU 낭비와 메모리 트래픽 문제가 남습니다. 특히 많은 스레드가 turn 값을 반복해서 읽으면서 캐시 라인이 흔들릴 수 있습니다.


5. 스핀의 문제와 해결: 큐와 대기#

스핀 락은 락을 기다리는 동안 CPU를 계속 소모합니다. 임계 영역이 짧고 경쟁이 약하면 괜찮지만, 경쟁이 커지거나 임계 영역이 길어지면 낭비가 급격히 커집니다. 이를 줄이기 위해 OS의 지원을 받는 방향으로 발전합니다.

5.1 양보 (Yield)#

yield() 같은 시스템 콜로 CPU를 다른 스레드에게 양보할 수 있습니다.

  • 스핀 대신 양보하면 CPU 낭비는 줄어듭니다.
  • 하지만 스레드 수가 많으면 문맥 교환(Context Switch) 비용이 커지고, 실행 큐가 혼잡해집니다.
  • 또한 특정 스레드가 계속 밀리는 기아 상태를 완전히 없애기 어렵습니다.

5.2 큐 사용: 잠들기 (Sleeping)#

더 근본적인 접근은 대기 스레드를 재우는 것입니다. OSTEP에서는 Solaris의 park()unpark() 같은 인터페이스를 예로 들며, 대기 스레드를 큐로 관리하는 방식으로 확장합니다.

개념적으로는 다음과 같습니다.

  • 락이 비어 있으면 즉시 획득
  • 이미 누군가 보유 중이면 대기 큐에 등록하고 잠듦
  • 락이 풀릴 때 큐에서 하나를 깨움
if (m->flag == 0) {
m->flag = 1; // 락 획득
} else {
queue_add(m->q, gettid());
park(); // 잠들기
}

이 방식은 스핀을 최소화하고 큐를 통해 공정성도 개선할 수 있습니다. 다만 여기에는 중요한 레이스가 숨어 있습니다.

  • 스레드가 park() 직전에 락이 해제되고 unpark()가 먼저 발생하면
  • 이후 해당 스레드는 깨울 이벤트를 놓친 채 영원히 잠들 수 있습니다

이를 막기 위해 setpark() 같은 추가 프로토콜이 필요합니다. 즉, sleep 기반 락은 성능을 얻는 대신 잠듦과 깨움의 순서를 안전하게 설계해야 합니다.


6. Linux의 Futex#

Linux는 futex(fast userspace mutex)라는 메커니즘을 제공합니다.

  • 경쟁이 없을 때: 커널 진입 없이 사용자 공간의 원자적 연산으로 빠르게 락을 획득하고 해제합니다.
  • 경쟁이 있을 때: 커널에 들어가 futex를 통해 잠들고, 해제 시 깨우는 경로를 사용합니다.

즉, 대부분의 경우는 빠른 경로를 유지하고, 필요한 순간에만 커널의 도움을 받아 대기와 깨움을 처리하는 방식입니다. 현대적인 락 구현이 목표로 하는 균형점이기도 합니다.


7. 요약 (Summary)#

  • 락(Lock) 은 임계 영역의 상호 배제를 보장하는 도구입니다.
  • 하드웨어는 락 구현을 위해 Test-And-Set, Compare-And-Swap, Fetch-And-Add 같은 원자적 명령어를 제공합니다.
  • 스핀 락은 구현이 단순하지만 공정성과 CPU 낭비 문제가 있습니다.
  • 티켓 락은 FIFO로 공정성을 개선하지만, 여전히 스핀 방식의 비용이 남습니다.
  • 큐 기반 락은 OS 지원을 받아 스핀을 줄이고 공정성과 성능을 함께 노립니다.
  • Linux의 futex는 사용자 공간 fast path와 커널 기반 대기 경로를 결합한 대표적인 예입니다.

8. 용어 정리#

  • Critical Section (임계 영역): 공유 자원에 접근하는 코드 구간으로, 한 번에 하나의 스레드만 실행해야 함.
  • Mutual Exclusion (상호 배제): 락의 가장 기본 기능으로, 동시 진입을 막는 것.
  • Spin Lock (스핀 락): 락을 얻을 때까지 루프를 돌며 기다리는 방식.
  • Ticket Lock (티켓 락): Fetch-And-Add로 번호표를 배부하고 순서대로 락을 주는 방식. 공정성을 보장함.
  • Futex: Linux에서 제공하는 고성능 락킹 프리미티브. 사용자 공간의 빠른 경로와 커널의 대기 경로를 혼합함.
  • Priority Inversion (우선순위 역전): 낮은 우선순위 스레드가 락을 보유해 높은 우선순위 스레드가 실행되지 못하는 현상.

Reference#