[OSTEP] 스터디 12주차 - 병행성 3 Part.1

운영체제에서 쓰레드를 다룰 때 우리가 해결해야 할 문제는 크게 두 가지다.

  1. 상호 배제(Mutual Exclusion): 한 번에 하나의 쓰레드만 임계 영역(Critical Section)에 들어가게 하는 것. (주로 Lock으로 해결)
  2. 순서 정렬(Ordering): 쓰레드 A가 작업을 마친 후에 쓰레드 B가 실행되어야 하는 것처럼, 작업의 순서를 조율하는 것.

Lock만으로는 두 번째 문제인 '순서 정렬'을 효율적으로 해결하기 어렵다. 부모 쓰레드가 자식 쓰레드가 끝날 때까지 기다려야 하는 경우를 생각해보자. 공유 변수를 계속 확인하며 무한 루프를 도는 방식(Spinning)은 CPU 자원을 심각하게 낭비한다. 이때 필요한 것이 컨디션 변수세마포어다.


1. 컨디션 변수 (Condition Variables)

컨디션 변수는 쓰레드가 특정 조건(Condition)이 참이 될 때까지 기다리게(Sleep) 하는 장치다. OSTEP에서는 이를 "쓰레드들이 실행을 멈추고, 조건이 충족되었다는 신호를 받을 때까지 대기하는 큐(Queue)"라고 정의한다.

주요 동작 (API)

컨디션 변수는 항상 Lock(Mutex)과 함께 사용되어야 한다. 주요 함수는 다음과 같다.

  1. wait(condition, lock):
    • 호출한 쓰레드를 대기 상태로 만든다(Sleep).
    • 핵심 동작: 이 함수는 호출 시 가지고 있던 Lock을 원자적(Atomically)으로 해제하고 잠에 든다. 그리고 나중에 깨어날 때 다시 Lock을 획득하고 리턴한다.
  2. signal(condition):
    • 대기 큐에서 자고 있는 쓰레드 중 하나를 깨운다.
  3. broadcast(condition):
    • 대기 큐에 있는 모든 쓰레드를 깨운다.

올바른 사용 패턴 (The Canonical Pattern)

// [대기하는 쓰레드]
pthread_mutex_lock(&lock);
while (ready == 0) {         // 조건이 만족되지 않았다면
    pthread_cond_wait(&cond, &lock); // 잠든다 (Lock 해제 -> Sleep -> 깨어나면 Lock 획득)
}
// (작업 수행)
pthread_mutex_unlock(&lock);

// [신호 보내는 쓰레드]
pthread_mutex_lock(&lock);
ready = 1;                   // 조건을 변경하고
pthread_cond_signal(&cond);  // 자고 있는 쓰레드를 깨운다
pthread_mutex_unlock(&lock);

왜 if가 아니라 while인가? (Mesa Semantics)

많은 입문자가 실수하는 부분이 while 대신 if를 사용하는 것이다. 하지만 반드시 while을 써야 한다. 그 이유는 현대 운영체제 대부분이 Mesa Semantics를 따르기 때문이다.

Mesa Semantics에서는 signal을 보내 쓰레드를 깨웠더라도, 그 쓰레드가 즉시 실행된다는 보장이 없다.

  1. 깨어난 쓰레드가 락을 다시 획득하기 전에 다른 쓰레드가 끼어들어 상태(ready)를 다시 0으로 바꿔버릴 수 있다.
  2. 허위 기상(Spurious Wakeup): 드물게 조건이 충족되지 않았는데도 쓰레드가 깨어나는 현상이 발생할 수 있다.

따라서 wait에서 리턴되었더라도, 조건이 정말로 만족되었는지 다시 한번 확인(Re-check)해야 하므로 while 루프가 필수적이다.


2. 세마포어 (Semaphores)

세마포어는 에츠허르 데이크스트라(Edsger Dijkstra)가 제안한 개념으로, Lock과 컨디션 변수의 기능을 모두 수행할 수 있는 강력하고 유연한 도구다.

정의와 동작

세마포어는 내부적으로 정수 값(Integer Value)을 하나 가진 객체다. 이 값은 오직 두 가지 원자적 연산으로만 조작할 수 있다.

  • sem_wait() (P 연산):
    • 세마포어 값을 1 감소시킨다.
    • 감소 후 값이 0 이상이면 즉시 리턴한다(진행).
    • 감소 후 값이 음수이면, 호출한 쓰레드는 대기 상태(Sleep)로 들어간다.
  • sem_post() (V 연산):
    • 세마포어 값을 1 증가시킨다.
    • 대기 중인 쓰레드가 있다면(값이 음수였다면) 하나를 깨운다.

참고: 세마포어의 값이 음수일 때, 그 절대값은 현재 대기 중인 쓰레드의 개수를 의미한다. (예: -3이면 3개의 쓰레드가 자고 있음)

세마포어의 활용법

1) 락(Lock)으로 사용하기 (Binary Semaphore)

세마포어의 초기값을 1로 설정하면 Mutex Lock처럼 동작한다.

  • 초기값 1:
    • A 쓰레드 sem_wait() 호출 -> 값은 0이 되고 A는 진입(Lock 획득).
    • B 쓰레드 sem_wait() 호출 -> 값은 -1이 되고 B는 대기(Sleep).
    • A 쓰레드 sem_post() 호출 -> 값은 0이 되고 B를 깨움. B가 락을 획득하게 됨.

2) 순서 정렬(Ordering)로 사용하기

세마포어의 초기값을 0으로 설정하면, join()과 같은 기능을 구현할 수 있다. (부모가 자식을 기다림)

  • 초기값 0:
    • 부모: sem_wait() 호출 -> 값은 -1이 되고 부모는 즉시 잠든다.
    • 자식: 작업을 마치고 sem_post() 호출 -> 값은 0이 되고 부모를 깨운다.
    • 결과적으로 부모는 자식이 끝날 때까지 기다리게 된다.

3) 생산자/소비자 문제 (Bounded Buffer Problem)

유한한 버퍼를 두고 데이터를 넣는 생산자와 데이터를 꺼내는 소비자가 있을 때, 세마포어 3개를 조합하여 해결한다.

  • empty: 버퍼의 빈 공간 개수 (초기값 = 버퍼 크기 MAX)
  • full: 버퍼에 차 있는 데이터 개수 (초기값 = 0)
  • mutex: 버퍼에 접근할 때 상호 배제를 위한 락 (초기값 = 1)

[생산자 코드]

void producer() {
    sem_wait(&empty); // 빈 공간이 생길 때까지 대기 (빈 공간 1 감소)
    sem_wait(&mutex); // 임계 영역 진입 (Lock 획득)
    put_data();       // 데이터 넣기
    sem_post(&mutex); // Lock 해제
    sem_post(&full);  // 찬 공간 1 증가 (소비자 깨움)
}

[주의할 점 - 데드락] 위 코드에서 sem_wait(&empty)와 sem_wait(&mutex)의 순서를 바꾸면 데드락(Deadlock)이 발생할 수 있다.

  • 생산자가 mutex를 잡았는데 버퍼가 꽉 차서(empty 대기) 잠들면, 소비자는 mutex를 얻지 못해 데이터를 꺼내지 못하고, 결국 영원히 서로 기다리게 된다. 반드시 락을 얻기 전에 자원(빈 공간)의 가용 여부부터 확인해야 한다.