노트

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

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • 마이크로태스크

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

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

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

  • use()로 Promise 읽기

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

  • 우선순위 큐

    원소마다 우선순위가 있고, 들어온 순서와 상관없이 우선순위가 가장 높은 것부터 꺼내는 추상 자료형(Abstract Data Type, ADT). 같은 우선순위끼리의 순서는 보장하지 않는다. 응급실 대기 순서가 좋은 비유다.

  • TanStack Query

    TanStack Query(구 React Query)는 서버에서 가져온 데이터를 캐시하고, 오래된 데이터를 다시 가져오고, 로딩·에러 상태를 관리해 주는 비동기 상태 관리자다. 데이터 요청 함수 자체가 아니라 그 결과의 생명주기를 맡는다.

보기 옵션