본문 바로가기

파이썬 asyncio

이 포스팅은 파이썬의 비동기 라이브러리인 asyncio에 대한 전반적인 설명과, 간단한 사용법을 다룬다.

 

asyncio — Asynchronous I/O — Python 3.10.7 documentation

asyncio — Asynchronous I/O asyncio is a library to write concurrent code using the async/await syntax. asyncio is used as a foundation for multiple Python asynchronous frameworks that provide high-performance network and web-servers, database connection

docs.python.org

 

1. 비동기 프로그래밍이란

우리가 그동안 작성하던 메인 로직의 제어아래 순서대로 흘러가는 프로그램들은 동기 프로그래밍이였다.

 

즉 메인 로직에서 어떠한 서브루틴(함수등)을 호출하고 그 서브루틴의 종료와 함께 다음 로직이 흘러가는 순차적(Sequential) 형태의 프로그램을 동기 프로그램이라 한다.

 

여기서는 순차적이지 않은 프로그래밍에 대해서 다룬다.

 

우선 비동기 프로그래밍을 위한 방법은 여러가지가 있다. 프로세스 포크, 멀티 스레드, 이벤트 루프등의 다양한 방법들이 있지만 여기서 다루는 방법은 이벤트 루프 기반의 비동기 프로그래밍 asyncio 라이브러리에 대해서 설명한다.

 

2. Terminology

우선 asyncio에 대해 설명하기 전 몇가지 용어에 대해 간략하게 짚고 넘어간다.

2.1 코루틴 (Coroutine)

코루틴이란 간단하게 비동기 작업을 위해 사용되는 서브루틴이라 할 수 있다.

 

코루틴(coroutine)은 cooperative routine의 의미로써, 종속관계가 아닌 협력 관계라 할 수 있다.

즉 기존 동기 프로그래밍의 종속관계인 메인루틴, 서브루틴과 같은 관계가 아닌 메인루틴과 동등한 관계로써 동작한다고 이해하면 된다.

 

 2.2 코루틴 함수

코루틴을 만드는 함수 자체를 말한다.

async def co():
	pass

코루틴 함수는 async라는 문법을 사용하여 정의한다.

위의 co()라는 코루틴 함수를 실행시키게 되면 코루틴 객체가 나오게 된다.

 

즉 co 라는 함수 자체는 코루틴 함수이며, 이 함수의 결과로 나오는 결과물이 코루틴인 것이다.

 

2.3 태스크 (Task)

태스크는 코루틴 객체를 실행시키기 위해 감싸는 래퍼(Wrapper)다.

코루틴 함수의 결과인 코루틴 객체 자체로는 실행되지 않으며, 코루틴이 실행되기 위해선 이벤트 루프에 등록해야 한다. 

이때 등록을 위해 코루틴 객체를 감싸는 Wrapper를 태스크라 한다.

 

2.4 이벤트 루프(Event Loop)

이벤트 루프란 말그대로 루프를 돌며 태스크(코루틴)를 실행시켜 주게된다.

이벤트 루프에 태스크(코루틴)을 등록시키게 되면 이벤트 루프는 루프를 돌며 실행시킬 코루틴을 확인하고 실행시켜주게 된다.

 

3. 핵심 키워드

3.1 async, await

async : 루틴(함수)를 코루틴으로 만들기 위한 키워드로써, 아래와 같이 사용하여 코루틴 함수를 만든다.

async def coro():
	...

 

await : awaitable객체 (코루틴, 태스크, 퓨처, 또는 await 매직 메소드가 구현된 클래스)가 종료되기까지 block합니다.

코루틴을 실행시킬 수 있다.

3.2 asyncio.run(coro)

코루틴을 실행시키기 위한 진입점(entry point)로써, 메인 루틴에서 asyncio.run()을 통해 코루틴을 실행시킬 수 있다.

 

3.3 asyncio.get_running_loop()

실행중인 이벤트 루프를 가져오기 위한 함수다.

 

3.4 asyncio.create_task(coro)

코루틴을 태스크로 이벤트 루프에 등록한다. 이벤트 루프에 등록된 태스크는 스케줄링에 맞춰 실행된다.

 

3.5 asyncio.run_in_executor(executor, func)

함수를 새로운 executor(풀) 에서 실행시킵니다. 블록킹 작업때문에 이벤트 루프가 블록되지 않도록 할때 사용됩니다.

 

4. 실제 사용

asyncio는 싱글 스레드 에서 동시성 프로그래밍이 가능하도록 고안된 라이브러리다. 이점 참고하고 진행한다.

 

예제 1) 가장 기본인 코루틴을 만들고, 해당 코루틴을 실행시키는 예제

import asyncio

async def main():
    print("start")
    print(asyncio.get_running_loop())
    print("end")

if __name__ == "__main__":
    asyncio.run(main())

async 키워드를 통해 main이라는 코루틴 함수를 만든다.

메인 루틴에서 asyncio.run()을 통해 코루틴을 실행시킨다.

메인 루틴에서 코루틴을 실행시키기 위한 예제다.

 

 

예제2)  이벤트 루프에 코루틴을 태스크로 등록하는 예제

import asyncio

async def co1():
    print("co1 start")
    
async def main():
    print("start")
    t1 = asyncio.create_task(co1())
    t2 = asyncio.create_task(co1())
    print("end")

if __name__ == "__main__":
    asyncio.run(main())

예제 2의 결과

위 예제에서 사용된 코루틴 co1() 은 3초동안 기다린 후 종료가 되는 코루틴이다.

 

