오늘의 주문서
#05 · 다섯째 밤
위기
민호는 칼을 쥔 채 도마를, 수아는 도마를 쥔 채 칼을 기다린다. 둘 다 먼저 놓을 생각이 없다. 주방이 멈췄다.
목표
칼과 도마를 함께 쓰는 요리사 셋이 한 번도 멈추지 않고 100번씩 요리한다.
심야 식당 동시식당
다섯째 밤 열한 시. 주방이 이상하게 조용합니다. 민호는 칼을 쥐고 도마 쪽을 보고 있고, 수아는 도마를 끌어안고 칼 쪽을 보고 있습니다. 둘 다 "먼저 쓰고 줄게"라는 말만 반복합니다.
탁 씨가 파를 썰다 말고 중얼거립니다. "둘 다 서 있으면 아무도 안 끝나."
윤서연 셰프가 목에 건 타이머를 멈춥니다. "둘 다 내려놔. 이건 누가 느려서 생긴 일이 아니야. 규칙이 없어서 생긴 일이지."
어젯밤 화구 규칙처럼, 칼과 도마도 한 번에 한 사람만 쓸 수 있는 잠금입니다. 민호는 칼 → 도마 순서로, 수아는 도마 → 칼 순서로 잡습니다.
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife: # 칼을 든다
time.sleep(0.1)
with board: # 도마를 기다린다
print("minho done")
def sua():
with board: # 도마를 든다
time.sleep(0.1)
with knife: # 칼을 기다린다
print("sua done")
a = threading.Thread(target=minho, daemon=True)
b = threading.Thread(target=sua, daemon=True)
a.start(); b.start()
a.join(timeout=1); b.join(timeout=1)
print("alive:", a.is_alive(), b.is_alive())
# alive: True Truejoin(timeout=1)은 1초까지만 기다리고 돌아옵니다. 스레드를 daemon=True로 만들었으니 프로그램은 그대로 끝납니다. 데드락을 직접 실험할 때는 언제나 이렇게 빠져나올 길을 만들어 두세요.time.sleep(0.1)은 두 사람이 첫 도구를 거의 동시에 집게 만드는 장치입니다. 실제 프로그램에서는 이 틈이 아주 드물게 생겨서, 데드락이 "가끔만" 일어나는 더 고약한 버그가 됩니다.멈췄다는 사실을 프로그램 안에서 알아차리려면 acquire(timeout=...)을 씁니다. 잡으면 True, 시간 안에 못 잡으면 False를 돌려줍니다. with lock:에는 시간 제한을 줄 수 없으니, 이때는 acquire와 release를 직접 부릅니다.
knife = threading.Lock() # 새 칼과 새 도마
board = threading.Lock()
def cook(name, first, second, patience):
with first:
time.sleep(0.1)
if second.acquire(timeout=patience): # patience초까지만 기다린다
second.release()
print(name, "done")
else:
print(name, f"gave up after {patience}s")
a = threading.Thread(target=cook, args=("minho", knife, board, 1.0))
b = threading.Thread(target=cook, args=("sua", board, knife, 0.5))
a.start(); b.start()
a.join(); b.join()
# sua gave up after 0.5s
# minho done수아가 0.5초 만에 포기하고 도마를 내려놓자, 아직 기다리던 민호가 도마를 잡고 요리를 끝냅니다. 한 사람만 양보해도 데드락은 풀립니다. 왜 그런지는 다음 절의 네 가지 조건으로 설명됩니다.
데드락은 아래 네 조건이 모두 동시에 맞을 때만 생깁니다. 거꾸로 말하면, 하나만 깨도 데드락은 생기지 않습니다.
| 조건 | 뜻 | 오늘 밤 주방에서 | 깨는 법 |
|---|---|---|---|
| 상호 배제 | 자원을 한 번에 한 명만 쓴다 | 칼은 한 사람 손에만 | 칼을 더 사거나, 공유가 필요 없게 설계한다 |
| 점유하며 대기 | 하나를 쥔 채 다른 것을 기다린다 | 칼을 든 채로 도마를 기다린다 | 둘을 한꺼번에 잡거나, 못 잡으면 내려놓는다 |
| 빼앗을 수 없음 | 남이 쥔 자원을 강제로 가져올 수 없다 | 민호 손의 칼을 뺏을 수 없다 | 시간 제한으로 스스로 내려놓게 한다 (해법 2) |
| 순환 대기 | 기다림이 원을 이룬다 | 민호 → 수아 → 민호 | 모두 같은 순서로 잡는다 (해법 1) |
셰프의 한마디
데드락은 CPU를 안 써. 에러도 안 나. 로그도 안 남아. 그냥 조용히 멈춰 있지. 그래서 '느려졌다'는 신고가 들어오면 난 제일 먼저 데드락부터 의심해.
셰프가 칠판에 규칙을 적습니다. "도구마다 번호를 붙인다. 칼 1번, 도마 2번. 언제나 작은 번호부터 잡는다."
민호도 수아도 칼부터 잡으면, 칼을 쥔 사람만 도마를 기다립니다. 칼을 못 잡은 사람은 아무것도 쥐지 않은 채 기다리니, 원이 생기지 않습니다. 규칙을 사람의 기억에 맡기지 말고 함수 하나에 넣어 둡시다.
from contextlib import contextmanager
KNIFE = (1, threading.Lock()) # (번호, 잠금)
BOARD = (2, threading.Lock())
@contextmanager
def hold(*tools):
ordered = sorted(tools, key=lambda t: t[0]) # 번호 작은 것부터
for _, lock in ordered:
lock.acquire()
try:
yield
finally:
for _, lock in reversed(ordered): # 놓을 때는 거꾸로
lock.release()
done = []
def minho():
for _ in range(100):
with hold(KNIFE, BOARD):
time.sleep(0)
done.append("minho")
def sua():
for _ in range(100):
with hold(BOARD, KNIFE): # 적는 순서는 달라도 잡는 순서는 같다
time.sleep(0)
done.append("sua")
ts = [threading.Thread(target=f, daemon=True) for f in (minho, sua)]
for t in ts: t.start()
for t in ts: t.join(timeout=2)
print(sorted(done), [t.is_alive() for t in ts])
# ['minho', 'sua'] [False, False]hold(BOARD, KNIFE)라고 적어도, hold 안에서 번호순으로 정렬하니 실제로는 칼 → 도마 순서로 잡습니다.time.sleep(0)으로 차례를 넘길 틈을 줘도 마찬가지입니다.id(lock)처럼 겹치지 않는 값을 써도 됩니다. 중요한 것은 모든 코드가 같은 기준을 쓰는 것입니다.잠금이 둘 이상 필요한 곳이 생기면 이 규칙을 코드 맨 위 주석이나 문서에 적어 두세요. 새로 온 사람이 순서를 모르고 반대로 잡는 순간, 데드락은 다시 찾아옵니다.
순서를 정하기 어려운 경우도 있습니다. 어떤 잠금이 필요한지 미리 알 수 없을 때입니다. 그럴 때는 "일정 시간 기다려도 못 잡으면, 들고 있던 것까지 내려놓고 처음부터 다시"라는 규칙을 씁니다.
import random
tries = {}
def polite_cook(name, first, second):
tries[name] = 0
while True:
tries[name] += 1
with first:
time.sleep(0.05)
if second.acquire(timeout=0.1): # 0.1초 안에 못 잡으면
try:
return # 둘 다 잡았다: 요리 끝
finally:
second.release()
# with를 벗어나며 first를 내려놓았다
time.sleep(random.uniform(0, 0.1)) # 무작위로 쉬었다가 다시
a = threading.Thread(target=polite_cook, args=("minho", knife, board))
b = threading.Thread(target=polite_cook, args=("sua", board, knife))
a.start(); b.start()
a.join(); b.join()
print(tries)
# 예: {'minho': 2, 'sua': 3} (실행할 때마다 다르다)몇 번 부딪히고 나서 결국 둘 다 끝납니다. 여기서 random.uniform(0, 0.1)을 빼면 새로운 문제가 생길 수 있습니다. 라이브락(livelock) 입니다.
| 순간 | 민호 | 수아 |
|---|---|---|
| 1 | 칼을 든다 | 도마를 든다 |
| 2 | 도마가 없다 → 칼을 내려놓는다 | 칼이 없다 → 도마를 내려놓는다 |
| 3 | 똑같이 0.1초 쉬고 다시 칼을 든다 | 똑같이 0.1초 쉬고 다시 도마를 든다 |
| … | 1~3을 끝없이 반복 | 1~3을 끝없이 반복 |
복도에서 마주친 두 사람이 같은 쪽으로 계속 비켜 주는 장면과 같습니다. 데드락과 달리 둘 다 바쁘게 움직이지만 아무도 요리를 끝내지 못합니다. 쉬는 시간을 무작위로 섞으면 누군가는 먼저 움직이게 되어 반복이 깨집니다.
진짜 스레드는 실행 시점이 조금씩 흔들리기 때문에, 짧은 예제로 라이브락을 매번 재현하기는 어렵습니다. 그래서 더 위험합니다. 부하가 높은 날에만 나타나니까요. 해법 2를 쓸 때는 무작위 쉬기를 꼭 함께 넣으세요.
컴퓨터 과학에는 식사하는 철학자 문제라는 유명한 문제가 있습니다. 원탁에 다섯 명이 앉고, 사이마다 포크가 하나씩 놓여 있습니다. 먹으려면 양옆 포크 두 개가 모두 필요합니다. 주방으로 옮기면, 둥근 조리대에 요리사 다섯 명과 칼 다섯 자루입니다.
N = 5
def service(ordered):
knives = [threading.Lock() for _ in range(N)] # 칼 다섯 자루
meals = [0] * N
def cook(i):
left, right = i, (i + 1) % N
if ordered:
first, second = min(left, right), max(left, right)
else:
first, second = left, right
for _ in range(3):
with knives[first]:
time.sleep(0.05) # 첫 칼을 든 채 잠깐
with knives[second]:
meals[i] += 1
ts = [threading.Thread(target=cook, args=(i,), daemon=True) for i in range(N)]
for t in ts: t.start()
for t in ts: t.join(timeout=1)
return sum(t.is_alive() for t in ts), meals
print("left first:", service(ordered=False))
print("small first:", service(ordered=True))
# left first: (5, [0, 0, 0, 0, 0])
# small first: (0, [3, 3, 3, 3, 3])아래 실습대는 이 상황을 한 단계씩 보여줍니다. 서버의 진짜 스레드를 데드락에 빠뜨리면 영영 풀 수 없어서, 이 실습대만은 시뮬레이션으로 돌아갑니다. 칼 위의 숫자는 그 칼을 쥔 요리사 번호입니다.
칼과 도마
1
2
3
4
5
·
·
·
·
·
단계 0
완성한 요리: 1:0 2:0 3:0 4:0 5:0
프로그램이 멈췄는데 어디서 멈췄는지 모를 때는 표준 라이브러리 faulthandler가 도와줍니다. 모든 스레드가 지금 몇 번째 줄에 있는지 출력합니다. 아래 코드를 stuck.py로 저장해 실행해 보세요.
import faulthandler
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife:
time.sleep(0.1)
with board:
pass
def sua():
with board:
time.sleep(0.1)
with knife:
pass
faulthandler.dump_traceback_later(1) # 1초 뒤에도 여기 있으면 모든 스레드를 출력
crew = [threading.Thread(target=f, name=f.__name__, daemon=True) for f in (minho, sua)]
for t in crew: t.start()
for t in crew: t.join(timeout=2)
faulthandler.cancel_dump_traceback_later() # 제때 끝났다면 예약을 지운다Timeout (0:00:01)!
Thread 0x00009560 [sua] (most recent call first):
File "stuck.py", line 17 in sua
...
Thread 0x0000a670 [minho] (most recent call first):
File "stuck.py", line 11 in minho
...출력은 줄였습니다(주소와 경로는 실행할 때마다 다릅니다). 수아는 17번째 줄 with knife:에서, 민호는 11번째 줄 with board:에서 멈춰 있습니다. 누가 무엇을 기다리는지 한눈에 보입니다.
faulthandler.dump_traceback_later(timeout): timeout초 뒤에 모든 스레드의 위치를 표준 오류로 출력하도록 예약한다. repeat=True면 계속 반복, exit=True면 출력 뒤 프로그램을 끝낸다.faulthandler.dump_traceback(all_threads=True): 예약 없이 지금 바로 출력한다. 오래 걸리는 작업 중간에 상태를 찍어 볼 때 쓴다.dump_traceback_later를 테스트 시작에 걸어 두세요. 멈춘 날 범인이 남습니다.셰프의 한마디
잠금을 쥔 채로 다른 함수를 부를 때 조심해. 그 함수 안에서 무슨 잠금을 또 잡는지 모르잖아. 잠금 안에서는 칠판에 숫자 적는 것처럼 짧고 뻔한 일만 해. 그게 순서 규칙보다 먼저야.
오늘 밤의 목표는 칼과 도마를 함께 쓰는 요리사 셋이 한 번도 멈추지 않고 100번씩 요리하는 것이었습니다. 탁 씨는 팬까지 셋을 씁니다. 도구마다 번호를 붙이고 hold로 잡게 하면, 누가 어떤 순서로 적든 상관없습니다.
PAN = (3, threading.Lock())
meals = {}
def cook(name, *tools):
for _ in range(100):
with hold(*tools): # 언제나 번호 작은 도구부터
time.sleep(0)
meals[name] = meals.get(name, 0) + 1
crew = [threading.Thread(target=cook, args=a, daemon=True) for a in [
("minho", KNIFE, BOARD), ("sua", BOARD, KNIFE), ("tak", PAN, BOARD, KNIFE)]]
for t in crew: t.start()
for t in crew: t.join(timeout=3)
print([meals[n] for n in ("minho", "sua", "tak")], sum(t.is_alive() for t in crew))
# [100, 100, 100] 0세 사람 모두 100번씩, 멈춘 사람은 0명입니다. 셰프가 칠판의 규칙 옆에 한 줄을 더 적습니다. "칼 1, 도마 2, 팬 3. 작은 번호부터."
"오늘은 도구 때문에 멈췄지. 내일은 사람 때문에 멈출 거야. 주문이 쏟아질 때, 누가 어떤 주문을 만들지는 누가 정하지?"
영업 일지 — 다섯째 밤. 칼과 도마를 서로 반대 순서로 잡다가 주방이 멈춤. 도구에 번호를 붙이고 작은 번호부터 잡기로 하자 세 사람이 100번씩 멈춤 없이 요리함.
time.sleep(0.1)을 지우고 여러 번 실행해 보세요. 데드락이 매번 일어나나요? "가끔만 멈추는 버그"가 왜 더 위험한지 한 문장으로 적어 보세요.service에서 ordered=True일 때 4번 요리사만 다른 순서로 잡는다는 것을 print로 확인해 보세요. 다른 요리사 한 명만 순서를 바꿔도 데드락이 풀리는 이유를 네 가지 조건으로 설명해 보세요.셰프의 레시피 카드
데드락의 네 조건: 상호 배제, 점유하며 대기, 빼앗을 수 없음, 순환 대기. 하나만 깨도 풀린다.
해법 1: 잠금에 번호를 붙이고 모두 작은 번호부터 잡는다. sorted(tools)로 함수 하나에 넣어 둔다.
해법 2: if not lock.acquire(timeout=0.1): 들고 있던 것을 내려놓고 무작위로 쉰 뒤 다시.
라이브락: 모두가 같은 박자로 양보만 하다 아무도 못 끝낸다. 무작위 쉬기로 깬다.
실험할 땐 daemon=True + join(timeout=1) + is_alive()로 빠져나올 길을 만든다.
멈춘 곳 찾기: faulthandler.dump_traceback_later(1), dump_traceback(all_threads=True).
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.