CS

파이썬의 동기와 비동기 처리

haong_ 2024. 9. 10. 22:36

동기적 처리와 비동기적 처리

동기(Synchronous) 처리란?

  • 동기 처리는 요청을 보낸 후 그 작업이 끝날 때까지 기다리는 방식으로 일반적인 함수 호출이 여기에 해당함
  • 코드가 순차적으로 실행되기 때문에 직관적이지만, I/O 작업처럼 시간이 오래 걸리는 작업에서는 비효율적임

비동기(Asynchronous) 처리란?

  • 비동기 처리는 시간이 오래 걸리는 작업을 처리하는 동안 다른 작업을 동시에 수행할 수 있는 방식으로 작업이 완료될 때까지 기다리지 않고, 그 사이에 다른 작업을 진행할 수 있음
  • 주로 네트워크 요청, 파일 입출력 등 시간이 오래 걸리는 작업에 유용하며, 특히 많은 요청을 동시에 처리해야 하는 상황에서는 비동기 처리가 훨씬 효율적

파이썬에서의 비동기 처리

파이썬에서 비동기 처리는 주로 asyncio 라이브러리를 사용하여 구현된다. asyncio는 비동기 작업을 효율적으로 처리할 수 있게 돕는 파이썬의 표준 라이브러리로, 이벤트 루프 기반의 비동기 작업 관리, 비동기 함수(코루틴)의 정의와 실행을 쉽게 처리할 수 있게 해준다.

코루틴(Coroutine)과 비동기 함수

  • 코루틴은 특정 시점에 자신의 실행과 관련된 상태를 어딘가에 저장한 뒤 실행을 중단하고, 나중에 그 상태를 복원하여 실행을 재개할 수 있는 서브 루틴을 의미
  • 쉽게 말해 함수의 일종으로, 중단되었다가 필요한 시점에 재개될 수 있는 함수
  • 파이썬에서는 async 키워드로 코루틴을 정의

비동기 함수(코루틴)는 단독으로 실행되지 않고, 이벤트 루프에서 관리되어야 실행된다. 그래서 async def로 정의된 함수가 있어도, 이를 호출하는 순간 바로 실행되는 것이 아니라 이벤트 루프에 등록 되어야만 실행이 시작됨

await 키워드

  • await 키워드는 비동기 함수 내에서 비동기 작업의 결과를 기다리는 데 사용
  • await로 대기하는 동안 이벤트 루프는 다른 작업을 처리할 수 있음
  • await를 만나면 코루틴은 중단되었다가, 작업이 완료되면 다시 실행 재개

이벤트 루프

  • 비동기 처리는 이벤트 루프라는 메커니즘으로 동작
  • 이벤트 루프는 처리해야 할 작업들을 큐에 넣고, 준비가 완료된 작업을 하나씩 처리
  • 파이썬의 asyncio 모듈은 이러한 이벤트 루프를 자동으로 생성하고 관리함

비동기 코드 이벤트 루프 흐름 코드예시 

import asyncio

async def say_hello():
    print("Hello")
    await asyncio.sleep(1)  # 비동기적으로 1초 대기
    print("World")

async def main():
    await say_hello()

# 여기서 이벤트 루프 실행
asyncio.run(main())

위의 코드 예시의 흐름을 살펴보자.

  1. main()이 호출되면, async로 정의된 함수이기 때문에 바로 실행되지 않고, 코루틴 객체가 생성됨
  2. asyncio.run(main())은 내부적으로 이벤트 루프를 생성하고, main() 코루틴을 이벤트 루프에서 실행시킴
  3. main()에서 say_hello()를 호출해 또 다른 코루틴 객체가 생성되고 이벤트 루프에서 실행됨.
  4. say_hello 실행시 await을 만나서 1초 대기함. 이 때 이벤트 루프는 작업을 일시 중지하고, 다른 대기 중인 작업을 처리
  5. 1초 대기가 끝나면 이벤트 루프는 say_hello() 코루틴으로 돌아가 나머지 코드를 실행
  6. say_hello()가 완료되면 다시 main() 코루틴으로 돌아가, await say_hello() 이후의 코드를 실행

FastAPI 에서 def aync def 실행

  • def (동기 함수)
    • 일반적인 동기 함수로 선언된 함수는 FastAPI에서 외부의 스레드 풀(thread pool)에서 실행
    • 호출할 때마다 새로운 스레드가 할당되어 병렬로 실행되므로, 블로킹(blocking) 문제가 발생하지 않음
    • 스레드 생성에 따른 메모리 및 CPU 자원을 소모하는게 단점
  • async def (비동기 함수)
    • async def는 FastAPI의 내부 이벤트 루프에서 실행
    • 이는 메인 이벤트 루프에서 실행되기 때문에, 하나의 비동기 함수가 끝나기 전에 다른 비동기 함수가 실행되면 블로킹이 발생할 수 있음
    • 하나의 스레드 내에서 여러 작업이 동시에 진행되는 대신 순차적으로 진행되므로, 잘못 관리하면 성능 저하가 있을 수 있다