main 코루틴에서 co1 코루틴을 태스크로 등록시키고, 종료되는 예제다.

결과를 보면 main의 start, end가 실행된 이후 코루틴이 실행된것을 볼 수 있다.

main 코루틴에서는 co1()을 태스크로 만들어 이벤트 루프에 등록시켜 스케줄링을 했을 뿐이지, 이게 바로 실행된다는 보장은 얻을 수 없다.

때문에 end가 출력된 이후 이벤트 루프가 돌며 태스크로 등록된 co1 을 두번 실행시키게 된것이다.

 

예제 3) 코루틴중 sleep()

import asyncio
import time

async def co1():
    print("co1 start")
    time.sleep(1)
    print("co1 end")
    
async def main():
    print("start")
    t1 = asyncio.create_task(co1())
    t2 = asyncio.create_task(co1())
    print("end")

if __name__ == "__main__":
    asyncio.run(main())

코루틴 co1()의 중간에 sleep()을 둔 코드다.

이 예제는 실행시 첫번째 태스크가 약 1초동안 걸려 수행되고, 이후 다음 태스크가 1초동안 걸려 수행되어 총 2초가 걸리게 된다.

이유는 asyncio가 싱글 스레드를 기반으로 동작하기 때문에 스레드를 정지시키는 time.sleep()메소드가 루프 자체를 정지시키기 때문에

루프가 정지되는 동안 다른 태스크가 루프에 있어도 해당 루프가 실행되지 못하게 된다.

즉 동기적으로 실행된다는 것이다.

 

태스크가 비동기적으로 실행되기 위해선 스레드를 정지시키는 time.sleep() 대신 asyncio에서 제공하는 sleep  메소드를 사용하여야 한다.

 

예제 4) asyncio.sleep()을 사용한 예제 3

import asyncio

async def co1():
    print("co1 start")
    await asyncio.sleep(1)
    print("co1 end")
    
async def main():
    print("start")
    t1 = asyncio.create_task(co1())
    t2 = asyncio.create_task(co1())
    print("end")

if __name__ == "__main__":
    asyncio.run(main())

예제 4는 예제 3과는 다르게 두개의 태스크가 동시에 (concurrent) 실행되었다.

asyncio.sleep()은 전체 루틴(스레드)를 정지시키는것이 아닌, 코루틴 하나만을 블록하게 된다.

때문에 해당 코루틴이 블록된후 이벤트 루프는 다음 작업을 수행하게 되고 두 작업은 동시에(concurrent) 실행된다.

 

하지만 sleep 이후 코드인 co1 end가 출력되기 전에 프로그램이 종료되고 만다.

 

이유는, sleep()을 통해 코루틴을 1초동안 정지시켰지만 1초가 지난 시점에서는 루프가 더이상 돌고있지 않기 때문이다.

이벤트 루프는 큐에있는 작업들을 수행 후 더이상 큐에 실행시킬 작업이 없으면 종료하게 된다.

 

 

예제 5) await를 사용한 예제

import asyncio

async def co1():
    print("co1 start")
    await asyncio.sleep(1)
    print("co1 end")

async def main():
    print("start")
    t1 = asyncio.create_task(co1())
    t2 = asyncio.create_task(co1())
        
    await t1
    await t2
    print("end")

if __name__ == "__main__":
    asyncio.run(main())

asyncio.create_task()는 코루틴을 태스크로 감싸 이벤트루프에 등록시키고, 해당 태스크를 리턴하게 된다.

이 태스크는 awaitable 객체로써,  await 키워드와 함께 사용할 수 있다.

 

이제 await를 통해 해당 작업이 끝나기를 기다리게 되면 두개의 이 종료되는것을 확인 후에 이벤트 루프가 종료 되게 된다.

 

예제 6) 블록킹 작업과 함께 하기

import asyncio
import requests

def get_data():
    reply = requests.get('http://httpbin.org/delay/3')
    print(reply.status_code)

async def co2():
    print("co2 start")
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, get_data)
    print('co2 end')

async def main():
    print("start")
    t1 = asyncio.create_task(co2())
    t2 = asyncio.create_task(co2())

    await t1
    await t2

    print("end")

if __name__ == "__main__":
    asyncio.run(main())

requests은 파이썬의 대표적인 http 동기 라이브러리이다.

요청을 보낸 httpbin.org 의 경우 http 테스트를 해볼 수 있는 주소로 다양한 기능들을 제공한다. (예시에서는 delay 사용)

 

co2 코루틴에서 사용할 get_data() 함수의 경우 requests를 사용하는 블록킹 함수다.

만약 이 함수를 run_in_executor 없이 사용할경우 첫번째 작업에서 requests로 루프가 블록킹 되는동안 다음 작업들이 수행되지 못하므로 약 3초 뒤에 두번째 작업이 수행되고, 3초 뒤 해당 작업이 끝나게 되어 약 6초의 시간이 소요되게 된다.

하지만 run_in_executor()를 통해 블록킹 작업을 다른 executor (스레드 풀)에서 수행시키므로 메인 이벤트 루프에는 영향이 없이 블록킹 작업을 수행할 수 있다.

 

 

5. 결론

asyncio는 이벤트 루프를 통해 파이썬에서 비동기 작업을 수행할 수 있게 해주는 라이브러리다.

이 이벤트 루프는 싱글 스레드에서 수행되므로 스레드 자체가 블락되면 이벤트 루프또한 멈추게 된다 (time.sleep, requests)

블록킹 작업들은 다른 executor에서 수행하여 메인 루프가 정지되는것을 막아야 효과적인 프로그래밍이 가능하다.