Python GIL과 비동기 프로그래밍: 멀티스레딩, 멀티프로세싱, 그리고 asyncio 활용법

Lucas Kim·2024년 8월 13일
post-thumbnail

이번 포스팅에서는 Python의 GIL(Global Interpreter Lock)에 대해 알아보겠습니다. GIL은 Python의 멀티 스레딩 성능에 어떤 영향을 미치는지, 그리고 GIL이 왜 존재하는지에 대해 쉽게 설명합니다. 또한, GIL의 한계와 Python에서 동시성 프로그래밍을 효율적으로 구현하기 위한 대안인 asynciomultiprocessing 모듈도 함께 살펴봅니다. 이 글을 통해 Python의 언어적 특징과 멀티 스레딩 및 비동기 프로그래밍의 이해를 높일 수 있을 것입니다.

추가로 Python 버전별로 asyncio 라이브러리의 주요 기능 변화와 그 차이도 살펴보도록 하겠습니다.

1. GIL(Global Interpreter Lock) 이란?

GIL(Global Interpreter Lock)은 Python의 CPython 인터프리터에서 사용되는 메커니즘으로, 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 제한합니다. GIL은 멀티 스레드 환경에서 데이터의 일관성을 보장하기 위해 존재하지만, 그 결과로 CPU 바운드 작업에서 멀티 스레딩의 성능이 제한될 수 있습니다. 이는 여러 스레드가 병렬로 실행되더라도, GIL 때문에 실제로는 하나의 스레드만 실행되는 상황이 발생하기 때문입니다.

Python의 특징과 GIL

  • 쉬운 동시성 처리: Python의 threading 모듈을 사용하여 멀티 스레딩을 쉽게 구현할 수 있지만, GIL이 활성화되어 있기 때문에 CPU 집약적인 작업에서는 성능이 크게 향상되지 않습니다.

  • I/O 바운드 작업: GIL은 I/O 작업에서는 상대적으로 덜 영향을 미칩니다. 이 경우, 스레드가 I/O 작업(예: 파일 읽기/쓰기, 네트워크 요청 등)을 기다리는 동안 다른 스레드가 실행될 수 있기 때문에 멀티 스레딩이 여전히 유효합니다.

  • 멀티프로세싱 대안: Python은 GIL의 제약을 우회하기 위해 multiprocessing 모듈을 제공하여, 각 프로세스가 별도의 Python 인터프리터를 사용하도록 하여 멀티코어 CPU의 병렬성 성능을 활용할 수 있게 합니다.

    (참고) Multi-processing vs Multi-threading

    • Multi-processing: 각 프로세스가 독립적인 메모리 공간을 사용하며, 서로 간섭 없이 병렬로 실행됩니다. 이는 멀티코어 CPU의 성능을 최대로 활용할 수 있지만, 프로세스 간 통신이 비용이 많이 듭니다.

    • Multi-threading: 같은 프로세스 내에서 여러 스레드가 메모리를 공유하며 실행됩니다. 메모리 공유로 인해 통신이 빠르지만, Python에서는 GIL로 인해 한 번에 하나의 스레드만 실행되므로 성능 향상이 제한적입니다.

    (참고) I/O 바운드 작업

    • I/O 바운드 작업 - 프로그램이 CPU 연산보다 입출력(I/O) 작업에 더 많은 시간을 소비하는 작업을 말합니다. 예를 들어, 파일 읽기/쓰기, 네트워크 요청 처리, 데이터베이스 접근 등이 이에 해당합니다. 이러한 작업은 CPU가 직접 연산을 수행하기보다는, 외부 자원(디스크, 네트워크 등)과의 데이터 교환을 기다리는 시간이 많기 때문에 CPU가 유휴 상태가 되기 쉽습니다.

GIL의 역할과 한계

GIL은 Python이 스레드 안전성을 쉽게 유지할 수 있도록 도와주지만, 그로 인해 Python이 CPU 집약적인 작업에서 다중 스레드를 사용하여 병렬 처리를 수행하는 데 어려움이 있습니다. 이러한 이유로, CPU 집약적인 작업에서는 multiprocessing 모듈을 사용하여 병렬 처리를 구현하는 것이 더 적합할 수 있습니다.

