LabHub

블로그

[운영체제] 07. 동기화 문제: 생산자-소비자, 철학자 식사

한국어English日本語

고전적 동기화 문제

동기화 도구의 올바른 사용법을 이해하기 위해, 운영체제 분야에서 전통적으로 다루는 세 가지 고전적 문제를 살펴본다.


1. 유한 버퍼 문제 (Bounded-Buffer Problem)

생산자는 데이터를 만들어 버퍼에 넣고, 소비자는 버퍼에서 데이터를 꺼내 사용한다. 버퍼의 크기가 유한하므로, 버퍼가 가득 차면 생산자는 대기하고, 비어 있으면 소비자가 대기해야 한다.

[유한 버퍼 구조]

생산자 -->  [  |  |  |  |  ]  --> 소비자
            0  1  2  3  4
            ^           ^
           out          in

세마포어:
  mutex = 1      (버퍼 접근 상호 배제)
  empty = N      (빈 슬롯 수, 초기값 = 버퍼 크기)
  full  = 0      (채워진 슬롯 수, 초기값 = 0)
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

#define BUFFER_SIZE 5

int buffer[BUFFER_SIZE];
int in = 0, out = 0;

sem_t empty;    // 빈 슬롯 수
sem_t full;     // 채워진 슬롯 수
pthread_mutex_t mutex;

void *producer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 10; i++) {
        int item = rand() % 100;

        sem_wait(&empty);           // 빈 슬롯이 있을 때까지 대기
        pthread_mutex_lock(&mutex); // 버퍼 접근 잠금

        // 임계 영역: 버퍼에 아이템 추가
        buffer[in] = item;
        printf("생산자 %d: buffer[%d] = %d 생산\n", id, in, item);
        in = (in + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&full);            // 채워진 슬롯 수 증가

        usleep(rand() % 500000);
    }
    return NULL;
}

void *consumer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 10; i++) {
        sem_wait(&full);            // 채워진 슬롯이 있을 때까지 대기
        pthread_mutex_lock(&mutex); // 버퍼 접근 잠금

        // 임계 영역: 버퍼에서 아이템 꺼냄
        int item = buffer[out];
        printf("소비자 %d: buffer[%d] = %d 소비\n", id, out, item);
        out = (out + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&empty);           // 빈 슬롯 수 증가

        usleep(rand() % 800000);
    }
    return NULL;
}

int main() {
    pthread_mutex_init(&mutex, NULL);
    sem_init(&empty, 0, BUFFER_SIZE);
    sem_init(&full, 0, 0);

    pthread_t prod[2], cons[2];
    int ids[] = {0, 1};

    for (int i = 0; i < 2; i++) {
        pthread_create(&prod[i], NULL, producer, &ids[i]);
        pthread_create(&cons[i], NULL, consumer, &ids[i]);
    }

    for (int i = 0; i < 2; i++) {
        pthread_join(prod[i], NULL);
        pthread_join(cons[i], NULL);
    }

    pthread_mutex_destroy(&mutex);
    sem_destroy(&empty);
    sem_destroy(&full);
    return 0;
}

세마포어 순서는 임의가 아니다

위 생산자 코드에서 sem_wait(&empty)pthread_mutex_lock(&mutex)보다 먼저 나온다. 이 두 줄을 맞바꾸면 컴파일도 되고 몇 초 동안 잘 돌다가 조용히 멈춘다. 왜 그런지는 코드를 들여다봐서는 보이지 않고, 두 스레드가 시간 축에서 어떻게 얽히는지 한 칸씩 따라가야 보인다.

먼저 순서를 바꾼 코드는 이렇게 생겼다.

// 잘못된 순서: 잠금을 먼저 잡고 나서 빈 슬롯을 기다린다
void *producer_broken(void *arg) {
    for (int i = 0; i < 10; i++) {
        int item = rand() % 100;

        pthread_mutex_lock(&mutex);   // (1) 먼저 잠금
        sem_wait(&empty);             // (2) 그다음 빈 슬롯 대기  <-- 위험

        buffer[in] = item;
        in = (in + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&full);
    }
    return NULL;
}

버퍼 크기가 5이므로 생산자가 다섯 개를 채우면 empty는 0이 된다. 그 상태에서 다음 일이 벌어진다.

[교착으로 가는 인터리빙]

