47.10 asyncio 사용하기

asyncio(Asynchronous I/O)는 비동기 프로그래밍을 위한 모듈이며 CPU 작업과 I/O를 병렬로 처리하게 해줍니다.

동기(synchronous) 처리는 특정 작업이 끝나면 다음 작업을 처리하는 순차처리 방식이고, 비동기(asynchronous) 처리는 여러 작업을 처리하도록 예약한 뒤 작업이 끝나면 결과를 받는 방식입니다.

그림 47-3 동기 처리와 비동기 처리

47.10.1                네이티브 코루틴 만들기

먼저 asyncio를 사용하려면 다음과 같이 async def로 네이티브 코루틴을 만듭니다(파이썬에서는 제너레이터 기반의 코루틴과 구분하기 위해 async def로 만든 코루틴은 네이티브 코루틴이라고 합니다).

async def 키워드는 파이썬 3.5 이상부터 사용 가능

async def 함수이름():
    코드

asyncio_async_def.py

import asyncio
 
async def hello():    # async def로 네이티브 코루틴을 만듦
    print('Hello, world!')
 
loop = asyncio.get_event_loop()     # 이벤트 루프를 얻음
loop.run_until_complete(hello())    # hello가 끝날 때까지 기다림
loop.close()                        # 이벤트 루프를 닫음

실행 결과

Hello, world!

실행을 해보면 'Hello, world!'가 출력됩니다.

먼저 async defhello를 만듭니다. 그다음에 asyncio.get_event_loop 함수로 이벤트 루프를 얻고 loop.run_until_complete(hello())와 같이 run_until_complete에 코루틴 객체를 넣습니다(네이티브 코루틴을 호출하면 코루틴 객체가 생성됩니다).

  • 이벤트루프 = asyncio.get_event_loop()
  • 이벤트루프.run_until_complete(코루틴객체 또는 퓨처객체)
loop = asyncio.get_event_loop()     # 이벤트 루프를 얻음
loop.run_until_complete(hello())    # hello가 끝날 때까지 기다림

run_until_complete는 네이티브 코루틴이 이벤트 루프에서 실행되도록 예약하고, 해당 네이티브 코루틴이 끝날 때까지 기다립니다. 이렇게 하면 이벤트 루프를 통해서 hello 코루틴이 실행됩니다. 할 일이 끝났으면 loop.close로 이벤트 루프를 닫아줍니다.

47.10.2                await로 네이티브 코루틴 실행하기

이번에는 await로 네이티브 코루틴을 실행하는 방법입니다. 다음과 같이 await 뒤에 코루틴 객체, 퓨처 객체, 태스크 객체를 지정하면 해당 객체가 끝날 때까지 기다린 뒤 결과를 반환합니다. await는 단어 뜻 그대로 특정 객체가 끝날 때까지 기다립니다.

await 키워드는 파이썬 3.5 이상부터 사용 가능, 3.4에서는 yield from을 사용

  • 변수 = await 코루틴객체
  • 변수 = await 퓨처객체
  • 변수 = await 태스크객체

여기서 주의할 점이 있는데 await는 네이티브 코루틴 안에서만 사용할 수 있습니다.

그럼 두 수를 더하는 네이티브 코루틴을 만들고 코루틴에서 1초 대기한 뒤 결과를 반환해보겠습니다.

asyncio_await.py

import asyncio
 
async def add(a, b):
    print('add: {0} + {1}'.format(a, b))
    await asyncio.sleep(1.0)    # 1초 대기. asyncio.sleep도 네이티브 코루틴
    return a + b    # 두 수를 더한 결과 반환
 
async def print_add(a, b):
    result = await add(a, b)    # await로 다른 네이티브 코루틴 실행하고 반환값을 변수에 저장
    print('print_add: {0} + {1} = {2}'.format(a, b, result))
 
loop = asyncio.get_event_loop()             # 이벤트 루프를 얻음
loop.run_until_complete(print_add(1, 2))    # print_add가 끝날 때까지 이벤트 루프를 실행
loop.close()                                # 이벤트 루프를 닫음

