본문 바로가기

진리는어디에/Python

[Python] 비동기 함수 - asyncio

이번 포스트는 파이썬 기초 강의의 마지막 장으로써 비동기 함수의 개념과 활용 방법에 대해 설명 한다. 비동기 함수는 프로그램의 성능 향상을 위해서는 필수적인 기능이고 마지막 답게 다소 어려운 내용이므로 집중해서 읽도록 하자.

비동기 함수의 개념

본론을 시작하기 전에 우리는 먼저 비동기 함수의 개념에 대해 이해해야 한다.

일반적으로 함수를 호출하게 되면 함수의 처음 부터 진행하다 함수의 끝에 다다르거나 도중에 return문을 만나게 되면 함수는 종료되고, 제어권은 다시 호출자에게 되돌아 간다. 이 경우 호출자는 자신이 호출한 함수가 종료하고 리턴 할 때까지 기다리고, 함수가 리턴 했다는 것은 호출 된 함수의 실행이 완료 되었다는 것을 보장한다.

import time

def foo() :
    print('hello ', end='', flush=True)
    time.sleep(1)
    print('world')

print(f"stated at {time.strftime('%X')}")
foo()                                     # foo() 함수가 종료 될때까지 기다림
print(f"finish at {time.strftime('%X')}") # 여기까지 왔다는 것은 foo()가 완벽히 종료 되었음을 의미 한다

이런 일반 함수를 '동기 함수(sync function)'이라고 한다.

"동기 함수(sync function)"
함수가 완료 될 때 까지 리턴하지 않음

반면에 비동기 함수가 있다. 함수를 호출하면 실행이 완료 되지 않더라도 호출자에게 리턴, 즉, 제어권을 넘기고 자기 혼자 백그라운드로 작업을 계속 한다. 그리고 어느 순간 작업이 완료 되면 호출자에게 작업이 완료 되었음을 '통보' 해준다. 이런것을 비동기 함수라고 한다.

"비동기 함수(async function)"
함수 완료 여부와 상관 없이 호출자에게 리턴하며, 작업이 완료 되면 호출자에게 완료를 통보한다.

동기 함수와 달리 비동기 함수는 리턴 되고 제어권이 호출자에게 넘어 왔다고 하더라도, 작업이 완료 되었음을 보장하지 않는다. 이렇게 비동기 함수가 작업 완료를 보장하지 않아 생기는 문제점과 해결 방법은 뒤쪽에서 다시 다룰 예정이니 일단 계속 진행 하도록 하자.

비동기 함수의 존재 이유

앞에서 동기함수가 무엇인지, 비동기 함수가 무엇인지에 대해 간단히 알아 보았다. 앞의 섹션을 읽고 나면 "작업이 완료 여부도 보장하지 못하는 비동기 함수를 왜 써야 하는가?"라는 의문이 들수도 있다. 그 이유에 대한 해답을 찾기 위해서 우리는 비동기 함수의 종류에 대해 먼저 알아 볼 필요가 있다.

연산 중심의 비동기 함수

"연산 중심"이라는 것은 함수가 실행 되는 동안 CPU가 쉬지 않고 연산을 계속 진행하는 경우를 말한다. 이런 경우는 대부분 복잡한 연산을 쉬지않고 해야하므로 일반적으로 독립적인 스레드를 생성하여 백그라운드에서 동작한다. 이런 형식의 프로그램을 멀티 스레드 프로그램이라고 부르며 요즘 보편화 되어있는 멀티코어, 다중 프로세서를 효과적으로 사용할 수 있다.

I/O중심 비동기 함수

예를 들어 엄청나게 큰 사이즈(대략 10기가?)의 파일을 읽는다고 가정하자. 파일을 읽을 때 대부분의 작업은 IO에 관련된 하드웨어가 하고 CPU는 대기 상태로 빠지게 된다. 엄청 큰 파일이라면 그 대기 시간도 상당히 길 것이다. 대기 시간 동안 프로그램은 아무것도 하지 않게 되고 그만큼 작업을 완료하기 위해 필요한 시간은 길어지게 된다.

