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

문석·2022년 7월 2일
0

간단한 비동기 로직을 구현해보고 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개의 댓글