실행 결과

add: 1 + 2
print_add: 1 + 2 = 3

add: 1 + 2가 출력되고 1초 뒤에 print_add: 1 + 2 = 3이 출력됩니다.

먼저 print_add부터 보겠습니다. print_add에서는 awaitadd를 실행하고 반환값을 변수에 저장했습니다. 이렇게 코루틴 안에서 다른 코루틴을 실행할 때는 await를 사용합니다.

async def print_add(a, b):
    result = await add(a, b)    # await로 다른 네이티브 코루틴 실행하고 반환값을 변수에 저장
    print('print_add: {0} + {1} = {2}'.format(a, b, result))

add에서는 await asyncio.sleep(1.0)로 1초 대기한 뒤 return a + b로 두 수를 더한 결과를 반환합니다. 사실 await asyncio.sleep(1.0)은 없어도 되지만 코루틴이 비동기로 실행되는 모습을 확인하기 위해 사용했습니다. 특히 asyncio.sleep도 네이티브 코루틴이라 await를 사용해야 합니다.

async def add(a, b):
    print('add: {0} + {1}'.format(a, b))
    await asyncio.sleep(1.0)    # 1초 대기. asyncio.sleep도 네이티브 코루틴
    return a + b    # 두 수를 더한 결과 반환
참고 | 퓨처와 태스크

퓨처(asyncio.Future)는 미래에 할 일을 표현하는 클래스인데 할 일을 취소하거나 상태 확인, 완료 및 결과 설정에 사용합니다.

태스크(asyncio.Task)는 asyncio.Future의 파생 클래스이며 asyncio.Future의 기능과 실행할 코루틴의 객체를 포함하고 있습니다. 태스크는 코루틴의 실행을 취소하거나 상태 확인, 완료 및 결과 설정에 사용합니다. 이 부분은 내용이 다소 복잡하므로 이정도까지만 설명하겠습니다.

참고 | 파이썬 3.5 이하에서 asyncio 사용하기

async defawait는 파이썬 3.5에서 추가되었습니다. 따라서 그 이하 버전에서는 사용할 수 없습니다. 파이썬 3.4에서는 다음과 같이 @asyncio.coroutine 데코레이터로 네이티브 코루틴을 만듭니다.

import asyncio
 
@asyncio.coroutine
async def 함수이름():
    코드

파이썬 3.4에서는 await가 아닌 yield from을 사용합니다.

변수 = yield from 코루틴객체

변수 = yield from 퓨처객체

변수 = yield from 태스크객체

파이썬 3.3에서 asynciopip install asyncioasyncio를 설치한 뒤 @asyncio.coroutine 데코레이터와 yield from을 사용하면 됩니다. 단, 3.3 이하 버전에서는 asyncio를 지원하지 않습니다.

47.10.3                비동기로 웹 페이지 가져오기

이번에는 asyncio를 사용하여 비동기로 웹 페이지를 가져와보겠습니다.

먼저 다음과 같이 asyncio를 사용하지 않고 웹 페이지를 순차적으로 가져오겠습니다. urllib.request의 urlopen으로 웹 페이지를 가져온 뒤 웹 페이지의 길이를 출력해봅니다.

urlopen.py

from time import time
from urllib.request import Request, urlopen
 
urls = ['https://www.google.co.kr/search?q=' + i
        for i in ['apple', 'pear', 'grape', 'pineapple', 'orange', 'strawberry']]
 
begin = time()
result = []
for url in urls:
    request = Request(url, headers={'User-Agent': 'Mozilla/5.0'})    # UA가 없으면 403 에러 발생
    response = urlopen(request)
    page = response.read()
    result.append(len(page))
 
print(result)
end = time()
print('실행 시간: {0:.3f}초'.format(end - begin))

실행 결과

[89590, 88723, 88802, 90142, 90628, 92663]
실행 시간: 8.422초

