오늘의 주문서
#11 · 열한째 밤
위기
중고로 들여온 낡은 오븐은 문을 닫으면 다 구울 때까지 손잡이를 놓아주지 않는다. 루가 그 앞에 서 있는 동안 홀 전체가 멈춘다.
목표
오븐이 굽는 동안에도 다른 테이블은 0.3초 만에 서빙한다.
심야 식당 동시식당
밤 열한 시. 3번 테이블이 그라탱을 시킵니다. 그라탱은 셰프가 중고로 들여온 낡은 오븐에서만 구울 수 있습니다. 이 오븐은 문을 닫으면 다 구울 때까지 손잡이가 잠기고, 누군가 손잡이를 잡고 서 있어야 합니다.
루가 오븐 앞에 섭니다. 그 순간 홀이 멈춥니다. 물 달라는 1번 테이블도, 계산하려는 5번 테이블도 루가 돌아오기만 기다립니다.
탁 씨가 혀를 찹니다. "오븐 앞에 서 있는 건 일하는 게 아니야. 그냥 서 있는 거지." 셰프가 고개를 끄덕입니다. "루는 기다리면 안 돼. 기다리는 건 다른 사람한테 맡겨."
asyncio의 이벤트 루프는 한 스레드에서 돕니다. 코루틴이 await에서 양보해야 루프가 다른 태스크로 넘어갑니다. 그런데 await를 모르는 함수, 예를 들어 time.sleep이나 오래된 동기 라이브러리를 부르면 루프는 그 함수가 끝날 때까지 꼼짝 못 합니다. 이런 호출을 블로킹 호출이라고 부릅니다.
import asyncio, time
start = time.perf_counter()
async def lu_rounds(): # 루는 0.1초마다 홀을 한 바퀴 돈다
for _ in range(5):
print(f"round {time.perf_counter() - start:.1f}s")
await asyncio.sleep(0.1)
def old_oven(): # await를 모르는 오래된 함수
time.sleep(0.35)
return "bread"
async def bake():
await asyncio.sleep(0.15)
print("oven", old_oven()) # 여기서 루프 전체가 0.35초 멈춘다
async def main():
await asyncio.gather(lu_rounds(), bake())
asyncio.run(main())
# round 0.0s
# round 0.1s
# oven bread
# round 0.5s
# round 0.6s
# round 0.7stime.sleep, requests.get 같은 동기 네트워크 라이브러리, 큰 파일 읽기, 무거운 계산.아래 타임라인에서 느린 테이블 하나가 낡은 오븐(time.sleep, 0.6초)을 씁니다. 다른 테이블은 0.1초씩 쪼개서 asyncio.sleep으로 기다립니다.
주방 타임라인
주문
6
느린 테이블
1
to_thread로 맡기기
요리
기다림
취소
실패
큰 프로그램에서는 어느 코루틴이 루프를 붙잡았는지 알기 어렵습니다. asyncio의 디버그 모드는 한 번에 오래 실행된 콜백과 태스크를 로그로 알려 줍니다.
import asyncio, logging, time
logging.basicConfig(level=logging.WARNING, format="%(name)s %(levelname)s: %(message)s")
def old_oven():
time.sleep(0.3)
async def bake():
old_oven()
async def main():
await asyncio.create_task(bake(), name="T3-oven")
asyncio.run(main(), debug=True)
# 예: asyncio WARNING: Executing <Task finished name='T3-oven' coro=<bake() done, defined at ...oven.py:8>
# result=None created at ...\asyncio\tasks.py:395> took 0.301 secondsasyncio.run(main(), debug=True)로 켭니다. 환경 변수 PYTHONASYNCIODEBUG=1이나 python -X dev로도 켤 수 있습니다.T3-oven), 코루틴이 정의된 파일과 줄, 붙잡은 시간(took 0.301 seconds)이 나옵니다. 태스크에 name=을 붙여 두면 범인을 찾기 쉽습니다.asyncio 로거의 WARNING으로 나옵니다. 경로는 실행하는 PC마다 다릅니다.기준 시간은 loop.slow_callback_duration이고 기본값은 0.1초입니다. 더 예민하게 잡고 싶으면 줄입니다.
import asyncio, time
async def main():
loop = asyncio.get_running_loop()
print(loop.get_debug(), loop.slow_callback_duration)
loop.slow_callback_duration = 0.05 # 0.05초만 넘어도 알려 줘
time.sleep(0.07)
asyncio.run(main(), debug=True)
# True 0.1
# 예: Executing <Task finished name='Task-1' coro=<main() done, defined at ...> result=None created at ...> took 0.070 seconds디버그 모드는 이 밖에도 await 없이 버려진 코루틴이 어디서 만들어졌는지 알려 주고, 다른 스레드에서 루프를 잘못 건드리면 오류를 냅니다. 개발할 때 켜 두고, 운영에서는 끕니다(느려집니다).
낡은 오븐을 고칠 수 없다면, 오븐 앞에 설 사람을 따로 둡니다. asyncio.to_thread()(3.9)는 블로킹 함수를 다른 스레드에서 돌리고, 루프에는 기다릴 수 있는 객체를 돌려줍니다.
import asyncio, time
start = time.perf_counter()
async def lu_rounds():
for _ in range(5):
print(f"round {time.perf_counter() - start:.1f}s")
await asyncio.sleep(0.1)
def old_oven(item, *, minutes):
time.sleep(0.35)
return f"{item} {minutes}m"
async def bake():
await asyncio.sleep(0.15)
bread = await asyncio.to_thread(old_oven, "bread", minutes=40)
print("oven", bread)
async def main():
await asyncio.gather(lu_rounds(), bake())
asyncio.run(main())
# round 0.0s
# round 0.1s
# round 0.2s
# round 0.3s
# round 0.4s
# oven bread 40mawait로 받습니다.to_thread(함수, *args, **kwargs)는 인자를 그대로 넘깁니다. 7교시의 스레드 풀(루프의 기본 executor)에서 돕니다.계산이 무거운 일은 분점 주방, 즉 프로세스 풀로 보냅니다. asyncio.get_running_loop()로 지금 도는 루프를 얻고, loop.run_in_executor(executor, 함수, *args)를 씁니다. 프로세스를 쓰므로 파일로 저장해 실행합니다.
import asyncio
from concurrent.futures import ProcessPoolExecutor
def knead(n): # 손이 바쁜 일(CPU)
return sum(i * i for i in range(n))
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor(max_workers=2) as pool:
doughs = await asyncio.gather(
loop.run_in_executor(pool, knead, 2_000_000),
loop.run_in_executor(pool, knead, 3_000_000),
)
print(doughs)
if __name__ == "__main__":
asyncio.run(main())
# [2666664666667000000, 8999995500000500000]run_in_executor의 첫 인자에 None을 주면 기본 스레드 풀을 씁니다. to_thread가 바로 그 일을 편하게 해 주는 함수입니다.run_in_executor는 키워드 인자를 받지 않습니다. 필요하면 functools.partial(knead, n=...)로 감싸 넘깁니다.if __name__ == "__main__":이 필요하고, 넘기는 함수와 값은 pickle 될 수 있어야 합니다.셰프의 한마디
async def 안에서 이름에 async도 await도 안 붙은 함수를 부를 땐 한 번 멈춰서 물어봐. '이거 오래 걸려?' 0.1초라도 걸리면 to_thread야. 루를 오븐 앞에 세우지 마.
4교시와 6교시의 도구에는 asyncio판이 따로 있습니다. 이름은 같지만, 기다릴 때 스레드를 멈추는 대신 await로 양보합니다.
| threading | asyncio | 쓰는 법 |
|---|---|---|
threading.Lock | asyncio.Lock | async with lock: |
threading.Semaphore | asyncio.Semaphore | async with sem: |
threading.Event | asyncio.Event | await event.wait() |
queue.Queue | asyncio.Queue | await q.put(x), await q.get() |
세마포어는 "동시 연결 수 제한"에 가장 많이 씁니다. 납품 업체에 전화를 다섯 통 걸어야 하는데, 업체는 동시에 두 통까지만 받습니다.
import asyncio, time
start = time.perf_counter()
supplier = asyncio.Semaphore(2) # 납품 업체 전화는 동시에 두 통까지
async def call(n):
async with supplier:
print(f"call #{n} at {time.perf_counter() - start:.1f}s")
await asyncio.sleep(0.2)
async def main():
await asyncio.gather(*(call(n) for n in range(1, 6)))
asyncio.run(main())
# call #1 at 0.0s
# call #2 at 0.0s
# call #3 at 0.2s
# call #4 at 0.2s
# call #5 at 0.4sasyncio.Queue는 6교시의 주문서 꽂이와 같은 모양입니다. maxsize, task_done(), join()도 똑같이 있습니다.
import asyncio
async def hall(q):
for n in range(1, 6):
await q.put(f"#{n}") # 꽂이가 가득 차면 여기서 기다린다
print("hall done")
async def cook(name, q, done):
while True:
ticket = await q.get()
await asyncio.sleep(0.1)
done.append((name, ticket))
q.task_done()
async def main():
q = asyncio.Queue(maxsize=2)
done = []
cooks = [asyncio.create_task(cook(c, q, done)) for c in ("A", "B")]
await hall(q)
await q.join() # 꽂힌 주문이 모두 task_done 될 때까지
for c in cooks:
c.cancel()
print(len(done), sorted(t for _, t in done))
asyncio.run(main())
# hall done
# 5 ['#1', '#2', '#3', '#4', '#5']루프는 한 스레드라서 경쟁 상태가 없을 것 같지만, 읽기와 쓰기 사이에 await가 있으면 3교시의 칠판 사고가 그대로 납니다.
import asyncio
async def main():
board = {"count": 0}
async def cook():
for _ in range(100):
v = board["count"]
await asyncio.sleep(0) # 여기서 다른 태스크로 넘어간다
board["count"] = v + 1
await asyncio.gather(*(cook() for _ in range(4)))
print(board["count"])
asyncio.run(main())
# 100400이어야 할 칠판이 100입니다. 네 태스크가 같은 값을 읽고, 양보했다가, 같은 값을 씁니다. asyncio.Lock으로 그 구간을 묶고, asyncio.Event로 "오픈!" 신호를 기다리게 하면 이렇습니다.
import asyncio
async def main():
lock = asyncio.Lock()
opened = asyncio.Event()
board = {"count": 0}
async def cook():
await opened.wait() # "오픈!"까지 기다린다
for _ in range(100):
async with lock: # 읽기와 쓰기 사이에 await가 있으면 잠금이 필요하다
v = board["count"]
await asyncio.sleep(0)
board["count"] = v + 1
tasks = [asyncio.create_task(cook()) for _ in range(4)]
await asyncio.sleep(0.1)
opened.set()
await asyncio.gather(*tasks)
print(board["count"])
asyncio.run(main())
# 400두 세계의 도구를 섞으면 안 됩니다. 코루틴에서 threading.Lock을 기다리면 루프가 통째로 멈춥니다. 잠금을 쥔 태스크가 돌아와 풀어 줄 기회도 사라집니다.
import asyncio, threading, time
lock = threading.Lock() # 스레드용 잠금을 코루틴에서 쓰면?
start = time.perf_counter()
async def cook(n):
if not lock.acquire(timeout=0.2): # 기다리는 동안 루프 전체가 멈춘다
print(f"#{n} gave up at {time.perf_counter() - start:.1f}s")
return
try:
await asyncio.sleep(0.1) # 잠근 채로 양보한다
print(f"#{n} done")
finally:
lock.release()
async def main():
await asyncio.gather(cook(1), cook(2))
asyncio.run(main())
# #2 gave up at 0.2s
# #1 donetimeout이 없었다면 #2는 영원히 기다리고, #1은 영영 깨어나지 못합니다. 반대로 asyncio의 도구는 스레드 안전하지 않습니다. 다른 스레드에서 asyncio.Queue에 넣으면 안 됩니다. 스레드에서 루프로 일을 넘기는 방법은 11-6에서 봅니다.
with와 for에도 기다리는 판이 있습니다.
async with: 들어갈 때와 나올 때 await가 필요한 자원(__aenter__, __aexit__). 잠금, 세마포어, 네트워크 연결.async for: 다음 값을 받으려면 기다려야 하는 반복(__aiter__, __anext__). 하나씩 도착하는 주문서, 스트리밍 응답.async def 안에 yield를 쓰면 async for로 도는 반복자가 됩니다.import asyncio
from contextlib import asynccontextmanager
async def tickets(n): # 비동기 제너레이터: async def + yield
for i in range(1, n + 1):
await asyncio.sleep(0.05) # 다음 주문서가 올 때까지 기다린다
yield f"#{i}"
@asynccontextmanager
async def stove(name): # async with 로 쓰는 화구
await asyncio.sleep(0.01)
print(f"{name} on")
try:
yield name
finally:
print(f"{name} off")
async def main():
async with stove("S1"):
async for t in tickets(3):
print("cook", t)
print([t async for t in tickets(2)])
asyncio.run(main())
# S1 on
# cook #1
# cook #2
# cook #3
# S1 off
# ['#1', '#2']contextlib.asynccontextmanager는 yield 하나로 async with용 자원을 만들어 줍니다. 리스트 내포에도 async for를 쓸 수 있습니다(코루틴 안에서만).
7교시의 as_completed도 asyncio판이 있습니다. 3.13부터는 async for로 돌 수 있고, 이때 처음에 넣은 태스크 그대로를 끝난 순서대로 돌려줍니다.
import asyncio
async def dish(n, sec):
await asyncio.sleep(sec)
return f"#{n}"
async def main():
tasks = [asyncio.create_task(dish(n, s)) for n, s in [(1, 0.3), (2, 0.1), (3, 0.2)]]
async for t in asyncio.as_completed(tasks): # 3.13부터
print(t in tasks, await t)
asyncio.run(main())
# True #2
# True #3
# True #1예전처럼 for f in asyncio.as_completed(tasks): await f로 써도 되지만, 그때 받는 f는 원래 태스크가 아니라 새 객체입니다. 어느 주문이 끝났는지 알고 싶다면 async for 쪽이 편합니다.
예약 전화를 받는 직원이 다른 스레드에서 일한다고 합시다. 그 스레드가 루의 일정에 일을 끼워 넣을 때는 asyncio.run_coroutine_threadsafe(코루틴, loop)를 씁니다. 돌려받는 것은 7교시의 concurrent.futures.Future라서, 스레드에서 result(timeout=)으로 기다릴 수 있습니다.
import asyncio
async def seat(table):
await asyncio.sleep(0.05)
return f"{table} seated"
def phone_line(loop): # 루프 밖의 스레드(예약 전화)
fut = asyncio.run_coroutine_threadsafe(seat("T7"), loop)
print(fut.result(timeout=1)) # concurrent.futures.Future
async def main():
loop = asyncio.get_running_loop()
await asyncio.to_thread(phone_line, loop)
asyncio.run(main())
# T7 seatedeager 태스크(3.12): 보통 create_task는 태스크를 일정에 올려 둘 뿐, 다음 차례가 올 때까지 실행하지 않습니다. asyncio.eager_task_factory를 쓰면 만드는 즉시 첫 await까지 실행합니다. await 없이 바로 끝나는 코루틴이라면 일정에 오르지도 않고 그 자리에서 끝납니다.
import asyncio
async def greet(n):
print(f"greet T{n}") # 첫 await 전까지
await asyncio.sleep(0)
return n
async def main(eager):
if eager:
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
t = asyncio.create_task(greet(1))
print("created, done =", t.done())
await t
asyncio.run(main(False))
asyncio.run(main(True))
# created, done = False
# greet T1
# greet T1
# created, done = False돌고 있는 태스크 보기(3.14): 실행 중인 asyncio 프로그램의 프로세스 번호(PID)를 알면, 다른 터미널에서 python -m asyncio ps <PID>로 태스크 목록을, python -m asyncio pstree <PID>로 누가 누구를 기다리는지 나무 모양으로 볼 수 있습니다. 예를 들어 hall 태스크가 TaskGroup으로 T1, T2를 기다리는 중이라면 이렇게 나옵니다(일부 열과 경로 생략).
task name coroutine stack awaiter name
Task-1 main
hall TaskGroup._aexit -> TaskGroup.__aexit__ -> hall Task-1
T1 sleep -> table hall
T2 sleep -> table hall"홀이 멈췄는데 루가 어디 서 있는지 모르겠다"는 순간에 쓰는 도구입니다. 태스크에 name=을 붙여 두면 목록이 훨씬 읽기 쉬워집니다.
3번 테이블의 그라탱만 낡은 오븐에 넣고, 오븐 앞에는 스레드를 세웁니다. 스위치 하나(fix)로 비교합니다.
import asyncio, time
def old_oven(sec): # 손댈 수 없는 오래된 라이브러리
time.sleep(sec)
return "gratin"
async def table(n, start, fix):
if n == 3:
dish = await asyncio.to_thread(old_oven, 0.6) if fix else old_oven(0.6)
else:
await asyncio.sleep(0.3)
dish = "noodle"
return f"T{n} {dish} {time.perf_counter() - start:.1f}s"
async def service(fix):
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, start, fix)) for n in range(1, 7)]
print([t.result() for t in tasks])
asyncio.run(service(fix=False))
asyncio.run(service(fix=True))
# ['T1 noodle 0.6s', 'T2 noodle 0.6s', 'T3 gratin 0.6s', 'T4 noodle 0.9s', 'T5 noodle 0.9s', 'T6 noodle 0.9s']
# ['T1 noodle 0.3s', 'T2 noodle 0.3s', 'T3 gratin 0.6s', 'T4 noodle 0.3s', 'T5 noodle 0.3s', 'T6 noodle 0.3s']to_thread로 맡기면 국수는 0.3초, 그라탱은 오븐 시간인 0.6초에 나갑니다. 오늘 밤의 목표입니다.마감 후 셰프가 주방 벽의 레시피 카드를 하나씩 손가락으로 짚습니다. 스레드, 잠금, 큐, 풀, 프로세스, 루프, TaskGroup, to_thread. "도구가 이만큼 쌓였어. 그런데 손님이 문을 열고 들어올 때, 어느 도구를 먼저 꺼낼지는 어떻게 정하지?"
영업 일지 — 열한째 밤. 낡은 오븐을 기다리느라 홀이 0.6초씩 멈추는 문제를 디버그 모드로 찾아냄. 오븐은 to_thread로 스레드에 맡기고, 납품 업체 전화는 asyncio.Semaphore로 두 통까지 묶음.
asyncio.run(main(), debug=True)로 돌려, 경고 메시지에 어느 태스크가 몇 초를 붙잡았다고 나오는지 확인해 보세요.asyncio.Semaphore(3)으로 바꾸면 call #5가 몇 초에 시작할지 먼저 계산해 보고 돌려 보세요.셰프의 레시피 카드
블로킹 호출(time.sleep, 동기 라이브러리)은 루프 위의 모든 태스크를 멈춘다.
범인 찾기: asyncio.run(main(), debug=True), loop.slow_callback_duration(기본 0.1초).
기다리는 블로킹 코드는 await asyncio.to_thread(f, *args). 무거운 계산은 loop.run_in_executor(ProcessPoolExecutor(), f, x).
asyncio판 도구: Lock, Semaphore, Event, Queue — async with / await로 쓴다. threading용과 섞지 않는다.
async with, async for, 비동기 제너레이터(async def + yield). 3.13부터 async for t in asyncio.as_completed(tasks).
스레드에서 루프로: run_coroutine_threadsafe(coro, loop). 들여다보기: python -m asyncio ps/pstree <PID>(3.14).
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.