시각  생산자 P                          소비자 C
----  --------------------------------  --------------------------------
 t0   (버퍼 가득 참, empty=0, full=5)   (아직 실행 전)

 t1   pthread_mutex_lock(&mutex) 성공
      -> P가 mutex를 보유

 t2   sem_wait(&empty) 호출
      empty가 0이므로 블록됨
      -> P는 mutex를 쥔 채로 잠듦

 t3                                     sem_wait(&full) 성공 (full=5)

 t4                                     pthread_mutex_lock(&mutex)
                                        P가 쥐고 있으므로 블록됨

 t5   PCsem_post(&empty)CP가 mutex를 놓기를
      해 주기를 기다린다                 기다린다
----  --------------------------------  --------------------------------
결과: 두 스레드 모두 영원히 깨어나지 않는다 = 교착(deadlock)

핵심은 t2다. 생산자가 잠금을 손에 쥔 채로 잠들어 버렸다. 소비자는 버퍼를 비워 줄 수 있는 유일한 스레드인데, 비우려면 먼저 잠금이 필요하고, 그 잠금은 소비자가 비워 주기를 기다리는 생산자가 들고 있다. 서로가 상대의 진행을 조건으로 삼는 순환 대기가 만들어진 것이다.

원래 순서가 이 순환을 끊는 이유는 단순하다. sem_wait(&empty)가 앞에 있으면 생산자는 잠금을 잡기 전에 잠든다. 잠들어 있는 동안 잠금은 아무도 소유하지 않으므로, 소비자는 언제든 들어가서 버퍼를 비우고 sem_post(&empty)로 생산자를 깨울 수 있다. 규칙으로 정리하면 이렇다. 오래 블록될 수 있는 대기는 항상 잠금 밖에서 하고, 잠금 안에서는 절대 무기한 대기하지 않는다.

반대로 뒤쪽의 pthread_mutex_unlock(&mutex)sem_post(&full)은 순서를 바꿔도 교착이 나지 않는다. 두 연산 모두 블록되지 않기 때문이다. 다만 sem_post를 먼저 하면 깨어난 소비자가 곧바로 잠금에서 한 번 더 튕기므로 문맥 교환이 한 번 더 생긴다. 이쪽은 정확성 문제가 아니라 성능 문제다.


2. 읽기-쓰기 문제 (Readers-Writers Problem)

데이터베이스를 여러 프로세스가 공유할 때, 읽기만 하는 프로세스(reader)는 동시에 접근해도 안전하지만, 쓰기 프로세스(writer)는 배타적 접근이 필요하다.

[읽기-쓰기 규칙]

Reader + Reader = 허용 (동시 읽기 가능)
Reader + Writer = 불허 (충돌)
Writer + Writer = 불허 (충돌)

첫 번째 변형: 읽기 우선

독자가 있으면 작가가 대기한다. 독자가 계속 들어오면 작가가 기아 상태에 빠질 수 있다.

#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t rw_mutex = PTHREAD_MUTEX_INITIALIZER;
int read_count = 0;
int shared_data = 0;

void *reader(void *arg) {
    int id = *(int *)arg;

    pthread_mutex_lock(&mutex);
    read_count++;
    if (read_count == 1) {
        // 첫 번째 독자가 작가를 차단
        pthread_mutex_lock(&rw_mutex);
    }
    pthread_mutex_unlock(&mutex);

    // 임계 영역: 읽기
    printf("독자 %d: 데이터 읽음 = %d (현재 독자 수: %d)\n",
           id, shared_data, read_count);

    pthread_mutex_lock(&mutex);
    read_count--;
    if (read_count == 0) {
        // 마지막 독자가 작가 차단 해제
        pthread_mutex_unlock(&rw_mutex);
    }
    pthread_mutex_unlock(&mutex);

    return NULL;
}

void *writer(void *arg) {
    int id = *(int *)arg;

    pthread_mutex_lock(&rw_mutex);  // 배타적 접근

    // 임계 영역: 쓰기
    shared_data++;
    printf("작가 %d: 데이터 갱신 = %d\n", id, shared_data);

    pthread_mutex_unlock(&rw_mutex);

    return NULL;
}

작가 기아를 실제로 추적해 보기

첫 번째 변형이 작가를 굶긴다는 말은 자주 나오지만, 정확히 어떤 조건에서 굶는지는 잘 설명되지 않는다. read_count가 어떻게 움직이는지 따라가면 조건이 그대로 드러난다.

[작가가 영원히 대기하는 흐름]

단계  사건                            read_count  rw_mutex 보유자
----  ------------------------------  ----------  ---------------
 1    R1 진입: read_count 0 -> 1          1        R1이 잠금
 2    W 도착: rw_mutex 요청               1        R1 (W는 블록)
 3    R2 도착: read_count 1 -> 2          2        R1
      2번째이므로 rw_mutex를 건드리지 않고 그대로 통과
 4    R3 도착: read_count 2 -> 3          3        R1
 5    R1 퇴장: read_count 3 -> 2          2        R1
      0이 아니므로 unlock 하지 않음
 6    R2 퇴장: read_count 2 -> 1          1        R1
 7    R4 도착: read_count 1 -> 2          2        R1
