노트

asyncio 태스크 예외가 조용히 사라지는 문제

Silently Lost asyncio Task Exceptions

백엔드#python#debugging · 연결된 개념 8개

쉽게 말하면

create_task로 띄운 백그라운드 작업은 혼자 밤 순찰을 나간 경비원 같아요. 도중에 넘어져도 무전을 안 치니, 누가 직접 찾아가 보기 전까지 아무도 모르고 다른 일은 멀쩡해 보여요.

비유의 한계 실제로 예외가 사라지는 건 아니고 태스크 객체에 저장만 돼 있어요. 누군가 await하거나 add_done_callback으로 종료를 감시해야 드러나고, 오래 도는 루프는 본문을 try로 감싸 한 번의 실패로 죽지 않게 해요.

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

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • 마이크로태스크

    현재 실행 중인 코드가 끝난 직후, 다음 태스크로 넘어가기 전에 몰아서 처리되는 작은 작업. 이벤트 루프는 태스크 하나를 끝낼 때마다 마이크로태스크 큐가 빌 때까지 전부 실행한다.

  • 롱 태스크

    메인 스레드를 50ms 넘게 붙잡는 작업. W3C Long Tasks 명세는 이벤트 루프 태스크(뒤따르는 마이크로태스크 처리 포함), 렌더링 갱신 단계, 이벤트 루프 밖에서 브라우저가 UI 스레드로 하는 일 가운데 50ms를 넘는 것을 롱 태스크로 본다. 태스크는 도중에 끊기지 않으므로 그동안 클릭·키 입력 처리와 화면 갱신이 모두 밀린다(이벤트 루프).

  • Promise continuation

    continuation은 "프로그램의 나머지 계산"을 값처럼 다루는 개념이다. JavaScript에서는 Promise가 settle된 뒤 이어서 실행될 코드를 가리킨다. await 다음 줄부터 함수 끝까지가 대표적인 continuation이다.

  • 커넥션 드레이닝과 무중단 재시작

    배포 중 서버를 재시작하는 몇 초 동안 로드밸런서가 그 서버로 요청을 보내면 502가 난다. 먼저 로드밸런서에서 빼고(드레이닝), 진행 중인 요청을 마친 뒤 재시작하고, 준비되면 다시 넣는다.

  • use()로 Promise 읽기

    use()는 렌더 중에 Promise나 Context 같은 리소스의 값을 읽는 React 19 API(Application Programming Interface)다. Promise가 아직 pending이면 컴포넌트가 suspend되어 가장 가까운 suspense의 fallback이 보이고, 끝나면 그 값으로 다시 렌더된다. reject되면 가장 가까운 에러 경계(Error Boundary)로 던져진다.

보기 옵션