교착상태를 피하기 위한 락 순서와 복구 전략

교착상태의 필요조건과 자원 할당 그래프를 바탕으로 예방, 회피, 탐지·복구 전략 및 락 설계 원칙을 정리한다.

2026-08-14 · 최초 발행 2026-01-04

서로 가진 자원을 기다리며 멈추는 상황

교착상태는 둘 이상의 프로세스가 상대가 점유한 자원을 기다리느라 무한 대기에 들어간 상태다. 외부 개입이 없다면 해당 프로세스는 작업을 끝낼 수 없고, 시스템의 진행도 멈춘다.

운영체제와 동시성 프로그램은 이 상태를 예방하거나, 위험한 할당을 피하거나, 발생 후 탐지와 복구를 수행하는 방식으로 다룬다. 어느 방식을 택하든 안전성과 자원 활용률 사이에는 트레이드오프가 있다.

보유요청보유요청Process 1Resource AResource BProcess 2

Process 1이 Resource A를 확보한 뒤 Resource B를 요청하고, Process 2는 Resource B를 확보한 뒤 Resource A를 요청한다고 하자. 두 프로세스는 상대가 가진 자원을 기다리게 되며, 어느 쪽도 먼저 진행할 수 없다.

아래 코드는 서로 다른 순서로 두 mutex를 획득해 이 상황을 만든다.

// Deadlock 발생 코드
pthread_mutex_t lock1, lock2;

// Thread 1
void *thread1_func(void *arg) {
    pthread_mutex_lock(&lock1);
    printf("Thread 1: Locked lock1\n");
    sleep(1);  // 타이밍 보장

    pthread_mutex_lock(&lock2);  // Deadlock!
    printf("Thread 1: Locked lock2\n");

    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
    return NULL;
}

// Thread 2
void *thread2_func(void *arg) {
    pthread_mutex_lock(&lock2);
    printf("Thread 2: Locked lock2\n");
    sleep(1);  // 타이밍 보장

    pthread_mutex_lock(&lock1);  // Deadlock!
    printf("Thread 2: Locked lock1\n");

    pthread_mutex_unlock(&lock1);
    pthread_mutex_unlock(&lock2);
    return NULL;
}

Coffman 조건이 동시에 성립할 때

교착상태가 되려면 다음 4가지 필요조건이 모두 충족돼야 한다. 따라서 처리 전략은 이 가운데 하나라도 제거하는 데서 출발한다.

상호배제(Mutual Exclusion)는 한 자원을 한 번에 하나의 프로세스만 사용할 수 있는 상태다. 프린터, 파일 잠금, 데이터베이스 레코드가 예가 되며, 공유 자원의 일관성을 위해 필요한 경우가 많다.

점유와 대기(Hold and Wait)는 이미 자원을 가진 프로세스가 다른 자원을 기다리는 상황이다. Lock A를 획득한 채 Lock B를 기다리는 경우가 여기에 해당한다.

비선점(No Preemption)은 프로세스가 자발적으로 자원을 반납할 때까지 강제로 회수할 수 없다는 조건이다. CPU와 메모리는 선점 가능한 자원이지만, 프린터나 데이터베이스 트랜잭션은 선점하면 일관성이 깨질 수 있다.

순환 대기(Circular Wait)는 프로세스 집합 {P0, P1, ..., Pn}에서 P0이 P1의 자원을, P1이 P2의 자원을, ..., Pn이 P0의 자원을 기다리는 구조다. 대기 그래프의 사이클이 바로 이 조건을 보여 준다.

요청요청요청할당할당할당P1R2P2R3P3R1

이 그래프에는 P1 → R2 → P2 → R3 → P3 → R1 → P1의 사이클이 있다. 순환 대기가 존재하므로 교착상태가 발생한다.

필요조건을 끊어 예방하기

예방은 교착상태의 4가지 필요조건 중 하나를 원천적으로 만족하지 못하게 하는 접근이다.

