Python/core

[Python] 235MB vs 30MB: 왜 Threading은 1만 개 처리에 실패했나?

baecode 2025. 12. 22. 16:39
반응형

0. 들어가기 전에: Python의 GIL과 I/O의 관계

본격적인 실험에 앞서, 우리가 반드시 알고 넘어가야 할 Python의 중요한 특성이 있습니다.

바로 GIL (Global Interpreter Lock)입니다.

🤔 왜 Python은 하필 GIL을 선택했을까?

이것은 Python의 메모리 관리 안전성을 위한 필연적인 선택이었습니다.

 

1. 참조 카운팅(Reference Counting)과 경쟁 상태(Race Condition) Python(정확히는 CPython)은 객체의 생명주기를 관리하기 위해 참조 카운팅 방식을 사용합니다. 어떤 객체가 몇 번 참조되고 있는지 숫자를 세다가, 0이 되면 메모리에서 해제하는 방식입니다.

만약 GIL이 없다면 멀티 스레드 환경에서 치명적인 문제가 발생합니다.

  • 시나리오: 스레드 A와 B가 동시에 동일한 변수 x를 참조하려고 할 때,
  • 문제: 두 스레드가 동시에 참조 카운트 변수를 수정하려다 충돌(Race Condition)이 일어납니다.
  • 결과: 카운트가 꼬여서 메모리 누수(Leak)가 발생하거나, 반대로 아직 쓰고 있는 객체를 삭제하여 프로그램이 뻗어버릴 수 있습니다.

2. 락(Lock)의 딜레마: 1개냐, 수억 개냐? 이를 막으려면 모든 객체마다 개별적으로 락(Mutex)을 걸어야 하는데, 여기엔 치명적인 단점이 있습니다.

  • 성능 저하: 락을 걸고 푸는 비용 때문에 단일 스레드 성능조차 느려집니다.
  • 데드락(Deadlock): 수많은 락이 얽히면서 서로를 기다리는 교착 상태에 빠질 위험이 큽니다.

많은 분들이 "파이썬은 GIL 때문에 멀티스레딩이 의미 없다"라고 알고 계십니다. 반은 맞고 반은 틀린 말입니다.

  1. 기본 원칙 (CPU 작업): Python 인터프리터는 한 번에 하나의 스레드만 바이트코드를 실행하도록 잠금(Lock)을 겁니다. 그래서 아무리 스레드를 많이 만들어도 CPU를 사용하는 계산 작업은 병렬로 처리되지 않습니다.
  2. 예외 (I/O 작업): 하지만 파일 읽기/쓰기, 네트워크 요청, time.sleep() 같은 I/O 작업(Input/Output)을 만나면 Python은 "어차피 대기해야 하니 다른 스레드에게 순서를 넘기자"며 GIL을 잠시 해제(Release)합니다.

즉, 우리가 하려는 실험(I/O 대기 위주의 작업)에서는 GIL이 해제되기 때문에 threading 모듈을 써도 동시성 효과를 볼 수 있습니다. 다만, 오늘 실험의 핵심은 "성능" 이 아니라 "OS 리소스 한계(메모리, 스레드 개수)에 부딪히느냐 아니냐" 입니다.

이 점을 염두에 두고, 스레드 1만 개의 운명을 확인해 봅시다.

1. 프롤로그: "스레드 1만 개, 가능할까?"

파이썬으로 동시성 프로그래밍(Concurrency)을 다루다 보면, 우리는 본능적으로 threading 모듈을 떠올립니다. 하지만 "동시 접속자 1만 명" 같은 상황에서도 과연 스레드가 정답일까요?

오늘은 파이썬의 ThreadingAsyncio가 극한의 상황(스레드/코루틴 1만 개 생성)에서 어떻게 다르게 동작하는지, 직접 코드로 부딪혀보고 깨진 결과를 공유합니다. 결론부터 말하자면, Threading은 실패했고 Asyncio는 가볍게 성공했습니다.


2. 기본 개념: Threading vs Asyncio

실험에 앞서, 왜 이런 차이가 발생하는지 간단히 짚고 넘어갑시다.

Threading (멀티 스레딩)

  • 작동 방식: 운영체제(OS)가 직접 관리하는 여러 스레드를 생성하여 병렬(처럼 보이게) 처리합니다.
  • 특징:
    • I/O 바운드 작업 시 GIL(Global Interpreter Lock)이 해제되어 성능 향상을 기대할 수 있습니다.
    • 하지만 Context Switching(문맥 교환) 비용이 발생하며, 각 스레드마다 독립적인 스택 메모리를 할당받습니다.

Asyncio (비동기 I/O)

  • 작동 방식: 단일 스레드 내에서 이벤트 루프(Event Loop)를 사용하여 작업을 전환합니다.
  • 특징:
    • OS가 아닌 Python 런타임이 관리합니다.
    • Context Switching 비용이 거의 없고, 스레드 대비 메모리 사용량이 극도로 적습니다.

핵심: 두 방식 모두 '동시성(Concurrency)'을 위한 것이지, CPU 연산을 위한 '병렬성(Parallelism)'을 위한 것은 아닙니다. (CPU 바운드 작업은 multiprocessing을 써야 합니다.)


3. 실험 코드: 1만 개의 작업에 도전

동일한 환경에서 두 가지 방식을 테스트했습니다.

  • 목표: 10,000개의 작업을 생성하여 각각 2초간 대기(Sleep)
  • 측정: 메모리 사용량(RSS) 및 정상 완료 여부
Python
 
