세마포어
뮤텍스는 한 번에 하나의 스레드만 임계 영역에 진입할 수 있습니다. 하지만 때로는 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 증가시킵니다. 대기 중인 스레드가 있으면 하나를 깨웁니다.
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)에 적합합니다 — 작업 완료 신호를 보내는 용도.
생산자-소비자 문제
생산자-소비자(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을 잡습니다. 정확히 독자-저자 문제의 구현입니다.
세마포어의 구현: 바쁜 대기 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 변경 + 큐 조작)을 보호합니다. 세마포어 내부의 스핀은 매우 짧은 코드이므로 효율적입니다.
다음 절에서는 더 높은 수준의 동기화 도구인 모니터와 고전적 동기화 문제들을 다루겠습니다.