python 네이티브 코루틴 다루기 (feat. asyncio)

문석·2022년 7월 2일

간단한 비동기 로직을 구현해보고 python 에서 네이티브 코루틴이 어떻게 작동하는지 이해해보겠습니다. (async def 키워드는 python3.5v 이상부터 지원됩니다.)

import asyncio, time, datetime


def 쿨쿨():
    time.sleep(5)
    print('깼당!')


def 재우기():
    for i in range(5):
        쿨쿨()


std = datetime.datetime.now()
print(f'Started at {std}')

재우기()

edd = datetime.datetime.now()
print(f'Ended at {edd}')
print(f'Total time = {edd - std}')

쿨쿨 이라는 함수는 5초 동안의 sleep 을 호출하고 재우기 에서는 쿨쿨을 5번 호출합니다.

python 에서는 함수명을 한글로 지정할 수 있으나 이정도의 낙서 혹은 테스트 코드 정도에만 활용하는 것이 좋습니다!
Started at 2022-07-02 19:40:04.979260
깼당!
깼당!
깼당!
깼당!
깼당!
Ended at 2022-07-02 19:40:29.992931
Total time = 0:00:25.013671

5초 sleep 하는 함수를 5번 호출한다면 모두 완료되는 시점까지 동기식일 경우 25+a 초가 필요합니다. 이를 비동기로 구현해보겠습니다.

import asyncio, time, datetime


async def 쿨쿨():
    await loop.run_in_executor(None, time.sleep, 5)
    print('깼당!')


async def 재우기_5번호출():
    futures = []
    for i in range(5):
        futures.append(asyncio.ensure_future(쿨쿨()))
    await asyncio.gather(*futures)


std = datetime.datetime.now()
print(f'Started at {std}')

loop = asyncio.get_event_loop()
loop.run_until_complete(재우기_5번호출())
loop.close()

edd = datetime.datetime.now()
print(f'Ended at {edd}')
print(f'Total time = {edd - std}') 
Started at 2022-07-02 18:35:58.227417
깼당!
깼당!
깼당!
깼당!
깼당!
Ended at 2022-07-02 18:36:03.231902
Total time = 0:00:05.004485

쿨쿨 함수를 5번을 호출했음에도 5.004 초 밖에 걸리지 않았습니다.

물론 비동기 함수를 n번 호출하였을 때 동기식 대비 total_time_sync/n 만큼의 시간이 걸린다는 것은 아닙니다. python 의 sleep 은 GIL을 우회하는 I/O bounded 작업처럼 python 의 GIL의 영향을 받지 않기 때문입니다.

그럼 조금 더 자세히 보겠습니다.

loop = asyncio.get_event_loop()
loop.run_until_complete(재우기_5번호출())
loop.close()

https://docs.python.org/ko/3/library/asyncio-eventloop.html
get_event_loop() : 현재의 이벤트 루프를 가져옵니다. get_running_loop() 와 달리 loop 가 존재하지 않을 경우 예외가 나오지 않고 새로운 이벤트 루프를 생성하여 줍니다. 3.10 버전부터는 get_or_create 기능이 deprecated 된다고 있으니 조심하세요!

run_until_complete(재우기_5번호출()) : 현재 이벤트 루프에서 coroutin 함수를 호출하고 Future 의 인스턴스의 결과를 반환 혹은 예외를 일으킵니다. 여기서 Future의 인스턴스는 재우기_5번호출 함수이며 이는 코루틴 객체 이기에 asyncio.task 로 실행되도록 묵시적으로 예약됩니다.

재우기_5번호출 함수는 엄격한 future 가 아닙니다!

asyncio.isfuture(재우기_5번호출()) == False

Python docs
문서의 ensure_future 에서는 coroutine 일 경우 isfuture 대신 iscoroutine 을 사용해 검사한다고 있으며 이는 하기 코드의 원리와 같습니다.

asyncio.isfuture(asyncio.Tasks(재우기_5번호출())) == True

이제 드디어 우리의 이벤트 루프에서 native coroutine 인 재우기_5번호출 함수가 호출되었습니다.

async def 재우기_5번호출():
    futures = []
    for i in range(5):
        futures.append(asyncio.ensure_future(쿨쿨()))
    await asyncio.gather(*futures)

위 사진과 같이 ensure_futre 는 인자로 받은 코루틴 쿨쿨을 Task 로 wrapping 한 결과를 반환합니다.

print(asyncio.ensure_future(쿨쿨())) 
====================================
<Task pending name='Task-3' coro=<쿨쿨() running at testtest.py:4>>

ensure_futre 로 반환받은 Task 들을 futures 리스트에 저장한 후 gather 함수를 통해 한번에 실행시켜 줍니다.

gather 는 다음과 같이 활용할 수도 있습니다. 아래 예제에서 쿨쿨() 은 총 15번 호출됩니다.
async def 재우기():
    futures, futures_a, futures_b = [], [], []

    for i in range(5):
        futures.append(asyncio.ensure_future(쿨쿨()))
        futures_a.append(asyncio.ensure_future(쿨쿨()))
        futures_b.append(asyncio.ensure_future(쿨쿨()))

    g1 = asyncio.gather(*futures)
    g2 = asyncio.gather(*futures_a)
    g3 = asyncio.gather(*futures_b)

    await asyncio.gather(g1, g2, g3)

이제 가장 내부에 있는 쿨쿨() 함수입니다.

async def 쿨쿨():
    await loop.run_in_executor(None, time.sleep, 5)
    print('깼당!')

run_in_executor 은 코루틴 함수가 아닌 함수를 코루틴으로 만들어줍니다. 만약 그냥 time.sleep(5) 을 호출한다면 실행시간은 25초가 됩니다. 코루틴 함수 안에서 실행하는 함수 또한 코루틴이어야하기 때문입니다. 쿨쿨 함수는 다음과 같이 사용할 수도 있습니다.

async def 쿨쿨():
    await asyncio.sleep(5)
    print('깼당!')

asyncio 에서 제공하는 sleep 은 그 자체가 코루틴 함수이며 이는 논블로킹 함수이기에 그냥 사용해도 되지만 time 모듈의 sleep 은 블로킹 함수여서 코루틴으로 만들어 주는 과정이 필요합니다. 우리가 자주 사용하는 대부분의 python 모듈 (bs4, request ...) 모두 코루틴 함수가 아니기 때문에 이에 대응되는 asyncio 에서 제공하는 모듈을 사용하거나 run_in_excutor 를 통해 코루틴 함수로 만들어 줄 필요가 있는 것이죠!

이 같은 과정들을 통해 비동기 로직을 구현해 보았습니다. async, await 는 node.js 와 비슷하고 future는 node.js 의 Promise 와 결이 비슷하다는 생각이 드네요.

작은 서비스 혹은 기능이나 aws lambda 와 같이 별도로 구축해 놓은 모듈에는 asyncio 를 사용하였지만 아직 코어 서비스에는 적용해본 부분이 없습니다. 코어 서비스에서 어떤 식으로 네이티브 코루틴이 사용될 수 있을지 고민해봐야겠네요.

0개의 댓글