오늘의 주문서
#04 · 넷째 밤
위기
단체 손님 때문에 요리사가 여섯으로 늘었다. 그런데 화구는 셋뿐이고, 셰프가 "오픈!"을 외치기 전에는 아무도 불을 켜면 안 된다.
목표
오픈 신호 전에는 아무도 불을 켜지 않고, 화구는 동시에 셋까지만 쓰면서 주문 여섯 개를 1초 안에 낸다.
심야 식당 동시식당
넷째 밤 아홉 시 사십 분. 단체 예약이 있어 하루짜리 요리사 셋이 더 왔습니다. 요리사는 여섯, 화구는 셋입니다.
민호가 제일 먼저 화구 앞에 서서 불을 켭니다. "미리 데워 두면 빠르잖아요." 셰프가 말없이 가스 밸브를 잠급니다. "오픈 전에 불 켜는 건 금지야. 그리고 화구 앞에 여섯이 몰려 서 있으면 아무도 못 움직여."
수아가 손을 듭니다. "화구 앞에 번호표를 세 장만 걸어 두면 어때요? 번호표를 든 사람만 불 앞에 서는 거예요." 셰프가 처음으로 웃습니다. "그게 오늘 밤의 첫 번째 도구야."
3교시의 Lock은 분필 하나, 즉 한 번에 한 명이었습니다. Semaphore(n)은 번호표 n장입니다. 동시에 n명까지 들어갈 수 있고, n+1번째 사람은 누군가 번호표를 돌려줄 때까지 기다립니다.
import threading
import time
stoves = threading.Semaphore(3) # 화구는 셋
start = time.perf_counter()
def cook(name):
with stoves: # 빈 화구가 날 때까지 기다렸다가 잡는다
print(f"{time.perf_counter() - start:.1f}s {name} on")
time.sleep(0.3)
names = [f"cook{i}" for i in range(1, 7)]
threads = [threading.Thread(target=cook, args=(n,)) for n in names]
for th in threads:
th.start()
for th in threads:
th.join()
print(f"total {time.perf_counter() - start:.2f}s")
# 예:
# 0.0s cook1 on
# 0.0s cook2 on
# 0.0s cook3 on
# 0.3s cook4 on
# 0.3s cook5 on
# 0.3s cook6 on
# total 0.60swith stoves:에 들어갈 때 번호표를 한 장 가져가고(acquire), 나올 때 돌려줍니다(release). 번호표가 0장이면 들어가는 쪽이 기다립니다.Semaphore(1)은 Lock처럼 한 번에 한 명입니다. 다만 Lock은 풀린 잠금을 또 풀면 RuntimeError가 나는데, 세마포어는 release()를 더 불러도 조용히 번호표가 늘어납니다(아래 BoundedSemaphore).아래 타임라인에서 화구 수를 바꿔 보세요. 노란 막대는 화구를 기다리는 시간, 주황 막대는 실제로 요리하는 시간입니다.
주방 타임라인
주문
6
요리사
3
화구
2
요리
기다림
취소
실패
Semaphore는 몇 장을 돌려받는지 세지 않습니다. 버그 때문에 release()를 한 번 더 부르면, 번호표가 슬그머니 넉 장이 됩니다. 화구는 셋인데 넷이 불 앞에 서게 됩니다.
loose = threading.Semaphore(3)
loose.release() # 실수로 한 번 더 놓았다
print(sum(loose.acquire(blocking=False) for _ in range(10))) # 화구가 넷이 됐다
# 4
strict = threading.BoundedSemaphore(3)
strict.release()
# ValueError: Semaphore released too many timesBoundedSemaphore는 처음 정한 수보다 많이 돌려받으면 ValueError를 냅니다. 개수를 제한하려고 세마포어를 쓴다면 거의 언제나 BoundedSemaphore가 낫습니다. 실수가 조용히 넘어가지 않고 그 자리에서 터지니까요.
셰프의 한마디
세마포어는 숫자를 세 주는 거지, 칠판을 지켜 주는 게 아니야. 화구 셋에 셋이 서 있어도, 그 셋이 같은 칠판에 숫자를 적으면 또 경쟁 상태야. 세 명까지 들어가는 방 안에서 공유 값을 만지면 그 안에서 다시 Lock을 써.
Event는 주방 벽의 신호등 하나입니다. 처음엔 꺼져 있고, 누군가 켜면(set()) 그 신호를 기다리던(wait()) 사람이 모두 한꺼번에 움직입니다.
opened = threading.Event() # 처음엔 꺼져 있다
start = time.perf_counter()
def cook(name):
opened.wait() # 신호가 켜질 때까지 기다린다
print(f"{time.perf_counter() - start:.1f}s {name} fire")
threads = [threading.Thread(target=cook, args=(n,)) for n in ["minho", "sua", "tak"]]
for th in threads:
th.start()
time.sleep(0.5)
print(opened.is_set())
opened.set() # "오픈!"
for th in threads:
th.join()
print(opened.is_set())
# False
# 0.5s minho fire (세 줄의 순서는 실행마다 다르다)
# 0.5s sua fire
# 0.5s tak fire
# Truewait()에 시간 한도를 줄 수도 있습니다. 이때 돌려주는 값이 중요합니다. 신호가 켜졌으면 True, 시간이 다 됐는데 여전히 꺼져 있으면 False입니다.
closing = threading.Event()
print(closing.wait(timeout=0.2)) # 0.2초 기다렸는데 신호가 없다
closing.set()
print(closing.wait(timeout=0.2)) # 이미 켜져 있으면 바로 True
closing.clear() # 다시 끈다
print(closing.is_set())
# False
# True
# False| 메서드 | 하는 일 |
|---|---|
set() | 신호를 켠다. 기다리던 스레드가 모두 깨어난다 |
wait() | 신호가 켜질 때까지 기다린다. 이미 켜져 있으면 바로 지나간다 |
wait(timeout) | 그 시간까지만 기다린다. 켜졌으면 True, 아니면 False |
clear() | 신호를 다시 끈다. 이후의 wait()는 다시 기다린다 |
is_set() | 지금 켜져 있는지 |
자주 쓰는 모양이 하나 있습니다. "영업 끝" 신호를 Event로 만들고, 일하는 스레드가 반복문을 돌 때마다 if closing.is_set(): break로 확인하게 하는 것입니다. 2교시의 daemon처럼 중간에 끊지 않고, 스스로 정리하고 끝나게 만드는 방법입니다.
아래 타임라인은 셰프가 0.5초 뒤에 "오픈!"을 외치는 주방입니다.
주방 타임라인
요리사
3
요리
기다림
취소
실패
set() 한 번이 기다리는 사람 전부를 깨우기 때문입니다.Event는 켜졌냐 꺼졌냐, 둘뿐입니다. 하지만 주방의 기다림은 대개 더 구체적입니다. 민호는 달걀을, 수아는 두부를 기다립니다. 선반에 재료가 들어올 때마다 "내 재료 왔나?" 확인하고, 아니면 다시 기다려야 합니다. 이럴 때 Condition을 씁니다.
Condition은 잠금 하나 + "깨워 줘" 명단입니다. 공유 상태(재료 선반)를 잠금으로 지키면서, 상태가 바뀌면 명단의 사람을 깨웁니다.
pantry = [] # 재료 선반(공유 상태)
arrived = threading.Condition() # 선반을 지키는 잠금 + "깨워 줘" 명단
start = time.perf_counter()
def cook(name, need):
with arrived:
ok = arrived.wait_for(lambda: need in pantry, timeout=0.5)
print(f"{time.perf_counter() - start:.1f}s {name} {need if ok else 'gave up'}")
def supplier():
for item in ["tofu", "egg"]: # 생선은 오늘 안 온다
time.sleep(0.1)
with arrived:
pantry.append(item)
arrived.notify_all() # 기다리는 사람을 모두 깨운다
threads = [threading.Thread(target=cook, args=a) for a in [("minho", "egg"), ("sua", "tofu"), ("tak", "fish")]]
threads.append(threading.Thread(target=supplier))
for th in threads:
th.start()
for th in threads:
th.join()
# 0.1s sua tofu
# 0.2s minho egg
# 0.5s tak gave upwith arrived:로 잠금을 잡은 채로만 선반을 보고 고칩니다. 3교시의 규칙 그대로입니다.wait_for(조건)은 조건이 참이 될 때까지 기다립니다. 기다리는 동안에는 잠금을 잠시 내려놓기 때문에, 납품업자가 선반에 재료를 넣을 수 있습니다. 깨어나면 잠금을 다시 잡고 조건을 다시 확인합니다.timeout을 주면 시간이 다 됐을 때 조건의 마지막 값을 돌려줍니다. 생선은 끝내 오지 않아서 탁 씨는 0.5초 뒤 False를 받고 포기했습니다.notify_all()은 명단의 모두를 깨웁니다. 깨어난 사람은 각자 자기 조건을 확인하고, 아니면 다시 잡니다. 두부가 왔을 때 민호와 탁 씨도 깼지만, 자기 재료가 아니라서 다시 기다렸습니다.notify()는 기다리는 사람 하나만 깨웁니다. 누가 가져가도 상관없는 일, 예를 들어 "완성된 접시 하나를 아무 홀 직원이나 가져가기"에 알맞습니다.
plates = 0
counter = threading.Condition()
def cook():
global plates
time.sleep(0.1)
with counter:
plates += 1
counter.notify() # 기다리는 사람 하나만 깨운다
def waiter(name):
global plates
with counter:
counter.wait_for(lambda: plates > 0)
plates -= 1
print(name, "took a plate")
ws = [threading.Thread(target=waiter, args=(n,)) for n in ["w1", "w2"]]
cs = [threading.Thread(target=cook) for _ in range(2)]
for th in ws + cs:
th.start()
for th in ws + cs:
th.join()
print(plates)
# 예:
# w1 took a plate
# w2 took a plate
# 0접시 하나에 한 명만 깨우니, 둘이 동시에 깨어나 한 접시를 놓고 다투는 일이 줄어듭니다. 반대로 사람마다 기다리는 조건이 다르면 notify()가 엉뚱한 사람을 깨울 수 있으니 notify_all()을 씁니다. 망설여지면 notify_all()이 안전합니다.
셰프의 한마디
Condition에서 wait()를 쓸 땐 꼭 while 안에서 조건을 다시 확인해. 깨웠다고 해서 내 재료가 왔다는 보장은 없어. 다른 사람 재료일 수도 있고, 누가 먼저 가져갔을 수도 있지. wait_for(조건)을 쓰면 그 while을 대신 돌아 주니까 그게 제일 편해.
단체석 4인 테이블에는 네 접시를 동시에 내야 합니다. 한 접시만 먼저 나가면 나머지 셋이 식는 걸 기다리며 앉아 있어야 하니까요. Barrier(n)은 n명이 모두 도착할 때까지 패스 앞에서 기다렸다가, 다 모이면 한꺼번에 내보냅니다.
start = time.perf_counter()
table = threading.Barrier(4, action=lambda: print("serve 4 plates"))
done = []
def cook(name, sec):
time.sleep(sec) # 각자 속도가 다르다
i = table.wait() # 넷이 다 올 때까지 패스 앞에서 기다린다
done.append((round(time.perf_counter() - start, 1), i, name))
crew = [("minho", 0.1), ("sua", 0.2), ("chef", 0.3), ("tak", 0.4)]
threads = [threading.Thread(target=cook, args=c) for c in crew]
for th in threads:
th.start()
for th in threads:
th.join()
print(sorted(done))
# serve 4 plates
# [(0.4, 0, 'minho'), (0.4, 1, 'sua'), (0.4, 2, 'chef'), (0.4, 3, 'tak')]wait()는 0부터 n-1까지, 사람마다 다른 번호를 돌려줍니다. CPython에서는 도착한 순서대로 0, 1, 2, 3입니다. 이 번호로 "한 명만 할 일"(예: if i == 0: 로그 남기기)을 정할 수 있습니다.action=에 준 함수는 마지막 사람이 도착했을 때 한 번만 실행됩니다. 접시를 내보내는 신호입니다.wait(timeout=)이 시간을 넘기면 Barrier가 깨지고 기다리던 모두가 BrokenBarrierError를 받습니다.| 도구 | 주방으로 치면 | 언제 |
|---|---|---|
Lock | 분필 하나 | 공유 값을 한 번에 한 명만 고칠 때(3교시) |
Semaphore(n), BoundedSemaphore(n) | 화구 앞 번호표 n장 | 동시에 n개까지만 쓰게 할 때 |
Event | 벽의 신호등 | "시작", "끝" 같은 한 번의 신호를 모두에게 |
Condition | 잠금 + "깨워 줘" 명단 | "이 조건이 되면 깨워 줘". 공유 상태가 바뀌는 걸 기다릴 때 |
Barrier(n) | 단체석 패스 | n명이 다 모이면 함께 출발 |
다섯 가지 모두 threading에 있고, 모두 3교시의 잠금 위에 지은 도구입니다. 실제로 Semaphore, Event, Barrier는 안에서 Condition을 쓰고, Condition은 안에 잠금(따로 주지 않으면 RLock)을 하나 들고 있습니다.
오늘 밤의 목표는 오픈 신호 전에는 아무도 불을 켜지 않고, 화구는 동시에 셋까지만 쓰면서 주문 여섯 개를 1초 안에 내는 것이었습니다. Event로 출발선을, Semaphore(3)으로 화구를, Lock으로 "지금 불 앞에 몇 명"을 셉니다.
opened = threading.Event()
stoves = threading.Semaphore(3)
lock = threading.Lock()
on_fire = peak = 0
log = []
start = time.perf_counter()
def cook(n):
global on_fire, peak
opened.wait() # 1. "오픈!" 전에는 불을 켜지 않는다
with stoves: # 2. 화구는 동시에 셋까지
with lock:
on_fire += 1
peak = max(peak, on_fire)
time.sleep(0.3)
with lock:
on_fire -= 1
log.append((round(time.perf_counter() - start, 1), f"#{n}"))
threads = [threading.Thread(target=cook, args=(n,)) for n in range(1, 7)]
for th in threads:
th.start()
time.sleep(0.3) # 손님 맞을 준비
opened.set()
for th in threads:
th.join()
print(sorted(log))
print(f"peak {peak}, total {time.perf_counter() - start:.2f}s")
# 예:
# [(0.6, '#1'), (0.6, '#2'), (0.6, '#3'), (0.9, '#4'), (0.9, '#5'), (0.9, '#6')]
# peak 3, total 0.90speak는 몇 번을 돌려도 3입니다. 0.6초에 나오는 세 주문이 어느 번호인지는 실행마다 다를 수 있습니다.on_fire와 peak는 여섯 요리사가 함께 고치는 값이라 lock으로 감쌌습니다. 세마포어 안이라도 그렇습니다.마감 무렵, 창가 자리에 혼자 앉은 여자 손님이 국수 한 그릇을 비웁니다. 손목시계를 한 번 보고, 작은 수첩에 무언가를 적고는 조용히 계산하고 나갑니다.
셰프가 화구 밸브를 하나씩 잠그며 말합니다. "오늘은 다들 줄을 잘 섰어. 그런데 화구 말고 칼과 도마도 하나씩뿐이라면? 민호가 칼을 들고 도마를 기다리는데, 수아가 도마를 들고 칼을 기다리면 어떻게 될까?"
영업 일지 — 넷째 밤. 요리사 6명, 화구 3개. 오픈 신호(Event) 전에는 대기, 화구는 번호표 세 장(Semaphore)으로 제한해 0.9초에 6건 완료. 창가 자리 손님 한 분, 국수 한 그릇.
closing = threading.Event()를 두고, 요리사 스레드가 0.1초마다 한 그릇씩 만들다가 closing.is_set()이면 멈추게 합니다. 메인 스레드는 0.35초 뒤 closing.set()을 부르고 join()합니다. 몇 그릇이 나왔나요?Barrier(4)를 Barrier(5)로 바꾸면 무슨 일이 생길지 생각해 보세요. 진짜로 영원히 멈추지 않도록, table.wait(timeout=1)로 바꾼 뒤 돌려서 어떤 예외가 나는지 확인하세요.셰프의 레시피 카드
Semaphore(n): with sem: 으로 동시에 n개까지. 개수 제한이면 BoundedSemaphore(n)으로 release 실수를 ValueError로 잡는다.
Event: set()으로 켜면 wait()하던 모두가 깨어난다. wait(timeout)은 켜졌으면 True, 아니면 False. clear(), is_set().
Condition: with cond: 안에서 cond.wait_for(조건). 상태를 바꾼 쪽이 notify()(한 명) 또는 notify_all()(모두).
Barrier(n): wait()로 n명이 모이면 함께 통과. 돌려주는 번호 0~n-1, action=은 한 번만.
세마포어 안이라도 공유 값을 고치면 Lock. 도구는 겹쳐 쓴다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.