운영체제 아주 쉬운 세가지 이야기 11주차(세마 포어)

2025. 11. 18. 19:26개념 공부

목차

  1. 세마포어란 무엇인가?
  2. 이진 세마포어 & 락으로 쓰기
  3. 이벤트 순서 제어(부모-자식 예제)
  4. 생산자/소비자 (유한 버퍼) 문제
  5. Reader–Writer Lock을 세마포어로 구현하기
  6. 식사하는 철학자 문제
  7. Thread Throttling (동시 실행 개수 제한)
  8. 세마포어를 락+조건변수로 구현해보기 (Zemaphore)
  9. 정리 & 공부 팁

1. 세마포어란 무엇인가?

1.1 기본 정의

세마포어(semaphore)는 정수 값 하나 + 두 개의 기본 연산으로 구성된 동기화 객체입니다.

  • sem_wait(sem_t *s)
    • 값을 1 감소시키고, 값이 < 0 이면 잠들기(블록)
    • 값이 ≥ 0 이면 그냥 리턴 (통과)
  • sem_post(sem_t *s)
    • 값을 1 증가시키고, 누군가 기다리고 있으면 그 중 한 스레드를 깨우기
#include <semaphore.h>

sem_t s;

// s를 1로 초기화 (초기 자원 1개를 의미)
sem_init(&s, 0, 1);
  • 두 번째 인자 0 : 같은 프로세스 안의 스레드들끼리 공유
  • 세 번째 인자 value : 세마포어의 초기 값

세마포어 값이 음수일 때, 그 절댓값은 현재 기다리는 스레드 수와 같습니다 (교재 정의 관점). 실제 POSIX 구현은 내부적으로 관리하지만, 개념을 이해하기에 좋은 관찰입니다.


2. 이진 세마포어: 락으로 쓰기

2.1 왜 초기값은 1인가?

세마포어를 락처럼 쓰고 싶다면, 상태는 두 개뿐이어야 합니다.

  • 0 → 잠겨 있음(누가 사용 중)
  • 1 → 열려 있음(누구나 가져갈 수 있음)
sem_t m;
sem_init(&m, 0, 1);  // "즉시 빌려줄 수 있는" 자원 1개

// 크리티컬 섹션 보호
sem_wait(&m);
// ---- 공용 데이터 수정 ----
sem_post(&m);

첫 번째 스레드는 sem_wait()에서 값을 1→0으로 만들고 통과, 두 번째 스레드는 0→-1이 되면서 잠들었다가 나중에 sem_post() 호출 시 깨어납니다. 이렇게 하면 동시에 한 스레드만 크리티컬 섹션에 들어갈 수 있습니다.

2.2 “몇 개를 바로 빌려줄 수 있나?” 관점

세마포어 초기값을 생각할 때, 이렇게 상상하면 편합니다:

  • 기본 락: 시작하자마자 1개의 락을 빌려줄 수 있으니 초기값 = 1
  • 이벤트 대기: 아직 “완료된 이벤트”가 없으니 초기값 = 0
  • 버퍼 슬롯: 버퍼가 MAX개 비어 있다면 초기값 = MAX (비어 있는 칸 수)

3. 이벤트 순서 제어: 부모가 자식 끝나길 기다리기

3.1 문제 상황

부모 스레드가 자식 스레드를 만들고, 자식이 printf("child\n") 를 끝낸 다음에야 printf("parent: end\n")를 찍고 싶다고 해 봅시다.

sem_t s;

void *child(void *arg) {
    printf("child\n");
    sem_post(&s);  // "나 끝났어" 신호
    return NULL;
}

int main() {
    sem_init(&s, 0, 0);  // 초기값 0: 아직 완료된 일이 없음

    printf("parent: begin\n");
    pthread_t c;
    pthread_create(&c, NULL, child, NULL);

    sem_wait(&s);        // 자식이 끝낼 때까지 대기
    printf("parent: end\n");
}

3.2 두 가지 스케줄링 순서 모두 안전

  1. 부모가 먼저 sem_wait() → 자식이 나중에 sem_post()
    → 부모는 잠들었다가 자식이 깨워주고, 그 뒤에 parent: end 출력
  2. 자식이 먼저 sem_post() → 세마포어 값 1 → 부모가 나중에 sem_wait() → 값 1→0으로 줄이고 바로 리턴 (기다릴 필요 없음)

