세마포어
카운팅·바이너리 세마포어의 동작을 익히고 생산자-소비자와 독자-저자 문제의 대기·신호 순서를 설계합니다.
뮤텍스는 한 번에 하나의 스레드만 임계 영역에 진입할 수 있습니다.
하지만 때로는 N개까지 동시 접근을 허용해야 합니다.
데이터베이스 커넥션 풀에 10개의 연결이 있다면 동시에 10개의 스레드까지 접근을 허용하고, 11번째 스레드는 대기해야 합니다.
이때 사용하는 것이 Dijkstra가 1965년에 제안한 세마포어(Semaphore)입니다.
뮤텍스가 yes/no 스위치라면, 세마포어는 카운터입니다.
세마포어의 동작
세마포어는 음이 아닌 정수 값과 두 가지 원자적 연산으로 구성됩니다.
wait(S) (또는 P 연산, down, acquire):
wait(S) {
while (S <= 0)
; // 대기
S--;
}값이 0보다 크면 1 감소시키고 진행합니다.
0이면 값이 양수가 될 때까지 대기합니다.
signal(S) (또는 V 연산, up, release):
signal(S) {
S++;
}값을 1 증가시킵니다.
대기 중인 스레드가 있으면 하나를 깨웁니다.
문헌과 API에 따라 P/V, wait/signal, acquire/release 등 다른 이름을 씁니다.
위 코드는 의미를 표현한 의사코드이며 일반 변수의 반복 검사와 감소만으로 안전한 세마포어가 구현되지는 않습니다. 허가 획득·반납과 대기 등록의 동기화가 필요합니다.
값을 확인하고 감소시키는 두 동작 사이에 다른 스레드가 끼어들 수 없습니다.
하드웨어 원자적 명령어(CAS 등)로 구현됩니다.
카운팅 세마포어
초기값이 N인 세마포어입니다.
N개의 동일한 자원을 관리할 때 사용합니다.
초기값 10이면, 10개의 스레드가 동시에 wait()을 성공합니다.
11번째 스레드는 누군가 signal()을 호출할 때까지 대기합니다.
실무 용도: DB 커넥션이나 동시 작업 수의 제한. 세마포어 하나는 동시성의 상한을 제어하며, 초당 요청 수 같은 속도 제한에는 시간에 따른 허가 보충 정책 등이 추가로 필요합니다.
바이너리 세마포어
값을 0과 1로 제한하는 세마포어 모델입니다. 카운팅 세마포어를 1로 초기화하기만 하면 추가 release로 1을 넘을 수 있습니다. Python에서는 BoundedSemaphore(1)이 상한 초과 반납을 오류로 감지합니다.
하지만 뮤텍스와 의미적 차이가 있습니다.
POSIX 뮤텍스는 소유권(Ownership)을 가지므로 소유자가 해제해야 합니다. 비소유자의 해제는 타입에 따라 오류 또는 정의되지 않은 동작입니다. 반면 Python의 기본 threading.Lock은 특정 스레드 소유권을 강제하지 않아 다른 스레드도 해제할 수 있습니다.
바이너리 세마포어는 소유권이 없어서, 한 스레드가 wait하고 다른 스레드가 signal할 수 있습니다.
이 특성 때문에 세마포어는 스레드 간 신호(Signaling)에 적합합니다 — 작업 완료 신호를 보내는 용도.
생산자-소비자 문제
생산자-소비자(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) 소비자가 블로킹
중요한 순서 문제
생산자가 mutex를 먼저 잡은 채 빈 슬롯을 기다리면, 버퍼가 가득 찬 상황에서 데드락이 발생할 수 있습니다.
# 잘못된 코드
with mutex: # 먼저 mutex를 잡고
empty.acquire() # empty가 0이면 여기서 블로킹
# mutex를 잡고 있으므로 소비자가 mutex를 잡지 못해
# empty.release()를 호출할 수 없음 → 데드락!이 생산자-소비자 구조에서는 용량 허가를 먼저 예약한 뒤 mutex를 잡습니다. 예약부터 반대쪽 release까지 처리 중인 허가를 held로 세면 empty + full + held = 5입니다. 모든 순간에 empty + full = 5이거나 full = len(buffer)인 것은 아닙니다.
이 예제는 예외 없는 생산·소비 각각 10회를 가정합니다. 예약 후 예외·취소 시 허가를 복구하는 경로는 생략되어 있으며, 난수 값·출력 순서·완료 시간은 보장하지 않습니다.
독자-저자 문제
독자-저자(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을 해제하여 저자가 진입할 수 있게 합니다. 이 예는 Python 기본 Lock의 비소유권 성질에 의존하므로 POSIX 뮤텍스로 그대로 치환할 수 없습니다. read_count 출력은 잠금 밖의 관측값이며, 예외 시 카운트·락 복구와 기아 방지는 구현하지 않은 함수 예제입니다.
실무 구현: Read-Write Lock
이 패턴이 너무 흔하므로, 대부분의 언어/프레임워크가 읽기-쓰기 락(Read-Write Lock)을 제공합니다.
- Java:
ReentrantReadWriteLock - C:
pthread_rwlock_t - Python: 표준
threading에 RWLock은 없습니다.RLock은 같은 스레드의 재진입을 허용하는 배타 락으로, 여러 독자의 동시 읽기를 제공하지 않습니다. - Rust:
std::sync::RwLock
데이터베이스의 공유 락(Shared Lock, S-Lock)은 읽기 락이고, 배타 락(Exclusive Lock, X-Lock)은 쓰기 락입니다.
하지만 모든 SELECT가 S-Lock을 잡지는 않습니다. 예를 들어 InnoDB의 일반 일관 읽기는 MVCC 스냅샷을 사용하며, SELECT ... FOR SHARE 같은 잠금 읽기와 구분합니다.
독자-저자 문제는 동시에 읽어도 되는가와 쓰기 대기자를 얼마나 빨리 들여보낼 것인가의 균형으로 이해하면 쉽습니다.
세마포어의 구현: 바쁜 대기 vs 큐 기반
세마포어를 바쁜 대기(스핀)로 구현하면 CPU를 낭비합니다.
블로킹 세마포어는 대기 큐를 사용할 수 있습니다. 다음은 음수로 대기자 수까지 표현하는 교과서 모델이며, Python Semaphore의 음이 아닌 내부 카운터와 구분합니다.
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는 아직 깨우지 않은 대기자 수를 표현합니다. 실제 API가 같은 값을 외부에 노출한다는 뜻은 아닙니다.
value = -3이면 3개의 스레드가 큐에서 대기 중입니다.
이 구현에서 wait()과 signal() 자체의 원자성은 어떻게 보장할까요?
실제 구현은 카운터·큐 조작을 짧은 락 구간 등으로 보호하고, 대기 등록 후 보호를 풀어 잠드는 과정을 깨우기와 연결해야 합니다. 스핀락을 잡은 채 block()하는 구현으로 옮기면 안 됩니다.
세마포어 내부의 스핀은 매우 짧은 코드이므로 효율적입니다.
다음 절에서는 더 높은 수준의 동기화 도구인 모니터와 고전적 동기화 문제들을 다루겠습니다.