----  ------------------------------  ----------  ---------------
read_count가 한 번도 0이 되지 않으면 W는 영원히 대기한다

조건을 한 문장으로 쓰면 이렇다. 마지막 독자가 나가기 전에 새 독자가 들어오는 상황이 계속 이어지면, 즉 독자 도착 간격이 독자 임계 영역 체류 시간보다 짧게 유지되면, read_count는 0을 찍지 못하고 작가는 무기한 대기한다. 독자가 아주 많거나 읽기가 느린 시스템에서는 우연이 아니라 정상 부하에서 일어나는 일이다.

주의할 점은 이것이 교착이 아니라는 것이다. 시스템 전체는 계속 전진하고 독자들은 전부 정상적으로 일을 마친다. 진행이 멈춘 것은 작가 하나뿐이다. 교착은 관련된 모두가 멈추지만 기아는 일부만 멈추므로, 모니터링에서 처리량만 보고 있으면 절대 보이지 않는다. 작가 쪽 지연 시간의 최대값이나 상위 백분위를 따로 재야 드러난다.

두 번째 변형: 쓰기 우선

작가가 준비되면 새로운 독자는 들어갈 수 없다. 기존 독자가 모두 나간 후 작가가 실행된다.

이 변형은 앞의 조건을 정면으로 깬다. 작가가 대기열에 들어선 순간부터 새 독자의 진입을 막는 관문을 하나 더 두기 때문이다. 그러면 이미 안에 있던 독자들만 빠져나가면 되고, read_count는 유한 시간 안에 반드시 0이 된다. 대신 부작용이 생긴다. 이번에는 작가가 연달아 도착하면 독자가 굶는다. 어느 쪽도 공짜가 아니며, 두 변형 중 무엇을 쓸지는 읽기와 쓰기 중 어느 쪽 지연이 서비스에 더 치명적인지로 정한다.

양쪽 기아를 모두 막으려면 도착 순서를 보존하는 세 번째 설계가 필요하다. 대기자를 하나의 큐에 넣고 도착한 순서대로 깨우는 방식이며, 읽기-쓰기 잠금을 제공하는 라이브러리 중에는 이런 공정성 여부를 선택하게 해 둔 것들이 있다. 다만 공정성을 켜면 순서를 지키느라 처리량이 떨어진다. 기본값은 사용 중인 버전의 문서에서 확인하세요.


3. 식사하는 철학자 문제 (Dining Philosophers Problem)

다섯 명의 철학자가 원형 테이블에 앉아 있다. 각 철학자 사이에 젓가락이 하나씩 있으며, 식사하려면 양쪽 젓가락 두 개가 필요하다.

[식사하는 철학자]

        P0
    C4      C0
  P4          P1
    C3      C1
        P3
      C2
        P2

P: 철학자 (Philosopher)
C: 젓가락 (Chopstick)

철학자 i는 젓가락 i와 젓가락 (i+1)%5를 사용

세마포어를 이용한 간단한 해법 (교착 위험)

// 주의: 이 해법은 교착 상태가 발생할 수 있다!
sem_t chopstick[5];

void *philosopher(void *arg) {
    int id = *(int *)arg;

    while (1) {
        printf("철학자 %d: 생각 중\n", id);
        usleep(rand() % 1000000);

        // 양쪽 젓가락 집기
        sem_wait(&chopstick[id]);           // 왼쪽
        sem_wait(&chopstick[(id + 1) % 5]); // 오른쪽

        printf("철학자 %d: 식사 중\n", id);
        usleep(rand() % 1000000);

        // 젓가락 내려놓기
        sem_post(&chopstick[id]);
        sem_post(&chopstick[(id + 1) % 5]);
    }
}

// 교착 상태: 5명 모두 동시에 왼쪽 젓가락을 들면
// 아무도 오른쪽 젓가락을 얻지 못함!

교착 상태를 방지하는 해법들

