Python asyncio 시리즈(2) - async, await, coroutine, task, future

SeongBin Jo·2023년 1월 28일
0

asyncio

목록 보기
2/2

Asyncio?

asyncio를 본격적으로 다루기에 앞서, 1편에서 동기-비동기, 동시성-병렬성, 블록-논블록에 대해서 다루었다. 이번 편에서는 asyncio에 대한 소개와 asyncio 특징, 장단점 그리고 간단한 사용 방법들을 다루어보겠다.

asyncio란 이벤트 루프에 의해 스케줄링되고, 실행되는 코루틴을 이용해 동시성을 구현하는 라이브러리이다. asyncio는 이벤트 루프를 이용해 일련의 태스크를 실행하는 방식으로 작동한다.

asyncio의 특징은 다음과 같다.

  • asyncio는 async, await 키워드를 통해 각 태스크에서 이벤트 루프로 제어권을 언제 넘겨줄 지, 특정 시점을 지정할 수 있다
  • 선점형 멀티태스킹 방식이 아닌 협력적 멀티태스킹(cooperative multitasking)을 사용해 동시성을 달성한다.

운영체제가 선점형 멀티태스킹을 이용해 여러 스레드 간 실행을 관리하는 것과 달리, asyncio는 협력적 멀티태스킹 방법을 이용해 여러 태스크 간 실행을 조율한다. 운영체제가 실행할 스레드를 변경하는 시점은 스케줄링 알고리즘에 따라 자동으로 작동하지만, 이것이 프로그램에 있어 최적의 스위칭 시기는 아닐 수 있다. 하지만, 협력적 멀티태스킹 방법은 프로그래머가 프로그램의 중지 시점을 명확하게 지정할 수 있다.

asyncio는 IO 작업이 발생했을 때, 운영체제의 이벤트 통지 시스템(event notification system)을 이용해 동시성을 구현하는데, 이는 3편 이벤트 루프 시리즈에서 다루어보도록 하겠다.

코루틴

코루틴은 필요에 따라 일시 정지할 수 있는 함수라 볼 수 있다. 완료하는 데 오랜 시간이 걸리는 작업이 발생하면 일시 중지하고, 코드의 다른 부분을 실행할 수 있는 기능을 갖추고 있다.

이렇게 완료에 오랜 시간이 걸리는 작업을 기다리는 동안 다른 코드를 실행하여 애플리케이션의 동시성을 제공한다.

async

파이썬에서는 async 키워드를 이용해 코루틴을 만들 수 있다. 다음 예시를 살펴보자

async def my_coroutine() -> None:
    print("My Corountine!")

