개발 공부
[운영체제][OSTEP] 세마포어
세마포어가 무엇인지, 락과 컨디션 변수 대신 세마포어를 사용하는 방법은 무엇인지에 대해 공부해봅니다.

![[운영체제][OSTEP] 세마포어](https://www.datocms-assets.com/66479/1686988115-ostep.jpg?auto=format&w=860)
1️⃣ 세마포어: 정의
세마포어는 정수 값을 갖는 객체로서 두 개의 루틴으로 조작할 수 있다.
➡️ POSIX 표준에서 sem_wait()
와 sem_post()
임.
🌱 세마포어는 초기값에 의해 동작이 결정
➡️ 사용하기 전 "제일 먼저" 값을 초기화해야 함.
다음은 세마포어를 초기화하는 코드이다.
#include <semaphore.h>
sem_t s; // 세마포어 선언
sem_init(&s, 0, 1); // 세마포어의 값을 1로 초기화
sem_init()
의 두 번째 인자는 같은 프로세스 내의 쓰레드 간에 세마포어를 공유한다는 것을 의미한다.
위 에서 제시한 두 루틴은 다음과 같이 나타낼 수 있다.
int sem_wait(sem_t *s) {
decrement the value of semaphore s by one;
wait if value of semaphore s is negative;
}
int sem_post(sem_t *s) {
increment the value of semaphore s by one;
if there are one or more threads waiting, wake one;
}
이 루틴들은 다수 쓰레드들에 의해 동시에 호출되는 것을 가정한다.
➡️ 임계 영역은 적절히 보호되어야 함.
위 루틴들의 핵심적인 성질을 살펴보자.
sem_wait()
함수는 즉시 리턴하거나(세마포어의 값 ≥ 1), 해당 세마포어 값이 1이상이 될 때까지 호출자를 대기시킴.다수의 쓰레드들이
sem_wait()
를 호출할 수 있으므로 대기큐에는 다수의 쓰레드가 존재할 수 있음.sem_wait()
함수와 달리sem_post()
함수는 대기하지 않고 세마포어 값을 증가시키고 대기 중인 쓰레드 중 하나를 깨움.세마포어가 음수라면 그 값은 현재 대기 중인 쓰레드의 개수와 같음.
➡️ 일반적으로 세마포어 사용자는 이 값을 알 수 없음.
2️⃣ 이진 세마포어(락)
다음에서 "락"에 세마포어를 적용하는 모습을 살펴보자.
sem_t m;
sem_init(&m, 0, X); // X로 세마포어를 초기화하기. 이때 X가 가져야 할 값은?
sem_wait(&m);
// 임계 영역 부분은 여기에 배치
sem_post(&m);
sem_wait()
/sem_post()
쌍으로 임계 영역 부분을 둘러싼 것을 확인할 수 있다.
이때, 위 코드가 동작하기 위한 핵심은 세마포어 m
의 초기값(위의 코드에서 X)임.
💡 두 루틴의 정의에 따라 초기값은 1이 되어야 한다는 것을 알 수 있음.
다음과 같이 쓰레드가 두 개인 경우를 가정하여 이를 살펴보자.
[1] 한 쓰레드가 임계 영역 내에 있을 때 다른 쓰레드가 락을 획득하려고 하지 않는 경우
Value of Semaphore Thread 0 Thread 1
────────────────────────────────────────────────────
1
1 call sem_wait()
0 sem_wait() returns
0 (crit sect)
0 call sem_post()
1 sem_post() returns
[2] 한 쓰레드가 임계 영역 내에 있을 때 다른 쓰레드가 락을 획득하려고 하는 경우
Val │ Thread 0 State │ Thread 1 State
────┼───────────────────────────────┼─────────────────────────
1 │ Run │ Ready
1 │ call sem_wait() Run │ Ready
0 │ sem_wait() returns Run │ Ready
0 │ (crit sect begin) Run │ Ready
0 │ Interrupt; Switch → T1 Ready │ Run
0 │ Ready │ call sem_wait() Run
-1 │ Ready │ decr sem Run
-1 │ Ready │ (sem<0) → sleep Sleep
-1 │ Run │ Switch → T0 Sleep
-1 │ (crit sect end) Run │ Sleep
-1 │ call sem_post() Run │ Sleep
0 │ incr sem Run │ Sleep
0 │ wake(T1) Run │ Ready
0 │ sem_post() returns Run │ Ready
0 │ Interrupt; Switch → T1 Ready │ Run
0 │ Ready │ sem_wait() returns Run
0 │ Ready │ (crit sect) Run
0 │ Ready │ call sem_post() Run
1 │ Ready │ sem_post() returns Run
락은 두 개의 상태(사용 가능, 사용 중)만 존재하므로 이진 세마포어(binary semaphore)라고도 불림.
3️⃣ 컨디션 변수로서의 세마포어
대기 중인 쓰레드(들)가 프로그램에서의 어떤 조건(condition)이 만족되기를 대기하기 때문에, 세마포어를 컨디션 변수처럼 사용할 수 있음.
다음과 같이 부모 쓰레드가 자식 쓰레드를 생성한 후 자식 쓰레드의 종료를 대기하고자 하는 예시를 살펴보자.
parent: begin
child
parent: end
sem_t s;
void *child(void *arg) {
printf("child\n");
sem_post(&s); // 시그널 전달: 자식의 동작이 끝남
return NULL;
}
int main(int argc, char *argv[]) {
sem_init(&s, 0, X); // x의 값은 무엇이 되어야 할까?
printf("parent: begin\n");
pthread_t c;
Pthread_create(c, NULL, child, NULL);
sem_wait(&s); //자식을 여기서 대기
printf("parent: end\n");
return 0;
}
부모 프로세스는 자식 프로세스 생성 후 sem_wait()
를 호출하여 자식의 종료를 대기함.
자식은 sem_post()
를 호출하여 종료되었음을 알림.
💡 이때 세마포어의 초기값은 0이 되어야 한다.
다음에서 발생할 수 있는 두 가지 상황을 통해 그 이유를 알아보자.
[1] 자식 프로세스 생성 후, 아직 자식 프로세스가 실행을 시작하지 않은 경우
Val │ Thread 0 State │ Thread 1 State
────┼───────────────────────────┼─────────────────────────────────
0 │ create(Child) Run │ (Child exists, can run) Ready
0 │ call sem_wait() Run │ Ready
-1 │ decr sem Run │ Ready
-1 │ (sem<0) → sleep Sleep │ Ready
-1 │ Switch → Child Sleep │ child runs Run
-1 │ Sleep │ call sem_post() Run
0 │ Sleep │ incr sem Run
0 │ Ready │ wake(Parent) Run
0 │ Ready │ sem_post() returns Run
0 │ Ready │ Interrupt → Parent Ready
0 │ sem_wait() returns Run │ Ready
[2] 부모 프로세스가 sem_wait()
를 호출하기 전에 자식 프로세스의 실행이 종료된 경우
Val │ Thread 0 State │ Thread 1 State
────┼───────────────────────────┼─────────────────────────────────
0 │ create(Child) Run │ (Child exists, can run) Ready
0 │ Interrupt → Child Ready │ child runs Run
0 │ Ready │ call sem_post() Run
1 │ Ready │ inc sem Run
1 │ Ready │ wake(nobody) Run
1 │ Ready │ sem_post() returns Run
1 │ parent runs Run │ Interrupt → Parent Ready
1 │ call sem_wait() Run │ Ready
0 │ decr sem Run │ Ready
0 │ (sem≥0) → awake Run │ Ready
0 │ sem_wait() returns Run │ Ready
4️⃣ 생산자/소비자(유한 버퍼) 문제
첫 번째 시도
먼저, empty
와 full
이라는 두 개의 세마포어를 사용하여 문제 해결을 시도해보자.
쓰레드는 empty
와 full
을 사용하여 버퍼 공간이 비었는지 채워졌는지를 표시한다.
다음과 같은 상황을 가정한 예시를 살펴보자.
MAX=1
인 상황(버퍼의 크기가 1인 경우)생산자와 소비자 쓰레드가 각 하나씩 존재
CPU도 한 개 존재
put()
과 get()
코드는 다음과 같다.
int buffer[MAX];
int fill = 0;
int use = 0;
void put(int value) {
buffer[fill] = value; // f1 라인
fill = (fill + 1) % MAX; // f2 라인
}
int get() {
int tmp = buffer[use]; // g1 라인
use = (use + 1) % MAX; // g2 라인
return tmp;
}
다음은 해결을 위해 시도한 코드이다.
sem_t empty;
sem_t full;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
sem_wait(&empty); // P1 라인
put(i); // P2 라인
sem_post(&full); // P3 라인
}
}
void *consumer(void *arg) {
int i, tmp = 0;
while (tmp != −1) {
sem_wait(&full); // C1 라인
tmp = get(); // C2 라인
sem_post(&empty); // C3 라인
printf("%d\n", tmp);
}
}
int main(int argc, char *argv[]) {
// ...
sem_init(&empty, 0, MAX); // MAX 버퍼는 비어 있는 상태로 시작...
sem_init(&full, 0, 0); // ... 그리고 0이 가득 참
// ...
}
소비자가 먼저 실행한다면, 소비자 쓰레드는 sem_wait(&full)
을 호출(C1)한다.
➡️ 변수 full
의 값은 0으로 초기화되었으므로 full
의 값은 -1이 되고, 소비자는 대기한다.
이후 생산자 쓰레드가 실행하여 sem_wait(&empty)
루틴을 호출(P1)한다.
➡️ empty
변수가 MAX(이 예시에서는 1)로 설정되었으므로 대기하지 않고 계속 실행한다.
empty 변수는 0이 되고 생산자가 데이터 값을 버퍼의 첫 번째 공간에 넣는다.(P2)
그 후 생산자는 sem_post(&full)
를 호출(P3)하여 full
의 값을 -1에서 0으로 변경하고 소비자 쓰레드를 깨운다.
➡️ 소비자 쓰레드는 대기 상태에서 준비 상태로 바뀜.
💡 이제 둘 중 하나의 상황이 발생할 수 있다.
[1] 생산자가 계속 실행하는 상황
empty
세마포어의 값이 0이므로 대기 상태로 들어간다.(P1)
[2] 생산자 쓰레드가 인터럽트에 걸리고 소비자 쓰레드가 실행하는 상황
sem_wait(&full)
문(C1)을 호출하여 버퍼가 찼다는 것을 발견하고 데이터를 소비한다.
🤔 그렇다면 MAX 값이 1보다 크고, 생산자와 소비자 쓰레드들이 여러 개 있다면 어떨까?
이때는 경쟁 조건이 발생한다.
두 개의 생산자 Pa, Pb가 put()
을 거의 동시에 호출하고 Pa가 먼저 실행된 경우
Pa가 버퍼의 첫 공간에 값을 넣기 시작(f1 라인에서
fill=0
)Pa 쓰레드가
fill
카운터 변수를 1로 변경하기 전에 인터럽트에 걸림.Pb가 실행되어 f1 라인에서 마찬가지로 버퍼의 첫 번째 공간에 데이터를 삽입한다.
➡️ ⚠️ Pa가 기록한 이전의 값은 새로운 값으로 대체됨.
상호 배제의 추가 but, ...
위에서는 상호 배제를 고려하지 않았다.
➡️ 이진 세마포어와 몇 개의 락을 추가하여 해결해보자.
sem_t empty;
sem_t full;
sem_t mutex;
void *producer(void *arg) {
int i;
for (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) {
int i;
for (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);
}
}
int main(int argc, char *argv[]) {
// ...
sem_init(&empty, 0, MAX); // MAX 버퍼는 비어 있는 상태로 시작...
sem_init(&full, 0, 0); // ... 그리고 0이 가득 참
sem_init(&mutex, 0, 1); // 락이기 때문에 mutex=1 (추가됨)
// ...
}
⚠️ 하지만 이 코드는 교착 상태가 발생할 수 있으므로 동작하지 않는다.
생산자와 소비자 쓰레드가 각 하나씩 있을 때, 소비자가 먼저 실행하는 경우
mutex
(c0)를 획득하고full
변수에 대하여sem_wait()
(c1)를 호출➡️ 아직 데이터가 없으므로 소비자는 대기해야 하고 CPU를 양보함.
➡️ ⭐ 소비자가 여전히 락을 획득하고 있음.
생산자가 실행하여 mutex 세마포어에 대해
sem_wait()
(p0)를 호출➡️ 이미 락은 소비자가 획득한 상태이므로 생산자 역시 대기 상태에 진입함.
소비자: mutex
를 갖고 다른 full
시그널 발생 대기
🆚
생산자: full
시그널을 발생시켜야 하지만 mutex
에서 대기
➡️ 전형적인 교착 상태의 모습
최종, 제대로 된 해법
이 문제를 해결하기 위해서는 락의 범위(scope)를 줄여야 함.
sem_t empty;
sem_t full;
sem_t mutex;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
sem_wait(&empty); // p1 라인
sem_wait(&mutex); // p1.5 라인(여기로 MUTEX 이동)
put(i); // p2 라인
sem_post(&mutex); // p2.5 라인(... 그리고 여기)
sem_post(&full); // p3 라인
}
}
void *consumer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
sem_wait(&full); // c1 라인
sem_wait(&mutex); // c1.5 라인(여기로 MUTEX 이동)
int tmp = get(); // c2 라인
sem_post(&mutex); // c2.5 라인(... 그리고 여기)
sem_post(&empty); // c3 라인
printf("%d\n", tmp);
}
}
int main(int argc, char *argv[]) {
// ...
sem_init(&empty, 0, MAX); // MAX 버퍼는 비어 있는 상태로 시작...
sem_init(&full, 0, 0); // ... 그리고 0이 가득 참
sem_init(&mutex, 0, 1); // 락이기 때문에 mutex=1
// ...
}
mutex
를 획득하고 해제하는 코드를 임계 영역 바로 이전과 이후로 이동하였다.
➡️ 드디어 멀티 쓰레드 프로그램에서 사용 가능한 유한 버퍼
5️⃣ Reader-Writer 락
다양한 자료 구조를 접근하는 데 여러 종류의 락 기법이 필요
리스트에 삽입하고 간단한 검색을 하는 것과 같은 병행 연산이 여러 개 있다고 가정하자.
삽입 연산: 리스트의 상태를 변경(전통적인 임계 영역 보호 방식으로 해결 가능)
검색 연산: 자료 구조를 단순히 읽음.
💡 삽입 연산이 없다는 보장만 된다면 다수의 검색 작업을 동시에 수행할 수 있음.
➡️ 이와 같은 경우를 위해 만들어진 락: reader-writer 락
코드는 다음과 같다.
typedef struct _rwlock_t {
sem_t lock; // 이진 세마포어(기본 락)
sem_t writelock; // 하나의 쓰기 또는 다수의 읽기 락을 위한 락
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);
}
void rwlock_acquire_readlock(rwlock_t *rw) {
sem_wait(&rw−>lock);
rw−>readers++;
if (rw−>readers == 1)
sem_wait(&rw−>writelock); // 읽기용 락이 writelock을 획득
sem_post(&rw−>lock);
}
void rwlock_release_readlock(rwlock_t *rw) {
sem_wait(&rw−>lock);
rw−>readers−−;
if (rw−>readers == 0)
sem_post(&rw−>writelock); // 마지막으로 읽기용 락이 writelock 해제
sem_post(&rw−>lock);
}
void rwlock_acquire_writelock(rwlock_t *rw) {
sem_wait(&rw−>writelock);
}
void rwlock_release_writelock(rwlock_t *rw) {
sem_post(&rw−>writelock);
}
자료 구조를 "갱신"하려면 동기화 연산 쌍을 사용한다.
락을 획득하기 위해서
rwlock_acquire_writelock()
을 사용락을 해제하기 위해서
rwlock_release_writelock()
을 사용
내부적으로는 writelock 세마포어를 사용하여 하나의 쓰기 쓰레드만이 락을 획득할 수 있도록 함.
⭐ 읽기 락(reader lock)의 획득과 해제 과정을 조금 더 살펴보자.
읽기 락 획득 시 읽기 쓰레드가 먼저 락을 획득
읽기 중인 쓰레드의 수를 표현하는 reader 변수를 증가시킴.
writelock 세마포어에 대해
sem_wait()
을 호출하여 쓰기 락을 함께 획득읽기 락을 해제할 때
sem_post()
로 쓰기 락을 다시 해제
이 과정을 통해서 읽기 락을 획득한 후, 다른 읽기 쓰레드들이 읽기 락을 획득할 수 있도록 함.
반면, 쓰기 락을 획득하려는 쓰기 쓰레드들은 모든 읽기 쓰레드가 끝날 때까지 대기해야 함.
⚠️ 하지만 이 방식은 공정성에 문제가 있음.
➡️ 상대적으로 쓰기 쓰레드가 불리
⚠️ 또한 이 방식은 오버헤드가 큼.
➡️ 기법이 정교하기 때문
6️⃣ 식사하는 철학자(Dining Philosopher)

