python

python의 비동기 async

aeongsseu 2023. 6. 2. 04:35

이번 글은 파이썬의 비동기 처리에 관한글입니다.

파이썬의 비동기처리는 제네레이터 기반 코루틴과 네이티브 코루틴이있고 파이썬 버전마다 비동기가 계속 발전되어 바뀌는 부분이 많은데 이 글에선 3.9+ 이상 버전 기준으로 설명 드리겠습니다.

 

 

 

GIL

GIL(Global Interpreter Lock)

파이썬의 비동기에 대해 알아보기 전에 GIL에 대해 먼저 알고있으셔야 합니다.

GIL이란 파이썬이 한번에 하나의 스레드만(프로세스 아님) 실행 시킬수 있도록하는 키의 개념입니다.

위 사진에서 보이듯이 쓰레드1에서 쓰레드2로 넘어 갈때 쓰레드1이 GIL을 풀어주고 쓰레드2가 GIL 키를 acquire해가는 모습이죠.

이 때문에 파이썬은 멀티 스레드를 사용시 오히려 성능이 안나온다(thread context switching 비용때문에)고 하는 것이죠.

참고로 멀티 스레드가 안되는 것이지 멀티 프로세스가 안되는 것은 아니니 이점 혼동하지 않으시길 바랍니다.

 

 

Concurrency / Parallelism

async에 대해 알아보기 전 진짜 마지막으로 동시성과 병렬성의 차이점에 대해 알고가면 좋습니다.

병렬성이란 일반적으로 생각하는 병렬 처리 즉 멀티 프로세스와 멀티 스레드를 사용해 여러 작업을 동시간에 수행하는 것이고

동시성이란 여러 작업을 조금씩 수행해 병렬적으로 처리하는 것처럼 보이게 하는 것이 동시성입니다.

파이썬의 비동기는 이 중 동시성을 띕니다.

 

 

Mechanism

그럼 GIL인 파이썬에서 어떻게 비동기를 가능하게 했는지 이론적인 부분 부터 알아봅시다.

프로그램이 하는 작업에는 크게 CPU bound task와 I/O bound task로 나눌 수 있는데 

CPU bound task는 압축, 정렬 인코딩 등등이있고

I/O bound task에는 통신, 파일 입출력 등등이 있습니다.

 

예시에서 말씀드렸듯이 CPU bound task는 cpu를 계속 사용하는 즉 쓰레드에서 계속 작업을 해야하는 task이고

I/O bound task는 작업처리 시간보다 대기 시간이 더 긴 작업들입니다.

파이썬의 비동기는 이 중 I/O bound task를 실행해놓고 대기 시간동안 다른 작업을 하는 방식입니다.

아래서 직접 코드를 짜보며 알게 되겠지만 CPU bound task 도 비동기 적으로 처리를 할 수는 있습니다.

하지만 별의미가 없겠죠 이에 대해 아래서 사진과 함께 설명드리겠습니다.

 

원래는 CPU, I/O bound task 둘다 커널에 명령을 내리고 리턴이 있을 때 까지 대기합니다. 이를 blocking함수라하는데 비동기 함수로 선언하면 이러한 함수를 event loop라는 queue가 관리합니다.

event loop는 쌓인 task들 [t1,t2,t3,t4]를 전부 무한 루프를 돌며 조금씩 실행합니다.

조금 실행시켜두고 다음 작업으로 넘어갈때 현재 상태를 저장해둡니다. 그리고 한바퀴를 돌고오면 해당 상태로 복귀해 다시 작업을 실행합니다.

그리고 돌던중 작업이 완료됐다고 response가 오면 콜백 함수를 실행시켜 결과를 반환합니다.

이 때문에 cpu bound task는 비동기로 실행시켜도 별 의미가 없다는 것입니다.

I/O bound task의 경우 실행시켜두고 대기하는 시간이 더 오래 걸리므로 대기 시간중 다른 작업을 할 수 있으니 실행 시간이 짧아지지만 cpu bound task의 경우엔 다른 작업을 하고 오는 동안 진행되는 것이 없기 때문입니다.

 

비유를 들자면 I/O bound task를 비동기로 돌리는 건 짜장면 집에서 짜장면을 먹고 주문을 하고 다시 짜장면을 먹고 주문한 음식이 나왔는지 확인하고 다시 짜장면 먹고 주문한 음식이 나왔는지 확인하고 음식이 나왔으면 음식을 가져오고 다시 짜장면을 먹고 이런식이면

 

cpu bound task는 짜장면을 먹고 다른 음식을 요리 조금 해두고 다시 짜장면을 먹고 요리 조금하고 짜장면 먹고 이런식입니다.

 

물론 아예 의미가 없는 것은 아닙니다. 속도면에서는 의미가 없겠지만 동시성 구현을(eg. GUI) 위해서라면 사용할 수 있겠죠.

 

Syntax

이제 실제로 비동기 프로그래밍을 하기 위해 파이썬 코드를 어떻게 짜야하는지 알아보겠습니다.

최근 버전에서 파이썬은 비동기를 async / await이라는 문법과 asyncio라는 라이브러리로 지원합니다.

 

먼저 비동기로 실행 시키고 싶은 함수를 async 키워드를 이용해 선언합니다.

async def func():
    return 'func'

그리고 이 함수를 그냥 실행시켜 보시면 실행이 되는 것이 아니라 어떤 객체가 반환 되는것을 확인 가능하실 겁니다.

이러한 비동기 함수를 실행 시키려면 await이란 키워드를 이용해야합니다.

그럼 위 코드는 아마 결과값으로

func2 start

func start

func end

func2 end