이 패턴은 “이벤트가 발생했다는 사실”을 표시하고, 그걸 기다리는 스레드가 순서를 맞추는 데 매우 자주 등장합니다.


4. 생산자/소비자 (유한 버퍼) 문제 – 세마포어 버전

4.1 버퍼 구조

#define MAX  ...   // 버퍼 크기

int buffer[MAX];
int fill = 0;   // 다음에 쓸 위치
int use  = 0;   // 다음에 읽을 위치
int count = 0;  // 현재 버퍼 안에 들어 있는 아이템 개수

void put(int value) {
    buffer[fill] = value;           // F1
    fill = (fill + 1) % MAX;        // F2
    count++;                        // 생산자 입장에서는 증가
}

int get() {
    int tmp = buffer[use];          // G1
    use = (use + 1) % MAX;          // G2
    count--;                        // 소비자 입장에서는 감소
    return tmp;
}

이 버퍼는 원형 큐입니다. filluse는 둘 다 0 ~ MAX-1 범위를 반복해서 돌며, count는 “현재 들어 있는 개수”를 나타냅니다.

4.2 첫 번째 시도: empty / full 세마포어만 사용

sem_t empty;  // 비어 있는 칸 수
sem_t full;   // 채워진 칸 수

void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&empty);   // P1: 비어 있는 칸 하나 확보
        put(i);             // P2
        sem_post(&full);    // P3: 채워진 칸 하나 증가
    }
}

void *consumer(void *arg) {
    int tmp;
    while ((tmp = get_something())) { // 예시
        sem_wait(&full);    // C1: 채워진 칸이 있을 때까지 기다림
        tmp = get();        // C2
        sem_post(&empty);   // C3: 비어 있는 칸 하나 증가
        printf("%d\n", tmp);
    }
}

int main() {
    sem_init(&empty, 0, MAX);  // 처음엔 모두 비어 있음
    sem_init(&full,  0, 0);    // 채워진 칸은 0개
}

이 코드는 동시에 한 Producer/Consumer만 존재하거나, 운이 좋으면 작동합니다. 그러나 여러 스레드가 동시에 돌아가면 문제가 터집니다.

4.3 왜 race condition이 생기는가?

여러 Producer가 put()을 동시에 호출한다고 상상해 봅시다.

  • Pₐ가 buffer[fill]에 쓰고 아직 fill을 증가시키기 전에 스케줄러가 바뀜
  • P_b가 똑같은 buffer[fill] 위치에 덮어씀
  • 결과적으로 Pₐ가 만든 데이터는 유실

fill, use, count공유 변수이므로, 이들을 변경하는 부분 전체가 임계구역이어야 합니다.

4.4 잘못된 mutual exclusion 추가 – Deadlock

우리가 흔히 저지르는 실수는 “그냥 전체를 락으로 감싸자” 입니다:

sem_t empty, full;
sem_t mutex;   // 이진 세마포어 (락)

// ❌ 잘못된 버전
void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&mutex);   // P0
        sem_wait(&empty);   // P1
        put(i);             // P2
        sem_post(&full);    // P3
        sem_post(&mutex);   // P4
    }
}

void *consumer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&mutex);   // C0
        sem_wait(&full);    // C1
        int tmp = get();    // C2
        sem_post(&empty);   // C3
        sem_post(&mutex);   // C4
        printf("%d\n", tmp);
    }
}

어떤 흐름이 가능한지 보죠:

  1. Consumer가 먼저 실행 → mutex를 잡음 → full이 0이라 sem_wait(&full)에서 블록
  2. Producer가 실행하려고 함 → sem_wait(&mutex)에서 막힘 (Consumer가 들고 있음)

서로 상대가 락을 풀어주기만 기다리며 둘 다 영원히 멈춤데드락.

4.5 최종 정답: 세마포어 + 락의 역할 분리

해결 방법은 간단합니다. 조건(“비었냐/찼냐”) 관리용 세마포어실제 데이터 접근 보호용 mutex를 분리하고, 잠드는 연산(sem_wait(empty), sem_wait(full))은 락 밖에서 수행합니다.

sem_t empty, full;
sem_t mutex;   // 이진 세마포어 = 락