상호배제를 없애는 방법은 자원을 공유 가능하게 만드는 것이다. Read-only 파일이나 프린터 Spooling이 예지만, 쓰기 가능한 파일처럼 본질적으로 배타적인 자원에는 적용하기 어렵다.

점유와 대기를 없애려면 필요한 락을 한 번에 확보하거나, 추가 락 획득에 실패했을 때 이미 가진 락을 해제해야 한다.

// 방법 1: 모든 자원을 한 번에 요청
void all_at_once() {
    // 원자적으로 모든 Lock 획득
    lock_multiple(&lock1, &lock2, &lock3);

    // Critical Section

    unlock_all(&lock1, &lock2, &lock3);
}

// 방법 2: 자원 보유 없이 요청
void no_hold_and_wait() {
    while (1) {
        if (try_lock(&lock1)) {
            if (try_lock(&lock2)) {
                // 성공 - 둘 다 획득
                break;
            } else {
                unlock(&lock1);  // lock2 실패 시 lock1 해제
            }
        }
        // 재시도
    }
}

이 방식은 자원 활용률을 낮출 수 있고, 기아 상태가 생길 수 있으며, 필요한 자원을 미리 예측하기 어렵다는 제약이 있다.

비선점 조건은 자원을 강제로 회수할 수 있게 만들어 끊는다. 다만 선점으로 일관성이 깨지는 자원에는 맞지 않는다.

순환 대기를 차단하는 가장 널리 쓰이는 방법은 모든 자원에 순서를 정하고, 그 순서를 지켜 획득하게 하는 것이다.

// 자원에 순서 부여
enum ResourceOrder {
    LOCK_A = 1,
    LOCK_B = 2,
    LOCK_C = 3
};

// 모든 프로세스가 동일한 순서로 획득
void ordered_locking() {
    pthread_mutex_lock(&lock1);  // Order 1
    pthread_mutex_lock(&lock2);  // Order 2
    pthread_mutex_lock(&lock3);  // Order 3

    // Critical Section

    pthread_mutex_unlock(&lock3);
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
}

락 순서 강제는 구현이 단순하고 순환 구조를 원천적으로 막을 수 있어 많은 시스템에서 채택된다. 반면 자원의 전체 순서를 정의하기 어려울 수 있고, 설계의 유연성은 줄어든다.

안전한 상태에서만 자원을 할당하는 방법