라는 결과 값을 예상 할 수 있습니다

하지만 실제로 나온 결과 값은 그렇지 않습니다.

 

이는 함수를 이벤트 루프에 적재해주지 않아서 그렇습니다.

이벤트 루프에 적재 돼있지 않은 비동기 함수에 await을 사용하는 것은 그저 동기 함수를 실행시키는 것과 같습니다.

asyncio.create_task 를 통해 이벤트 루프에 적재해주니 예상한 결과 값이 나오는 것을 확인할 수 있습니다.

 

다음은 cpu bound task를 비동기로 돌려보겠습니다.

import asyncio
import os
import time


async def async_sleep():
    start = time.time()
    await asyncio.sleep(5.0)
    print(f"in async sleep task in {os.getpid()}, It took {time.time()-start} ")



def sync_cpu_bound_task():
    result = sum(i * i for i in range(10 ** 8))
    print(f"in heavy cpu bound in {os.getpid()}")

    return result


async def next_job():
    print(f"in coroutine task {os.getpid()}")


async def main():
    _loop = asyncio.get_event_loop()

    t3 = _loop.run_in_executor(
        None,
        sync_cpu_bound_task
    )
    t4 = _loop.run_in_executor(
        None,
        sync_cpu_bound_task
    )
    t5 = _loop.run_in_executor(
        None,
        sync_cpu_bound_task
    )
    t6 = _loop.run_in_executor(
        None,
        sync_cpu_bound_task
    )
    t7 = _loop.run_in_executor(
        None,
        sync_cpu_bound_task
    )

    t1 = asyncio.create_task(next_job())
    t2 = asyncio.create_task(async_sleep())

    await t7
    await t6
    await t4
    await t5
    await t3
    await t1
    await t2

if __name__ == '__main__':
    start = time.time()
    asyncio.run(main())
    print(f'it took {time.time() - start}')

위 코드를 실행시켜보면 아래와 같이 나옵니다.

또한 직접 실행시켜 보시면 알겠지만 

'in heavy cpu bound in 87523'이란 문구가 동시에 나오는 것을 확인하실 수 있습니다.

또한 sync_cpu_bound_task의 갯수를 늘리면 늘릴수록 시간이 더 오래 걸립니다.

즉 모든 태스크를 조금씩 실행시키며 동시에 끝난 것이죠.

 

sync cpu bound task가 아닌 I/O bound task나 (eg. request.get) time.sleep()같은 함수의 갯수를 늘리면 실행시간에 별 차이가 없습니다.

 

또 자세히 보시면 main을 실행 시킬때 이번에는 asyncio.run을 통해 실행 시켰는데요.

원래는 비동기 함수를 실행시킬때 asyncio.get_running_loop를 통해 event loop를 얻고 비동기 함수를 실행시켜야하지만 jupyter notebook의 경우 이미 event loop가 돌아가고 있어 await으로 바로 비동기 함수를 실행시켜도 됐던겁니다.

asyncio.run의 내부 코드를 보시면 아래와 같은데

def run(main, *, debug=None):
    """Execute the coroutine and return the result.

    This function runs the passed coroutine, taking care of
    managing the asyncio event loop and finalizing asynchronous
    generators.

    This function cannot be called when another asyncio event loop is
    running in the same thread.

    If debug is True, the event loop will be run in debug mode.

    This function always creates a new event loop and closes it at the end.
    It should be used as a main entry point for asyncio programs, and should
    ideally only be called once.

    Example:

        async def main():
            await asyncio.sleep(1)
            print('hello')

        asyncio.run(main())
    """
    if events._get_running_loop() is not None:
        raise RuntimeError(
            "asyncio.run() cannot be called from a running event loop")

    if not coroutines.iscoroutine(main):
        raise ValueError("a coroutine was expected, got {!r}".format(main))

    loop = events.new_event_loop()
    try:
        events.set_event_loop(loop)
        if debug is not None:
            loop.set_debug(debug)
        return loop.run_until_complete(main)
    finally:
        try:
            _cancel_all_tasks(loop)
            loop.run_until_complete(loop.shutdown_asyncgens())
            loop.run_until_complete(loop.shutdown_default_executor())
        finally:
            events.set_event_loop(None)
            loop.close()

event loop를 생성하는 것을 확인 하실 수 있습니다.

 

 

python의 비동기에 대한 글은 여기서 마치겠습니다.

좀더 깊히 이해하려면 iterator, generator부터 시작해 이해하면 좋지만 이 글에선 여기까지만 설명드리고 참고하시면 좋을 글 몇가지만 소개해드리고 마치겠습니다.

https://blog.humminglab.io/posts/python-coroutine-programming-1/

 

Python 비동기 프로그래밍 제대로 이해하기(1/2) - Asyncio, Coroutine

Python2 와 비교하여 python3의 가장 돋보이는 killer feature 는 비동기 프로그래밍 지원이라고 할 수 있다. 이를 위하여 python 3.4에 asyncio 모듈이 추가되었고, python 3.5 에는 native coroutine 지원을 위한 async

blog.humminglab.io

https://ojt90902.tistory.com/1336

 

Python : asyncio의 시작과 종료

들어가기 전 이 글은 파이썬 비동기 라이브러리 Asyncio를 공부하며 작성한 글입니다. 3.10 asyncio의 시작 asyncio의 시작은 간단하게 할 수 있다. 일반적인 방법은 다음과 같다 async def로 main() 함수를

ojt90902.tistory.com

 

제가 이해한대로 조금 각색한 부분이 있어 틀린 부분이 보이시면 지적해주시면 감사히 받고 수정하도록 하겠습니다.