달나라 노트

Python Basic : Python coroutine, 코루틴 (비동기 프로그래밍, asynchronous, coroutine, 코루틴, 코루틴 함수, 동기 함수, asyncio) 본문

Python/Python Basic

Python Basic : Python coroutine, 코루틴 (비동기 프로그래밍, asynchronous, coroutine, 코루틴, 코루틴 함수, 동기 함수, asyncio)

CosmosProject 2022. 2. 14. 01:13
728x90
반응형

 

 

 

비동기(Asynchronous) 처리는 병렬처리를 의미합니다.
동기(Synchronous) 처리는 순차적으로 실행되는 직렬 처리이죠.


간단하게 예를 들어봅시다.
어떠한 코드를 적었고 여기에 3개의 함수가 실행되는 순서는 아래와 같습니다.
함수1 -> 함수2 -> 함수3
동기 처리는 흔하게 접할 수 있는 Python 코드입니다. 코드가 순차적으로 실행되죠.
이 과정에서는 먼저 실행된 함수가 끝나기 전까지 다음에 실행될 함수가 시작되진 않습니다.
즉, 위 코드에서 함수1이 완료되기 전까지는 함수2가 실행되지 않습니다.
이게 동기 처리입니다. 직렬적으로 실행되죠.

근데 비동기 처리는 다릅니다.
위 코드에 있는 여러 작업(여러 함수)를 처리하도록 예약해두고 시작하는 작업입니다.
비동기 처리에서는 함수1이 완전히 종료되지 않았는데 함수2가 동시에 실행되는 경우가 존재할 수 있죠.

좀 더 와닿는 예시를 들어봅시다.
주말에 집에서 밀린 집안일을 하고 쉬려고 합니다. 처리해야 할 일들을 정리해보니 다음과 같았습니다.
세탁기 돌리기, 살짝 설거지를 한 후 식기세척기 돌리기, 겨울 옷 세탁소에 맡기기
청소나 집안일만 하면 재미없으니 이번에 발표된 신곡도 한번 들어보기로 했습니다.


만약 당신이 집안일을 직렬로 동기 처리를 한다면,
1. 세탁기를 돌리고 세탁기가 완료된 후 빨래를 넌다. (세탁기가 완료될 때 까지 다른건 하지 않는다.)
2. 살짝 설거지를 해서 묵은때만 벗겨준 후 식기세척기를 돌린다. 식기세척기가 완료되면 그릇을 꺼내준다.
3. 겨울 옷을 모두 꺼내서 세탁소에 맡겨준다.
4. 최근에 발표된 신곡을 듣는다.

위처럼 순차적으로 실행합니다.

세탁기가 완료될 때 까지는 아무것도 안하는거예요.

식기세척기를 돌리고 그릇을 꺼내는 것 까지 완료된 후에야 세탁소에 겨울 옷을 맡기러 가는 겁니다.


근데 위 내용을 병렬적으로 비동기 처리를 한다고 해봅시다.
세탁기를 돌리고 세탁가 돌고있는 동안 설거지도 좀 해서 식기세척기도 돌립니다.
세탁기와 식기세척기가 완료되기까지 겨울 옷을 모두 꺼내서 세탁소에 맡기고 옵니다.
갔다왔는데 아직도 세탁기/식기세척기가 돌고 있어서 이번에 발표된 신곡을 듣다가 세탁기/식기세척기가 완료되어 빨래를 널고 그릇들을 꺼냈습니다.

동기 처리와 비동기 처리의 차이가 이해되시나요?
동기 처리는 반드시 하나의 작업이 끝나야 다음 작업을 진행합니다.
비동기 처리는 하나의 작업이 완전히 끝나지 않아도 다음 작업을 진행할 수 있습니다.
(다만 모든 작업이 동시에 진행되는 것은 아닐 수 있습니다. 예를들어 세탁기에 세탁물을 넣으면서 설거지를 동시에 하지는 않았기 때문이죠. 프로그램의 비동기 함수에서도 동일합니다.)

 

동기 처리와 비동기 처리를 그림으로 나타내면 위와 같습니다.