// 해법 1: 비대칭 - 짝수 철학자는 왼쪽 먼저, 홀수는 오른쪽 먼저
void *philosopher_asymmetric(void *arg) {
    int id = *(int *)arg;

    while (1) {
        printf("철학자 %d: 생각 중\n", id);

        if (id % 2 == 0) {
            sem_wait(&chopstick[id]);
            sem_wait(&chopstick[(id + 1) % 5]);
        } else {
            sem_wait(&chopstick[(id + 1) % 5]);
            sem_wait(&chopstick[id]);
        }

        printf("철학자 %d: 식사 중\n", id);
        usleep(rand() % 1000000);

        sem_post(&chopstick[id]);
        sem_post(&chopstick[(id + 1) % 5]);
    }
}

모니터를 이용한 해법

// 모니터 기반 해법 (Pthreads 조건 변수 사용)
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

#define N 5
#define THINKING 0
#define HUNGRY   1
#define EATING   2

int state[N];
pthread_mutex_t monitor_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t self[N];

// 왼쪽, 오른쪽 이웃 인덱스
#define LEFT(i)  ((i + N - 1) % N)
#define RIGHT(i) ((i + 1) % N)

void test(int i) {
    // 내가 배고프고, 양쪽 이웃이 식사 중이 아니면 먹을 수 있음
    if (state[i] == HUNGRY &&
        state[LEFT(i)] != EATING &&
        state[RIGHT(i)] != EATING) {
        state[i] = EATING;
        pthread_cond_signal(&self[i]);
    }
}

void pickup(int i) {
    pthread_mutex_lock(&monitor_mutex);

    state[i] = HUNGRY;
    printf("철학자 %d: 배고픔\n", i);
    test(i);  // 바로 먹을 수 있는지 확인

    while (state[i] != EATING) {
        pthread_cond_wait(&self[i], &monitor_mutex);
    }

    printf("철학자 %d: 식사 시작\n", i);
    pthread_mutex_unlock(&monitor_mutex);
}

void putdown(int i) {
    pthread_mutex_lock(&monitor_mutex);

    state[i] = THINKING;
    printf("철학자 %d: 식사 완료, 생각 시작\n", i);

    // 이웃들이 먹을 수 있는지 확인
    test(LEFT(i));
    test(RIGHT(i));

    pthread_mutex_unlock(&monitor_mutex);
}

void *philosopher(void *arg) {
    int id = *(int *)arg;
    while (1) {
        usleep(rand() % 1000000);  // 생각
        pickup(id);                 // 젓가락 집기
        usleep(rand() % 1000000);  // 식사
        putdown(id);                // 젓가락 내려놓기
    }
}

모니터 해법은 왜 교착에 빠지지 않는가

세마포어 해법과 모니터 해법은 코드 길이가 비슷한데 한쪽만 교착에 빠진다. 차이는 젓가락을 몇 개씩 집는가에 있다.

세마포어 해법에서 철학자는 sem_wait(&chopstick[id])로 왼쪽을 집은 뒤, 오른쪽을 집으려고 다시 sem_wait을 호출한다. 이 두 호출 사이에는 틈이 있고, 그 틈에서 철학자는 자원 하나를 보유한 채로 다른 자원을 기다리는 상태에 있다. 다섯 명이 모두 이 상태에 동시에 들어가면 순환 대기가 완성된다.

모니터 해법에는 그 틈이 없다. pickupmonitor_mutex를 잡은 뒤 state[i]를 HUNGRY로 표시하고 test(i)를 호출한다. test는 양쪽 이웃이 식사 중이 아닐 때만 상태를 EATING으로 바꾼다. 즉 젓가락 두 개를 한꺼번에 얻거나 아예 하나도 얻지 않으며, 그 판정은 모니터 잠금 아래에서 원자적으로 이루어진다. 철학자가 젓가락 한 짝만 쥔 중간 상태는 아예 존재하지 않는다.

교착의 네 조건 중 점유하며 대기(hold and wait)가 성립하지 않으므로 교착은 구조적으로 불가능하다. 대기하는 철학자는 젓가락을 하나도 들고 있지 않고, pthread_cond_waitmonitor_mutex까지 놓아 주므로 이웃이 putdown으로 들어오는 것도 막지 않는다.

[상태 배열이 보장하는 것]

state[] = [THINKING, EATING, HUNGRY, THINKING, HUNGRY]
             P0       P1      P2       P3        P4

P2는 왼쪽 이웃 P1EATING이므로 대기 중
  -> 젓가락 0개 보유. 아무도 막지 않는다.

P1putdown() 호출:
  state[1] = THINKING
  test(LEFT(1)=P0)  -> P0THINKING이라 조건 불성립
  test(RIGHT(1)=P2) -> P2HUNGRY, 양옆 P1/P3 모두 EATING 아님
                       -> state[2] = EATING 으로 바꾸고 P2를 깨움

P2는 깨어난 시점에 이미 "먹어도 된다"가 확정된 상태