실행을 해보면 웹 페이지의 크기가 출력되고 실행 시간은 약 8초가 걸립니다(웹 페이지 크기는 매번 달라질 수 있고, 실행 시간은 컴퓨터마다 달라질 수 있습니다).

여기서는 urls에 저장된 순서대로 주소에 접근해서 웹 페이지를 가져오도록 만들었습니다. 이렇게 하면 웹 페이지 하나를 완전히 가져온 뒤에 다음 웹 페이지를 가져와야 해서 비효율적입니다.

47.10.5                웹 페이지를 비동기로 가져오기

그럼 asyncio를 사용해서 비동기로 실행해보겠습니다.

asyncio_urlopen.py

from time import time
from urllib.request import Request, urlopen
import asyncio
 
urls = ['https://www.google.co.kr/search?q=' + i
        for i in ['apple', 'pear', 'grape', 'pineapple', 'orange', 'strawberry']]
 
async def fetch(url):
    request = Request(url, headers={'User-Agent': 'Mozilla/5.0'})    # UA가 없으면 403 에러 발생
    response = await loop.run_in_executor(None, urlopen, request)    # run_in_executor 사용
    page = await loop.run_in_executor(None, response.read)           # run in executor 사용
    return len(page)
 
async def main():
    futures = [asyncio.ensure_future(fetch(url)) for url in urls]
                                                           # 태스크(퓨처) 객체를 리스트로 만듦
    result = await asyncio.gather(*futures)                # 결과를 한꺼번에 가져옴
    print(result)
 
begin = time()
loop = asyncio.get_event_loop()          # 이벤트 루프를 얻음
loop.run_until_complete(main())          # main이 끝날 때까지 기다림
loop.close()                             # 이벤트 루프를 닫음
end = time()
print('실행 시간: {0:.3f}초'.format(end - begin))

실행 결과

[89556, 88682, 89925, 90164, 90513, 93965]
실행 시간: 1.737초

asyncio를 사용하니 실행 시간이 8초대에서 1초대로 줄었습니다.

urlopen이나 response.read 같은 함수(메서드)는 결과가 나올 때까지 코드 실행이 중단(block)되는데 이런 함수들을 블로킹 I/O(blocking I/O) 함수라고 부릅니다. 특히 네이티브 코루틴 안에서 블로킹 I/O 함수를 실행하려면 이벤트 루프의 run_in_executor 함수를 사용하여 다른 스레드에서 병렬로 실행시켜야 합니다.

run_in_executor의 첫 번째 인수는 executor인데 함수를 실행시켜줄 스레드 풀 또는 프로세스 풀입니다. 여기서는 None을 넣어서 기본 스레드 풀을 사용합니다. 그리고 두 번째 인수에는 실행할 함수를 넣고 세 번째 인수부터는 실행할 함수에 들어갈 인수를 차례대로 넣어줍니다.

  • 이벤트루프.run_in_executor(None, 함수, 인수1, 인수2, 인수3)

run_in_executor도 네이티브 코루틴이므로 await로 실행한 뒤 결과를 가져옵니다.

async def fetch(url):
    request = Request(url, headers={'User-Agent': 'Mozilla/5.0'})    # UA가 없으면 403 에러 발생
    response = await loop.run_in_executor(None, urlopen, request)    # run_in_executor 사용
    page = await loop.run_in_executor(None, response.read)           # run in executor 사용
    return len(page)

main에서는 네이티브 코루틴 여러 개를 동시에 실행하는데, 이때는 먼저 asyncio.ensure_future 함수를 사용하여 태스크(asyncio.Task) 객체를 생성하고 리스트로 만들어줍니다.

  • 태스크객체 = asyncio.ensure_future(코루틴객체 또는 퓨처객체)

그다음에 태스크 리스트를 asyncio.gather 함수에 넣어줍니다. asyncio.gather는 모든 코루틴 객체(퓨처, 태스크 객체)가 끝날 때까지 기다린 뒤 결과(반환값)를 리스트로 반환합니다.

  • 변수 = await asyncio.gather(코루틴객체1, 코루틴객체2)