>>> my_coro = my_coroutine()
>>> print(f"Coroutine Type is {type(my_coro)})
Coroutine Type is <class 'coroutine'>

일반적인 함수는 호출 즉시 실행되지만, async 키워드를 이용해 생성한 코루틴은 코드가 실행되지 않고, 코루틴 객체를 반환한다.

“코루틴을 호출할 때 코루틴이 실행되지 않는다”를 기억하자. 코루틴을 실행하는 다양한 방법을 asyncio에서 제공하는데, 우선 가장 간단한 asyncio.run 부터 살펴보자.

import asyncio

async def my_coro() -> str:
    return "Hello World"

>>> result = asyncio.run(my_coro())
>>> print(result)
Hello World

asyncio.run 은 여러 작업을 수행한다.

  1. 새로운 이벤트를 생성한다.
  2. 성공적으로 이벤트가 생성되면, 전달된 코루틴이 완료될 때까지 실행하고 결과를 반환한다.

asyncio.run에서 중요한 점은 해당 함수가 애플리케이션의 주요 진입점이 되도록 설계되었다는 것이다. 하나의 코루틴만 실행하고 해당 코루틴이 애플리케이션의 다른 코루틴을 실행하게끔 프로그램을 디자인할 수 있다.

asyncio.run 이 하는 일을 보다 구체적으로 알기 위해 다음 예시를 살펴보자.

async def my_coro() -> str:
    return "Hello World"

my_coro = my_coroutine()

try:
    my_coro.send(None)
except StopIteration as e:
    print('Coroutine Stopped. Return Value is :', e.value)

>>> Coroutine Stopped. Return Value is : 123
  1. 코루틴 객체 생성
  2. 코루틴에 None을 전달(send)해 초기화

asyncio에서 제공하는 API들은 대부분 위와 동일한 방식으로 코루틴을 초기화한다. 즉, loop.create_task(coro), asyncio.run(coro) 혹은 await coro 를 실행하면 이벤트 루프가 알아서 내부적으로 send(None)을 실행한다.

await

위의 예시들은 실행 결과를 바로 반환한다는 점에서 일반 파이썬 함수와 차이점이 없다. asyncio의 장점은 await 키워드를 이용해 실행을 일시 중지하여 이벤트 루프가 다른 작업을 실행할 수 있도록 하는 것이다.

await 키워드 뒤에는 awaitable 객체가 온다. await 키워드를 사용하면 코루틴 객체를 생성하는 코루틴을 호출하는 것이 아니라, await 키워드 뒤에 오는 코루틴을 실행한다.

await 키워드는 뒤에 오는 awaitable 객체가 완료되고 결과를 반환할 때까지 await 키워드를 포함하고 있는 상위 코루틴이 일시중지한다. 기다리던 await 키워드 뒤의 하위 코루틴이 완료되면 상위 코루틴이 재개되어 반환된 결과에 접근할 수 있으며 변수에 할당 가능해진다.

말이 조금 복잡한데 아래 코드를 살펴보자.

import asyncio

async def my_coro() -> str:
    return "Hello World"

async def main() -> None:
    result = await my_coro()
    print(result)

asyncio.run(main())
  • main() 코루틴이 실행되고 await 키워드에 도달한다
  • await 키워드 뒤에 오는 코루틴인 my_coro 코루틴이 실행되고, 상위 코루틴인 main() 코루틴이 일시 중지된다.
  • my_coro()가 실행 완료되면, 반환값인 “Hello World”에 접근할 수 있게 되고, 다시 main() 코루틴이 실행 재개된다.

여기까지만 봐도 아직까진 파이썬 함수와 다른 점을 살펴볼 수 없을 것이다. asyncio의 이점을 볼 수 있는 장기 실행 작업을 이제 살펴보자.

import asyncio

async def my_coroutine() -> "str":
    print("Coroutine Entry Point")
    await asnycio.sleep(1)
    print("Coroutine Second Entry Point")

async def main() -> None:
    await my_coroutine()
    await my_coroutine()

asyncio.run(main())

실행이 오래 걸리는 작업을 흉내내기 위해 asyncio.sleep() 함수를 사용한다. asyncio.sleep()은 코루틴이므로 await 키워드 뒤에 사용한다.

위의 예시를 실행하면 다음과 같은 결과가 나타난다.

Coroutine Entry Point
Coroutine Second Entry Point
Coroutine Entry Point
Coroutine Second Entry Point

왜 이런 결과가 나타날까. 우리는 실행이 오래 걸리는 작업(여기에서는 asyncio.sleep(1)) 동안 다른 코드가 실행되기를 원했지만, 위의 예시에서는 순차적으로 코드가 실행되었다.

그 이유는 await 키워드에 있다. await은 현재 코루틴을 일시 중지하고, await 키워드 뒤의 코루틴이 작업을 완료할 때까지 다른 코드를 실행하지 않는다. 이러한 순차 실행이 아니라, 비동기적으로 코드를 실행하고 싶다면 Task를 사용해야 한다.

Task

Task는 이벤트 루프에 코루틴을 예약하는 코루틴의 래퍼(wrapper)이다. 코루틴 스케줄링 및 코루틴 실행은 논블로킹 방식으로 일어나며, Task를 생성하면 Task가 실행되는 동안 다른 코드를 실행할 수 있다.

import asyncio

async def my_coroutine() -> "str":
    print("Coroutine Entry Point")
    await asnycio.sleep(1)
    print("Coroutine Second Entry Point")

async def main():
    task1 = asyncio.create_task(my_coroutine())
    task2 = asyncio.create_task(my_coroutine())
    print(type(task1))
    await task1
    await task2

asyncio.run(main())

위의 예시를 실행하면 다음과 같은 결과가 나온다.

<class '_asyncio.Task'>
Coroutine Entry Point
Coroutine Entry Point
Coroutine Second Entry Point
Coroutine Second Entry Point

이제야 순차 방식이 아닌, 비동기 방식으로 코드가 실행됨을 확인할 수 있다.

위의 예시에서 먼저 asyncio.create_task(coro) 활용해 Task를 생성한다. Task는 생성됨과 동시에 코루틴을 이벤트 루프에 예약하고 실행한다.

이렇게 2개의 태스크를 생성한 후에 해당 태스크를 await하게 된다. 여기서 주목해야할 점은 await 키워드를 “특정 시점”에서 “반드시 사용”해야 한다는 점이다.

“특정 시점”과 “반드시 사용”이 중요하다. 멀티 스레딩과 비교했을 때 asyncio의 장점으로 프로그래머가 애플리케이션의 일시 중지 시점과 실행 재개 시점을 지정할 수 있다는 점을 들고는 한다. 위 예시에서도 여러 태스크를 한번에 생성한 후, 뒤늦게 await 키워드를 통해 해당 태스크의 실행 완료를 기다렸다.

만약, await 키워드를 이용해 태스크의 실행 완료를 기다리지 않는다면, asyncio.run() 이 이벤트 루프를 종료할 때 해당 태스크의 실행 완료를 기다리지 않고 정리하는 문제가 발생한다.

지금은 2개의 태스크를 생성하는 예시를 들었지만, asyncio는 무수히 많은 태스크의 실행 또한 처리 가능하다. 태스크는 즉시 생성되고 가능한 빨리 실행되도록 이벤트 루프에 예약되기 때문에 많은 수의 태스크가 동시에 실행되도록 작성할 수 있다.

async def main():
    tasks = []
    for _ in range(10):
        task = asyncio.create_task(hello_world_message())
        tasks.append(task)

    for task in tasks:
        await task

위의 예시에서는 총 10개의 태스크를 생성했다. 각각의 태스크는 1초의 대기 시간을 갖게 되는데, 위 코드의 총 실행 시간은 10초가 아닌 1초가 조금 넘는다.

awaitable

앞서 await 키워드 뒤에는 awaitable 객체가 온다 했는데, 그렇다면 awaitable 객체란 무엇일까?

파이썬에서 awaitable 객체는 크게 코루틴, 태스크, 퓨처(Future)이다. asyncio API와 asyncio로 동작하는 여러 라이브러리들을 이해하기 위해서는 이 3가지에 대해 모두 알아야 한다. 먼저 Future에 대해 살펴보자.

Future

Future는 미래의 어느 시점에 값을 얻을 것으로 예상하지만, 아직 얻지 못한 객체를 나타낸다. Future는 결과를 설정(set_result)해줌으로써 완성된다. 즉, Future의 결과가 설정되어야 해당 객체가 완료된 것으로 간주하고 Future에서 결과를 추출할 수 있다.

>>> from asyncio import Future
>>> fut = Future()
>>> fut.done()
False
>>> fut.set_result("VALUE")
>>> fut.done()
True
>>> fut.result()
VALUE

Future가 제공하는 메소드는 다음과 같다.

  • done() : 완료 여부 확인
  • add_done_callback() : 완료 시 실행할 콜백 함수 등록
  • result() : Future 결과 확인

Future와 Task 관계

태스크는 Future를 상속한 객체이다. 태스크는 코루틴과 Future의 조합으로 볼 수 있으며, 이벤트 루프 내에서 코루틴을 실행하기 위해 사용된다. 우리가 태스크를 생성하면 먼저 Future를 만들고 코루틴을 실행한다. 그런 다음 코루틴이 완료되면 Future의 결과 또는 예외를 설정한다.

코루틴, Future, Task를 정리하면 다음과 같다.

  • 코루틴 : async def 키워드로 정의한 결과가 코루틴 객체이며, 아직 await하지 않은 상태이다.
  • asyncio.Future : 최종 결과를 나타내는 클래스이며, 결과를 수동으로 설정 가능하다.
  • asyncio.Task : 편리하고 일관된 인터페이스 갖도록 코루틴을 래핑하기 위한 asyncio.Future의 하위 구현이다.

Task 취소

IO 위주 작업의 경우 asyncio의 장점을 최대한 살릴 수 있다. 대표적인 IO 위주 작업인 네트워크 연결을 예로 들어보자.

네트워크 연결은 불안정할 수 있으며, 예상치 못한 상황으로 인해 연결이 끊어지거나 요청에 대한 응답을 반환받지 못할 수 있다. asyncio에서 태스크를 생성해 네트워크 작업을 처리하면, 네트워크 요청이 무한정 지연되는 상황이 발생할 수 있다. 이렇게 특정 시간 이상 걸리는 작업에 대해 특별한 조치가 필요한데, asyncio는 다양한 방법으로 이를 지원한다.

Task.cancel

먼저 태스크 취소에 대해 알아보자. 태스크는 cancel 메소드를 통해 작업 중지가 가능하다. 태스크가 취소되면 CancelledError가 발생한다.

import asyncio

async def long_delay_coro() -> None:
    await asyncio.sleep(5)
    print("Long Delay Coroutine Finished")

async def main():
    task = asyncio.create_task(long_delay_coro())
    task.cancel()

    try:
        await task
    except asyncio.CancelledError:
        print("Long Delay Coroutine Cancelled")

asyncio.run(main())

위 예시에서 주목할 점은 태스크가 취소되었을 때 바로 CancelledError가 발생하지 않는다는 점이다. asyncio에서의 예외는 다음에 더 자세히 다뤄보겠다. 일단은, CancelledError가 cancel 메소드가 일어난 시점에 바로 발생하는 것이 아니라는 점을 알아두자.

asnycio.wait_for

위의 예시는 5초가 걸리는 코루틴을 바로 실행 취소했다. 특정 태스크를 주어진 시간 동안 기다리고, 그 시간 안에 완료되지 않았다면 예외를 발생시키는 기능이 존재한다.

import asyncio

async def long_delay_coro() -> None:
    await asyncio.sleep(5)
    print("Long Delay Coroutine Finished")

async def main():
    task = asyncio.create_task(long_delay_coro())
    try:
        await asyncio.wait_for(task, timeout=2)
    except asyncio.exceptions.TimeoutError:
        print("2초 경과")

wait_for 함수는 주어진 timeout 시간 동안 결과를 기다리고, TimeoutError를 발생시킨다.

asyncio.shield

태스크가 특정 시간보다 오래 걸리는 경우 추가 처리를 진행하되 태스크를 취소하지는 않고 싶은 경우 asyncio.shield로 태스크를 래핑하여 태스크가 취소되는 것을 방어한다.

asyncio.wait_for(asyncio.shield(task), timeout=2)

0개의 댓글