동기 처리는 함수가 완료된 후 다음 함수가 실행됩니다.

그러나 비동기 처리는 이전에 실행된 함수가 완료되지 않았는데도 다음 함수가 실행됩니다.

그래서 시작과 끝 간의 간격을 보면 비동기 처리가 더 짧으며 전체 코드가 완료되는 시간도 비동기 처리가 더 빠릅니다.

 

 

 

 


우리가 보는 def 로 정의된 일반적인 Python 함수는 모두 동기(Synchronous) 함수입니다.
Python에서 비동기 함수를 선언하러면 def 앞에 async 키워드를 붙입니다. 아래처럼 말이죠.

async def test_func():
    print('test')

이렇게 async 키워드로 만든 비동기(Asynchronous) 함수는 코루틴(Coroutine) 또는 코루틴 함수라고 합니다.

 

 

 

 

 

 

그러면 async 키워드로 만든 코루틴 함수는 어떻게 사용할 수 있을까요?

다음 예시를 봅시다.

async def show_text():
    print('Hello world!')

show_text()



-- Result
RuntimeWarning: coroutine 'show_text' was never awaited
  show_text()
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

show_text라는 이름의 코루틴을 선언하고 호출했습니다.
근데 위 코드를 실행시켜보면 Python version에 따라 위 예시에서처럼 'show_text' was never awaited 같은 에러가 발생하거나 코루틴 객체가 반환될겁니다.

어떤 경우건 정상적으로 실행되는 것은 아닌 것처럼 보입니다. 저는 Hello world!라는 텍스트를 출력하고 싶으니까요.

위처럼 async 키워드로 선언된 코루틴 함수는 일반적인 방식으로 호출할 수는 없고, 특수한 방식으로 호출을 해야 정상적으로 작동합니다.
이를 위해 존재하는 것이 Python 내장 모듈인 asyncio입니다.

 

 

 

 

import asyncio

async def show_text():  # 코루틴 함수 선언
    print('Hello world!')

loop = asyncio.get_event_loop()       # asyncio 모듈의 event loop 객체 생성
loop.run_until_complete(show_text())  # show_text 함수가 완전히 완료될 때 까지 기다림
loop.close()                          # event loop를 종료



-- Result
Hello world!

위 코드가 코루틴 함수를 호출하는 가장 기본적인 방법입니다.


asyncio 모듈의 get_event_loop를 얻어와서 run_until_complete의 인자로 show_text() 함수를 전달하여 실행합니다.


비동기 함수는 show_text()함수가 완료되기까지 기다리지 않습니다.

즉, 어떤 함수가 진행하다가 좀 막히게되면 다음 함수를 진행해야하기 때문에 계속해서 loop를 돌려야 하기 때문에 asyncio 라이브러리의 get_event_loop를 통해 루프객체를 생성해서 코루틴 함수를 실행해야 합니다.


그리고 run_until_complete을 이용해 show_text()가 완료될 때 까지 계속 실행되게 하는것이죠.

 

여기서 용어를 하나 더 알고 가자면

위 코드의 show_text() 함수처럼 async 키워드로 선언된 코루틴을 네이티브 코루틴이라고 합니다.

 

 

 

 

그러면 코루틴 함수를 다른 함수 내에서 호출하는 방법은 어떻게 될까요?

import asyncio

async def show_text():  # show_text() 코루틴 함수 선언
    print('Hello world!')

async def main():  # main() 코루틴 함수 선언
    show_text()  # show_text() 코루틴 함수 호출

loop = asyncio.get_event_loop()
loop.run_until_complete(main())  # asyncio에서 main 코루틴 함수 호출
loop.close()



-- Result
RuntimeWarning: coroutine 'show_text' was never awaited
  show_text()
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

위 코드에 있는 함수들의 관계는 다음과 같습니다.

 

1. show_text()라는 코루틴 함수를 선언

2, main()이라는 코루틴 함수에서 show_text() 함수를 호출

3. main() 코루틴 함수를 asyncio의 event loop로 실행


왠지 논리상으론 큰 문제가 없어보이지만 결과는 에러가 발생합니다.