요약하자면, GIL은 Python의 멀티 스레드 환경에서 데이터 일관성(메모리 관리의 일관성)을 보장하지만, 그로 인해 멀티 스레딩의 성능 향상이 제한되며, 특히 CPU 바운드 작업에서 이러한 문제가 두드러집니다. I/O 바운드 작업에서는 GIL의 영향을 덜 받기 때문에 여전히 멀티 스레딩이 유효하게 사용될 수 있습니다.

Python의 Multi-Threading이란?

Python은 멀티 스레드로 동작할 수 있습니다. Python에서 멀티 스레딩은 threading 모듈을 사용하여 구현할 수 있으며, 이를 통해 여러 스레드를 생성하여 동시에 실행할 수 있습니다. 그러나 Python의 GIL(Global Interpreter Lock)로 인해 동일한 프로세스 내에서 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있습니다. 이 때문에 CPU 바운드 작업에서는 멀티 스레딩의 성능 이점이 제한적일 수 있지만, I/O 바운드 작업에서는 여전히 유용할 수 있습니다.

예시 코드

import threading

def worker(num):
    print(f'Worker: {num}')

threads = []
for i in range(5):
    t = threading.Thread(target=worker, args=(i,))
    threads.append(t)
    t.start()

위 코드에서 5개의 스레드가 동시에 실행되어 "Worker" 메시지를 출력합니다. 이처럼 Python에서 멀티 스레드를 사용할 수 있지만, GIL로 인해 그 성능은 특정 상황에 따라 제한적일 수 있습니다. CPU 집약적인 작업에서는 멀티프로세싱(multiprocessing 모듈)이 더 적합할 수 있습니다.

2. Asyncio의 필요성

asyncio를 Python에서 사용하는 이유는 Python의 언어적 특징과 비동기 프로그래밍의 요구사항을 충족시키기 위해서입니다. Python은 기본적으로 싱글 스레드로 동작하며, CPU 연산보다는 I/O 바운드 작업에서 효율성을 높이기 위해 비동기 처리가 중요합니다. asyncio는 이러한 비동기 작업을 지원하기 위해 설계된 라이브러리로, 동시에 여러 작업을 효율적으로 처리할 수 있게 해줍니다. 이를 통해 네트워크 통신, 파일 입출력, 데이터베이스 접근 등 I/O 바운드 작업에서 성능을 극대화할 수 있습니다.

Asyncio를 쓰는 주요 이유

  1. 싱글 스레드 기반의 동시성: Python의 GIL(Global Interpreter Lock) 때문에 멀티스레딩이 한계가 있는데, asyncio는 싱글 스레드 내에서 동시성을 구현하여 이 문제를 회피할 수 있습니다.

  2. I/O 바운드 작업 최적화: 웹 서버, 크롤러, 네트워크 애플리케이션 등에서 많은 I/O 작업이 필요한 경우, asyncio를 사용하면 대기 시간을 최소화하면서 더 많은 작업을 동시에 처리할 수 있습니다.

  3. 코루틴을 통한 비동기 코드 작성: asyncioawaitasync 키워드를 활용해 비동기 코드를 직관적이고 명확하게 작성할 수 있게 해줍니다. 이는 기존의 콜백 기반 비동기 처리보다 코드의 가독성과 유지보수성을 크게 향상시킵니다.

  4. 이벤트 루프 관리: asyncio는 이벤트 루프를 사용하여 여러 비동기 작업을 효율적으로 스케줄링하고 관리할 수 있습니다. 이는 여러 작업을 병렬적으로 수행할 때 효율적인 자원 관리와 성능 최적화를 가능하게 합니다.

예시 코드

import asyncio

async def fetch_data():
    print("Fetching data...")
    await asyncio.sleep(2)
    print("Data fetched")

async def main():
    await asyncio.gather(fetch_data(), fetch_data())

asyncio.run(main())

위 코드에서 fetch_data() 함수는 asyncio.sleep()을 사용하여 비동기적으로 대기합니다. asyncio.gather()는 여러 비동기 작업을 동시에 실행하며, asyncio.run()은 이벤트 루프를 관리해줍니다. 이와 같이 asyncio를 사용하면 Python에서 비동기 프로그래밍을 효율적으로 구현할 수 있습니다.

3. Python Version에 따른 Asyncio 기능 차이

