asyncio.create_task()로 띄운 태스크 안에서 예외가 나면, 그 태스크를 누군가 await하기 전까지 예외는 어디에도 전파되지 않는다. 백그라운드 루프가 아무 로그 없이 멈추고, 나머지 프로그램은 멀쩡해 보인다.
async def poll_loop():
while True:
await refresh() # 여기서 예외가 나면 루프가 끝난다
await asyncio.sleep(2)
task = asyncio.create_task(poll_loop()) # 아무도 await하지 않음- 예외는 태스크 객체에 저장만 된다. "Task exception was never retrieved" 경고는 태스크가 가비지 컬렉션(garbage collection)될 때에야 찍히고, 참조가 살아 있으면 끝내 안 찍힌다
- 반대로 참조를 하나도 안 남기면 실행 도중 태스크가 수거될 수 있다. 이벤트 루프는 태스크를 약한 참조(weak reference)로만 들고 있다
- 증상은 "일부 기능만 멈춤"으로 나타나 원인 찾기가 어렵다. 다른 경로로 들어오는 데이터는 계속 보이기 때문이다
대응
async def poll_loop():
while True:
try:
await refresh()
except Exception:
logging.exception("poll 실패") # 로그 남기고 다음 주기 계속
await asyncio.sleep(2) # sleep은 try 밖에
def log_exit(t: asyncio.Task):
if not t.cancelled() and t.exception():
logging.error("태스크 종료", exc_info=t.exception())
task = asyncio.create_task(poll_loop()) # 참조를 보관한다
task.add_done_callback(log_exit)- 오래 도는 루프는 한 번의 실패로 죽지 않게 본문을 감싼다. 단
CancelledError는 잡지 않는다(Exception은 3.8부터 이를 포함하지 않는다) - 태스크 참조를 보관하고
add_done_callback으로 종료를 감시한다 - 파이썬 3.11+의
asyncio.TaskGroup은 자식 태스크 예외를 부모로 전파해 조용한 실패를 막는다
Promise의 처리되지 않은 거부(unhandled rejection)와 같은 종류의 문제다. 예외를 잡되 삼키지는 않는다는 원칙은 예외 삼키기과 예외 처리 원칙에서, 비동기 실행 모델은 이벤트 루프에서 다룬다.
출처: asyncio 문서: create_task · Task.add_done_callback · Task Groups