지난글에 이은 두번째 글입니다. 지난 글에서는 비동기 프로그래밍이 의미하는 바와 쓰레딩과 비동기의 차이점에 대해 알아봤습니다. 이번 글에서는 파이썬에서 어떻게 비동기를 구현할 수 있는지에 대해 알아보겠습니다.


용어정리

우선 용어부터 최대한 쉽게 설명해보겠습니다. 파이썬에서 비동기 프로그래밍을 하기 위해서는 이벤트 루프코루틴을 이해해야 합니다.

1. 이벤트 루프(Event Loop)

이벤트 루프는 작업들을 루프(반복문)를 돌면서 하나씩 실행시키는 역할을 합니다. 이때, 만약 실행된 작업이 특정한 데이터를 요청하고 응답을 기다려야 한다면, 이 작업은 다시 이벤트 루프에 통제권을 넘겨줍니다. 통제권을 받은 이벤트 루프는 다음 작업을 실행하게 됩니다. 그리고 응답을 받은 순서대로 멈췄던 부분부터 다시 통제권을 가지고 작업을 마무리합니다.

2. 코루틴(Coroutine)

이때, 이러한 작업은 파이썬에서 코루틴(Coroutine)으로 이루어져 있습니다. 코루틴은 응답이 지연되는 부분에서 이벤트 루프에 통제권을 줄 수 있으며, 응답이 완료되었을 때 멈추었던 부분부터 기존의 상태를 유지한 채 남은 작업을 완료할 수 있는 함수를 의미합니다. 파이썬에서 코루틴이 아닌 일반적인 함수는 서브루틴(Subroutine)이라고 합니다.


출처: https://medium.freecodecamp.org/a-guide-to-asynchronous-programming-in-python-with-asyncio-232e2afa44f6

기초

파이썬에서 비동기를 사용하기 위해서는 asyncio 모듈을 이용합니다. 함수앞에 async를 붙이면 코루틴을 만들 수 있습니다. 또한, 병목이 발생해서 다른 작업으로 통제권을 넘겨줄 필요가 있는 부분에서는 await를 써줍니다. 이때, await뒤에 오는 함수도 코루틴으로 작성되어 있어야 합니다. asyncio.sleep함수는 코루틴으로 구현되어 있기 때문에 비동기로 동작합니다. 따라서 time.sleep는 사용할 수 없습니다. (코루틴이 아닌 함수도 비동기에서 사용하는 법은 뒤에서 다룹니다.)

코루틴으로 태스크를 만들었다면, asyncio.get_event_loop함수를 활용해 이벤트 루프를 정의하고 run_until_complete으로 실행시킬 수 있습니다.

import asyncio
import time

async def sleep():
    await asyncio.sleep(5)

start = time.time()
# 이벤트 루프 정의
loop = asyncio.get_event_loop()
# 이벤트 루프 실행
loop.run_until_complete(sleep())
end = time.time()

print(str(end-start)+'s')
> 5.00485897064209s

위의 예는 태스크가 하나이기 때문에, 비동기적 방식을 사용하나 하지 않으나 차이가 없습니다. 다음의 예제를 봅시다.

동기적 처리

import time

def sub_routine_1():
    print('서브루틴 1 시작')
    print('서브루틴 1 중단... 5초간 대기')
    time.sleep(5)
    print('서브루틴 1 종료')

def sub_routine_2():
    print('서브루틴 2 시작')
    print('서브루틴 2 중단... 4초간 대기')
    time.sleep(4)
    print('서브루틴 2 종료')

if __name__ == "__main__":

    sub_routines = [sub_routine_1, sub_routine_2]

    start = time.time()
    for i in range(2):
        sub_routines[i]()
    end = time.time()
    print(f'time taken: {end-start}')
>>
서브루틴 1 시작
서브루틴 1 중단... 5초간 대기
서브루틴 1 종료
서브루틴 2 시작
서브루틴 2 중단... 4초간 대기
서브루틴 2 종료
time taken: 9.001360893249512

비동기적 처리