그런데 만일 파일 읽기 작업을 다른 누군가에게 맡기고, 프로그램은 다른 일을 하다가, 파일 작업이 완료 되었다고 알림이 오면 다시 파일 처리를 할 수 있다면 어떨까? 당연히 대기 시간만큼 전체 프로그램의 효율이 증가할 것이다.

어디서 본 것 같은 시나리오지 않은가? 그렇다 앞에서 설명한 비동기 함수의 개념이 바로 위 파일을 읽는 동안 다른 일을 하고, 파일 읽기 완료 "통보"를 받으면 다시 파일에 대한 처리를 할 수 있도록 하는 것이다. 다행히 대부분의 모던 OS에서는 이런 비동기 입출력을 지원하며 우리는 파이썬을 통해 그것을 사용하기만 하면 된다.

비동기 함수를 사용하면 CPU의 유휴 시간을 줄여 전체 프로그램 효율을 향상 시킬 수 있다.

동기 vs 비동기

우리는 이제 비동기 함수를 사용하면 효율이 더 좋아 질 수 있다라는것 까지 알게 되었다(사실 일방적으로 들었다). 이번 섹션에서는 동일한 웹페이지를 동기 방식과 비동기 방식으로 다운로드 받는 예제를 구현해 봄으로써 비동기 방식이 어떻게 더 효율적인지 살펴 보도록 하겠다.

아래 예제는 동기식으로 다섯개의 웹페이지를 다운 받는것을 시뮬레이션한 코드다(실제 웹페이지를 다운 받으면 대부분 너무 빨라 시간 비교가 어려워 웹페이지 다운로드 대신 sleep()함수를 호출하고 있다). 하나의 페이지를 다운로드 하는데 1초가 걸린다고 하면 다른 작업을 제외하고도 최소한 5초가 걸리게 된다.

import time

def download_page(url) :
    time.sleep(1) # 페이지를 다운로드
    #html 분석
    print("complete download:", url)
    
def main() :
    download_page("url_1")
    download_page("url_2")
    download_page("url_3")
    download_page("url_4")
    download_page("url_5")

print(f"stated at {time.strftime('%X')}")
main()
print(f"finish at {time.strftime('%X')}")

이렇게 작업이 오래 걸리는 이유는 앞에서도 설명했듯이 프로그램이 일을 하지 않고 자고(?) 있기 때문이다.
동기 함수는 작업을 완료하고 리턴을 해야 다음 작업을 진행 할 수 있는데, sleep() 함수 때문에 매번 download_page() 호출 마다 1초씩 아무것도 하지 않고 멈추는 시간 때문에 프로그램을 완료하는데 5초가 넘는 시간이 걸린다.

그럼 우리가 여기서 생각 해볼수 있는 것이, 다운로드 작업(IO작업)의 대부분은 CPU를 사용하는 것이 아니라, 다운로드(위 예제에서는 sleep()이)가 완료 될 때까지 아무것도 안하고 기다린는 것인데, 다운로드가 완료 되는 것을 기다리지 말고 다른 다운로드를 시작한다면 조금 더 빨라지지 않을까?

이 아이디를 가지고 위의 예제를 비동기로 변경해보도록 하자. 비동기 함수를 만들기 위해서 우리는 몇 가지 추가 작업을 해줘야 한다.

asyncio 임포트

비동기 함수를 만들기 위해서는 비동기 관련 기능을 담고 있는 asyncio 모듈을 임포트(import) 해야 한다.

import asyncio

def 대신 async def

비동기 함수는 def 대신 'async def' 키워드를 사용한다. async def문을 이용해 구현된 함수를 파이썬에선 코루틴이라고 부른다. 이후 설명 부터 등장하는 코루틴과 비동기 함수는 같은 의미를 가지고 있다고 이해 하도록 하자.

앞의 download_page()와 main()함수를 비동기 함수, 즉 코루틴으로 변경해 보자(파이썬 코루틴에 대한 설명은 [여기] 참조).

import asyncio