다만 이 해법이 기아까지 막아 주지는 않는다. test가 순서를 전혀 고려하지 않기 때문이다. P2가 오래 기다리고 있어도 P1과 P3이 번갈아 가며 계속 식사하면 P2의 조건은 영원히 성립하지 않을 수 있다. 교착이 없다는 것과 모두가 언젠가 먹는다는 것은 서로 다른 보장이며, 후자를 원하면 배고파진 시각을 기록해 두고 오래 기다린 철학자를 우선하는 로직을 test에 직접 넣어야 한다.


POSIX 동기화

POSIX 뮤텍스

#include <pthread.h>

pthread_mutex_t lock;

// 정적 초기화
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

// 동적 초기화
pthread_mutex_init(&lock, NULL);

// 사용
pthread_mutex_lock(&lock);
// 임계 영역
pthread_mutex_unlock(&lock);

// 비블로킹 시도
if (pthread_mutex_trylock(&lock) == 0) {
    // 잠금 획득 성공
    pthread_mutex_unlock(&lock);
} else {
    // 다른 스레드가 보유 중
}

// 정리
pthread_mutex_destroy(&lock);

POSIX 세마포어

POSIX는 이름 있는(named) 세마포어와 이름 없는(unnamed) 세마포어를 제공한다.

#include <semaphore.h>

// 이름 없는 세마포어 (스레드 간)
sem_t sem;
sem_init(&sem, 0, 1);  // 0: 스레드 간, 초기값 1
sem_wait(&sem);
sem_post(&sem);
sem_destroy(&sem);

// 이름 있는 세마포어 (프로세스 간)
sem_t *sem = sem_open("/my_sem", O_CREAT, 0644, 1);
sem_wait(sem);
sem_post(sem);
sem_close(sem);
sem_unlink("/my_sem");

POSIX 조건 변수

#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;

// 생산자
void *producer(void *arg) {
    pthread_mutex_lock(&mutex);

    // 데이터 생성
    data_ready = 1;
    printf("생산자: 데이터 준비 완료\n");

    pthread_cond_signal(&cond);    // 대기 중인 소비자 깨움
    pthread_mutex_unlock(&mutex);
    return NULL;
}

// 소비자
void *consumer(void *arg) {
    pthread_mutex_lock(&mutex);

    while (!data_ready) {
        // 조건이 만족될 때까지 대기
        // wait 중에는 mutex가 자동으로 해제됨
        pthread_cond_wait(&cond, &mutex);
    }

    printf("소비자: 데이터 소비\n");
    data_ready = 0;

    pthread_mutex_unlock(&mutex);
    return NULL;
}

while 루프가 if면 안 되는 이유

위 소비자 코드에서 pthread_cond_waitif가 아니라 while 안에 들어 있다. 이것은 취향 문제가 아니라 정확성 문제이며, 조건 변수를 쓰는 코드에서 가장 자주 나오는 버그의 원인이다.

POSIX 표준은 두 가지를 명시한다. 첫째, 가짜 깨어남(spurious wakeup)이 일어날 수 있다. 표준 문구는 "Spurious wakeups from the pthread_cond_timedwait() or pthread_cond_wait() functions may occur"이다. 둘째, 반환 자체가 조건에 대해 아무것도 말해 주지 않는다. "the return from pthread_cond_timedwait() or pthread_cond_wait() does not imply anything about the value of this predicate"라고 못 박는다. 반환은 누군가 신호를 보냈을 수도 있다는 힌트일 뿐, 기다리던 조건이 참이라는 보증이 아니다.

가짜 깨어남만이 이유는 아니다. 신호가 진짜로 왔더라도 내가 깨어났을 때는 조건이 이미 사라졌을 수 있다.

[깨어났지만 조건이 사라진 경우]

대기자 A와 대기자 B가 모두 data_ready를 기다린다.

 1  A: pthread_cond_wait 진입, mutex 해제하고 대기
 2  B: pthread_cond_wait 진입, mutex 해제하고 대기
 3  P: mutex 획득, data_ready = 1, cond_broadcast, mutex 해제
 4  A: 깨어나서 mutex 재획득 성공
 5  A: data_ready = 0 으로 소비하고 mutex 해제
 6  B: 깨어나서 mutex 재획득 성공
 7  B: if 였다면 여기서 그냥 통과 -> data_ready가 0인데 소비 시도
       while 이었다면 조건을 다시 검사 -> 거짓이므로 다시 대기