식사하는 철학자 문제는 다음과 같은 상황을 나타낸다.
다섯 명의 "철학자"가 식탁 주위를 둘러앉았다.
총 다섯 개의 포크가 철학자와 철학자 사이에 하나씩 놓여 있다.
철학자는 식사하는 때가 있고 생각하는 때가 있다.
생각 중일 때는 포크가 필요없다.
식사를 위해서는 자신의 왼쪽과 오른쪽에 있는 포크를 들어야 한다.
💡 이 포크를 잡기 위한 경쟁과 그에 따른 동기화 문제가 병행 프로그래밍에서 다루려는 식사하는 철학자 문제이다.
다음은 각 철학자의 동작을 나타낸 기본 반복문이다.
while (1) {
think();
getforks();
eat();
putforks();
}
주요 쟁점은 getfork()
와 putfork()
의 루틴을 작성하되,
[1] 교착 상태의 발생을 방지해야 하고,
[2] 어떤 철학자도 못 먹어서 굶주리면 안되며
[3] 병행성이 높아야 한다.
➡️ 즉, 가능한 많은 철학자가 동시에 식사를 할 수 있어야 함.
문제 해결을 위해서는 Downey의 해법과 같이 몇 가지 함수를 사용함.
int left(int p) { return p; } // 철학자 p가 자신의 왼쪽 포크를 잡기 위해 호출
int right(int p) { return (p + 1) % 5; } // 철학자 p가 자신의 오른쪽 포크를 잡기 위해 호출
이 문제를 해결하기 위해 세마포어가 필요하므로, 각 포크마다 한 개씩하여 sem_t fork[5]
로 정의한다.
[첫 번째 시도 - 불완전한 해답]
forks
배열에 있는 각 포크에 대한 세마포어를 1로 초기화하고 각 철학자는 자신의 순번(p
)을 알고 있다고 가정하자.
void getforks() {
sem_wait(forks[left(p)]);
sem_wait(forks[right(p)]);
}
void putforks() {
sem_post(forks[left(p)]);
sem_post(forks[right(p)]);
}
원리는 다음과 같다.
포크가 필요할 때 단순히 하나의 "락"을 획득
먼저 왼쪽의 것을 잡고 그 다음에 오른쪽의 것을 잡음.
식사가 끝나면 잡은 순서대로 놓음.
🚫 문제는 교착 상태이다.
➡️ 만약 각 철학자가 자신의 왼쪽 포크를 다른 철학자가 자신의 오른쪽 포크를 잡기 전에 먼저 잡았다면, 각 철학자는 하나의 포크만 들고서 다른 포크를 잡기를 평생 기다림.
다음의 그림을 통해 이러한 상황을 이해해보자.