# def download_page(url) :
async def download_page(url) :  # async def 키워드로 대체
    time.sleep(1) 
    #html 분석
    print("complete download:", url)
    
# def main()     
async def main() :              # async def 키워드로 대체
    download_page("url_1")
    download_page("url_2")
    download_page("url_3")
    download_page("url_4")
    download_page("url_5")

하지만 아직 끝이 아니다. 앞에서 다운로드를 시뮬레이션 하기 위해 time.sleep()은 1초가 지나기 전까진 리턴하지 않고 멈춰 있는 동기 함수다. 이것을 asyncio.sleep() 함수로 대체 하겠다. asyncio.sleep()은 비동기 함수로써 시작하자 마자 바로 리턴하지만, 백그라운드로 1초간 대기 후 시간이 만료 되면 만료를 통보한다.

import asyncio

async def download_page(url) :
    # time.sleep(1) 
    asyncio.sleep(1)           # 비동기 함수로 대체
    #html 분석
    print("complete download:", url)
    
async def main() :
    download_page("url_1")
    download_page("url_2")
    download_page("url_3")
    download_page("url_4")
    download_page("url_5")

그러면 여기서 문제가 하나 발생한다. asyncio.sleep()에서 1초가 지나지 않아 바로 리턴 해버리면(다운로드가 완료되지 않았다는 의미) 아래 "html 분석" 부분에서 html을 처리하려다 오류가 발생 할 것이다.

await

비동기 함수가 바로 리턴 해버리면서 완료 되지 않은 결과에 접근하는 것을 방지하기 위해 아래 처럼 비동기 함수(asyncio.sleep) 호출 앞에 await 키워드를 사용한다.

※ 아래 처럼 함수(또는 객체가) await 표현식에서 사용 될 수 있을 때, 이를 어웨이터블 객체라고 말한다.

import asyncio

async def download_page(url) : 
    # asyncio.sleep(1)
    await asyncio.sleep(1)     # await 키워드 사용
    #html 분석
    print("complete download:", url)
    
async def main() :
    await download_page("url_1")
    await download_page("url_2")
    await download_page("url_3")
    await download_page("url_4")
    await download_page("url_5")

await 키워드는 asyncio.sleep() 함수가 리턴하면 바로 다음 코드를 진행하지 말고, OS로 부터 완료 통보가 올 때 까지 기다리라는 의미다.

그러면 여기서 문제가 또 하나 발생한다. time.sleep()으로 1초 기다리나 asyncio.sleep()으로 1초 기다리나 결국 코드가 1초 동안 진행되지 못하고 대기 상태로 빠지는 것은 마찮가지다. 여기서 await의 진정한 가치가 나타난다.

await 키워드를 사용하면 요청 했던 비동기 작업의 완료 통보가 올 때까지 기다린다. 그런데 마냥 아무것도 하지 않으면서 기다리는 것이 아니고 "이벤트 루프"를 먼저 확인하고, 이벤트 루프에 일거리가 있다면 그 일들을 처리하면 기다린다.

"await"
다음으로 바로 진행하지 않고 완료 통보가 올 때 까지 진행한다.
이벤트 루프에 일거리가 있다면 해당 작업들을 처리하면서 기다린다.

asyncio.run()

그럼 이 이벤트 루프라는 것은 무엇인가? 이벤트 루프는 파일 읽기/쓰기, 타이머, 네트워크 IO 같은 비동기 함수(작업)들을 등록하면 내부적으로 루프를 돌며 등록된 작업들을 하나씩 실행하고 완료 시 그 결과를 통보 해준다.

이벤트 루프는 파이썬의 모든 asyncio 응용 프로그램의 핵심이다. 중요하지만 그만큼 추상화가 잘 되어 있어 대부분의 경우 여러분이 비동기 함수를 사용하면서 이벤트 루프의 내부까지 속속들이 알아야 할 필요는 없다. 이벤트 루프에 대한 자세한 설명은 따로 설명하는 장을 마련하도록 하고 여기서는 간략한 요약과 이벤트 루프를 사용하는 인터페이스들에 대해서만 집중한다.