그 이유는 코루틴 함수는 일반적인 함수처럼 단순 호출로는 사용할 수 없기 때문입니다.
위처럼 다른 코루틴 함수 내에서 어떤 코루틴 함수를 호출하려는 상황에서는 await 키워드를 사용해야 합니다.
위 코드를 아래처럼 바꾸고 실행시켜봅시다.

 

import asyncio

async def show_text():  # 코루틴 함수 선언
    print('Hello world!')

async def main():
    await show_text()

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()



-- Result
Hello world!

그러면 error 없이 Hello world! 라는 텍스트가 출력될겁니다.


여기서 await라는 단어의 의미는 ~를 기다리다 라는 의미입니다.
즉, 말 그대로 show_text()가 완료되기까지 기다렸다가 그 결과를 가져온다는 의미입니다.


여기서 기다린다는게 직렬 함수처럼 show_text() 함수가 완료될 때 까지 아무것도 하지 말고 기다리라는게 아니라
show_text()가 완료될 때 까지 다른 작업을 하고있을테니까 show_text()가 완료될 때 까지 '기다렸다가' 그 결과를 가져와 라는 의미입니다.

 

 

 

 

살짝 다른 예시를 봐봅시다.

import asyncio

async def calculator(x, y):
    return x * y

async def main(a, b):
    cal_result = await calculator(a, b)  # 1
    print('{} * {} = {}'.format(a, b, cal_result))

loop = asyncio.get_event_loop()
loop.run_until_complete(main(2, 5))
loop.close()



-- Result
2 * 5 = 10

위 코드는 원하는대로 2 * 5 = 10 이라는 결과를 성공적으로 출력했습니다.


여기서 중요한 것은 다음과 같습니다.
await 키워드로 호출한 코루틴 함수로부터 나온 결과를 cal_result라는 변수에 저장할 수 있다는 것이죠.

 

 

 

 

 

비동기 함수를 설명하면 가장 흔하게 예시로서 등장하는게 바로 여러 개의 URL에 대한 결과를 동기 방식과 비동기 방식으로 호출해보는 것입니다.


동기 방식은 웹사이트 하나를 완전히 가져온 후 다음 웹사이트를 가져오게 됩니다. 즉, 하나의 웹사이트에서 렉이 걸리면 그 웹사이트의 로딩이 끝날 때 까지 아무것도 하지 않고 완료될 떄 까지 기다립니다.

 

비동기 방식은 웹사이트 하나를 로딩하는 동안 다음 웹사이트를 가져오기 시작합니다. 따라서 각 웹사이트마나 로딩을 시작하는 시점은 어느정도 차이가 있을 수 있어도, 하나의 웹사이트의 로딩 시간에 상관없이 동시에 로딩을 하게 되므로 더 빠르게 웹사이트 결과를 얻어올 수 있습니다.


미리 결과를 예측해보면 비동기 방식으로 여러 웹사이트를 얻어오는 것이 더 빨리 끝날겁니다.
이제 직접 코드를 작성해봅시다.

import datetime
import requests
from bs4 import BeautifulSoup as bs

url_list = [
    'https://www.google.com/search?q=laptop',
    'https://www.google.com/search?q=mouse',
    'https://www.google.com/search?q=keyboard',
    'https://www.google.com/search?q=monitor',
    'https://www.google.com/search?q=speaker'
]

start_dttm = datetime.datetime.now()
print('Start = {}'.format(start_dttm))


url_result_list = []
for url in url_list:
    rq = requests.get(url)
    html_code = bs(rq.content, 'html.parser')

    url_result_list.append(html_code)


end_dttm = datetime.datetime.now()
print('End = {}'.format(end_dttm))
print('Running time = {}'.format(end_dttm - start_dttm))



-- Result
Start = 2022-02-14 02:10:48.467965
End = 2022-02-14 02:10:57.281379
Running time = 0:00:08.813414

위 코드는 url_list에 있는 5개의 url은 순차적으로 읽어서

각 url의 html code를 가져오는 프로그램입니다.

 

request와 bs4 관련 내용은 아래 링크를 참고하면 좋습니다.

https://cosmosproject.tistory.com/158

 