asyncio.gather는 리스트가 아닌 위치 인수로 객체를 받으므로 태스크 객체를 리스트로 만들었다면 asyncio.gather(*futures)와 같이 리스트를 언패킹해서 넣어줍니다. 또한, asyncio.gather도 코루틴이므로 await로 실행한 뒤 결과를 가져옵니다.

async def main():
    futures = [asyncio.ensure_future(fetch(url)) for url in urls]
                                                           # 태스크(퓨처) 객체를 리스트로 만듦
    result = await asyncio.gather(*futures)                # 결과를 한꺼번에 가져옴
    print(result)

참고로 asyncio.gather에 퓨처 객체를 넣은 순서와 결과 리스트에서 요소의 순서는 일치하지 않을 수도 있습니다.

웹 페이지를 순서대로 가져올 때와 asyncio를 사용하여 비동기로 가져올 때를 비교해보면 다음과 같은 모양이 됩니다(비동기 부분은 간략화한 개념도이며 실제 실행 과정은 상당히 복잡합니다).

그림 47-4 웹 페이지를 순서대로 가져올 때와 비동기로 가져올 때
참고 | run_in_executor에 키워드 인수를 사용하는 함수 넣기

run_in_executor 같은 함수는 위치 인수만 넣을 수 있는데 파이썬에서는 키워드 인수를 많이 사용합니다. run_in_executor에 키워드 인수를 사용하는 함수를 넣을 때는 functools.partial을 사용해야 합니다. functools.partial은 이름 그대로 부분 함수를 만들어주는 기능입니다.

functools.partial(함수, 위치인수, 키워드인수)

import functools
 
async def hello(executor):
    await loop.run_in_executor(None, functools.partial(print, 'Hello', 'Python', end=' '))

functools.partial은 인수가 포함된 부분 함수를 반환하는데, 반환된 함수에 다시 인수를 지정해서 호출할 수 있습니다.

>>> import functools
>>> hello = functools.partial(print, 'Hello', 'Python', end=' ')    # 'Hello', 'Python' end=' '이 
>>> hello()                                                         # 포함된 함수 생성
Hello Python 
>>> hello('Script', sep='-')    # 부분 함수에 다시 'Script'와 sep='-'를 넣어서 호출
Hello-Python-Script

47.10.6                async with과 async for 사용하기

이번에는 async withasync for 문법을 사용하는 방법입니다. 먼저 async with은 클래스나 함수를 비동기로 처리한 뒤 결과를 반환하는 문법입니다. 그리고 async for는 비동기로 반복하는 문법입니다

47.10.7                async with

async withwith 다음에 클래스의 인스턴스를 지정하고 as 뒤에 결과를 저장할 변수를 지정합니다.

async with은 파이썬 3.5 이상부터 사용 가능

async with 클래스() as 변수:
    코드

async with으로 동작하는 클래스를 만들려면 __aenter____aexit__ 메서드를 구현해야 합니다(asynchronous enter, asynchronous exit라는 뜻). 그리고 메서드를 만들 때는 반드시 async def를 사용합니다.

class 클래스이름:
    async def __aenter__(self):
        코드
 
    async def __aexit__(self, exc_type, exc_value, traceback):
        코드

그럼 1초 뒤에 덧셈 결과를 반환하는 클래스를 만들어보겠습니다.

asyncio_async_with.py

import asyncio
 
class AsyncAdd:
    def __init__(self, a, b):
        self.a = a
        self.b = b
    
    async def __aenter__(self):
        await asyncio.sleep(1.0)
        return self.a + self.b    # __aenter__에서 값을 반환하면 as에 지정한 변수에 들어감
 
    async def __aexit__(self, exc_type, exc_value, traceback):
        pass
 
async def main():
    async with AsyncAdd(1, 2) as result:    # async with에 클래스의 인스턴스 지정
        print(result)    # 3
 
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

실행 결과

3