void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&empty);      // (1) 빈 칸 확보
        sem_wait(&mutex);      // (2) 버퍼 내부에 대한 락
        put(i);                // (3) 크리티컬 섹션
        sem_post(&mutex);      // (4) 락 해제
        sem_post(&full);       // (5) 채운 칸 증가
    }
}

void *consumer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&full);       // (1) 채워진 칸 확보
        sem_wait(&mutex);      // (2) 버퍼 내부 락
        int tmp = get();       // (3) 크리티컬 섹션
        sem_post(&mutex);      // (4) 락 해제
        sem_post(&empty);      // (5) 빈 칸 증가
        printf("%d\n", tmp);
    }
}

핵심 포인트:

  • empty/full“칸 개수” 관리 (자원 수)
  • mutex“버퍼 내부 구조” 보호
  • 절대 mutex를 잡은 채로 empty/full에서 블록되지 않기 (데드락 위험)

5. Reader–Writer Lock을 세마포어로 구현하기

5.1 목표

  • 여러 Reader는 동시에 들어와도 OK
  • Writer는 혼자만 접근해야 함 (Reader도 없음)
typedef struct _rwlock_t {
    sem_t lock;       // 내부 상태 보호용 (reader 수 등)
    sem_t writelock;  // Writer 혹은 첫 Reader가 잡는 락
    int readers;      // 현재 Reader 수
} rwlock_t;

void rwlock_init(rwlock_t *rw) {
    rw->readers = 0;
    sem_init(&rw->lock, 0, 1);
    sem_init(&rw->writelock, 0, 1);
}

5.2 Reader 진입/이탈

void rwlock_acquire_readlock(rwlock_t *rw) {
    sem_wait(&rw->lock);
    rw->readers++;
    if (rw->readers == 1) {
        // 첫 번째 Reader: Writer를 막기 위해 writelock을 잡음
        sem_wait(&rw->writelock);
    }
    sem_post(&rw->lock);
}

void rwlock_release_readlock(rwlock_t *rw) {
    sem_wait(&rw->lock);
    rw->readers--;
    if (rw->readers == 0) {
        // 마지막 Reader: 이제 Writer에게 길 열어줌
        sem_post(&rw->writelock);
    }
    sem_post(&rw->lock);
}

5.3 Writer 진입/이탈

void rwlock_acquire_writelock(rwlock_t *rw) {
    sem_wait(&rw->writelock);
}

void rwlock_release_writelock(rwlock_t *rw) {
    sem_post(&rw->writelock);
}

아이디어는 간단합니다.

  • 첫 번째 Reader가 들어올 때 writer를 막기 위해 writelock을 잡습니다.
  • 마지막 Reader가 나갈 때 writelock을 풀어 Writer가 들어오도록 합니다.
  • Writer는 writelock만 보고 “지금 누가 쓰거나(Writer) 읽는 중인지(Readers > 0)” 판단합니다.

다만 이 구조는 Writer starvation(Reader가 계속 들어와 Writer가 영원히 못 들어가는 현상)이 생길 수 있습니다. 실무에서는 더 정교한 RWLock을 쓰거나, 그냥 평범한 mutex를 쓰는 게 더 빠르고 단순한 경우도 많습니다.


6. 식사하는 철학자 문제 – 세마포어 버전

6.1 문제 요약

  • 철학자 5명, 포크 5개, 동그랗게 앉아 있음
  • 각 철학자는 왼쪽/오른쪽 포크 2개를 잡아야 밥을 먹을 수 있음
  • 모두 동시에 “왼쪽부터” 잡으면? → 데드락
sem_t forks[5];

int left(int p)  { return p; }
int right(int p) { return (p + 1) % 5; }

// ❌ 데드락 가능
void get_forks(int p) {
    sem_wait(&forks[left(p)]);
    sem_wait(&forks[right(p)]);
}

void put_forks(int p) {
    sem_post(&forks[left(p)]);
    sem_post(&forks[right(p)]);
}

6.2 간단한 해법: 한 철학자만 순서 반대로

모든 철학자가 같은 순서(왼 → 오른)를 사용하면 순환 대기가 가능해집니다. 한 명만 “오른 → 왼”으로 바꾸면, 완전한 원형 의존 관계가 깨집니다.

void get_forks(int p) {
    if (p == 4) {
        // 마지막 철학자는 반대로 잡음
        sem_wait(&forks[right(p)]);
        sem_wait(&forks[left(p)]);
    } else {
        sem_wait(&forks[left(p)]);
        sem_wait(&forks[right(p)]);
    }
}

