스핀락의 Busy-Waiting 구조와 선택 기준
스핀락의 원자적 연산 기반 동작, 경쟁 상황의 문제, 최적화 기법과 Mutex 선택 기준을 정리한다.
2026-08-14 · 최초 발행 2026-01-04
잠금이 풀릴 때까지 CPU를 쓰는 방식
스핀락은 잠금을 얻을 때까지 반복적으로 상태를 확인하는 Busy-Waiting 동기화 메커니즘이다. 대기 중인 프로세스나 스레드를 Sleep 상태로 보내지 않으므로 CPU를 계속 점유한다. 임계 영역이 짧고 멀티프로세서에서 다른 CPU가 곧 잠금을 해제할 수 있는 상황에서는 컨텍스트 스위치 비용을 피할 수 있다.
핵심은 하드웨어가 제공하는 원자적 연산이다. Test-and-Set, Compare-and-Swap 같은 연산으로 잠금 상태를 검사하고 갱신한다. 대기자는 블로킹되지 않지만, 그만큼 CPU를 양보하지 않는다.
Mutex와 대기 방식이 갈리는 지점
Mutex를 기다리는 흐름은 대기자를 멈추고 나중에 깨운다.
Process P1 획득 Lock
Process P2 요청 → Block → Sleep → Context Switch
...
Process P1 해제 Lock
OS Wake up P2 → Context Switch → P2 획득
스핀락은 잠금이 해제되는 순간까지 실행을 멈추지 않는다.
Process P1 획득 Lock
Process P2 요청 → Spin (while loop) → CPU 점유 유지
...
Process P1 해제 Lock
Process P2 즉시 획득 (Context Switch 없음)
원자적 연산으로 잠금을 획득하는 구현
Test-and-Set은 이전 값을 읽고 잠금 값을 설정하는 작업을 원자적으로 수행한다.
typedef struct {
volatile int locked; // 0: unlocked, 1: locked
} spinlock_t;
// 초기화
void spinlock_init(spinlock_t *lock) {
lock->locked = 0;
}
// 하드웨어 원자적 명령어 (의사 코드)
int test_and_set(int *target) {
int old_value = *target;
*target = 1;
return old_value; // 원자적으로 실행됨
}
// Lock 획득
void spinlock_acquire(spinlock_t *lock) {
while (test_and_set(&lock->locked) == 1) {
// Busy-waiting: Lock이 해제될 때까지 반복
// CPU는 이 루프를 계속 실행
}
}
// Lock 해제
void spinlock_release(spinlock_t *lock) {
lock->locked = 0;
}
Compare-and-Swap은 기대한 값일 때만 새 값으로 바꾼다. 잠금 값이 0일 때 1로 바꾸는 데 성공한 실행 흐름만 임계 영역으로 들어간다.
// CAS 원자적 명령어
bool compare_and_swap(int *ptr, int expected, int new_value) {
if (*ptr == expected) {
*ptr = new_value;
return true;
}
return false;
}
void spinlock_acquire_cas(spinlock_t *lock) {
while (!compare_and_swap(&lock->locked, 0, 1)) {
// 0을 기대하고 1로 변경 시도
// 실패하면 계속 반복
}
}
Exchange 기반 구현도 이전 값을 원자적으로 교환해 이미 잠겨 있었는지 판단한다.
int exchange(int *ptr, int new_value) {
int old = *ptr;
*ptr = new_value;
return old; // 원자적 교환
}
void spinlock_acquire_xchg(spinlock_t *lock) {
while (exchange(&lock->locked, 1) == 1) {
// 1을 쓰고 이전 값이 1이면 이미 잠김
}
}
대기 시간이 길어질 때 생기는 비용
임계 영역 안에서 I/O처럼 오래 걸리는 작업이 실행되면 스핀락의 장점은 사라진다.
// 문제 상황: P1이 I/O 중
spinlock_acquire(&lock);
perform_io(); // 100ms 소요
spinlock_release(&lock);
// P2는 100ms 동안 CPU를 낭비하며 대기
while (test_and_set(&lock->locked));
단일 CPU에서는 성능 저하가 특히 심각하다. 멀티 CPU 환경에서도 잠금을 기다리는 CPU 하나가 낭비된다.
선점형 스케줄러에서는 우선순위 역전도 문제가 된다.
P1이 잠금을 획득한 뒤 높은 우선순위의 P2가 P1을 선점하고 잠금을 요청하면, P2는 스핀하면서 CPU를 양보하지 않을 수 있다. P1은 실행되지 못해 잠금을 해제할 수 없고 Deadlock 가능성이 생긴다. 커널에서는 인터럽트를 비활성화하거나, 우선순위 상속 프로토콜을 적용하거나, 스핀락 사용 중 선점을 금지하는 방식으로 다룬다.
기본 스핀락은 먼저 기다린 실행 주체가 먼저 획득한다는 FIFO 보장도 없다. 특정 프로세스가 계속 잠금 획득에 실패해 기아 상태가 될 수 있으며, Ticket Lock이나 MCS Lock이 이를 보완한다.
경쟁을 줄이기 위한 스핀 전략
PAUSE 명령어는 CPU에 스핀 상태임을 알린다.
void spinlock_acquire_pause(spinlock_t *lock) {
while (test_and_set(&lock->locked) == 1) {
__asm__ __volatile__("pause" ::: "memory");
// x86의 PAUSE: CPU에 스핀 중임을 알림
// - 파이프라인 효율 개선
// - 전력 소비 감소
// - 메모리 순서 최적화 방지
}
}
PAUSE는 CPU 파이프라인 플러시를 막고, 하이퍼스레딩 환경에서 다른 쓰레드에 자원을 양보하며, 전력 소비를 약 10~20% 줄인다.
Test-and-Test-and-Set은 잠금 상태를 먼저 읽기만 하다가 해제된 것으로 보일 때 원자적 획득을 시도한다.
void spinlock_acquire_ttas(spinlock_t *lock) {
while (1) {
// 먼저 읽기만 수행 (캐시 친화적)
while (lock->locked == 1) {
// Shared read - 여러 CPU가 동시 가능
__asm__ __volatile__("pause");
}
// Lock이 풀린 것으로 보이면 획득 시도
if (test_and_set(&lock->locked) == 0) {
break; // 성공
}
}
}
읽기 연산은 캐시에서 처리되어 버스 트래픽을 줄이고, 쓰기 연산인 Test-and-Set은 잠금 해제 시점에 집중된다. 멀티프로세서 환경에서 성능을 높이는 방식이다.
경합이 심하면 Exponential Backoff로 재시도 간격을 늘릴 수 있다.
#include <unistd.h>
void spinlock_acquire_backoff(spinlock_t *lock) {
int backoff = 1;
const int max_backoff = 1024;
while (test_and_set(&lock->locked) == 1) {
// 지수적으로 대기 시간 증가
for (int i = 0; i < backoff; i++) {
__asm__ __volatile__("pause");
}
backoff = (backoff * 2 < max_backoff) ? backoff * 2 : max_backoff;
}
}
이 방식은 경쟁이 커질수록 대기 간격을 늘려 버스 경쟁과 전력 소비를 줄인다.
공정성과 확장성을 겨냥한 락
Ticket Lock은 티켓 번호와 현재 처리 번호를 분리해 FIFO 순서를 보장한다.
typedef struct {
volatile unsigned int next_ticket;
volatile unsigned int now_serving;
} ticket_lock_t;
void ticket_lock_init(ticket_lock_t *lock) {
lock->next_ticket = 0;
lock->now_serving = 0;
}
void ticket_lock_acquire(ticket_lock_t *lock) {
unsigned int my_ticket = __sync_fetch_and_add(&lock->next_ticket, 1);
while (lock->now_serving != my_ticket) {
__asm__ __volatile__("pause");
}
}
void ticket_lock_release(ticket_lock_t *lock) {
lock->now_serving++;
}
기아를 방지하고 공정성을 제공하지만, 모든 CPU가 now_serving을 감시하므로 캐시 일관성 트래픽이 증가한다.
MCS Lock은 대기자마다 노드를 두고 자신의 변수만 감시하게 만든다.
typedef struct mcs_node {
struct mcs_node *next;
volatile int locked;
} mcs_node_t;
typedef struct {
mcs_node_t *tail;
} mcs_lock_t;
void mcs_lock_init(mcs_lock_t *lock) {
lock->tail = NULL;
}
void mcs_lock_acquire(mcs_lock_t *lock, mcs_node_t *my_node) {
my_node->next = NULL;
my_node->locked = 1;
mcs_node_t *predecessor = __sync_lock_test_and_set(&lock->tail, my_node);
if (predecessor != NULL) {
predecessor->next = my_node;
while (my_node->locked) {
__asm__ __volatile__("pause");
}
}
}
void mcs_lock_release(mcs_lock_t *lock, mcs_node_t *my_node) {
if (my_node->next == NULL) {
if (__sync_bool_compare_and_swap(&lock->tail, my_node, NULL)) {
return;
}
while (my_node->next == NULL);
}
my_node->next->locked = 0;
}
각 노드가 자신의 변수만 감시하므로 확장성이 좋고, 캐시 일관성 트래픽을 최소화하며 NUMA 환경에도 적합하다.
커널과 사용자 공간에서의 사용
커널에서는 인터럽트 핸들러와 일반 코드 사이를 동기화하거나, 수십 명령어 수준의 짧은 임계 영역을 보호할 때 스핀락을 사용한다.
// Linux Kernel SpinLock
spinlock_t my_lock;
void kernel_function(void) {
unsigned long flags;
// 인터럽트 비활성화 + Lock 획득
spin_lock_irqsave(&my_lock, flags);
// Critical Section
// 인터럽트가 비활성화되어 있어 안전
// Lock 해제 + 인터럽트 복원
spin_unlock_irqrestore(&my_lock, flags);
}
사용자 공간에서는 Lock-free 자료구조 내부, 고성능 멀티쓰레드 서버, 실시간 시스템에서 활용할 수 있다.
// pthread SpinLock
#include <pthread.h>
pthread_spinlock_t spin;
void init() {
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
}
void critical_section() {
pthread_spin_lock(&spin);
// Critical Section
pthread_spin_unlock(&spin);
}
void cleanup() {
pthread_spin_destroy(&spin);
}
스핀락과 Mutex를 가르는 조건
| 조건 | 스핀락 | Mutex |
|---|---|---|
| 짧은 임계 영역 | Context Switch 비용보다 작을 때 적합 | |
| 긴 임계 영역 | CPU 낭비를 막기 위해 적합 | |
| 멀티프로세서 | 다른 CPU에서 곧 해제될 때 적합 | |
| 단일 프로세서 | Spin의 의미가 없어 적합 | |
| 인터럽트 컨텍스트 | Sleep이 불가능해 적합 | |
| I/O 포함 | 블로킹이 필요 | |
| 높은 빈도 접근 | Lock 경쟁이 낮으면 적합 | |
| 많은 경쟁 | Busy-Waiting이 비효율적 | |
| 실시간 시스템 | 예측 가능한 지연이 필요할 때 적합 | |
| 우선순위 필요 | 우선순위 상속이 가능 |
짧게 스핀한 뒤 실패하면 블로킹으로 전환하는 Hybrid 접근도 있다.
// Adaptive Mutex (JVM 방식)
void adaptive_lock(lock_t *lock) {
int spin_count = 0;
const int max_spin = 1000;
// 먼저 짧게 Spin 시도
while (spin_count < max_spin) {
if (try_acquire(lock)) {
return; // 성공
}
spin_count++;
__asm__ __volatile__("pause");
}
// Spin 실패 시 Blocking으로 전환
blocking_acquire(lock);
}
운영체제가 제공하는 스핀락 형태
Linux Kernel 구현은 잠금 획득 전에 선점을 비활성화하고, 해제 뒤 선점을 다시 활성화한다. Ticket SpinLock을 사용해 공정성을 제공하며 Lockdep으로 Deadlock을 탐지한다.
// arch/x86/include/asm/spinlock.h
typedef struct {
arch_spinlock_t raw_lock;
} spinlock_t;
#define spin_lock(lock) \
do { \
preempt_disable(); \
_raw_spin_lock(lock); \
} while (0)
#define spin_unlock(lock) \
do { \
_raw_spin_unlock(lock); \
preempt_enable(); \
} while (0)
Windows는 KSPIN_LOCK과 IRQL을 함께 사용해 임계 영역을 보호한다.
KSPIN_LOCK spinlock;
KeInitializeSpinLock(&spinlock);
KIRQL old_irql;
KeAcquireSpinLock(&spinlock, &old_irql);
// Critical Section
KeReleaseSpinLock(&spinlock, old_irql);
Java에서는 AtomicBoolean의 Compare-and-Set 연산으로 간단한 스핀락을 구성할 수 있다.
import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
private AtomicBoolean locked = new AtomicBoolean(false);
public void lock() {
while (!locked.compareAndSet(false, true)) {
Thread.onSpinWait(); // JDK 9+ hint to CPU
}
}
public void unlock() {
locked.set(false);
}
}