import threading
import asyncio
import time
import os
import psutil
import sys
from threading import Thread

# 테스트할 작업의 수
test_size = 10_000
process = psutil.Process(os.getpid())

def get_memory_usage():
    """현재 프로세스의 메모리 사용량을 반환 (Byte -> MB 변환 필요)"""
    return process.memory_info().rss

def io_bound_task(id, duration):
    """Threading용 Blocking I/O 작업"""
    time.sleep(duration)
    if id == test_size - 1:
        print(f"Thread {id}: Finished I/O bound task")

def thread_example():
    threads = []
    print("Starting Threading Example...")
    for i in range(test_size):
        # OS 스레드 생성 (메모리 할당 발생)
        thread = Thread(target=io_bound_task, args=(i, 2))
        threads.append(thread)
        thread.start()
    
    for thread in threads:
        thread.join()
    print("All threads finished")

async def async_io_bound_task(id, duration):
    """Asyncio용 Non-blocking I/O 작업"""
    await asyncio.sleep(duration)
    if id == test_size - 1:
        print(f"Coroutine {id}: Finished I/O bound task")

async def asyncio_example():
    print("Starting Asyncio Example...")
    tasks = []
    for i in range(test_size):
        # 코루틴 객체 생성 (가벼움)
        tasks.append(async_io_bound_task(i, 2))

    await asyncio.gather(*tasks)

if __name__ == "__main__":
    # 1. Threading 테스트
    print("=== [Threading Example] ===")
    print(f"Before Memory: {get_memory_usage() / (1024 * 1024):.2f} MB")
    try:
        thread_example()
    except RuntimeError as re:
        print(f"🔴 Runtime Error: {re}")
        print(f"After Memory: {get_memory_usage() / (1024 * 1024):.2f} MB")
        # 스레드 생성 실패 시, 여기서 멈춤 (Asyncio 테스트를 위해선 주석 처리 필요할 수 있음)
        # sys.exit(0) 

    print("-" * 30)

    # 2. Asyncio 테스트 (Threading 주석 처리 후 실행 권장)
    # (실제 환경에서는 위에서 스레드가 죽으면 프로세스가 불안정할 수 있음)
    print("=== [Asyncio Example] ===")
    print(f"Before Memory: {get_memory_usage() / (1024 * 1024):.2f} MB")
    try:
        asyncio.run(asyncio_example())
    except RuntimeError as re:
        print(f"Runtime Error: {re}")
        sys.exit(0)
    print(f"After Memory: {get_memory_usage() / (1024 * 1024):.2f} MB")
    print("All coroutines finished")

4. 충격적인 결과 분석

테스트 결과는 극명하게 갈렸습니다. (OS 환경에 따라 수치는 다를 수 있습니다.)

A. Threading의 패배

Threading Example:
Before Memory Usage: 21.52 MB
Runtime Error: can't start new thread
After Memory Usage: 234.91 MB
  • 결과: 약 6,000~8,000개 사이에서 Runtime Error: can't start new thread 발생.
  • 메모리: 21MB → 235MB (약 10배 폭증)
  • 원인:
    1. OS 리소스 제한: 운영체제는 프로세스당 생성할 수 있는 스레드 개수(ulimit)를 제한합니다.
    2. 스택 메모리 오버헤드: 스레드는 생성될 때마다 고유의 스택 메모리(보통 1MB~8MB)를 예약합니다. 아무 일도 안 해도 메모리를 잡아먹습니다.

B. Asyncio의 압승

Asyncio Example:
Before Memory Usage: 21.67 MB
Coroutine 9999: Finished I/O bound task
All coroutines finished
After Memory Usage: 30.28 MB
  • 결과: 10,000개 작업 성공
  • 메모리: 21MB → 30.28 MB (겨우 9MB 증가)
  • 비결:
    1. User-Level Threading: 코루틴은 OS 스레드가 아니라, 파이썬 힙 메모리에 있는 작은 객체(Object)일 뿐입니다.
    2. No Stack Overhead: OS 스택을 할당받지 않으므로, 1만 개를 만들어도 메모리 증가량이 미미합니다.

5. 결론: 무엇을 써야 할까?

실험 결과가 말해주듯, 대규모 I/O 작업(웹 서버, 크롤러, 채팅 서버 등) 에서는 asyncio가 압도적으로 유리합니다.

  1. Threading: 소규모의 백그라운드 작업이나, 기존 라이브러리가 비동기를 지원하지 않을 때 제한적으로 사용하세요. (단, 스레드 풀 ThreadPoolExecutor 사용 권장)
  2. Asyncio: 높은 동시성이 요구되는 네트워크 프로그래밍에서는 선택이 아닌 필수입니다.

⚠️ 주의할 점 

위 실험에서는 sleep을 사용했기에 asyncio가 제한 없이 작동하는 것처럼 보였습니다. 하지만 실제 네트워크 I/O 상황에서는 OS의 파일 디스크립터(File Descriptor) 제한에 걸리면 asyncio 역시 Too many open files 에러와 함께 실패하게 됩니다. 즉, 진정한 대용량 처리를 위해서는 코드 최적화(Asyncio)뿐만 아니라 OS 튜닝(ulimit)이 반드시 동반되어야 합니다.

교훈: "스레드는 공짜가 아니다. 아주 비싼 자원이다."


반응형

'Python > core' 카테고리의 다른 글

Pydantic, Dataclass, TypedDict는 언제 쓰는 게 좋을까?  (1) 2025.03.27
__contains__  (0) 2025.01.25