__aenter__ 메서드에서 1초 대기한 뒤 self.aself.b를 더한 결과를 반환하도록 만듭니다. 이렇게 __aenter__에서 값을 반환하면 as에 지정한 변수에 들어갑니다. __aexit__ 메서드는 async with as를 완전히 벗어나면 호출되는데 여기서는 특별히 만들 부분이 없으므로 pass를 넣습니다(메서드 자체가 없으면 에러가 발생합니다).

47.10.8                async for

이번에는 async for입니다. async for로 동작하는 클래스를 만들려면 __aiter____anext__ 메서드를 구현해야 합니다(asynchronous iter, asynchronous next라는 뜻). 그리고 메서드를 만들 때는 반드시 async def를 사용합니다.

async for는 파이썬 3.5 이상부터 사용 가능

다음은 1초마다 숫자를 생성하는 비동기 이터레이터입니다.

async_for.py

import asyncio
 
class AsyncCounter:
    def __init__(self, stop):
        self.current = 0
        self.stop = stop
 
    def __aiter__(self):
        return self
 
    async def __anext__(self):
        if self.current < self.stop:
            await asyncio.sleep(1.0)
            r = self.current
            self.current += 1
            return r
        else:
            raise StopAsyncIteration
 
async def main():
    async for i in AsyncCounter(3):    # for 앞에 async를 붙임
        print(i, end=' ')
 
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

실행 결과

0 1 2

메서드가 __anext__, __aiter__라는 점만 다를 뿐 일반적인 이터레이터와 만드는 방법과 같습니다. 반복을 끝낼 때는 StopAsyncIteration 예외를 발생시키면 됩니다. 물론 네이티브 코루틴을 사용할 때는 앞에 await를 붙입니다. 비동기 이터레이터를 다 만들었다면 네이티브 코루틴 안에서 async for i in AsyncCounter(3):과 같이 async for에 사용하면 됩니다.

참고 | 제너레이터 방식으로 비동기 이터레이터 만들기

yield를 사용하여 제너레이터 방식으로 비동기 이터레이터를 만들 수도 있습니다. 다음과 같이 async def로 네이티브 코루틴을 만들고 yield를 사용하여 값을 바깥으로 전달하면 됩니다.

파이썬 3.6 이상부터 사용 가능

async_for_yield.py

import asyncio
 
async def async_counter(stop):    # 제너레이터 방식으로 만들기
    n = 0
    while n < stop:
        yield n
        n += 1
        await asyncio.sleep(1.0)
 
async def main():
    async for i in async_counter(3):    # for 앞에 async를 붙임
        print(i, end=' ')
 
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

실행 결과

0 1 2

실행을 해보면 1초 간격으로 0, 1, 2가 출력됩니다.

참고 | 비동기 표현식

asyncawait를 사용하면 리스트, 딕셔너리, 세트, 제너레이터 표현식을 비동기 표현식으로 만들 수 있습니다

비동기 표현식은 파이썬 3.6 이상부터 사용 가능

리스트: [변수 async for 변수 in 비동기이터레이터()]

딕셔너리: {키: 값 async for 키, 값 in 비동기이터레이터()}

세트: {변수 async for 변수 in 비동기이터레이터()}

제너레이터: (변수 async for 변수 in 비동기이터레이터())

async def main():
    a = [i async for i in AsyncCounter(3)]
    print(a)    # [0, 1, 2]

다음과 같이 표현식 안에서 await로 코루틴을 실행할 수도 있습니다. 여기서는 리스트 표현식을 예로 들었지만 딕셔너리, 세트, 제너레이터 표현식 안에서도 await를 사용할 수 있습니다.

[await 코루틴함수() for 코루틴함수 in 코루틴함수리스트]

async def async_one():
    return 1
 
async def main():
    coroutines = [async_one, async_one, async_one]
    a = [await co() for co in coroutines]
    print(a)    # [1, 1, 1]

asyncio는 내용이 방대하여 책 한권으로도 부족합니다. 여기서는 asyncio의 기본적인 사용 방법만 소개했습니다. 좀 더 깊이 학습하려면 파이썬 공식 문서와 관련 서적을 참고하기 바랍니다.