함께 끝나는 작업 단위
주문 하나를 처리하며 가격과 재고를 동시에 조회했는데 재고 조회만 실패했다고 가정합니다. 다른 task를 background에 남긴 채 성공 응답을 만들면 한 요청의 수명주기가 갈라집니다. asyncio TaskGroup 실패 전파를 이해하면 함께 시작한 작업이 어떤 조건에서 함께 종료되는지 기준을 세울 수 있습니다.
asyncio.TaskGroup에서 child task 하나가 CancelledError 이외의 예외로 실패하면 나머지 task를 cancel하고 모두 기다린 뒤, 실패들을 ExceptionGroup 또는 BaseExceptionGroup으로 묶어 전달합니다.
실패와 cancellation의 경계
async with가 끝날 때 TaskGroup은 모든 child task를 기다립니다. 일반 예외가 처음 발생하면 남은 task에 cancellation을 요청합니다. cancellation은 다음 await 지점에서 전달되므로 coroutine이 CPU loop를 오래 돌거나 CancelledError를 삼키면 종료가 늦어질 수 있습니다. 정리 코드는 finally에 두고, 특별한 이유 없이 CancelledError를 숨기지 않습니다.
import asyncio
async def load_price():
await asyncio.sleep(0.1)
return 12000
async def load_stock():
await asyncio.sleep(0.05)
raise RuntimeError("재고 조회 실패")
async def load_order():
async with asyncio.TaskGroup() as group:
price = group.create_task(load_price())
stock = group.create_task(load_stock())
return price.result(), stock.result()
오류 계약을 확인하는 법
호출자는 Python 3.11 이상에서 except* RuntimeError로 묶인 실패를 처리할 수 있습니다. 다만 모든 실패를 한 문장으로 바꾸기보다 어느 dependency가 실패했는지 log와 cause를 남깁니다. test에서는 하나가 즉시 실패하고 다른 하나는 finally에서 resource를 닫게 만들어, 바깥으로 예외가 전달되기 전에 cleanup이 끝나는지 확인합니다. 부분 성공을 허용해야 한다면 TaskGroup의 기본 실패 계약을 우회하지 말고 각 coroutine이 명시적인 result type을 반환하도록 설계를 바꿉니다.
공식 문서
Python version별 TaskGroup 변경 사항과 cancellation 규칙을 공식 문서에서 확인합니다.