이런 식으로 “순환 의존 관계”를 깨뜨리는 것이 데드락 회피의 전형적인 패턴입니다.


7. Thread Throttling – 너무 많이 동시에 하지 마!

7.1 문제 상황

예를 들어, 100개의 스레드가 모두 동시에 “대용량 메모리 작업”을 시작하면, 물리 메모리를 초과해서 OS가 스왑을 미친 듯이 하게 되고, 전체 성능이 폭락할 수 있습니다 (thrashing).

7.2 세마포어로 제한하기

sem_t limit;

int main() {
    int max_concurrent = 8;  // 동시에 8개만 "헤비한" 작업 허용
    sem_init(&limit, 0, max_concurrent);
    // ... 스레드 생성 ...
}

void *worker(void *arg) {
    // 메모리 헤비 영역 진입 전
    sem_wait(&limit);

    // ---- 메모리 집약 작업 ----
    heavy_task();

    // 종료 후
    sem_post(&limit);
    return NULL;
}

이렇게 하면 항상 최대 max_concurrent개까지만 동시에 위험한 구간에 들어가므로, 전체 시스템이 폭발하지 않도록 Admission Control을 걸 수 있습니다.


8. 세마포어를 락+조건변수로 직접 구현해보기 (Zemaphore)

8.1 구조체 정의

typedef struct __Zem_t {
    int value;
    pthread_cond_t  cond;
    pthread_mutex_t lock;
} Zem_t;

8.2 초기화

void Zem_init(Zem_t *s, int value) {
    s->value = value;
    pthread_cond_init(&s->cond, NULL);
    pthread_mutex_init(&s->lock, NULL);
}

8.3 wait (P 연산)

void Zem_wait(Zem_t *s) {
    pthread_mutex_lock(&s->lock);
    while (s->value <= 0) {
        // 조건이 false면 잠든다
        pthread_cond_wait(&s->cond, &s->lock);
        // 여기서 깨어날 때는 lock을 다시 잡은 상태로 깨어남
    }
    s->value--;
    pthread_mutex_unlock(&s->lock);
}

8.4 post (V 연산)

void Zem_post(Zem_t *s) {
    pthread_mutex_lock(&s->lock);
    s->value++;
    pthread_cond_signal(&s->cond);  // 누군가 깨어날 수 있게 알림
    pthread_mutex_unlock(&s->lock);
}

이 구현은 “세마포어의 동작 의미”를 잘 보여줍니다:

  • value > 0 → 자원이 남아 있음 → wait 즉시 통과
  • value <= 0 → 자원이 없음 → 조건변수로 잠들었다가 post에서 깨어남

9. 정리 & 공부 팁

9.1 세마포어를 볼 때 항상 떠올려야 할 질문

  1. 초기값이 의미하는 “자원 수”가 무엇인가?
    • 락? → 1
    • 이벤트 완료 횟수? → 0
    • 버퍼 칸 수? → MAX
  2. wait/post가 어떤 “물리적인 이벤트”를 표현하나?
    • 버퍼가 비어/차는 것?
    • 스레드가 일을 끝낸 것?
    • Reader/Writer가 크리티컬 섹션에 들어가는 것?
  3. 락과 역할 분리가 되었는가?
    • 조건(“비었냐/찼냐”) 관리 vs 데이터 구조 보호
    • 조건 세마포어 잡은 상태로 오래 일하면 X

9.2 실무 관점 인사이트

  • 세마포어만으로 락+조건변수를 모두 구현할 수 있지만, 코드 난이도가 올라가므로 실무에서는 보통 mutex+cond를 직접 사용
  • Bounded buffer, Reader–Writer, Dining philosophers 같은 고전 문제는 면접이나 시스템 디자인 토론에서 “동시성 감각”을 보는 데 자주 쓰임
  • 높은 수준에서 보면, 세마포어는 “자원을 빌려 주고/반납받는 계약”을 표현하는 도구로 볼 수 있음

9.3 연습 아이디어

  • 교재 뒷부분 homework 코드들 직접 구현해보기 (fork-join, barrier, reader-writer 등)
  • 생산자/소비자 코드를 세마포어 버전, 조건변수 버전 두 가지로 구현해 비교
  • 스스로 ticket lock, queue lock을 세마포어로 흉내 내 보기