Python GIL과 동시성 모델 (1) — Thread냐 Process냐

Published:

Python GIL 시리즈

  1. GIL과 동시성 모델 ← 현재
  2. 프로덕션 케이스 스터디

Python으로 두 개의 API 서비스를 만들면서 동시성 모델을 서로 다르게 골랐다. 하나는 Uvicorn Workers만 썼고, 다른 하나는 Uvicorn Workers + ThreadPoolExecutor 3개를 썼다. 셋째 선택지인 ProcessPoolExecutor는 결국 한 번도 안 썼다.

왜 같은 Python 웹 API인데 이렇게 달랐나. 결론부터 말하면 GIL이 해제되는 구간이 다르고, 공유할 메모리 크기가 다르고, 요청이 독립적인지 아닌지가 다르기 때문이다. 이 글은 그 기준을 세우는 이야기. 실제 프로덕트 코드는 Part 2에서 다룬다.

GIL — 하나의 인터프리터, 하나의 스레드

CPython(우리가 python3 하면 돌아가는 그 인터프리터)은 하나의 프로세스 안에서 동시에 단 하나의 스레드만 Python 바이트코드를 실행하도록 만들어져 있다. 이게 Global Interpreter Lock, GIL이다.

import threading

counter = 0

def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1   # 순수 Python 바이트코드 → GIL 필요

threads = [threading.Thread(target=increment) for _ in range(4)]

스레드가 4개지만 Python 코드 구간에서는 한 번에 하나만 돈다. 순수 Python CPU 작업을 멀티스레드로 묶어도 전혀 빨라지지 않는다. 오히려 스레드 전환 비용 때문에 살짝 더 느려질 수도 있다. 처음 이걸 벤치 돌려서 확인했을 때 좀 허망했다.

GIL이 해제되는 구간

그런데 현실의 Python 코드는 대부분 C로 짠 라이브러리를 얇게 감싼 바인딩이다. OpenCV, NumPy, Pandas, scikit-learn, Pillow, requests, psycopg — 전부 그렇다. 그리고 이 C 레벨 코드 안에 있는 동안은 GIL이 해제된다.

import cv2
import numpy as np

# OpenCV (C++) — GIL 해제
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

# NumPy (C) — GIL 해제
result = np.mean(large_array, axis=0)

# Prophet (Stan C++) — fit 내부에서 GIL 해제
model.fit(df)

이게 핵심이다. Python으로 병렬 I/O를 하면 스레드들이 각자 recv() / read() 시스템 콜 안에서 대기하고, 그 사이 다른 스레드가 Python 코드를 돌릴 수 있다. 마찬가지로 NumPy 연산이 C 레벨에서 행렬을 돌리는 동안 다른 스레드의 Python 코드가 같이 돈다.

즉:

  • 순수 Python 루프 → 스레드 여럿이라도 순차 실행 (GIL에 묶임)
  • C 라이브러리가 열일하는 구간 → 스레드 여럿이 진짜 병렬

라이브러리가 GIL을 제대로 놓는지 아닌지는 생각보다 문서화가 빈약한데, 기본 감은 이렇다: “Python 루프가 없는 컴퓨팅은 거의 다 GIL을 놓는다”. 안에서 Python 콜백을 부르는 라이브러리가 아니면 보통 괜찮다.

Thread vs Process — 메모리 모델이 먼저다

GIL 얘기만 하면 “그럼 무조건 Process 쓰면 되는 거 아냐?” 싶어지는데 그렇지 않다. 둘의 차이는 실행 모델보다 메모리 모델에서 더 크게 벌어진다.

구분ThreadProcess
메모리 공간프로세스 내부 공유완전히 분리
데이터 전달참조 (거의 무료)pickle 직렬화 (비쌈)
생성 비용1ms 안쪽100ms 전후
격리없음 (한 놈 죽으면 같이 죽음)완전 격리
순수 Python CPUGIL로 순차진짜 병렬
C 라이브러리 CPU병렬 OK병렬 OK (메모리는 더 쓰지만)

실제로 내가 Prophet 예측에서 Thread를 고른 이유는 GIL 얘기 때문이 아니라 메모리 때문이었다.

100MB CSV 한 개를 10개 카테고리가 동시에 참조한다면

Thread:  100MB (공유) + 카테고리별 slice 5MB × 10  ≈  150MB
Process: 100MB × 8 (각 워커가 pickle로 복사)        ≈  800MB

Process 방식은 모든 데이터를 워커마다 복사한다. 100MB 넘어가면 이게 금방 기가 단위로 뛴다. 게다가 pickle.dumps / pickle.loads 시간도 추가된다. 작업이 아주 짧으면 pickle 비용이 실제 계산보다 더 오래 걸리는 웃픈 상황이 된다.

반대로 Process가 이기는 지점도 분명하다: 순수 Python CPU 작업. C 라이브러리가 GIL을 놔주는 게 없고, 스레드로 묶어봐야 순차 실행이라면, 프로세스를 쪼개서 GIL 자체를 복제하는 수밖에 없다.

Uvicorn Workers는 또 뭐가 다른가

FastAPI/Uvicorn을 쓰면 선택지가 하나 더 늘어난다.