비동기로 두 개 이상의 작업(코루틴)을 돌릴 때에는 asyncio.gather함수를 이용합니다. 이때, 각 태스크들은 unpacked 형태로 넣어주어야 합니다. 즉, asyncio.gather(coroutine_1(), coroutine_2())처럼 넣어주거나
asyncio.gather(*[coroutine_1(), coroutine_2()])처럼 넣어주어야 합니다.

import asyncio
import time


async def coroutine_1():  # 코루틴 정의 (async를 앞에 붙여준다.)
    print('코루틴 1 시작')
    print('코루틴 1 중단... 5초간 대기')
    # await으로 중단점 설정 (블락킹되는 부분에서 사용)
    await asyncio.sleep(5)
    print('코루틴 1 재개')


async def coroutine_2():
    print('코루틴 2 시작')

    print('코루틴 2중단... 4초간 대기')
    await asyncio.sleep(4)
    print('코루틴 2 재개')

if __name__ == "__main__":
    # 이벤트 루프 정의
    loop = asyncio.get_event_loop()

    start = time.time()

    # 두 개의 코루틴을 이벤트 루프에서 돌린다.
    # 코루틴이 여러개일 경우, asyncio.gather을 먼저 이용 (순서대로 스케쥴링 된다.)
    loop.run_until_complete(asyncio.gather(coroutine_1(), coroutine_2()))
    end = time.time()

    print(f'time taken: {end-start}')
>>
코루틴 1 시작
코루틴 1 중단... 5초간 대기
코루틴 2 시작
코루틴 2중단... 4초간 대기
코루틴 2 재개
코루틴 1 재개
time taken: 5.004844665527344

동기적으로 처리했을 때는 약 "9초(5초 + 4초)" 걸렸던 작업이 비동기적으로 처리했을 때 거의 "5초"만에 처리할 수 있었습니다. 또한, 비동기적 처리에서 코루틴1을 먼저 실행했지만 코루틴2의 대기가 먼저 끝나자 코루틴2를 먼저 처리하고 코루틴1을 처리하는 것을 확인할 수 있습니다.

코루틴으로 짜여있지 않은 함수 비동기적으로 이용하기

위에서 await뒤에 오는 함수 역시 코루틴으로 작성되어 있어야 비동기적인 작업을 할 수 있다고 했습니다. 파이썬의 대부분의 라이브러리들은 비동기를 고려하지 않고 만들어졌기 때문에 비동기로 이용할 수 없습니다. 하지만, 이벤트 루프의 run_in_executor함수를 이용하면 가능합니다.

asyncio.get_event_loop()를 활용해서 현재 이벤트 루프를 loop라는 이름으로 받아오고, loop.run_in_executor를 사용하면 됩니다. 이 함수의 첫 번째 인자로는 concurrent.futures.Executor의 객체가 들어가고(None을 써주시면 asyncio의 내장 executor가 들어갑니다), 두 번째 인자로는 사용하고자 하는 함수, 그 이후의 인자(*args) 에는 사용하고자 하는 함수의 인자들을 써주면 됩니다.

import asyncio
import time


async def coroutine_1():
    print('코루틴 1 시작')
    print('코루틴 1 중단... 5초간 기다립니다.')
    loop = asyncio.get_event_loop()
    # run_in_executor: 코루틴으로 짜여져 있지 않은 함수(서브루틴)을
    # 코루틴처럼 실행시켜주는 메소드

    # Params of run_in_executor:
    # executor(None: default loop executor), func, *args
    # 또는 concurrent.futures.Executor의 인스턴스 사용가능
    await loop.run_in_executor(None, time.sleep, 5)

    print('코루틴 1 재개')


async def coroutine_2():
    print('코루틴 2 시작')

    print('코루틴 2중단... 5초간 기다립니다.')
    await loop.run_in_executor(None, time.sleep, 4)
    print('코루틴 2 재개')

if __name__ == "__main__":
    # 이벤트 루프 정의
    loop = asyncio.get_event_loop()

    # 두 개의 코루틴이 이벤트 루프에서 돌 수 있게 스케쥴링

    start = time.time()
    loop.run_until_complete(asyncio.gather(coroutine_1(), coroutine_2()))
    end = time.time()

    print(f'time taken: {end-start}')