코루틴을 실행하기 위해선 일반 함수의 호출과는 달리 asyncio.run() 함수를 통해서만 호출이 가능하다.

asyncio.run(coro, *, debug=False)

코루틴을 coro를 실행하고 결과를 반환한다. debug가 True면 이벤트 루프가 디버그 모드로 실행 된다.

이 함수는 전달된 코루틴을 실행하고, asyncio 이벤트 루프를 관리한다. 이 함수는 항상 새 이벤트 루프를 만들고 끝에 이벤트 루프를 닫는다. asyncio 프로그램의 메인 진입점으로 사용해야 하고, 한번만 호출 되어야 한다.

asyncio.run()함수에 우리가 작성한 main() 함수를 인자로 넘겨 주고 실행 시켜 보도록 하자. 예상 대로라면 main() 함수가 실행 되면서 내부에서 호출 중인 download_page() 함수들이 실행 될 것이다.

import time
import asyncio

async def download_page(url) : 
    # asyncio.sleep(1)
    await asyncio.sleep(1)
    #html 분석
    print("complete download:", url)
    
async def main() :
    await download_page("url_1")
    await download_page("url_2")
    await download_page("url_3")
    await download_page("url_4")
    await download_page("url_5")
    
print(f"stated at {time.strftime('%X')}")
asyncio.run(main())            # asyncio.run 함수의 인자로 main을 넘겨주어 코루틴 실행
print(f"finish at {time.strftime('%X')}") # 시간을 확인하면 여전히 5초가 넘는 시간이 걸린다.

하지만 위 예제를 실행하면 우리의 예상과 달리 일반 동기 함수 호출과 동일하게 5초가 넘는 시간이 걸리는 것을 볼 수 있다. 무엇이 잘못된 것일까?

앞의 설명에서 await 키워드는 "다음으로 바로 진행하지 말고, 이벤트 루프에 일거리가 있다면 해당 작업들을 처리하면서 완료 통보가 올 때까지 기다린다"라고 이야기 했었다. 위 예제의 main() 함수 안의 download_page() 함수의 호출 앞에는 모두 await 키워드가 붙어 있다. 이는 이전의 download_page() 함수 호출로 부터 완료 통보가 떨어지기 전까진 다음 download_page() 호출로 진행 되지 않는다는 뜻이다.

asyncio.gather()

그렇다면 어떻게 이벤트 루프에 모든 작업들을 한번에 등록 할 수 있을까?

awaitable asyncio.gather(*aws, loop=None, return_exceptions=False)

aws 시퀀스에 있는 어웨이터블 객체들을 동시에 실행한다.

aws에 있는 어웨이터블이 자동으로 이벤트 루프에 등록 된다. 모든 어웨이터블이 성공적으로 완료되면, 각 어웨이터블에서 반환된 값들이 합쳐진 리스트를 반환한다. 반환된 결과값들의 순서는 aws에 있는 어웨이터블의 순서와 동일하다. 함수에 관한 자세한 사항은 [여기]를 참고 한다.

NOTE : loop 매개 변수는 버전 3.8부터 사용되지 않으며, 3.10부터 삭제 된다.

asyncio 모듈에서는 여러 비동기 함수를 한번에 등록 할 수 있는 gather() 함수를 제공한다. 그럼 이번에는 gather()함수를 이용해 download_page 함수들을 한번에 등록 해보도록 하자.

import time
import asyncio

async def download_page(url) :
    await asyncio.sleep(1) # 페이지를 다운로드
    #html 분석
    print("complete download:", url)
    
async def main() :
    await asyncio.gather(
        download_page("url_1"),
        download_page("url_2"),
        download_page("url_3"),
        download_page("url_4"),
        download_page("url_5")
    )    

print(f"stated at {time.strftime('%X')}")
asyncio.run(main())
print(f"finish at {time.strftime('%X')}")

# OUTPUT :
# stated at 20:50:32
# complete download: url_1
# complete download: url_3
# complete download: url_5
# complete download: url_2
# complete download: url_4
# finish at 20:50:33 <-------- 1초 걸림