uvicorn app:app --workers 4

이건 ProcessPoolExecutor랑 성격이 비슷한데 웹 서버 레벨에서 프로세스를 쪼갠다는 게 다르다. 요청이 Master 프로세스에 들어오면 4개 Worker 중 하나로 라우팅되고, 각 Worker는 독립된 파이썬 인터프리터(즉 독립된 GIL)를 들고 있다.

HTTP Request → Uvicorn Master
                 ├─ Worker 1 (Process) → 요청 A
                 ├─ Worker 2 (Process) → 요청 B
                 ├─ Worker 3 (Process) → 요청 C
                 └─ Worker 4 (Process) → 요청 D

이 구조가 깔끔한 이유:

  1. 요청끼리는 어차피 독립적이다. 유저 A의 이미지 분석과 유저 B의 이미지 분석이 서로 뭘 공유할 일이 거의 없다.
  2. 크래시 격리가 공짜로 된다. Worker 1에서 세그폴트가 나도 Worker 2,3,4는 멀쩡하다. Uvicorn이 죽은 워커를 재시작해준다.
  3. 설정이 플래그 하나다. 애플리케이션 코드에 동시성 로직이 0줄이다.

그래서 “요청 단위 병렬화만 필요하면 Uvicorn Workers로 끝내고, 요청 내부에서 또 병렬이 필요하면 Thread/Process를 쓴다”가 기본 공식이 된다.

의사결정 프레임워크

지금까지 깔아둔 걸 한 장으로 압축하면 이렇다.

작업의 성격은?
│
├─ 독립적인 HTTP 요청들
│   └─> Uvicorn Workers (끝)
│
├─ I/O 집약적 (네트워크, 파일, DB)
│   └─> ThreadPoolExecutor
│
└─ CPU 집약적
    │
    ├─ C 라이브러리가 GIL을 놓는가? (OpenCV/NumPy/Prophet/…)
    │   │
    │   ├─ Yes + 공유할 큰 데이터 있음  → ThreadPoolExecutor
    │   └─ Yes + 데이터 작음            → Thread/Process 둘 다 OK
    │
    └─ 순수 Python 루프
        │
        ├─ 작업이 충분히 무거움 (>1초)  → ProcessPoolExecutor
        └─ 가벼움 (<100ms)              → 병렬화하지 마라 (오버헤드만 커짐)

체크리스트 형태로도 적어두면:

ProcessPoolExecutor

  • 순수 Python으로 도는 CPU 작업
  • 작업 단위가 충분히 무거움 (pickle 비용 아무리 커도 무시할 만큼)
  • 공유 데이터가 작음
  • 혹은, 완전한 격리가 꼭 필요함

ThreadPoolExecutor

  • I/O 바운드
  • 또는, GIL을 놓는 C 라이브러리(OpenCV/NumPy/Prophet/…)가 중심인 CPU 작업
  • 큰 데이터를 여러 작업이 공유

Uvicorn Workers

  • 웹 서버
  • HTTP 요청이 독립적
  • 프로세스 격리를 공짜로 얻고 싶음

자주 밟는 지뢰

1. 요청마다 Executor를 새로 만들기

# 나쁜 예
async def endpoint():
    with ThreadPoolExecutor(max_workers=8) as executor:
        ...

동시 요청 100개면 스레드가 800개 뜬다. Executor는 서비스 객체에 한 번만 붙여야 한다.

class Service:
    def __init__(self):
        self.executor = ThreadPoolExecutor(max_workers=8)

    async def endpoint(self):
        return await loop.run_in_executor(self.executor, task)

2. 순수 Python CPU에 Thread 붙이기

def pure_python_task(n):
    return sum(i * i for i in range(n))

# Thread 4개 써봐야 GIL로 순차 실행 — 체감 속도 동일

이럴 때는 둘 중 하나다. (a) Process로 옮긴다. (b) 그냥 NumPy로 바꾼다.

def numpy_task(n):
    arr = np.arange(n)
    return np.sum(arr * arr)  # GIL 해제

대부분의 경우 (b)가 훨씬 낫다. 알고리즘이 벡터화 가능하면 프로세스 관리 오버헤드 없이 GIL 문제까지 풀린다.

3. CPU 4코어에 워커 32개

max_workers 크게 잡는다고 빨라지지 않는다. CPU 바운드 작업이면 코어 수 근처, I/O 바운드면 코어 수 × 2~5 정도가 보통 적정선. 과하면 컨텍스트 스위칭만 늘어나고 메모리만 먹는다.

workers = min(os.cpu_count() or 4, 8)

CPU 4코어에서 max_workers=32로 Prophet을 돌려봤을 때, 8개일 때보다 오히려 느려지는 걸 확인한 적 있다. 벤치 없이 숫자 올리는 건 거의 항상 손해.

다음 글

이론은 여기까지. Part 2에서는 이 프레임워크가 실제 두 프로덕션 서비스에서 어떻게 적용됐는지 — 왜 이미지 분석은 Uvicorn Workers로 끝냈고, 왜 Prophet은 ThreadPool 3개까지 가게 됐는지 — 코드와 함께 본다.