start_dttm은 시작 시간을 의미하고 end_dttm은 URL의 html code 정보를 모두 가져온 후 종료된 시간을 의미하죠.

결과를 보니 두 시점의 차이는 대략 8.8초입니다.

즉, 5개의 URL을 순차적으로 읽어오는데 8.8초 정도가 걸렸다는 의미죠.

(참고로 위 코드에서 url_result_list를 마지막에 출력해보면 각 페이지의 html code를 담고있는 list가 출력됩니다.)

 

 

 

 

 

그러면 동일한 페이지를 가져오는 코드를 코루틴 함수를 통해서 작성해봅시다.

import datetime
import requests
from bs4 import BeautifulSoup as bs
import asyncio

url_list = [
    'https://www.google.com/search?q=laptop',
    'https://www.google.com/search?q=mouse',
    'https://www.google.com/search?q=keyboard',
    'https://www.google.com/search?q=monitor',
    'https://www.google.com/search?q=speaker'
]

start_dttm = datetime.datetime.now()
print('Start = {}'.format(start_dttm))


async def get_html(url):
    rq = await loop.run_in_executor(None, requests.get, url)
    html_code = await loop.run_in_executor(None, bs, rq.content, 'html.parser')

    return html_code

async def main():
    fts = [asyncio.ensure_future(get_html(url)) for url in url_list]
    result = await asyncio.gather(*fts)


loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()


end_dttm = datetime.datetime.now()
print('End = {}'.format(end_dttm))
print('Running time = {}'.format(end_dttm - start_dttm))



-- Result
Start = 2022-02-14 02:29:17.645737
End = 2022-02-14 02:29:22.867350
Running time = 0:00:05.221613

그 결과를 보니 5.2초 정도가 걸린 것을 알 수 있습니다.

약 3.6초 정도가 단축되었죠.

 

하나의 URL 로딩이 완료되기 전에 미리 다음 웹페이지에 대한 로딩도 실행시킨 결과입니다.

 

 

 

이제 위 코드를 일부분씩 나눠서 알아봅시다.

 

 

먼저 get_html 코루틴 함수 부분입니다.

async def get_html(url):
    rq = await loop.run_in_executor(None, requests.get, url)
    html_code = await loop.run_in_executor(None, bs, rq.content, 'html.parser')

    return html_code

get_html 코루틴 함수에서 하고싶은건 URL 하나를 받아 그 URL의 html code를 얻어오는 것입니다.

그래서 그 기능이 위 함수에 적혀있는거죠.

 

여기서 한 가지 생소한 것은 run_in_executor입니다. 이게 왜 쓰였을까요?

이를 이해하려면 먼저 한가지 당연한 사실을 한번 더 인식해야합니다.

 

URL의 정보를 가져오기 위해서 사용한 method는 reuqest.get, bs가 있습니다.

근데 문제는 reuqest.get, bs method는 코루틴 함수가 아닙니다.

즉, 내가 async, await 등으로 아무리 코루틴 함수로 사용하더라도 request.get, bs 같은 method는 애시당초 코루틴 함수가 아니기 때문에 URL 하나에 대한 정보를 다 가져오기 전까지 다른 함수의 실행을 금지시켜 URL의 정보를 가져오지 않습니다.

 

이렇게 함수가 완료될 때 까지 다른 작업을 중단하는 함수들을 블로킹 함수라고 합니다. (중요하진 않습니다.)

 

이건 단순히 request.get, bs method에 한정되는 것이 아니라 거의 대부분의 python method들은 코루틴 함수가 아니라서 동일한 상황에 처할겁니다.

 

따라서 우리는 일반 함수들을 코루틴 함수처럼 사용할 수 있도록 해주는 수단이 필요합니다.

그것이 바로 run_in_executor입니다.

 

run_in_executor는 다음과 같이 사용할 수 있습니다.

run_in_executor(None, 함수, argument1, argument2, argument3, ...)

첫 번째 인자인 None은 executor를 의미합니다. None 값은 기본 executor를 사용하겠다는 의미입니다. 지금은 무슨 소린지 몰라도 그냥 '첫 번째 인자로 None을 전달하면 된다'라고만 알면 됩니다.

 