>>
코루틴 1 시작
코루틴 1 중단... 5초간 기다립니다.
코루틴 2 시작
코루틴 2중단... 5초간 기다립니다.
코루틴 2 재개
코루틴 1 재개
time taken: 5.00641131401062

asyncio.sleep을 사용한 것과 거의 유사한 결과를 볼 수 있습니다. 원리는 무엇일까요? 사실 이것은 비동기적 처리처럼 보이지만 실제로는 쓰레딩을 이용한 것이라고 할 수 있습니다. 첫번째 글에서 언급했던 멀티쓰레드기억나시나요? 비동기적 처리보다는 비효율적이었지만 작업이 완료되길 기다리고 다른 작업을 시작하는 것보다는 빠르게 작업을 처리할 수 있었습니다.

하지만, 쓰레딩을 이용했을 때는 비용도 만만치 않았습니다. 파이썬에서는 GIL 때문에 쓰레드들이 동시에 작업이 불가능하기 때문에, 다른 쓰레드를 호출하는데 걸리는 시간을 낭비하게 됩니다. (컨텍스트 스위칭의 비용)

run_in_executor(쓰레딩)를 활용한 비동기

import asyncio
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor


async def sleep(executor=None):
    loop = asyncio.get_event_loop()
    await loop.run_in_executor(executor, time.sleep, 1)


async def main():

    # max_workers에 따라서 실행시간이 달라지는 것을 확인할 수 있다.
    # (하지만 workers가 많아질수록 컨텍스트 스위칭 비용도 커진다.)
    # None으로 하는 경우는 디폴트로 설정한 workers수가 작아서 인지 훨씬 더 오래걸린다.

    executor = ThreadPoolExecutor(max_workers=10000)

    # asyncio.ensure_future함수는 태스크를 현재 실행하지 않고,
    # 이벤트 루프가 실행될 때 실행할 것을 보증해주는 함수
    futures = [
        asyncio.ensure_future(sleep(executor)) for i in range(10000)
    ]
    await asyncio.gather(*futures)


if __name__ == "__main__":
    start = time.time()
    # python 3.7부터는 이벤트 루프를 따로 명시적으로 지정하지 않고,
    # asyncio.run으로 돌릴 수 있다.
    asyncio.run(main())
    end = time.time()
    print(f'time taken: {end-start}')
>> time taken: 3.2648983001708984

컨텍스트 스위칭에 대한 비용이 발생했습니다. (약 2초가량)

asyncio.sleep을 활용한 비동기

import asyncio
import sqlite3
import time


async def sleep():
    await asyncio.sleep(1)


async def main():
    # asyncio.sleep은 아무리 많아져도 비동기적으로 잘 돌아간다.
    futures = [
        asyncio.ensure_future(sleep()) for i in range(10000)
    ]
    await asyncio.gather(*futures)


if __name__ == "__main__":
    start = time.time()
    asyncio.run(main())
    end = time.time()
    print(f'{end-start}')
>> 1.1979601383209229

asyncio.sleep는 컨텍스트 스위칭에 대한 비용이 발생하지 않았습니다.

파이썬에서 비동기 프로그래밍 활용하기

지금까지 파이썬에서 asyncio모듈을 이용해 비동기적 코드를 짜보았습니다. 하지만 sleep함수만 사용해서 어떤 부분에서 비동기를 사용해야 할지 의아해하실분들이 많을 것 같습니다. 3편에서는 파이썬에서 비동기를 활용해 네트워크 통신의 성능을 어떻게 향상시킬 수 있을지에 대해 다뤄보겠습니다.

<!-- [여기]()를 눌러주세요! -->

참고사이트

https://mingrammer.com/translation-asynchronous-python/

https://medium.freecodecamp.org/a-guide-to-asynchronous-programming-in-python-with-asyncio-232e2afa44f6

https://hackernoon.com/a-simple-introduction-to-pythons-asyncio-595d9c9ecf8c