4번과 6번 사이에는 반드시 간격이 생긴다. pthread_cond_wait은 반환하기 전에 뮤텍스를 다시 잡아야 하는데("Upon successful return, the mutex shall have been locked and shall be owned by the calling thread"), 뮤텍스는 한 번에 한 스레드만 쥘 수 있기 때문이다. 그 간격 동안 세상이 바뀔 수 있고, if는 바뀐 세상을 확인하지 않는다.

반대 방향의 사고도 있다. 신호를 놓치는 경우다. 조건을 참으로 만들기 전에, 그것도 잠금 밖에서 pthread_cond_signal을 호출하면 대기자가 아직 대기 상태에 들어가지 않은 시점에 신호가 날아가 버린다. 조건 변수에는 기억이 없어서 대기자가 없을 때 보낸 신호는 그냥 사라진다. 이 경우 while이 있어도 첫 검사에서 조건이 거짓이면 그대로 잠들고, 깨워 줄 신호는 이미 없어진 뒤다.

// 위험: 조건을 세팅하기 전에, 그것도 잠금 밖에서 신호를 보낸다
pthread_cond_signal(&cond);      // 대기자가 없으면 이 신호는 사라진다
pthread_mutex_lock(&mutex);
data_ready = 1;
pthread_mutex_unlock(&mutex);

// 안전: 잠금 안에서 조건을 세팅한 뒤에 신호를 보낸다
pthread_mutex_lock(&mutex);
data_ready = 1;                  // 먼저 조건을 참으로 만든다
pthread_cond_signal(&cond);      // 그다음 신호
pthread_mutex_unlock(&mutex);

// 대기 측은 언제나 while
pthread_mutex_lock(&mutex);
while (!data_ready) {            // if 로 바꾸면 위의 모든 보호가 무너진다
    pthread_cond_wait(&cond, &mutex);
}
data_ready = 0;
pthread_mutex_unlock(&mutex);

이 버그가 특히 고약한 이유는 증상이 크래시가 아니라 멈춤이기 때문이다. 스택 트레이스도 없고 코어 덤프도 남지 않는다. 프로세스는 살아 있고 CPU 사용률은 0에 가까우며 로그는 그냥 끊긴다. 테스트에서는 타이밍이 맞지 않아 재현되지 않다가 운영에서 몇 시간에 한 번씩 나온다. 조건 변수를 쓸 때 while은 협상 대상이 아니라고 기억해 두는 편이 낫다.


Java 동기화

synchronized 키워드

public class Counter {
    private int count = 0;

    // 메서드 수준 동기화
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }

    // 블록 수준 동기화
    public void incrementBlock() {
        synchronized (this) {
            count++;
        }
    }
}

ReentrantLock

synchronized보다 유연한 잠금 메커니즘이다.

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;

public class BoundedBufferLock {
    private final Object[] buffer;
    private int count = 0, in = 0, out = 0;

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public BoundedBufferLock(int size) {
        buffer = new Object[size];
    }

    public void produce(Object item) throws InterruptedException {
        lock.lock();
        try {
            while (count == buffer.length) {
                notFull.await();    // 버퍼 빌 때까지 대기
            }
            buffer[in] = item;
            in = (in + 1) % buffer.length;
            count++;
            notEmpty.signal();      // 소비자 깨움
        } finally {
            lock.unlock();
        }
    }

    public Object consume() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();   // 데이터 올 때까지 대기
            }
            Object item = buffer[out];
            out = (out + 1) % buffer.length;
            count--;
            notFull.signal();       // 생산자 깨움
            return item;
        } finally {
            lock.unlock();
        }
    }
}

대안적 접근법

트랜잭셔널 메모리 (Transactional Memory)

데이터베이스의 트랜잭션 개념을 메모리 접근에 적용한다.

// 개념적 코드 (실제 문법은 구현에 따라 다름)
atomic {
    // 이 블록 안의 모든 메모리 연산은 원자적으로 실행
    // 충돌 발생 시 자동으로 재시도
    account1.balance -= amount;
    account2.balance += amount;
}

함수형 프로그래밍

불변(immutable) 데이터 구조를 사용하면 공유 상태가 변경되지 않으므로 동기화가 불필요하다. Erlang, Scala, Haskell 등의 함수형 언어가 이 접근법을 활용한다.


실패 사례와 함정

동기화 버그는 증상이 세 가지 형태로만 나타난다. 증상을 먼저 분류하면 어떤 도구를 꺼낼지가 자동으로 정해진다.

증상 1: 멈췄는데 CPU 사용률이 0에 가깝다