동기 + 비동기 혼합된 예시 코드

import asyncio
import asyncpg


def cpu_intensive_task():
    print("CPU 연산 시작")
    result = sum(i ** 2 for i in range(1000000))  # 시간이 오래 걸리는 연산
    print("CPU 연산 완료")
    return result

# 데이터베이스에서 데이터를 비동기적으로 가져오는 작업
async def fetch_from_db():
    print("DB 쿼리 시작")
    conn = await asyncpg.connect(user='user', password='password', database='testdb', host='127.0.0.1')
    result = await conn.fetch('SELECT * FROM users')  # DB 쿼리 대기
    await conn.close()
    print("DB 쿼리 완료")
    return result

# 메인 코루틴
async def main():
    db_task = asyncio.create_task(fetch_from_db()) 

    print("DB 쿼리 대기 중 CPU 연산 수행")
    cpu_result = cpu_intensive_task() 
    print(f"CPU 연산 결과: {cpu_result}")

    users = await db_task  # DB 쿼리 결과를 기다림
    print("DB 결과:", users)

# 이벤트 루프 실행
asyncio.run(main())

위의 예시는 DB 쿼리 작업을 비동기적으로 실행하고, 그 쿼리가 완료되기를 기다리는 동안 동기적인 CPU 연산 작업을 진행하는 구조를 보여준다.

  1. db_task는 비동기적으로 백그라운드에서 실행되며 main 함수는 다음 코드인 cpu 연산으로 진행된다.
  2. 동기 함수인 cpu_intensive_task이벤트 루프의 비동기 처리와 상관없이 바로 실행되며, 그동안 db_task 작업은 비동기적으로 대기 상태에 있다.
  3. CPU 연산이 완료된 후, await db_task를 만나 결과가 나왔을 경우 출력, 아닐 경우 await로 대기하다가 결과가 나오면 출력이 된다.

블로킹 문제

여기서 주의해야 할 점은 블로킹 문제이다. 위에서 봤듯이 이벤트 루프는 단일 스레드에서 처리되기 때문에, 하나의 비동기 함수가 오랜 시간 동안 실행되면, 같은 이벤트 루프 내의 다른 비동기 작업들도 그 작업이 끝나기 전까지 처리되지 못할 수 있다. 비동기 함수 내에서 await 없이 동기적인 작업이나 오래 걸리는 CPU 연산을 실행하면, 이벤트 루프는 해당 작업이 끝날 때까지 다른 비동기 작업을 처리하지 못해 블로킹이 발생한다. 이런 블로킹 문제는 스레드 풀을 사용해 해결할 수 있다.

스레드 풀 코드 예시

import asyncio
from concurrent.futures import ThreadPoolExecutor

def cpu_intensive_task():
    print("CPU 연산 시작")
    result = sum(i ** 2 for i in range(10000000000)) 
    print("CPU 연산 완료")
    return result

async def run_in_thread():
    print("스레드에서 CPU 연산 시작")
    loop = asyncio.get_running_loop()
    with ThreadPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, cpu_intensive_task)  # 스레드 풀에서 동기 작업 실행
    print(f"스레드에서 CPU 연산 결과: {result}")

async def main():
    await run_in_thread()  # 스레드에서 비동기적으로 CPU 작업 실행


asyncio.run(main())
  • cpu_intensive_task()함수는 CPU를 많이 사용하는 오래 걸리는 작업이다
  • ThreadPoolExecutor를 통해 스레드 풀을 생성하고, 그 안에서 cpu_intensive_task()스레드에서 실행
  • loop.run_in_executor()로 스레드 풀에서 동기 작업을 실행하고, 그 작업이 완료될 때까지 비동기적으로 대기하면 메인 스레드의 이벤트 루프를 차단하지 않고 CPU 작업을 처리할 수 있다.
  • 즉, 별도의 스레드 풀을 이용하여 동기 작업을 마치 비동기처럼 동작하게 하는 것

간단 정리

  • 비동기 처리가 꼭 필요하지 않다면 def (동기함수)를 사용해 매번 새로운 스레드로 처리 될 수 있게 할 수 있음
  • 네트워크 요청이나 파일 입출력 등 동시성이 중요한 상황에서는 비동기 처리를 하는게 좋으며, 이벤트 루프를 적절히 설정해서 블로킹 없이 효율적으로 작업이 일어날 수 있게 해야함
  • 비동기 작업을 하면서 자원을 효율적으로 관리하고 싶다면 스레드 풀을 사용하거나, 프로세스 자체를 여러개로 설정해 비동기 작업이 여러 프로세스에 걸쳐 병렬로 실행 될 수 있게 할 수도 있다
  • # 워커 설정해서 프로세스 개수 정할 수 있음 
    uvicorn app:app --workers 5