두 번째 인자인 함수는 내가 코루틴 함수로 실행시킬 함수를 명시해줍니다.

 

세 번째 이후 인자인 arguement1, argument2, argument3, ... 등은 함수에 전달할 인자들을 의미합니다.

 

 

 

 

async def get_html(url):
    rq = await loop.run_in_executor(None, requests.get, url)
    html_code = await loop.run_in_executor(None, bs, rq.content, 'html.parser')

    return html_code

이제 이 부분을 다시 봅시다.

rq = await loop.run_in_executor(None, requests.get, url)

- request.get 함수를 코루틴 함수로 변환해서 사용할 것이며, request.get 함수의 인자로 url을 전달한다는 의미입니다. 그리고 그 결과를 rq 변수에 저장합니다.

 

 

html_code = await loop.run_in_executor(None, bs, rq.content, 'html.parser')

- bs 함수를 코루틴 함수로 변환해서 사용할 것이며, bs 함수의 인자로서 rq.content와 'html.parser'를 전달한다는 의미입니다. 그리고 그 결과를 html_code 변수에 저장합니다.

 

 

위 코드에서 run_in_executor 부분을 일반 함수 실행식으로 변환해보면 다음과 같이 인식할 수 있습니다.

rq = await loop.run_in_executor(None, requests.get, url)

rq = requests.get(url)

위 2개 코드는 동일합니다.

다만 코루틴으로 실행될지 일반 함수처럼 실행될지 차이입니다.

 

 

html_code = await loop.run_in_executor(None, bs, rq.content, 'html.parser')

html_code = bs(rq.content, 'html.parser')

위 2개 코드는 동일합니다.

다만 코루틴으로 실행될지 일반 함수처럼 실행될지 차이입니다.

 

 

 

 

 

 

다음 부분은 main() 함수 부분입니다.

async def main():
    fts = [asyncio.ensure_future(get_html(url)) for url in url_list]
    result = await asyncio.gather(*fts)

main() 코루틴 함수에서 본격적으로 get_html() 함수를 실행시킬 것입니다.

다만 일반적으로 실행시키는게 아니라 병렬적으로 여러 개를 동시에 실행할겁니다.

 

5개의 URL이 존재하며 각각에 대해 get_html() 함수를 실행시켜야 합니다.

이것을 병렬적으로 실행시키려면 먼저 이것들을 어떻게 실행시켜야할지 계획을 세워야하는데 이때 사용되는게 ensure_future 함수입니다.

 

그래서 위 코드의 의미는 ensure_future 함수에 get_html 함수를 전달하여 각 함수의 실행 계획을 만들고, 이 실행 계획이 담긴 list를 ftp 변수에 저장하는 것입니다.

(마치 함수 실행 계획서라고 보시면 됩니다.)

이 단계는 아직 계획 단계이므로 get_html 함수가 실행된게 아닙니다.

 

 

ensure_future의 사용법은 다음과 같습니다.

미리 정의된 코루틴 함수를 전달하면 됩니다.

asyncio.ensure_future(코루틴 객체/함수 or 퓨처 객체/함수)

 

 

 

- result = await asyncio.gather(*fts)

그 다음으로 위 부분이 있습니다.

여기서는 위에서 선언한 get_html 함수가 실행될 계획 정보가 담긴 fts 변수를 asyncio.gater 함수에 전달합니다.

바로 이 부분에서 get_html 함수가 코루틴의 형태로 실행됩니다.

그리고 그 결과를 result 변수에 저장합니다.

(asyncio.gather 함수도 코루틴 함수이므로 await 키워드를 붙여주어 호출합니다.)

 

여기서 *(asterisk) 기호가 사용된 이유는 fts 리스트에 담긴 요소의 개수가 항상 정해진 개수가 아니기 때문이며

list 형태로 구성된 fts 변수에 저장된 데이터를 풀어서 개개의 요소로 전달해야하기 때문입니다.

 

 

 

이러한 프로그래밍을 비동기 프로그래밍이라고 합니다.

 

 

 

 

 

 

728x90
반응형
Comments