드디어 우리가 기대하던 결과가 나왔다. main() 에서 시작된 첫번째 download_page("url_1") 함수는 실행과 동시에 - 내부의 asyncio.sleep()이 즉시 - 리턴하고, 이벤트 루프에 있는 다른 비동기 함수를 실행한다. 이런 식으로 모든 download_url() 함수들을 호출하고, 모든 비동기 함수가 완료 되면 asyncio.run() 함수는 그 결과를 모아 한번에 리턴하고 프로그램은 종료 된다.

asyncio 예제

이제 실제 웹페이지를 비동기 다운로드 하는 코드를 만들어 보도록 하자. 웹페이지를 다운로드하기 위해 requests 모듈을 이용할 예정이다. requests 모듈의 자세한 설명은 포스트의 주제를 벗어나므로 간단히 http에 관련된 기능들을 담고 있는 파이썬 모듈이라고만 이해하고 넘어가도록 하자. requests 모듈은 파이썬의 빌트인 모듈이 아니므로 pip을 통해 따로 설치 히야 한다. 윈도우 커맨트 콘솔에서 아래와 같이 입력 하면 자동으로 requests 모듈이 인스톨 된다.

pip install requests

아래는 requests 모듈을 이용해 웹페이지를 다운로드 하는 코드다. 여기서 주목해야 할 것은 requests.get()은 동기화 함수라는 것이다.

import time
import requests

def download_page(url) :
    req = requests.get(url)  # 동기 함수로 웹페이지 다운로드
    html = req.text
    print("complete download:", url, ", size of page(", len(html),")")
    
def main() :
    download_page("https://www.python.org/")
    download_page("https://www.python.org/")
    download_page("https://www.python.org/")
    download_page("https://www.python.org/")
    download_page("https://www.python.org/")

print(f"stated at {time.strftime('%X')}")
start_time = time.time()
main()    
finish_time = time.time()
print(f"finish at {time.strftime('%X')}, total:{finish_time-start_time} sec(s)")

# OUTPUT :
# stated at 22:04:23
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# finish at 22:04:25, total:2.5677530765533447 sec(s)

위 예제는 웹페이지를 다운로드하는데 테스트 마다 약간씩 차이가 있었지만 보통 2초가 걸렸다. 이제 위 함수를 비동기로 변경해보자. 이미 한번 살펴 봤던 내용들이므로 중요한 내용만 설명하고 나머지는 예제의 주석을 통해 살펴 보도록하겠다.

위 예제에서 사용한 requests.get 함수는 동기함수라고 이야기 했다. 하지만 우리가 원하는 것은 비동기 함수다. 파이썬에서는 동기 함수를 비동기로 동작 할 수 있도록 이벤트 루프에서 run_in_executor() 함수를 제공한다.

awaitable loop.run_in_executor(executor, func, *args)

지정된 executor에서 func가 호출되도록 등록한다. executor 가 None 이면 기본 executor가 사용 된다.

하지만 run_in_executor()함수를 사용하기 위해서는 먼저 이벤트 루프 객체가 필요하므로 asyncio.get_event_loop()를 통해 이벤트 루프 객체를 얻어 와야한다.

asyncio.get_event_loop()

현재의 이벤트 루프를 리턴한다. 함수에 관한 자세한 사항은 [여기]를 참고 한다.

위 함수들을 적용한 비동기 다운로드 코드는 아래와 같다.

import time
import requests
import asyncio                       # asyncio 모듈 임포트

async def download_page(url) :       # async def로 함수 정의
    loop = asyncio.get_event_loop()  # 이벤트 루프 객체 얻기
    req = await loop.run_in_executor(None, requests.get, url) # 동기함수를 비동기로 호출
    
    html = req.text
    print("complete download:", url, ", size of page(", len(html),")")
    
async def main() :
    await asyncio.gather(
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/")
    )    

print(f"stated at {time.strftime('%X')}")
start_time = time.time()
asyncio.run(main())
finish_time = time.time()
print(f"finish at {time.strftime('%X')}, total:{finish_time-start_time} sec(s)")

