icon

안동민 개발노트

6장 : 프로세스 동기화

하드웨어 동기화와 뮤텍스


임계 영역 문제를 해결하려면, 한 번에 하나의 스레드만 진입을 보장하는 메커니즘이 필요합니다. 소프트웨어 알고리즘(Peterson's 등)으로도 가능하지만, 현대 CPU는 메모리 재정렬과 다코어 캐시 일관성 때문에 소프트웨어 방법만으로는 불충분합니다. 그래서 CPU가 원자적(atomic) 명령어를 하드웨어 수준에서 제공하고, 이를 기반으로 뮤텍스, 세마포어 등의 동기화 도구가 구축됩니다.


인터럽트 비활성화

가장 원시적인 방법입니다. 임계 영역에 진입하기 전에 인터럽트를 비활성화하면, 타이머 인터럽트가 발생하지 않아 선점되지 않습니다.

개념 (커널 내부에서만 사용)
disable_interrupts();
// 임계 영역
enable_interrupts();

이 방법은 커널 내부의 짧은 임계 영역에서만 사용됩니다. 문제가 많기 때문입니다. 인터럽트가 비활성화된 동안 I/O 완료, 타이머 등 모든 이벤트가 처리되지 않습니다. 멀티코어에서는 효과가 없습니다 — 한 코어의 인터럽트를 비활성화해도 다른 코어는 영향을 받지 않습니다. 그리고 사용자 프로세스에게 인터럽트 비활성화 권한을 주면, 악의적인 프로세스가 CPU를 영원히 독점할 수 있습니다.


하드웨어 원자적 명령어

현대 CPU는 여러 메모리 연산을 단일 하드웨어 명령어로 수행하는 원자적 명령어를 제공합니다. 원자적이란 중간에 끊어지지 않음을 의미합니다. 다른 코어가 끼어들 수 없습니다.

Test-and-Set (TAS)

값을 읽고, 새 값을 쓰는 두 동작을 쪼갤 수 없는 하나의 연산으로 수행합니다.

Test-and-Set 의사코드
/* 하드웨어가 원자적으로 수행 */
bool test_and_set(bool *target) {
    bool old = *target;
    *target = true;
    return old;
}

반환값이 false이면 이전에 잠겨있지 않았고, 지금 잠겼다 → 락 획득 성공. 반환값이 true이면 이미 잠겨있었다 → 락 획득 실패.

x86에서는 XCHG 명령어가 이 역할을 합니다. ARM에서는 LDREX/STREX 쌍을 사용합니다.

Compare-and-Swap (CAS)

기대되는 값과 현재 값이 같으면 새 값으로 교체하는 원자적 연산입니다. TAS보다 범용적입니다.

CAS 의사코드
/* 하드웨어가 원자적으로 수행 */
bool compare_and_swap(int *value, int expected, int new_value) {
    if (*value == expected) {
        *value = new_value;
        return true;   /* 성공 */
    }
    return false;      /* 실패: 다른 스레드가 먼저 변경 */
}

CAS가 실패하면 다른 스레드가 먼저 값을 변경했다는 의미입니다. 보통 루프를 돌면서 재시도합니다. 이것이 CAS 루프(또는 compare-and-swap loop)패턴입니다.

CAS로 안전한 counter++
void atomic_increment(int *counter) {
    int old_val, new_val;
    do {
        old_val = *counter;
        new_val = old_val + 1;
    } while (!compare_and_swap(counter, old_val, new_val));
}

x86에서는 CMPXCHG 명령어가 CAS를 수행합니다. Java의 AtomicInteger.compareAndSet(), C++의 std::atomic::compare_exchange_weak()가 이 명령어를 사용합니다.

CAS와 Lock-Free 프로그래밍

CAS의 강력함은 락 없이도 스레드 안전한 연산이 가능하다는 것입니다. 위의 atomic_increment는 뮤텍스 없이 안전합니다. 이것이 Lock-Free 프로그래밍의 기반입니다.

Lock-Free가 중요한 이유: 뮤텍스를 사용하면, 락을 잡은 스레드가 선점되거나 지연되면 다른 모든 스레드가 블로킹됩니다. Lock-Free에서는 최소 하나의 스레드가 항상 진행합니다.

Java의 java.util.concurrent.atomic 패키지, C++의 std::atomic, Rust의 std::sync::atomic이 CAS 기반입니다.

ABA 문제

CAS의 알려진 함정입니다. 값이 A → B → A로 변하면, CAS는 값이 여전히 A이므로 변경 없었다고 판단합니다. 하지만 실제로는 변경이 있었습니다. 연결 리스트에서 이것이 문제가 될 수 있습니다.

해결책: 버전 번호를 함께 비교합니다. Java의 AtomicStampedReference가 이 방법을 사용합니다.


스핀락

스핀락(Spinlock)은 TAS로 구현하는 가장 단순한 동기화 방법입니다.

spinlock 구현
typedef struct {
    int locked;
} spinlock_t;

void spin_lock(spinlock_t *lock) {
    while (test_and_set(&lock->locked)) {
        /* 락이 풀릴 때까지 CPU를 태우며 반복 (busy waiting) */
    }
}

void spin_unlock(spinlock_t *lock) {
    lock->locked = 0;
}

락을 얻을 때까지 while 루프를 돌며 기다립니다. 이것을 바쁜 대기(Busy Waiting) 또는 스피닝(Spinning)이라고 합니다. CPU가 아무 유용한 일을 하지 않으면서 계속 명령어를 실행합니다.

스핀락이 효율적인 경우

스핀락은 무조건 나쁘지 않습니다. 멀티코어 환경에서 임계 영역이 매우 짧을 때 오히려 뮤텍스보다 빠릅니다.

뮤텍스는 락 획득 실패 시 스레드를 슬립시키고, 나중에 깨웁니다. 이 과정에 커널 진입(시스템 콜) + 컨텍스트 스위칭이 필요합니다. 임계 영역이 수십 나노초만 지속된다면, 스레드를 재우고 깨우는 비용이 그냥 스피닝하는 비용보다 큽니다.

Linux 커널 내부의 짧은 임계 영역에서 스핀락이 광범위하게 사용됩니다. 사용자 공간에서는 뮤텍스가 일반적입니다.

스핀락이 위험한 경우

싱글코어에서 스핀락은 무의미합니다. 락을 잡고 있는 스레드가 같은 코어에서 실행되고 있으므로, 스피닝하는 스레드가 CPU를 계속 점유하면 락을 잡고 있는 스레드가 실행되지 못합니다. 결국 타이머 인터럽트로 선점될 때까지 스피닝이 계속됩니다.


뮤텍스 (Mutex)

뮤텍스(Mutex, Mutual Exclusion Lock)는 스핀락의 바쁜 대기 문제를 해결합니다. 락을 얻지 못하면, 스레드를 슬립(Sleep) 상태로 전환하여 CPU를 양보합니다. 락이 풀리면 대기 중인 스레드를 깨웁니다. 이것을 Sleep-and-Wake 방식이라고 합니다.

C — pthread_mutex

pthread_mutex.c
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);    /* 락 획득 (대기 가능) */
        counter++;                     /* 임계 영역 */
        pthread_mutex_unlock(&lock);   /* 락 해제 */
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Counter: %d\n", counter);  /* 항상 2000000 */
    return 0;
}