모든 스레드가 블록되어 있다는 뜻이다. 교착이거나, 놓친 신호이거나, 아무도 채워 주지 않는 세마포어를 기다리는 상태다. 진단 순서는 이렇다.

  1. top이나 ps로 그 프로세스의 CPU 사용률이 정말 0에 가까운지 확인한다. 100퍼센트에 붙어 있다면 증상 2로 간다.
  2. 실행 중인 프로세스에 디버거를 붙여 모든 스레드의 호출 스택을 뜬다.
  3. 스택에서 잠금 대기 프레임을 찾고, 어느 스레드가 어느 잠금을 기다리는지 짝을 맞춘다.

GDB 문서에 따르면 모든 스레드에 명령을 적용하는 문법은 thread apply [thread-id-list | all [-ascending]] [flag]… command이고, 스레드 목록은 info threads로 본다.

# 멈춘 프로세스의 PID를 찾아 붙는다
pgrep -a myprogram

gdb -p 12345

예시 출력이다.

(gdb) info threads
  Id   Target Id                        Frame
* 1    Thread 0x7f2a1c0 (LWP 12345)     __lll_lock_wait () at ...
  2    Thread 0x7f2a1c1 (LWP 12346)     futex_wait (...) at ...
  3    Thread 0x7f2a1c2 (LWP 12347)     futex_wait (...) at ...

(gdb) thread apply all backtrace

Thread 3 (Thread 0x7f2a1c2):
#0  futex_wait (...)
#1  __new_sem_wait_slow (...)
#2  producer (arg=0x0) at bb.c:22        <-- sem_wait(&empty)에서 멈춤
#3  start_thread (...)

Thread 2 (Thread 0x7f2a1c1):
#0  __lll_lock_wait (...)
#1  pthread_mutex_lock (...)
#2  consumer (arg=0x0) at bb.c:41        <-- mutex를 기다림
#3  start_thread (...)

읽는 법은 단순하다. __lll_lock_wait이나 pthread_mutex_lock이 위쪽에 보이면 뮤텍스를 기다리는 것이고, futex_wait이 세마포어나 조건 변수 함수 아래에 깔려 있으면 신호를 기다리는 것이다. 프레임 번호 2번쯤에 나오는 자기 코드의 파일과 줄 번호가 실제 범인이다. 위 예시는 앞에서 본 교착 그대로다. 생산자는 empty를 기다리고 소비자는 mutex를 기다린다.

증상 2: 멈춘 것 같은데 CPU 사용률이 100퍼센트다

블록이 아니라 회전이다. 스핀락에서 도는 중이거나, 조건을 만족시키지 못한 채 서로 양보만 반복하는 라이브락이다. 라이브락은 교착과 달리 스레드들이 계속 상태를 바꾸므로 스택을 한 번만 떠서는 알 수 없다. 몇 초 간격으로 여러 번 스택을 떠서 같은 함수 구간을 왕복하는지 확인한다. 재시도 루프에 상한이나 백오프가 없는 코드가 대개 원인이다.

증상 3: 결과가 가끔 틀린다

멈추지도 않고 죽지도 않는데 카운터가 어쩌다 한 번 어긋난다. 잠금 없이 접근하는 공유 변수가 있다는 뜻이며, 눈으로 찾는 것은 시간 낭비다. 경쟁 조건 탐지기를 쓴다.

GCC 문서는 -fsanitize=thread를 "ThreadSanitizer, a fast data race detector"로 설명한다. 메모리 접근 명령을 계측해서 데이터 경쟁을 잡아낸다. 같은 문서에 따르면 이 옵션은 -fsanitize=address-fsanitize=leak과 함께 쓸 수 없고, 출력을 읽을 만하게 만들려면 -g를 같이 주라고 권한다. 실행 시 동작은 TSAN_OPTIONS 환경 변수로 제어한다.

gcc -fsanitize=thread -g -O1 counter.c -o counter -lpthread
./counter

예시 출력이다.

WARNING: ThreadSanitizer: data race (pid=4711)
  Write of size 4 at 0x55a1c8 by thread T2:
    #0 increment counter.c:14 (counter+0x1234)

  Previous write of size 4 at 0x55a1c8 by thread T1:
    #0 increment counter.c:14 (counter+0x1234)

  Location is global 'shared_counter' of size 4 at 0x55a1c8

SUMMARY: ThreadSanitizer: data race counter.c:14 in increment

읽는 순서는 아래에서 위다. SUMMARY 줄이 파일과 줄 번호를 알려 주고, 그 위의 두 블록이 충돌한 두 접근이다. 같은 줄이 두 번 나온다면 같은 코드가 두 스레드에서 동시에 실행된 것이고, 서로 다른 줄이라면 한쪽은 읽기 한쪽은 쓰기인 경우가 많다.

