8.5. 타임아웃과 취소

취소는 “이 코루틴 실행을 멈추고, 정리할 기회를 갖도록 그 안에서 예외를 발생시키고, 스케줄러에서 제거한다”는 것에 대한 asyncio의 이름입니다. 타임아웃은 무언가를 취소하는 가장 흔한 이유이고, 수동 task.cancel() 이 다른 하나입니다.

8.5.1. 작업 취소하기

Task.cancel 을 호출하면 실행 중인 코루틴의 다음 await 에서 asyncio.CancelledError 가 발생하도록 스케줄링됩니다. 코루틴은 취소를 무시하거나(예외를 잡고 계속 실행) 따를 수 있습니다 – 보통의 선택은 정리 코드를 실행한 후 따르는 것입니다:

async def network_worker(stream):
    try:
        while True:
            data = await stream.read(64)
            # ...
    finally:
        stream.close()

단순한 finally 절이 가장 깔끔한 패턴입니다: 코루틴이 정상적으로 종료했든, 무관한 예외를 발생시켰든, 취소되었든 정리가 실행됩니다. CancelledErrorfinally 를 거쳐 다시 위로 전파되고, 루프는 작업이 완료된 것을 보며, await task 의 호출자는 취소를 보게 됩니다.

CancelledError 를 명시적으로 잡는 것도 애플리케이션이 그것으로 특정한 일 – 로그 기록, 리소스를 깔끔하게 넘겨주기 등 – 을 하고 싶을 때는 괜찮습니다. 규칙은 이렇습니다: 정리가 실행된 후 그것을 다시 발생시켜라. CancelledError 를 삼켜버리는 것은 호출자가 멈추라고 요청했는데도 코루틴을 계속 살려두는 것이며, 이는 거의 항상 버그입니다:

async def worker():
    try:
        await long_running_thing()
    except asyncio.CancelledError:
        log("worker cancelled, cleaning up")
        close_resources()
        raise          # << this line is the important one

8.5.2. wait_for 를 사용한 타임아웃

asyncio.wait_for() 는 awaitable을 마감 기한으로 감쌉니다. awaitable이 타임아웃 내에 완료되면 그 결과가 반환됩니다. 그렇지 않으면 awaitable이 취소되고 호출자는 asyncio.TimeoutError 를 받습니다:

try:
    frame = await asyncio.wait_for(grab_frame(), timeout=2)
except asyncio.TimeoutError:
    print("camera took too long")

초 단위 인자는 부동소수점을 받습니다. 밀리초 형태의 마감 기한을 위해서는 asyncio.wait_for_ms() 가 정수 밀리초 카운트를 받습니다 – 펌웨어의 밀리초 단위 타이밍 조절값과 맞아떨어지는 MicroPython 확장입니다.

내부적으로 wait_for 는 수동 취소가 하는 것과 정확히 같은 일을 합니다: 마감 기한이 만료되면 감싼 작업에 cancel() 을 호출하고, 코루틴 내부에서 CancelledError 가 발생하며, finally 절이 실행되고, 정리가 끝나면 예외가 호출자를 위한 TimeoutError 로 변환됩니다.

그것은 CancelledError 를 잡고 무시하는 코루틴이 타임아웃을 무력화한다는 것을 의미합니다 – 마감 기한은 만료되었지만 코루틴이 멈추기를 거부했고, wait_for 는 그것을 강제할 수 없습니다. 앞서의 규칙이 여기에도 적용됩니다: CancelledError 는 정리를 실행하기 위해서만 잡고, 그 다음 다시 발생시키세요.

8.5.3. gather를 통한 취소

취소는 gather() 를 통해 아래로 전파됩니다. gather 호출을 대기 중인 작업이 취소되면, gather 안에서 아직 실행 중인 모든 awaitable도 취소됩니다 – 각각은 취소가 호출자로 거슬러 올라가기 전에 자신의 finally 절을 통해 정리할 기회를 얻습니다.

타임아웃과 결합하면, 이것이 연산 그룹에 마감 기한을 두는 표준적인 방법입니다:

await asyncio.wait_for(
    asyncio.gather(a(), b(), c()),
    timeout=5,
)

모든 하위 연산이 5초 내에 완료되거나, 아니면 그것들 모두가 함께 취소됩니다.

8.5.4. 애플리케이션 종료하기

취소는 또한 실제 애플리케이션이 깔끔하게 멈추는 방법이기도 합니다. 패턴은 스크립트 전반에 걸쳐 일관됩니다: main 이 시작한 장기 실행 백그라운드 작업의 핸들을 포착하고, 최상위 작업을 실행한 다음, finally 블록에서 각 핸들을 취소하고 대기합니다:

async def main():
    sender = asyncio.create_task(uplink())
    watcher = asyncio.create_task(button_watcher())
    try:
        await snapshot_loop()
    finally:
        sender.cancel()
        watcher.cancel()
        await asyncio.gather(sender, watcher,
                             return_exceptions=True)

return_exceptions=True 는 각 자식 작업이 곧 전달할 CancelledError 를 gather가 다시 발생시키지 않도록 막는 묘책입니다. 그래서 애플리케이션 자신의 종료 이유 – snapshot_loop 이 발생시켰거나 발생시키지 않은 무엇이든 – 가 main 밖으로 떠오르게 됩니다.