2025. 11. 18. 19:26ㆍ개념 공부
목차
- 세마포어란 무엇인가?
- 이진 세마포어 & 락으로 쓰기
- 이벤트 순서 제어(부모-자식 예제)
- 생산자/소비자 (유한 버퍼) 문제
- Reader–Writer Lock을 세마포어로 구현하기
- 식사하는 철학자 문제
- Thread Throttling (동시 실행 개수 제한)
- 세마포어를 락+조건변수로 구현해보기 (Zemaphore)
- 정리 & 공부 팁
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 두 가지 스케줄링 순서 모두 안전
- 부모가 먼저
sem_wait()→ 자식이 나중에sem_post()
→ 부모는 잠들었다가 자식이 깨워주고, 그 뒤에parent: end출력 - 자식이 먼저
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;
}
이 버퍼는 원형 큐입니다. fill과 use는 둘 다 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);
}
}
어떤 흐름이 가능한지 보죠:
- Consumer가 먼저 실행 →
mutex를 잡음 →full이 0이라sem_wait(&full)에서 블록 - 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
- 이벤트 완료 횟수? → 0
- 버퍼 칸 수? → MAX
- wait/post가 어떤 “물리적인 이벤트”를 표현하나?
- 버퍼가 비어/차는 것?
- 스레드가 일을 끝낸 것?
- Reader/Writer가 크리티컬 섹션에 들어가는 것?
- 락과 역할 분리가 되었는가?
- 조건(“비었냐/찼냐”) 관리 vs 데이터 구조 보호
- 조건 세마포어 잡은 상태로 오래 일하면 X
9.2 실무 관점 인사이트
- 세마포어만으로 락+조건변수를 모두 구현할 수 있지만, 코드 난이도가 올라가므로 실무에서는 보통 mutex+cond를 직접 사용
- Bounded buffer, Reader–Writer, Dining philosophers 같은 고전 문제는 면접이나 시스템 디자인 토론에서 “동시성 감각”을 보는 데 자주 쓰임
- 높은 수준에서 보면, 세마포어는 “자원을 빌려 주고/반납받는 계약”을 표현하는 도구로 볼 수 있음
9.3 연습 아이디어
- 교재 뒷부분 homework 코드들 직접 구현해보기 (fork-join, barrier, reader-writer 등)
- 생산자/소비자 코드를 세마포어 버전, 조건변수 버전 두 가지로 구현해 비교
- 스스로 ticket lock, queue lock을 세마포어로 흉내 내 보기
'개념 공부' 카테고리의 다른 글
| 운영체제 아주 쉬운 세가지 이야기 12주차(이벤트 기반의 병행성(고급)) (0) | 2025.11.25 |
|---|---|
| 운영체제 아주 쉬운 세가지 이야기 12주차(병행성 관련 버그) (0) | 2025.11.25 |
| 운영체제 아주 쉬운 세가지 이야기 11주차(컨디션 변수) (0) | 2025.11.18 |
| 운영체제 아주 쉬운 세가지 이야기 10주차(락) (1) | 2025.11.11 |
| 운영체제 아주 쉬운 세가지 이야기 9주차(쓰레드 API) (0) | 2025.11.03 |