Python Official 문서의 asyncio를 보면 version 별 지원가능한 기능에 대해 정리가 되어있습니다. 아래 내용은 해당 링크의 내용을 정리한 것입니다.

Python 3.7

  • asyncio.run() 도입 전후: 이전에는 비동기 함수 실행을 위해 명시적으로 이벤트 루프를 생성하고 관리해야 했습니다. asyncio.run()이 도입되면서 이 과정이 단순화되어, 루프를 자동으로 생성하고 종료하는 편리한 방법을 제공하게 되었습니다.
# 이전 방식 (3.6 및 그 이전)
import asyncio

async def main():
    print("Hello, asyncio!")

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

# 3.7 이후
asyncio.run(main())

Python 3.8

  • Task.get_coro() 도입 전후: 이전에는 Task 객체가 실행하는 코루틴을 직접 접근할 수 없었습니다. Python 3.8에서 Task.get_coro() 메서드가 추가되어, 해당 Task가 실행 중인 코루틴을 손쉽게 확인할 수 있게 되었습니다.
import asyncio

async def foo():
    await asyncio.sleep(1)

task = asyncio.create_task(foo())
print(task.get_coro())  # 3.8 이전에는 불가능했던 기능

Python 3.9

  • asyncio.to_thread() 도입 전후: 이전에는 블로킹 작업을 별도의 스레드에서 실행하려면 concurrent.futuresloop.run_in_executor를 사용해야 했습니다. asyncio.to_thread()가 도입되면서 더 직관적이고 간결한 코드로 블로킹 작업을 처리할 수 있게 되었습니다.
import asyncio
import time

def blocking_io():
    time.sleep(2)
    print("Blocking IO finished")

async def main():
    # 이전 방식
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, blocking_io)

    # 3.9 이후 간편한 방식
    await asyncio.to_thread(blocking_io)

Python 3.10

  • loop.shutdown_default_executor() 도입 전후: 이전에는 기본 실행자(executor)가 명시적으로 종료되지 않아 리소스 누수가 발생할 가능성이 있었습니다. 이 기능이 도입되면서, 이벤트 루프의 기본 실행자를 안전하게 종료할 수 있게 되어 리소스 관리가 개선되었습니다.
import asyncio

async def main():
    loop = asyncio.get_running_loop()
    # 3.10 이전에는 명시적으로 종료할 필요가 없었으나, 리소스 누수 위험 존재
    await loop.shutdown_default_executor()

asyncio.run(main())

Python 3.11

  • TaskGroup 도입 전후: 이전에는 여러 태스크를 관리할 때 개별적으로 태스크를 생성하고 관리해야 했습니다. TaskGroup이 도입되면서 태스크들을 그룹으로 묶어 구조화된 방식으로 관리할 수 있게 되었고, 태스크 간의 오류 처리 및 취소도 간편해졌습니다.
import asyncio

async def worker(name):
    await asyncio.sleep(1)
    print(f"Worker {name} done")

async def main():
    # 3.11 이전
    task1 = asyncio.create_task(worker('A'))
    task2 = asyncio.create_task(worker('B'))
    await asyncio.gather(task1, task2)

    # 3.11 이후 TaskGroup 사용
    async with asyncio.TaskGroup() as tg:
        tg.create_task(worker('A'))
        tg.create_task(worker('B'))

asyncio.run(main())

Python 3.12

  • eager_task_factory 도입 전후: 이전에는 태스크가 생성되었을 때, 이벤트 루프에 의해 실행 순서가 정해졌습니다. eager_task_factory를 사용하면 태스크가 생성될 때 즉시 실행이 가능해져, 실행 순서를 세밀하게 제어할 수 있게 되었습니다.
import asyncio

async def foo():
    print("Foo started")
    await asyncio.sleep(1)

# 3.12 이후 eager_task_factory 사용
loop = asyncio.get_event_loop()
loop.set_task_factory(lambda loop, coro: asyncio.Task(coro, eager_start=True))

asyncio.run(foo())

이처럼 Python의 asyncio 라이브러리는 버전별로 비동기 작업 관리를 더욱 간편하고 효율적으로 만들기 위해 발전해 왔습니다. 각 기능의 도입은 이전 방식의 불편함이나 한계를 극복하기 위한 개선이라고 볼 수 있습니다.

Reference

profile
AI/ML Research Engineer

0개의 댓글