Python GIL과 동시성 모델 (1) — Thread냐 Process냐
Published:
Python GIL 시리즈
- GIL과 동시성 모델 ← 현재
- 프로덕션 케이스 스터디
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 쓰면 되는 거 아냐?” 싶어지는데 그렇지 않다. 둘의 차이는 실행 모델보다 메모리 모델에서 더 크게 벌어진다.
| 구분 | Thread | Process |
|---|---|---|
| 메모리 공간 | 프로세스 내부 공유 | 완전히 분리 |
| 데이터 전달 | 참조 (거의 무료) | pickle 직렬화 (비쌈) |
| 생성 비용 | 1ms 안쪽 | 100ms 전후 |
| 격리 | 없음 (한 놈 죽으면 같이 죽음) | 완전 격리 |
| 순수 Python CPU | GIL로 순차 | 진짜 병렬 |
| 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
이 구조가 깔끔한 이유:
- 요청끼리는 어차피 독립적이다. 유저 A의 이미지 분석과 유저 B의 이미지 분석이 서로 뭘 공유할 일이 거의 없다.
- 크래시 격리가 공짜로 된다. Worker 1에서 세그폴트가 나도 Worker 2,3,4는 멀쩡하다. Uvicorn이 죽은 워커를 재시작해준다.
- 설정이 플래그 하나다. 애플리케이션 코드에 동시성 로직이 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개까지 가게 됐는지 — 코드와 함께 본다.
