세마포어
카운팅·바이너리 세마포어의 동작을 익히고 생산자-소비자와 독자-저자 문제의 대기·신호 순서를 설계합니다.
뮤텍스는 한 번에 하나의 스레드만 임계 영역에 진입할 수 있습니다.
하지만 때로는 N개까지 동시 접근을 허용해야 합니다.
데이터베이스 커넥션 풀에 10개의 연결이 있다면 동시에 10개의 스레드까지 접근을 허용하고, 11번째 스레드는 대기해야 합니다.
이때 사용하는 것이 Dijkstra가 1965년에 제안한 세마포어(Semaphore)입니다.
뮤텍스가 yes/no 스위치라면, 세마포어는 카운터입니다.
카운터가 남아 있으면 바로 진입하고, 0이면 대기 큐에서 signal을 기다린다. 핵심은 wait와 signal이 카운터를 원자적으로 바꾼다는 점이다.
- wait, 남은 슬롯 있음
> 0 → S-- → 진입
- wait, 슬롯 없음
= 0 → 큐 대기
- signal
반납 → S++ 또는 1명 깨움
| 구분 | 의미 | 헷갈리기 쉬운 지점 |
|---|---|---|
| 뮤텍스 | 한 번에 하나만 들어가는 소유 기반 잠금 | 잠근 실행 흐름이 직접 풀어야 한다. |
| 카운팅 세마포어 | N개까지 동시 진입을 허용하는 자원 카운터 | 데이터베이스 커넥션 풀처럼 개수 제한에 잘 맞다. |
| 바이너리 세마포어 | 값이 0 또는 1인 세마포어 | 뮤텍스처럼 보이지만 소유권 의미는 약하다. |
세마포어의 동작
세마포어는 음이 아닌 정수 값과 두 가지 원자적 연산으로 구성됩니다.
wait(S) (또는 P 연산, down, acquire):
wait(S) {
while (S <= 0)
; // 대기
S--;
}값이 0보다 크면 1 감소시키고 진행합니다.
0이면 값이 양수가 될 때까지 대기합니다.
signal(S) (또는 V 연산, up, release):
signal(S) {
S++;
}값을 1 증가시킵니다.
대기 중인 스레드가 있으면 하나를 깨웁니다.
P와 V라는 이름은 네덜란드어 Proberen(시도하다)과 Verhogen(증가시키다)에서 유래합니다.
Dijkstra가 네덜란드 사람이었기 때문입니다.
중요: wait와 signal은 각각 원자적으로 실행됩니다.
값을 확인하고 감소시키는 두 동작 사이에 다른 스레드가 끼어들 수 없습니다.
하드웨어 원자적 명령어(CAS 등)로 구현됩니다.
카운팅 세마포어
초기값이 N인 세마포어입니다.
N개의 동일한 자원을 관리할 때 사용합니다.
초기값 10이면, 10개의 스레드가 동시에 wait()을 성공합니다.
11번째 스레드는 누군가 signal()을 호출할 때까지 대기합니다.
실무 용도: DB 커넥션 풀(최대 연결 수 제한), 스레드 풀(최대 동시 작업 수 제한), 속도 제한(Rate Limiting)
바이너리 세마포어
초기값이 1인 세마포어입니다.
0과 1 사이에서만 변동하므로 뮤텍스와 비슷하게 동작합니다.
하지만 뮤텍스와 의미적 차이가 있습니다.
뮤텍스는 소유권(Ownership)이 있어서, 락을 건 스레드만 풀 수 있습니다.
다른 스레드가 unlock을 시도하면 오류입니다.
바이너리 세마포어는 소유권이 없어서, 한 스레드가 wait하고 다른 스레드가 signal할 수 있습니다.
이 특성 때문에 세마포어는 스레드 간 신호(Signaling)에 적합합니다 — 작업 완료 신호를 보내는 용도.
생산자-소비자 문제
세마포어에서는 공유 자원, 락 경계, 대기 큐 이동을 확인합니다.
empty는 넣을 공간, items는 꺼낼 데이터, mutex는 버퍼 배열 접근을 지킨다.
- wait empty
producer wait empty 빈 슬롯 확보
- mutex
buffer mutex 한 번에 한 스레드
- wait item
consumer wait item 데이터 도착 대기
- 상태 갱신
signal 상태 갱신 empty/items 반대 증가
생산자-소비자(Producer-Consumer)는 동기화의 가장 대표적인 고전 문제입니다.
실무에서 메시지 큐, 이벤트 버퍼, 파이프라인 등 어디서나 나타나는 패턴입니다.
문제 설정: 생산자는 데이터를 유한한 크기의 버퍼에 넣고, 소비자는 버퍼에서 데이터를 꺼냅니다.
세 가지 동기화가 동시에 필요합니다.
- 버퍼가 가득 차면 생산자가 대기해야 합니다 →
empty세마포어 - 버퍼가 비어 있으면 소비자가 대기해야 합니다 →
full세마포어 - 버퍼에 동시 접근하면 데이터가 손상됩니다 →
mutex
import threading
import time
import random
buffer = []
BUFFER_SIZE = 5
mutex = threading.Lock()
empty = threading.Semaphore(BUFFER_SIZE) # 빈 슬롯 수 (초기값 = 버퍼 크기)
full = threading.Semaphore(0) # 차 있는 슬롯 수 (초기값 = 0)
def producer(name):
for i in range(10):
item = random.randint(1, 100)
empty.acquire() # 빈 슬롯 대기 (0이면 블로킹)
with mutex: # 버퍼 접근 보호
buffer.append(item)
print(f"[{name}] Produced: {item}, Buffer({len(buffer)}): {buffer}")
full.release() # 차 있는 슬롯 증가
time.sleep(random.uniform(0.05, 0.15))
def consumer(name):
for i in range(10):
full.acquire() # 차 있는 슬롯 대기 (0이면 블로킹)
with mutex: # 버퍼 접근 보호
item = buffer.pop(0)
print(f"[{name}] Consumed: {item}, Buffer({len(buffer)}): {buffer}")
empty.release() # 빈 슬롯 증가
time.sleep(random.uniform(0.1, 0.25))
t1 = threading.Thread(target=producer, args=("P1",))
t2 = threading.Thread(target=consumer, args=("C1",))
t1.start(); t2.start()
t1.join(); t2.join()
print("Done!")동작 흐름:
- 처음에
empty=5,full=0 - 생산자가
empty.acquire()→ empty가 4가 됨 → 아이템 추가 →full.release()→ full이 1이 됨 - 소비자가
full.acquire()→ full이 0이 됨 → 아이템 제거 →empty.release()→ empty가 5가 됨 - 버퍼가 가득 차면(empty=0) 생산자가 블로킹, 버퍼가 비면(full=0) 소비자가 블로킹
중요한 순서 문제
empty.acquire()와 mutex.acquire()의 호출 순서가 바뀌면 데드락이 발생합니다.
# 잘못된 코드
with mutex: # 먼저 mutex를 잡고
empty.acquire() # empty가 0이면 여기서 블로킹
# mutex를 잡고 있으므로 소비자가 mutex를 잡지 못해
# full.release()를 호출할 수 없음 → 데드락!반드시 세마포어를 먼저 잡고, 그 다음에 mutex를 잡아야 합니다.
독자-저자 문제
독자-저자(Readers-Writers) 문제는 데이터베이스, 파일 시스템, 캐시에서 자주 만나는 패턴입니다.
규칙:
- 여러 독자(Reader)가 동시에 읽는 것은 안전합니다 — 읽기는 데이터를 변경하지 않으므로
- 저자(Writer)가 쓰는 동안에는 다른 누구도 접근하면 안 됩니다 — 읽기도 불가
- 저자가 쓰려면 모든 독자가 나갈 때까지 기다려야 합니다
이 문제에는 여러 변형이 있습니다.
첫 번째 독자-저자 문제 (독자 우선): 독자가 있으면 새 독자는 즉시 진입할 수 있습니다.
저자는 모든 독자가 나갈 때까지 대기합니다.
문제: 독자가 계속 오면 저자가 기아 상태에 빠집니다.
두 번째 독자-저자 문제 (저자 우선): 저자가 대기 중이면 새 독자의 진입을 막습니다.
기존 독자들이 나가면 저자가 먼저 실행됩니다.
문제: 저자가 계속 오면 독자가 기아 상태에 빠집니다.
import threading
read_count = 0
read_count_lock = threading.Lock()
resource_lock = threading.Lock()
def reader(name):
global read_count
with read_count_lock:
read_count += 1
if read_count == 1:
resource_lock.acquire() # 첫 독자가 저자를 차단
# 읽기 수행 (여러 독자가 동시에 여기 있을 수 있음)
print(f"[{name}] Reading... (readers: {read_count})")
with read_count_lock:
read_count -= 1
if read_count == 0:
resource_lock.release() # 마지막 독자가 저자를 허용
def writer(name):
resource_lock.acquire() # 독점 접근
print(f"[{name}] Writing...")
resource_lock.release()첫 번째 독자가 resource_lock을 잡아 저자를 차단합니다.
이후의 독자들은 resource_lock 없이 진입합니다.
마지막 독자가 나갈 때 resource_lock을 해제하여 저자가 진입할 수 있게 합니다.
실무 구현: Read-Write Lock
이 패턴이 너무 흔하므로, 대부분의 언어/프레임워크가 읽기-쓰기 락(Read-Write Lock)을 제공합니다.
- Java:
ReentrantReadWriteLock - C:
pthread_rwlock_t - Python:
threading.RLock(Python 표준 라이브러리에는 RWLock이 없어 직접 구현하거나 서드파티 사용) - Rust:
std::sync::RwLock
데이터베이스의 공유 락(Shared Lock, S-Lock)은 읽기 락이고, 배타 락(Exclusive Lock, X-Lock)은 쓰기 락입니다.
SELECT는 S-Lock, UPDATE/DELETE는 X-Lock을 잡습니다.
정확히 독자-저자 문제의 구현입니다.
독자-저자 문제는 동시에 읽어도 되는가와 쓰기 대기자를 얼마나 빨리 들여보낼 것인가의 균형으로 이해하면 쉽습니다.
아래 다이어그램은 공유 읽기, 배타 쓰기, 기아 방지 정책이 어디에서 갈라지는지 정리합니다.
같은 자료구조라도 읽기와 쓰기의 위험이 다르므로, 입장 규칙은 현재 독자 수와 대기 중인 저자 여부를 함께 본다.
- reader저자가 없으면 함께 진입
reader 읽기는 상태를 바꾸지 않으므로 여러 독자가 같은 공유 락을 동시에 잡을 수 있다.
- writer독자가 0명이 될 때까지 대기
writer 쓰기는 데이터를 바꾸므로 모든 독자와 다른 저자가 빠진 뒤 배타 락을 얻는다.
- release마지막 독자가 문을 연다
release read_count가 0이 되는 순간 대기 중인 저자가 쓰기 락을 획득할 수 있다.
- 독자 우선
처리량은 높지만 독자가 계속 오면 저자가 오래 밀릴 수 있다.
- 저자 우선
대기 중인 저자가 있으면 새 독자를 막아 쓰기 기아를 줄인다.
- 공정 큐
도착 순서를 보존해 어느 쪽도 무기한 기다리지 않게 만든다.
세마포어의 구현: 바쁜 대기 vs 큐 기반
세마포어를 바쁜 대기(스핀)로 구현하면 CPU를 낭비합니다.
실제 OS는 대기 큐를 사용합니다.
typedef struct {
int value;
struct list_head wait_queue;
} semaphore;
void wait(semaphore *S) {
S->value--;
if (S->value < 0) {
/* 현재 스레드를 wait_queue에 추가 */
/* 스레드를 블로킹(슬립) 상태로 전환 */
block();
}
}
void signal(semaphore *S) {
S->value++;
if (S->value <= 0) {
/* wait_queue에서 스레드 하나를 꺼냄 */
/* 해당 스레드를 Ready 상태로 전환 */
wakeup(thread);
}
}value가 음수가 되면, 그 절대값이 대기 중인 스레드 수입니다.
value = -3이면 3개의 스레드가 큐에서 대기 중입니다.
이 구현에서 wait()과 signal() 자체의 원자성은 어떻게 보장할까요?
단일 코어에서는 인터럽트 비활성화로, 멀티코어에서는 스핀락으로 짧은 임계 영역(value 변경 + 큐 조작)을 보호합니다.
세마포어 내부의 스핀은 매우 짧은 코드이므로 효율적입니다.
세마포어의 value는 단순히 남은 자원 수만 뜻하지 않습니다.
양수이면 즉시 통과 가능한 여유 슬롯이고, 0이면 다음 wait()부터 대기이며, 음수이면 그 절대값만큼 스레드가 큐에 잠들어 있다는 신호입니다.
아래 다이어그램은 wait과 signal이 카운터와 대기 큐를 어떻게 함께 움직이는지 한 번에 추적하도록 정리한 것입니다.
큐 기반 세마포어에서는 카운터가 0 아래로 내려가는 순간부터 대기 큐의 길이까지 함께 표현한다.
- 즉시 통과
value > 0 남은 슬롯이 있으므로 wait은 값을 1 줄이고 바로 진행한다.
- 경계 상태
value = 0 마지막 슬롯이 사용 중이며 다음 wait부터 잠들 준비를 한다.
- 대기 큐 존재
value < 0 절대값이 잠든 스레드 수를 나타내며 signal이 하나씩 깨운다.
- wait(S)
먼저 value를 감소시킨다. 결과가 음수이면 현재 스레드를 wait queue에 넣고 블로킹한다.
- signal(S)
value를 증가시킨다. 증가 후에도 0 이하라면 큐에서 하나를 꺼내 Ready 상태로 보낸다.
- 원자성 보호
value 변경과 큐 조작은 세마포어 내부의 아주 짧은 임계 영역이므로 커널은 인터럽트 비활성화나 스핀락으로 이 구간만 보호한다.
다음 절에서는 더 높은 수준의 동기화 도구인 모니터와 고전적 동기화 문제들을 다루겠습니다.
카운팅 세마포어는 빈 칸과 채운 칸을 예약하고, mutex는 실제 버퍼 변경만 직렬화한다.
- empty + full + held = N
처리 중 예약까지 합치면 용량이 보존된다.
- init(mutex) = 1
put/get 임계 구역에는 한 스레드만 진입한다.
- value = 0
POSIX wait는 감소할 수 있을 때까지 잠든다.
- 생산자
빈 칸 → 데이터 wait(empty) → wait(mutex) → put → post(mutex) → post(full)
- wait(empty)
- wait(mutex)
- post(mutex)
- post(full)
- 소비자
데이터 → 빈 칸 wait(full) → wait(mutex) → get → post(mutex) → post(empty)
- wait(full)
- post(empty)
세마포어는 정수 값과 대기 queue로 여러 실행 흐름의 통과를 제어한다. mutex처럼 하나만 막을 수도 있고, 제한된 자원 개수를 표현할 수도 있다.
- 1허가 의미 결정
세마포어 값이 남은 buffer 칸인지, 채워진 item 수인지, 진입권인지 먼저 이름 붙인다.
- 2wait 순서를 배치한다
생산자-소비자에서는 empty, full, mutex의 wait/signal 순서가 deadlock과 race를 좌우한다.
- 3대기자를 깨운다
signal은 값을 올리는 것뿐 아니라 잠든 실행 흐름이 다시 조건을 확인할 기회를 준다.
- 4초기값
세마포어 초기 값이 실제 자원 개수와 맞는가.
- 5순서
wait와 signal 순서가 deadlock을 만들지 않는가.
- 6짝
허가를 얻은 모든 경로가 결국 돌려주는가.
producer: wait(empty); wait(mutex); put(); signal(mutex); signal(full)
consumer: wait(full); wait(mutex); get(); signal(mutex); signal(empty)
아래 다이어그램은 세마포어 값 변화와 wait/signal이 임계구역 진입을 제어하는 흐름을 핵심 질문과 판단 순서로 정리한 것입니다.