Devin.KR
로그인

파이썬 GIL 과 동시성 - 스레드 프로세스 선택 기준과 실측 비교 (파이썬 고급 18단원)

개발자 조회 1

이 단원에서 배우는 것

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 까지)부모 메모리를 복사. 빠르지만 스레드·락 상태까지 복제돼 데드락 위험
spawnmacOS, 윈도우새 인터프리터를 띄우고 메인 모듈을 다시 임포트. 느리지만 안전
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))(리눅스)를 쓰거나 환경변수로 명시하는 편이 안전하다.

스스로 확인하기

  1. 1000개 URL 에 HTTP HEAD 요청을 보낸다. 요청당 평균 200ms 대기, 응답 파싱은 0.1ms 다. 스레드풀 워커를 몇 개로 잡는 것이 합리적인가? CPU 코어 수와 관계가 있는가?
  2. 다음 코드는 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)
  3. numpy 로 큰 행렬 곱을 4개 동시에 해야 한다. 스레드와 프로세스 중 무엇을 고르고, 그 판단이 맞는지 어떻게 확인하겠는가?

정답

  1. CPU 코어 수와 거의 무관하다. 총 CPU 작업은 1000 × 0.1ms = 0.1초뿐이고 나머지는 전부 대기다. 워커 수는 상대 서버가 견디는 동시 요청 수와 스레드당 메모리(기본 스택 8MB, 실제 사용은 훨씬 적음)로 정한다. 50~100 정도가 현실적이고, 그 이상이면 스레드보다 asyncio 가 낫다. 무엇보다 상대 서버에 대한 예의로 동시 요청 수를 제한해야 한다.
  2. 워커가 만든 Entry 수백만 개를 전부 pickle 로 직렬화해 파이프로 부모에 보내고, 부모가 다시 역직렬화한다. 이 왕복 비용이 파싱 비용보다 크고, 게다가 역직렬화는 부모 프로세스 한 곳에서 순차로 일어나므로 병목이 된다. 워커 안에서 집계까지 끝내고 숫자만 돌려주도록 고쳐야 한다.
  3. 스레드를 먼저 시도한다. numpy 의 @ 연산은 BLAS 호출로 들어가면서 GIL 을 놓기 때문이다. 확인 방법은 측정이다. 순차 시간과 스레드 4개 시간을 재서 3배 이상 줄면 스레드로 확정, 거의 안 줄면 그 연산이 GIL 을 안 놓는다는 뜻이므로 프로세스로 바꾼다. 단 BLAS 자체가 이미 여러 코어를 쓰고 있을 수 있으니 OMP_NUM_THREADS=1 로 고정하고 비교해야 결과가 해석된다.

다음 단원에서는 연결 수가 수천 개로 늘어나는 경우를 다룬다. 스레드 수천 개는 답이 아니고, 그 자리를 asyncio 가 채운다.