# OUTPUT :
# stated at 22:33:50
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# complete download: https://www.python.org/ , size of page( 49866 )
# finish at 22:33:51, total:0.5518977642059326 sec(s) <-- 실행 시간이 줄어 든것을 확인 할 수 있다

위 비동기 예제를 실행하면 이전 동기 예제 보다 확실히 시간이 줄어든 것을 확인 할 수 있다. 비동기 방식은 파이썬을 통한 네트워크 프로그래밍, 특히 웹프로그래밍을 할 때 상당히 유용하게 쓰이니 잘 숙지해 두도록 하자.

마치며

이상으로 파이썬의 비동기 함수(코루틴)을 사용하는 방법에 대해 알아 보았다. 비동기 함수를 사용하기 위한 방법을 요약하면 아래와 같다.

  • import asyncio
  • def 대신 async def로 함수 선언
  • 비동기 함수의 결과를 기다려야 할 때는 await
  • asyncio.gather() 함수를 이용해 여러 비동기 함수 동시 등록
  • asyncio.run() 함수를 이용해 비동기 함수 실행

이상으로 파이썬 기초 강의를 모두 마치도록 하겠다. 다음 계획으로는 파이썬에서 C코드를 호출하는 익스텐션(extension)과 C에서 파이썬 코드를 호출하는 임베딩(embedding)에 대해 부록 형식으로 다뤄 볼 예정이다.

지금까지 긴 강의 따라와준 여러분께 감사의 인사를 드린다.

부록 1. 같이 보면 좋은 글

부록 2. 클래스 데코레이터를 적용해 실행 시간 측정하기

앞의 내용을 복습 할 겸 보다 편리하게 성능을 측정 할 겸 아래 예제를 만들어 보았다. 아래 예제의 결과를 보면 개별 페이지를 다운 받는데 각각 약 0.5초가 걸렸지만 전체를 다운 받는 시간 또한 비슷한 것을 확인 할 수 있다.

import time
import requests
import asyncio                      

class time_measure :
    def __init__(self, func) :
        self.start_time = time.time()
        self.func = func
    async def __call__(self, *args, **kwargs) :
        print(f"{self.func.__name__}{args} stated at {time.strftime('%X')}")
        await self.func(*args, **kwargs)
        finish_time = time.time()
        print(f"{self.func.__name__}{args} finish at {time.strftime('%X')}, total:{finish_time-self.start_time} sec(s)")
        
@time_measure
async def download_page(url) :      
    loop = asyncio.get_event_loop() 
    req = await loop.run_in_executor(None, requests.get, url) #
    
    html = req.text
    print("complete download:", url, ", size of page(", len(html),")")

@time_measure    
async def main() :
    await asyncio.gather(
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/"),
        download_page("https://www.python.org/")
    )    

asyncio.run(main())

# OUTPUT :
# main() stated at 22:43:24
# download_page('https://www.python.org/',) stated at 22:43:24
# download_page('https://www.python.org/',) stated at 22:43:24
# download_page('https://www.python.org/',) stated at 22:43:24
# download_page('https://www.python.org/',) stated at 22:43:24
# download_page('https://www.python.org/',) stated at 22:43:24
# complete download: https://www.python.org/ , size of page( 49866 )
# download_page('https://www.python.org/',) finish at 22:43:25, total:0.47882962226867676 sec(s)
# complete download: https://www.python.org/ , size of page( 49866 )
# download_page('https://www.python.org/',) finish at 22:43:25, total:0.48082995414733887 sec(s)
# complete download: https://www.python.org/ , size of page( 49866 )
# download_page('https://www.python.org/',) finish at 22:43:25, total:0.4917140007019043 sec(s)
# complete download: https://www.python.org/ , size of page( 49866 )
# download_page('https://www.python.org/',) finish at 22:43:25, total:0.5168471336364746 sec(s)
# complete download: https://www.python.org/ , size of page( 49866 )
# download_page('https://www.python.org/',) finish at 22:43:25, total:0.6335766315460205 sec(s)
# main() finish at 22:43:25, total:0.635563850402832 sec(s)

이제 진짜 끝!

 

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!