파이썬 GIL 과 동시성 - 스레드 프로세스 선택 기준과 실측 비교 (파이썬 고급 18단원)
이 단원에서 배우는 것
17단원에서 로그 파일 하나를 스트리밍으로 처리하는 read_log 를 만들었다. 이제 logs/ 디렉터리에 날짜별 파일 100개가 있고, 전부 처리해 느린 요청 비율을 내야 한다. 순차로 돌리면 20분이 걸린다. 코어는 10개 놀고 있다. 여기서 "그럼 스레드로 돌리자"는 결론이 파이썬에서는 대개 틀린다. 왜 틀리는지, 언제는 맞는지를 이 단원에서 실측으로 가른다.
- GIL 이 정확히 무엇을 직렬화하고 무엇은 직렬화하지 않는지 설명한다
- CPU 바운드와 I/O 바운드를 측정으로 구분하고 스레드/프로세스를 근거 있게 고른다
concurrent.futures로 병렬화하고, 프로세스가 요구하는 비용(직렬화·재임포트)을 계산한다
왜 필요한가
동시성 도구를 잘못 고르면 코드는 정상 동작하고 결과도 맞는데 아무것도 빨라지지 않는다. 버그처럼 보이지 않으므로 원인을 찾는 데 며칠이 든다. 스레드 8개를 띄우고 CPU 사용률이 100%(1코어)에서 멈춰 있는 그래프를 보면서 "왜 800% 가 안 나오지"라고 묻게 되는 상황이 그것이다.
반대의 실수도 흔하다. HTTP 요청 500개를 프로세스 500개로 던져 메모리를 다 쓰고 서버를 재기동하는 경우다. 둘 다 원인은 하나다. 내 작업이 CPU 를 태우고 있는지, 무언가를 기다리고 있는지를 확인하지 않았다. GIL 은 이 구분에 정확히 대응한다.
문법과 예제
GIL 을 한 문장으로
GIL(Global Interpreter Lock)은 한 프로세스 안에서 파이썬 바이트코드를 실행할 수 있는 스레드를 항상 하나로 제한하는 락이다. 참조 카운트 같은 인터프리터 내부 상태를 락 없이 다루기 위한 설계다. 결과만 보면 이렇다.
- 막는 것 — 순수 파이썬 코드의 병렬 실행. 스레드를 몇 개 띄우든 파이썬 연산은 한 번에 하나씩이다.
- 막지 않는 것 1 — I/O 대기. 소켓·파일·
time.sleep처럼 OS 를 기다리는 동안 스레드는 GIL 을 놓는다. 그래서 다른 스레드가 그동안 일한다. - 막지 않는 것 2 — GIL 을 명시적으로 푸는 C 확장.
zlib,hashlib, numpy 의 큰 배열 연산 등은 C 루프에 들어가기 전에 GIL 을 놓는다. 이 구간은 CPU 작업인데도 스레드로 병렬화된다.
실측 1: CPU 작업에 스레드는 효과가 없다
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def cpu_task(seed: int) -> int:
total = 0
for i in range(8_000_000):
total += i ^ seed
return total
def io_task(_: int) -> int:
time.sleep(0.3) # 네트워크 응답을 기다린다고 치자
return 1
def bench(fn, pool_cls, n: int = 4) -> float:
started = time.perf_counter()
if pool_cls is None:
[fn(i) for i in range(n)]
else:
with pool_cls(max_workers=n) as ex:
list(ex.map(fn, range(n)))
return time.perf_counter() - started
if __name__ == "__main__":
print("cpu 순차 %.2fs / 스레드 %.2fs / 프로세스 %.2fs" % (
bench(cpu_task, None),
bench(cpu_task, ThreadPoolExecutor),
bench(cpu_task, ProcessPoolExecutor),
))
print("io 순차 %.2fs / 스레드 %.2fs" % (
bench(io_task, None),
bench(io_task, ThreadPoolExecutor),
))
10코어 맥에서 파이썬 3.13 으로 돌린 결과다.
cpu 순차 1.44s / 스레드 1.35s / 프로세스 0.43s
io 순차 1.21s / 스레드 0.31s
CPU 작업은 스레드 4개를 써도 6% 밖에 안 줄었다. 프로세스는 3.3배 빨라졌다. I/O 작업은 정반대로 스레드만으로 4배가 나왔다. 이 표 하나가 선택 기준의 전부다.
실측 2: GIL 을 놓는 C 확장에서는 스레드가 통한다
import threading
import time
import zlib
data = b"x" * (40 * 1024 * 1024)
def work() -> None:
zlib.compress(data, 1) # C 안에서 GIL 을 놓는다
started = time.perf_counter()
work(); work()
print("순차 %.2fs" % (time.perf_counter() - started))
started = time.perf_counter()
ts = [threading.Thread(target=work) for _ in range(2)]
for t in ts:
t.start()
for t in ts:
t.join()
print("스레드 2개 %.2fs" % (time.perf_counter() - started))
순차 0.11s
스레드 2개 0.05s
같은 CPU 작업인데 이번엔 스레드가 2배 빨라졌다. "CPU 바운드면 무조건 프로세스"가 아니라 "파이썬 바이트코드를 태우는 CPU 작업이면 프로세스"가 정확한 규칙이다. 압축·해시·이미지 디코딩·numpy 행렬 연산처럼 무거운 일이 C 안에서 벌어진다면 스레드로 충분하고, 프로세스의 직렬화 비용도 안 든다.
선택 기준표
| 작업 성격 | 예 | 선택 | 이유 |
|---|---|---|---|
| 네트워크·디스크 대기 | HTTP 호출, DB 쿼리, S3 업로드 | 스레드 또는 asyncio | 대기 중 GIL 을 놓는다. 프로세스는 과잉 |
| 파이썬 루프·정규식·파싱 | 로그 파싱, JSON 변환, 집계 | 프로세스 | GIL 때문에 스레드로는 안 빨라진다 |
| C 확장이 무거운 일을 함 | zlib, hashlib, numpy, Pillow | 스레드 | C 구간에서 GIL 이 풀린다 |
| 연결 수가 수천 개 | 크롤러, 헬스체크 | asyncio (19단원) | 스레드 하나당 메모리·컨텍스트 전환 비용이 크다 |
logkit 을 프로세스풀로 병렬화하기
로그 파싱은 정규식과 파이썬 루프다. 프로세스가 맞다.
from __future__ import annotations
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from logkit.parser import read_log # 17단원에서 만든 제너레이터
def file_stats(path: str, limit_ms: int = 1000) -> tuple[str, int, int]:
"""워커에서 실행된다. 큰 객체가 아니라 숫자 두 개만 돌려준다."""
total = slow = 0
for e in read_log(path):
total += 1
slow += e.ms >= limit_ms
return path, total, slow
def aggregate(paths: list[str], limit_ms: int = 1000) -> float:
total = slow = 0
workers = min(len(paths), os.cpu_count() or 1)
with ProcessPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(file_stats, p, limit_ms): p for p in paths}
for fut in as_completed(futures):
try:
_, t, s = fut.result() # 예외는 여기서 다시 던져진다
except OSError as e:
print(f"건너뜀 {futures[fut]}: {e}")
continue
total += t
slow += s
return slow / total if total else 0.0
if __name__ == "__main__": # spawn 환경에서 필수다
paths = [str(p) for p in sorted(Path("logs").glob("access-*.log"))]
print(f"{aggregate(paths):.4f}")
설계에서 중요한 두 가지가 있다.
- 워커에 파일 경로를 보내고 숫자를 받는다. 파싱된
Entry수백만 개를 돌려주면 pickle 직렬화와 파이프 전송에 파싱보다 오래 걸린다. 프로세스 간 경계에서는 데이터를 최대한 줄여서 넘긴다. as_completed로 끝나는 순서대로 받는다.ex.map은 입력 순서대로 결과를 내주므로 첫 작업이 느리면 나머지가 다 끝나도 기다린다. 순서가 필요 없으면as_completed가 낫다.
fork 와 spawn
프로세스를 만드는 방식은 플랫폼마다 다르다.
| 방식 | 기본 플랫폼 | 특징 |
|---|---|---|
fork | 리눅스 (3.13 까지) | 부모 메모리를 복사. 빠르지만 스레드·락 상태까지 복제돼 데드락 위험 |
spawn | macOS, 윈도우 | 새 인터프리터를 띄우고 메인 모듈을 다시 임포트. 느리지만 안전 |
forkserver | - | 깨끗한 서버 프로세스에서 fork. 스레드 쓰는 앱에 권장 |
import multiprocessing as mp
if __name__ == "__main__":
mp.set_start_method("spawn") # 개발/운영 환경을 일치시킨다
스레드를 쓰는 프로그램에서
fork를 하면 자식은 "락이 잠긴 상태"를 그대로 물려받고 그 락을 풀어 줄 스레드는 복사되지 않아 영구 대기에 빠진다. 파이썬 3.12 부터 이 상황에DeprecationWarning이 뜨고, 리눅스 기본값도 앞으로forkserver로 바뀐다. macOS 에서 잘 돌던 코드가 리눅스에서만 멈춘다면 이 문제일 가능성이 높으니, 지금 작성하는 코드는 시작 방식을 명시하는 편이 낫다.
Executor 의 나머지 API 와 initializer 인자는 concurrent.futures 공식 문서에 정리돼 있다.
실무에서 자주 틀리는 것
1. "GIL 이 있으니 스레드에서는 락이 필요 없다"
가장 비싼 오해다. 간단한 테스트가 오해를 굳혀 준다.
import threading
stats = {"hit": 0}
def bump():
for _ in range(200_000):
stats["hit"] += 1 # 락 없음
ts = [threading.Thread(target=bump) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print(stats["hit"]) # 800000 — 맞게 나온다
맞게 나오는 이유는 안전해서가 아니라, 이 루프 안에 함수 호출이 하나도 없어서 인터프리터가 스레드를 바꿀 틈을 잡지 않기 때문이다. 실무 코드에는 함수 호출이 반드시 들어간다. 하나만 끼워 보자.
import sys, threading
sys.setswitchinterval(1e-6) # 재현 확률을 높인다. 없어도 언젠간 터진다
def weight() -> int:
return 1
stats = {}
def bump():
for _ in range(100_000):
cur = stats.get("hit", 0)
stats["hit"] = cur + weight()
ts = [threading.Thread(target=bump) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print("기대 400000, 실제", stats["hit"])
기대 400000, 실제 125373
70% 가 사라졌다. GIL 은 한 줄의 파이썬 문장이 원자적이라고 보장하지 않는다. 보장하는 것은 인터프리터 내부 자료구조가 깨지지 않는다는 것뿐이다. 읽고-고쳐-쓰기가 있으면 threading.Lock 을 쓰거나, 각 스레드가 자기 부분합만 계산하고 마지막에 합치는 구조로 바꾼다. 후자가 더 빠르고 더 안전하다.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as ex:
partials = list(ex.map(count_one_file, paths)) # 공유 상태 없음
total = sum(partials)
2. if __name__ == "__main__": 가 없어서 무한 증식한다
spawn 방식에서는 자식 프로세스가 메인 모듈을 다시 임포트한다. 모듈 최상단에서 풀을 만들었다면 자식이 또 풀을 만들고, 그 손자가 또 만든다. 실제로 print(mp.get_start_method()) 를 모듈 최상단에 두면 워커 수만큼 더 찍히는 것으로 확인된다. 파이썬이 대개는 RuntimeError 로 막아 주지만, 막히기 전에 최상단의 무거운 초기화 코드(설정 로드, DB 연결, 모델 로딩)는 이미 워커마다 한 번씩 실행된 뒤다. 프로세스풀을 쓰는 스크립트의 실행 코드는 반드시 main() 안으로 넣는다.
3. 워커에 못 보내는 것을 보낸다
with ProcessPoolExecutor() as ex:
list(ex.map(lambda x: x * 2, range(4)))
PicklingError: Can't pickle <function <lambda> at 0x105791260>
람다, 지역 함수, 열린 파일 객체, DB 커넥션, 락은 pickle 이 안 되므로 프로세스 경계를 넘지 못한다. 모듈 최상위 함수로 빼고, 커넥션은 워커 안에서 새로 만든다. 커넥션을 자식에게 물려주는 것(fork 로 상속되는 경우)은 더 나쁘다. 소켓 하나를 여러 프로세스가 동시에 쓰면서 응답이 뒤섞인다.
4. submit 만 하고 result() 를 안 불러 예외를 잃는다
with ThreadPoolExecutor(max_workers=2) as ex:
futures = [ex.submit(upload, p) for p in paths]
print("전송 완료") # 전부 실패했어도 이 줄이 찍힌다
워커에서 난 예외는 Future 안에 담겨 있다가 result() 나 exception() 을 부를 때 재현된다. 아무도 안 부르면 조용히 사라진다. with 블록 종료(shutdown)는 작업이 끝나기를 기다릴 뿐 실패를 알려 주지 않는다. 반드시 결과를 회수하고, 실패를 세어서 종료 코드에 반영한다.
failed = 0
for fut in as_completed(futures):
try:
fut.result()
except Exception:
logging.exception("업로드 실패")
failed += 1
if failed:
raise SystemExit(1)
5. 워커 수를 코어 수로 고정한다
CPU 바운드라면 os.cpu_count() 가 출발점이지만, 컨테이너에서는 이 값이 호스트의 코어 수다. cgroup 으로 1 CPU 만 할당된 파드에서 워커 64개를 띄우면 컨텍스트 전환만 하다 끝난다. 3.13 부터는 os.process_cpu_count() 로 실제 할당량을 볼 수 있고, 그 이전 버전에서는 len(os.sched_getaffinity(0))(리눅스)를 쓰거나 환경변수로 명시하는 편이 안전하다.
스스로 확인하기
- 1000개 URL 에 HTTP HEAD 요청을 보낸다. 요청당 평균 200ms 대기, 응답 파싱은 0.1ms 다. 스레드풀 워커를 몇 개로 잡는 것이 합리적인가? CPU 코어 수와 관계가 있는가?
- 다음 코드는 4개 프로세스로 로그를 파싱하는데 순차 실행보다 느리다. 이유는?
def parse_file(path): return list(read_log(path)) # Entry 수백만 개 with ProcessPoolExecutor(4) as ex: all_entries = [] for entries in ex.map(parse_file, paths): all_entries.extend(entries) - numpy 로 큰 행렬 곱을 4개 동시에 해야 한다. 스레드와 프로세스 중 무엇을 고르고, 그 판단이 맞는지 어떻게 확인하겠는가?
정답
- CPU 코어 수와 거의 무관하다. 총 CPU 작업은 1000 × 0.1ms = 0.1초뿐이고 나머지는 전부 대기다. 워커 수는 상대 서버가 견디는 동시 요청 수와 스레드당 메모리(기본 스택 8MB, 실제 사용은 훨씬 적음)로 정한다. 50~100 정도가 현실적이고, 그 이상이면 스레드보다 asyncio 가 낫다. 무엇보다 상대 서버에 대한 예의로 동시 요청 수를 제한해야 한다.
- 워커가 만든
Entry수백만 개를 전부 pickle 로 직렬화해 파이프로 부모에 보내고, 부모가 다시 역직렬화한다. 이 왕복 비용이 파싱 비용보다 크고, 게다가 역직렬화는 부모 프로세스 한 곳에서 순차로 일어나므로 병목이 된다. 워커 안에서 집계까지 끝내고 숫자만 돌려주도록 고쳐야 한다. - 스레드를 먼저 시도한다. numpy 의
@연산은 BLAS 호출로 들어가면서 GIL 을 놓기 때문이다. 확인 방법은 측정이다. 순차 시간과 스레드 4개 시간을 재서 3배 이상 줄면 스레드로 확정, 거의 안 줄면 그 연산이 GIL 을 안 놓는다는 뜻이므로 프로세스로 바꾼다. 단 BLAS 자체가 이미 여러 코어를 쓰고 있을 수 있으니OMP_NUM_THREADS=1로 고정하고 비교해야 결과가 해석된다.
다음 단원에서는 연결 수가 수천 개로 늘어나는 경우를 다룬다. 스레드 수천 개는 답이 아니고, 그 자리를 asyncio 가 채운다.