컴파일을 다시 하기 어려운 상황이라면 Valgrind의 Helgrind를 쓴다. 매뉴얼에 따르면 --tool=helgrind로 지정하며, 세 종류의 오류를 잡는다. POSIX pthreads API 오용, 일관되지 않은 잠금 순서, 데이터 경쟁이다. 잠금 순서 검사는 --track-lockorders가 제어하고 기본값은 yes다. 과거 접근 정보를 얼마나 모을지는 --history-level이 정하며 기본값은 full이다.

valgrind --tool=helgrind --track-lockorders=yes ./counter

예시 출력이다.

==5123== Possible data race during write of size 4 at 0x60105C by thread #3
==5123== Locks held: none
==5123==    at 0x4006B1: increment (counter.c:14)
==5123==
==5123== This conflicts with a previous write of size 4 by thread #2
==5123== Locks held: none
==5123==    at 0x4006B1: increment (counter.c:14)

여기서 결정적인 단서는 Locks held 줄이다. none이면 접근 시점에 아무 잠금도 쥐고 있지 않았다는 뜻이므로 그 줄을 감싸는 잠금을 넣으면 된다. 두 접근이 서로 다른 잠금을 쥐고 있었다면 잠금은 있으나 잘못된 잠금을 쓴 것이다.

세 도구 모두 프로그램을 크게 느리게 만든다는 점은 미리 알아 두는 편이 좋다. 재현 시나리오를 최소한으로 줄여서 돌리는 것이 실전에서 훨씬 빠르다.


언제 쓰지 않나

이 장의 세 문제는 동기화를 배우기 위한 교재이지 그대로 베껴 쓰는 템플릿이 아니다. 실무 코드에서 세마포어를 손으로 조립해야 하는 경우는 생각보다 드물다.

먼저, 공유 메모리 대신 메시지 전달이 맞는 경우가 있다. 스레드들이 사실은 데이터를 넘겨주기만 하고 같은 자료구조를 동시에 뜯어고칠 필요가 없다면, 채널이나 큐로 소유권을 통째로 넘기는 편이 낫다. 이렇게 하면 임계 영역이 아예 존재하지 않으므로 교착도 경쟁 조건도 설계 단계에서 사라진다. 앞에서 본 유한 버퍼 문제는 사실 그 큐를 직접 구현해 보는 연습이며, 이미 검증된 큐가 표준 라이브러리에 있다면 직접 만들 이유가 없다.

응용 코드라면 대개 표준 라이브러리의 동시성 컬렉션이 정답이다. 자바의 java.util.concurrent 패키지나 각 언어의 스레드 안전 큐는 이 장의 문제들을 이미 풀어 둔 결과물이고, 수년간 실전에서 검증됐다. 직접 만든 유한 버퍼가 그보다 나을 확률은 낮다.

락프리 자료구조는 마지막 선택지다. 잠금 경합이 실제 병목이라는 것을 측정으로 확인한 다음에만 손을 대야 한다. 락프리 코드는 메모리 모델과 메모리 순서를 정확히 이해해야 하고, 틀렸을 때 증상이 특정 CPU에서 특정 부하일 때만 나타난다. 측정 없이 잠금은 느리니까라는 이유로 시작하면, 잘 돌던 코드를 디버깅 불가능한 코드로 바꾸는 결과가 된다.

아예 동기화가 필요 없게 만드는 길도 늘 검토할 가치가 있다. 스레드마다 자기 몫의 데이터를 따로 갖고 마지막에 한 번만 합치는 구조, 불변 자료구조, 단일 스레드 이벤트 루프 같은 것들이다. 가장 빠른 임계 영역은 없는 임계 영역이다.

반대로 이 장의 내용이 정말 필요한 자리도 분명히 있다. 스레드 풀, 커넥션 풀, 레이트 리미터처럼 개수가 정해진 자원을 나눠 주는 구조에서는 계수 세마포어가 정확히 맞는 도구다. 운영체제 커널이나 언어 런타임을 만드는 쪽이라면 애초에 선택의 여지가 없다.


참고 자료


정리

유한 버퍼, 읽기-쓰기, 식사하는 철학자 문제는 동기화의 핵심 과제를 보여주는 고전적 예제다. POSIX와 Java는 뮤텍스, 세마포어, 조건 변수 등 다양한 동기화 도구를 제공한다. 트랜잭셔널 메모리나 함수형 프로그래밍 같은 대안적 접근법도 동시성 문제를 해결하는 방법으로 주목받고 있다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다