[두 번째 시도 - 의존성 제거]
위 문제를 해결하는 가장 간단한 방법은 최소한 하나의 철학자가 다른 순서로 포크를 집도록 하는 것이다.
가장 높은 순번의 철학자 4가 포크를 다른 순서로 획득한다고 가정하자. 그때의 코드는 다음과 같다.
void getforks() {
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️⃣ 쓰레드 쓰로틀링
세마포어의 또 다른 간단한 사용 사례가 때때로 발생함.
➡️ 프로그래머가 "너무 많은" 쓰레드들이 동시에 작업을 수행하여 시스템이 중단되는 것을 어떻게 방지할 수 있을까?
💡 "너무 많은"에 대한 임계 값을 결정한 후 세마포어를 사용하여 동시에 실행하는 쓰레드 수를 제한한다.
➡️ 이러한 접근 방식을 쓰로틀링(throttling)이라고 부름.
➡️ 승인 제어(admission control)의 한 형태라고 여김.
어떤 문제를 병렬로 처리하기 위해 수백 개의 쓰레드를 생성하는 상황을 가정하자.
이때, 코드의 특정 부분에서 각 쓰레드는 계산의 일부를 수행하기 위해 많은 양의 메모리를 확보함.
➡️ 이 부분을 메모리 집약 영역(memory-intensive region)이라고 부르자.
⚠️ 모든 쓰레드가 동시에 메모리 집약 영역에 진입하면 모든 메모리 할당 요청의 합계가 시스템의 실제 메모리 양을 초과하게 됨.
➡️ 결과적으로 컴퓨터는 쓰레싱(thrashing)(즉, 디스크에서 페이지 교환)을 시작하고 전체 계산이 매우 느려짐.
🔖 쓰레싱: 지속적인 페이징 및 페이지 폴트(page fault) 상태로 이어져 CPU 이용률이 급격히 떨어지는 현상
✅ 간단한 세마포어가 이 문제를 해결할 수 있음.
세마포어의 값을 메모리 집약 영역에 한 번에 진입시키려는 최대 쓰레드 수로 초기화한 후 해당 영역 주위에 sem_wait()
과 sem_post()
를 배치하여 세마포어가 자연스럽게 쓰레드 수를 조절할 수 있도록 함.
8️⃣ 세마포어 구현
저수준 동기화 기법인 락과 컨디션 변수를 사용하여 우리만의 세마포어인 "제마포어(Zemaphore)"를 만들어 보자.
typedef struct __Zem_t {
int value;
pthread_cond_t cond;
pthread_mutex_t lock;
} Zem_t;
// 오직 하나의 쓰레드만 이 문장을 호출할 수 있음
void Zem_init(Zem_t *s, int value) {
s−>value = value;
Cond_init(&s−>cond);
Mutex_init(&s−>lock);
}
void Zem_wait(Zem_t *s) {
Mutex_lock(&s−>lock);
while (s−>value <= 0)
Cond_wait(&s−>cond, &s−>lock);
s−>value−−;
Mutex_unlock(&s−>lock);
}
void Zem_post(Zem_t *s) {
Mutex_lock(&s−>lock);
s−>value++;
Cond_signal(&s−>cond);
Mutex_unlock(&s−>lock);
}
하나의 락과 하나의 컨디션 변수를 사용하고 세마포어의 값을 나타내는 상태 변수 하나를 사용하고 있다.
💡 Dijkstra가 정의한 세마포어와 여기서 정의한 제마포어 간의 중요한 차이가 있다.
➡️ 세마포어는 음수 값이 대기 중인 쓰레드의 수를 나타낸다는 것
➡️ 제마포어에서는 이 값이 0보다 작을 수가 없음.
이 방식이 구현하기도 쉽고 현재 Linux에 구현된 방식이기도 함.
세마포어를 사용하여 락과 컨디션 변수를 만드는 것은 까다로운 문제