회피(Avoidance)는 교착상태가 될 가능성이 있는 할당을 승인하지 않는다. 은행원 알고리즘(Banker's Algorithm)은 시스템이 안전 상태인지 검사한 뒤에만 요청을 처리하는 대표적인 방식이다.

// 시스템 상태
int available[RESOURCES];           // 사용 가능한 자원
int maximum[PROCESSES][RESOURCES];  // 최대 필요량
int allocation[PROCESSES][RESOURCES]; // 현재 할당량
int need[PROCESSES][RESOURCES];     // 추가 필요량

// 안전 상태 검사
bool is_safe_state() {
    int work[RESOURCES];
    bool finish[PROCESSES] = {false};

    // work = available
    for (int i = 0; i < RESOURCES; i++)
        work[i] = available[i];

    // 완료 가능한 프로세스 찾기
    while (true) {
        bool found = false;

        for (int p = 0; p < PROCESSES; p++) {
            if (!finish[p]) {
                // need[p] <= work 확인
                bool can_finish = true;
                for (int r = 0; r < RESOURCES; r++) {
                    if (need[p][r] > work[r]) {
                        can_finish = false;
                        break;
                    }
                }

                if (can_finish) {
                    // 프로세스 p 완료 가능
                    for (int r = 0; r < RESOURCES; r++)
                        work[r] += allocation[p][r];
                    finish[p] = true;
                    found = true;
                }
            }
        }

        if (!found)
            break;
    }

    // 모든 프로세스가 완료 가능하면 안전
    for (int p = 0; p < PROCESSES; p++)
        if (!finish[p])
            return false;

    return true;
}

// 자원 요청 처리
bool request_resources(int process, int request[]) {
    // 1. request <= need 확인
    // 2. request <= available 확인

    // 임시로 할당
    for (int r = 0; r < RESOURCES; r++) {
        available[r] -= request[r];
        allocation[process][r] += request[r];
        need[process][r] -= request[r];
    }

    // 안전 상태 확인
    if (is_safe_state()) {
        return true;  // 할당 승인
    } else {
        // 롤백
        for (int r = 0; r < RESOURCES; r++) {
            available[r] += request[r];
            allocation[process][r] -= request[r];
            need[process][r] += request[r];
        }
        return false;  // 할당 거부
    }
}

이론적으로는 교착상태를 발생시키지 않지만, 각 프로세스의 최대 자원 필요량을 사전에 알아야 한다. 프로세스 수와 자원 종류가 고정돼야 하고 계산 오버헤드도 커서 실용성이 낮다.

발생을 허용한 뒤 탐지하고 복구하기

탐지 및 복구 방식은 교착상태 자체는 허용하고, 발생 여부를 확인한 뒤 개입한다. Wait-for 그래프의 사이클을 찾는 방식으로 탐지할 수 있다.

// Wait-for 그래프 생성 및 사이클 탐지
typedef struct {
    bool **graph;  // graph[i][j]: i가 j를 기다림
    int processes;
} WaitForGraph;

// DFS로 사이클 탐지
bool has_cycle_util(WaitForGraph *g, int v, bool visited[], bool rec_stack[]) {
    visited[v] = true;
    rec_stack[v] = true;

    for (int i = 0; i < g->processes; i++) {
        if (g->graph[v][i]) {
            if (!visited[i]) {
                if (has_cycle_util(g, i, visited, rec_stack))
                    return true;
            } else if (rec_stack[i]) {
                return true;  // 사이클 발견!
            }
        }
    }

    rec_stack[v] = false;
    return false;
}

bool detect_deadlock(WaitForGraph *g) {
    bool visited[g->processes];
    bool rec_stack[g->processes];

    for (int i = 0; i < g->processes; i++) {
        visited[i] = false;
        rec_stack[i] = false;
    }

    for (int i = 0; i < g->processes; i++) {
        if (!visited[i]) {
            if (has_cycle_util(g, i, visited, rec_stack))
                return true;
        }
    }

    return false;
}

복구에서는 교착상태에 든 프로세스를 종료하거나, 희생자를 골라 자원을 선점한다.

// 방법 1: 모든 교착상태 프로세스 종료
void abort_all_deadlocked_processes() {
    // 빠르지만 비용이 큼
}

// 방법 2: 하나씩 종료하며 재탐지
void abort_one_at_a_time() {
    while (detect_deadlock()) {
        int victim = select_victim();  // 비용 최소화
        terminate_process(victim);
    }
}

희생자는 우선순위가 낮은 프로세스, 실행 시간이 짧은 프로세스, 자원을 적게 사용한 프로세스, 대화식이 아닌 배치 프로세스 등을 기준으로 선택할 수 있다.

void recover_by_preemption() {
    // 1. 희생자 선택
    int victim = select_victim();

    // 2. 자원 회수
    rollback_process(victim);  // 체크포인트로 되돌림

    // 3. 자원 재할당
    allocate_to_waiting_process();
}

자원 선점에서는 같은 프로세스만 반복해서 희생돼 기아 상태에 빠지지 않게 해야 한다. 체크포인트로 되돌리는 비용과, 선점 대상 자원의 일관성도 함께 고려해야 한다.

교착상태 발생 빈도가 매우 낮다면 무시(Ignorance) 전략도 선택할 수 있다. Ostrich Algorithm은 문제를 무시하고 발생 시 재부팅하는 방식이며, UNIX와 Windows의 일부 상황이 예다. 예방·회피·탐지 비용이 가끔 재부팅하는 비용보다 클 때 적용한다.

운영 환경에서의 대응 방식

Linux Kernel은 락 순서 강제와 Lockdep 같은 탐지 도구를 함께 사용한다.

// 1. Lock 순서 강제 (Prevention)
spin_lock(&parent->lock);
spin_lock_nested(&child->lock, SINGLE_DEPTH_NESTING);

// 2. Lockdep (Detection)
// 런타임에 Lock 순서 위반 탐지
CONFIG_PROVE_LOCKING=y

// 3. Deadlock 탐지 도구
// - Lockdep: 컴파일 시간 + 런타임 검증
// - Kcsan: 동시성 버그 탐지

데이터베이스에서는 Timeout, Wait-for 그래프 검사, 트랜잭션 Rollback을 조합할 수 있다.

-- 1. Timeout 기반 탐지
SET LOCK_TIMEOUT = 5000;  -- 5초

-- 2. Deadlock 자동 탐지 및 복구
-- InnoDB는 주기적으로 Wait-for 그래프 검사
-- 탐지 시 하나의 트랜잭션 Rollback

-- 3. Deadlock 정보 조회
SHOW ENGINE INNODB STATUS;

Java에서는 고정된 순서의 synchronized, tryLock과 Timeout, JMX 기반 탐지를 사용할 수 있다.

// 1. 순서 강제 (Prevention)
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();

public void orderedLocking() {
    synchronized(lock1) {  // 항상 lock1 먼저
        synchronized(lock2) {
            // Critical Section
        }
    }
}

// 2. tryLock with Timeout (Avoidance)
Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();

public void tryLockExample() throws InterruptedException {
    while (true) {
        if (lock1.tryLock(50, TimeUnit.MILLISECONDS)) {
            try {
                if (lock2.tryLock(50, TimeUnit.MILLISECONDS)) {
                    try {
                        // Success
                        return;
                    } finally {
                        lock2.unlock();
                    }
                }
            } finally {
                lock1.unlock();
            }
        }
        // Retry
    }
}

// 3. JMX 기반 Deadlock 탐지
ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
long[] deadlocked = tmx.findDeadlockedThreads();
if (deadlocked != null) {
    System.out.println("Deadlock detected!");
}

락을 다루는 코드와 설계의 기준

락 순서를 모든 코드 경로에서 일관되게 유지하고, 필요한 범위만 락으로 보호해야 한다. 팀이 공유하는 락 순서를 문서화하고, 주기적인 교착상태 탐지와 동시성 테스트 도구를 운영 과정에 포함하는 편이 좋다. TSan과 Helgrind를 활용하는 방법도 있다.

// 1. Lock 순서 일관성 유지
// Good
void safe_transfer(Account *from, Account *to, int amount) {
    Account *first = (from < to) ? from : to;
    Account *second = (from < to) ? to : from;

    lock(first);
    lock(second);
    // Transfer
    unlock(second);
    unlock(first);
}

// 2. try-lock 패턴
bool try_acquire_locks(Lock *l1, Lock *l2) {
    if (try_lock(l1)) {
        if (try_lock(l2)) {
            return true;
        }
        unlock(l1);
    }
    return false;
}

// 3. Timeout 사용
bool acquire_with_timeout(Lock *lock, int ms) {
    return timed_lock(lock, ms);
}

// 4. Lock-free 알고리즘 고려
atomic_int counter;
__atomic_fetch_add(&counter, 1, __ATOMIC_SEQ_CST);

예방은 안전하지만 자원 활용률을 낮출 수 있다. 탐지와 복구는 유연한 대신 오버헤드가 따른다. 따라서 락 순서 강제를 기본으로 두고, 모니터링으로 보완하는 하이브리드 접근이 효과적이다.

교착상태운영체제동시성자원관리