pthread_mutex_lock()은 락을 획득합니다. 이미 다른 스레드가 잡고 있으면, 현재 스레드는 블로킹(슬립)됩니다. pthread_mutex_unlock()은 락을 해제하고, 대기 중인 스레드 중 하나를 깨웁니다.

Python — threading.Lock

mutex_python.py
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(1_000_000):
        with lock:        # acquire() + 자동 release()
            counter += 1

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Counter: {counter}")  # 항상 2000000

with lock: 구문은 lock.acquire()를 호출하고 블록이 끝나면(예외가 발생해도) lock.release()를 호출합니다. C에서 lock/unlock을 직접 호출하면 예외 시 unlock을 놓칠 수 있으므로, Python의 with 문이 더 안전합니다.

뮤텍스 사용 주의 사항

이중 잠금 금지: 같은 뮤텍스를 같은 스레드가 두 번 lock()하면 데드락입니다. 재진입이 필요하면 재진입 뮤텍스(Recursive Mutex)를 사용합니다. pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE)로 설정합니다.

최소 범위 잠금: 임계 영역은 최대한 짧아야 합니다. 락 안에서 I/O, 네트워크 호출, 대용량 연산을 하면 다른 스레드가 오래 대기합니다.

락 순서 일관성: 여러 뮤텍스를 사용할 때, 항상 같은 순서로 잡아야 합니다. A → B 순서로 잡는 곳과 B → A 순서로 잡는 곳이 있으면 데드락이 발생합니다. 7장에서 자세히 다룹니다.

뮤텍스 vs 스핀락 비교

특성스핀락뮤텍스
대기 방식바쁜 대기 (CPU 소비)슬립 (CPU 양보)
적합한 상황매우 짧은 임계 영역, 멀티코어긴 임계 영역, 일반 용도
오버헤드락 자체는 낮지만 CPU 낭비커널 전환 비용
싱글코어비효율적 (의미 없음)효율적
사용 공간주로 커널 내부사용자 공간 + 커널

Futex — 두 세계의 장점

Linux의 Futex(Fast Userspace Mutex)는 스핀락과 뮤텍스의 장점을 결합합니다. 경합이 없을 때(락이 비어있을 때)는 커널 진입 없이 사용자 공간에서 CAS로 락을 획득합니다(스핀락 수준의 속도). 경합이 있을 때만 커널에 진입하여 스레드를 슬립시킵니다(뮤텍스의 CPU 절약).

pthread_mutex가 내부적으로 futex를 사용합니다. 즉, 개발자가 pthread_mutex_lock()을 호출하면, 경합이 없으면 시스템 콜 없이 즉시 반환되고, 경합이 있으면 futex() 시스템 콜로 슬립합니다. 이것이 현대 뮤텍스가 빠른 이유입니다.

다음 절에서는 뮤텍스보다 더 유연한 동기화 도구인 세마포어를 다루겠습니다.

목차