모던 파이썬 고급 #5 GIL과 동시성 — threading vs multiprocessing vs asyncio

7 분 소요

#4 비동기 깊이에서 “CPU 바운드는 asyncio로 해결되지 않는다” 고 짧게 봤습니다. 이번 글이 그 주제입니다. GIL의 정체, threading/multiprocessing/asyncio 세 도구의 분담, 그리고 Python 3.13~3.14가 가져온 free-threaded 빌드 까지를 정리합니다.

GIL — Global Interpreter Lock #

CPython (표준 파이썬 구현)에는 GIL이라는 전역 락이 있습니다. 한 번에 하나의 스레드만 파이썬 바이트코드를 실행할 수 있습니다.

왜 있나 #

CPython 내부의 객체 참조 카운팅을 안전하게 만들기 위해서입니다. 락 없이 동시에 객체 참조를 만지면 카운팅이 어긋나 메모리가 망가집니다. 간단함 + 단일 스레드 성능 + C 확장 호환을 위해 도입됐고 30년 넘게 유지됐습니다.

결과 — CPU 바운드 멀티스레딩이 무의미 #

🚫 멀티 스레드인데 안 빨라짐
import threading

def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i ** 2
    return total

threads = [threading.Thread(target=cpu_heavy, args=(10_000_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

스레드 4개로 같은 일을 4번 한다고 4배 빠르지 않습니다. GIL 때문에 사실상 직렬로 실행됩니다. CPU 코어가 8개 있어도 1개만 쓰고 있는 것입니다.

GIL이 풀리는 시점 — I/O #

다행히 I/O 동작 중에는 GIL이 풀립니다. socket.recv, time.sleep, 파일 읽기, DB 쿼리 등은 다른 스레드가 동시에 진행할 수 있습니다. 그래서 I/O 바운드 멀티스레딩은 의미 있고, CPU 바운드는 의미 없습니다.

NumPy, Pandas 같은 C 확장도 무거운 연산 중에는 GIL을 푸는 경우가 많아 수치 계산은 어느 정도 멀티스레드 효과가 있습니다. 라이브러리마다 다릅니다.

세 도구의 분담 #

도구어울리는 경우코어 활용
asyncioI/O 바운드, 동시 N 천 ~ N만1
threadingI/O 바운드, 동기 라이브러리, 적은 동시성1
multiprocessingCPU 바운드N
concurrent.futures두 모드 통합 인터페이스모드에 따라

threading — 동기 코드의 동시성 #

threading 기본
import threading

def fetch(url):
    response = requests.get(url)
    return response.text

threads = []
for url in urls:
    t = threading.Thread(target=fetch, args=(url,))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

장점:

  • 동기 라이브러리를 그대로 사용
  • 기존 코드 변경 최소
  • I/O 바운드는 충분히 빠름

단점:

  • 동시성이 N천 단위로 가면 스레드 자체 비용 (메모리, 컨텍스트 스위칭)
  • 락/공유 상태가 잦으면 디버그 어려움
  • CPU 바운드는 GIL 때문에 무의미

Lock, RLock, Semaphore, Event #

공유 상태 보호
counter = 0
lock = threading.Lock()

def increment():
    global counter
    with lock:
        counter += 1

+=가 원자적이지 않습니다 (load → add → store). 락 없이는 race condition이 일어납니다. 비동기에는 이 문제가 없는데 (한 번에 한 코루틴), 스레드에는 있습니다.

concurrent.futures.ThreadPoolExecutor — 더 편한 인터페이스 #

ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(fetch, urls))

직접 Thread 객체를 만들 일은 거의 없습니다. ThreadPoolExecutor가 표준 답입니다.

multiprocessing — 진짜 병렬 #

CPU 바운드는 별도 프로세스로 보냅니다. 각 프로세스가 자기 GIL을 가지므로 진짜 병렬 실행이 됩니다.

multiprocessing 기본
from concurrent.futures import ProcessPoolExecutor

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

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=8) as pool:
        results = list(pool.map(cpu_heavy, [10**7] * 8))

8개의 프로세스가 8개 코어를 동시에 씁니다. 진짜 8배 가까운 속도가 나옵니다.

비용 — 프로세스 생성과 IPC #

  • 프로세스 생성이 스레드보다 비쌈
  • 데이터 전송이 직렬화/역직렬화 (pickle)를 거침 — 큰 데이터 전송은 비쌈
  • 디버깅이 어려워짐 (프로세스 격리)

그래서 무거운 계산을 묶어서 보내야 효과가 있습니다. 작은 일을 자주 보내면 IPC 비용이 계산보다 큽니다.

if __name__ == "__main__": — 필수 #

진입점 가드 필수
def worker(x): ...

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        pool.map(worker, [1, 2, 3])

multiprocessing은 자식 프로세스를 만들 때 모듈을 다시 import합니다. 그때 pool.map(...) 같은 호출이 자식에서 또 실행되면 무한 재귀입니다. 가드가 필수입니다.

공유 상태 — Queue, Manager, shared_memory #

프로세스 간 데이터 공유는 까다롭습니다.

공유 큐
from multiprocessing import Queue, Process

def worker(q):
    q.put("hello from worker")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    print(q.get())   # hello from worker
    p.join()
  • multiprocessing.Queue — 프로세스 간 안전한 큐
  • multiprocessing.Manager — dict/list 등을 프록시로 공유
  • multiprocessing.shared_memory (3.8+) — 큰 numpy 배열 등을 복사 없이 공유

asyncio — 한 번 더 #

#4의 비동기는 단일 스레드 + 협력적 양도입니다.

항목asyncio
강점동시성 N만, 적은 메모리, 명시적 양도점
약점비동기 라이브러리 필요, CPU 바운드 효과 없음

asyncio와 threading 섞기 #

asyncio.to_thread로 동기 함수를 비동기 안에서 안전하게.

섞기
import asyncio

async def fetch(url):
    return await asyncio.to_thread(requests.get, url)

asyncio와 multiprocessing 섞기 #

비동기 + 프로세스 풀
import asyncio
from concurrent.futures import ProcessPoolExecutor

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, cpu_heavy, 10_000_000)

CPU 바운드 일을 별도 프로세스로 보내고, 결과는 비동기로 await.

Free-threaded — Python 3.13~3.14의 큰 변화 #

PEP 703이 GIL 없는 빌드를 도입했습니다. 3.13에서 실험 단계, 3.14에서 PEP 779로 정식 지원 단계로 들어왔습니다.

무엇이 바뀌나 #

  • GIL이 사라진다 — CPU 바운드 멀티스레딩이 진짜로 동작
  • 기존 동기 코드를 멀티스레드로 돌리면 자동으로 코어 수만큼 가속
  • 새 동시성 모델 (단일 프로세스 + 진짜 멀티스레드)가 가능

비용 — 단일 스레드 성능 #

GIL이 단순한 만큼 단일 스레드 성능을 끌어올리는 도구이기도 했습니다. 없애면 약간의 단일 스레드 성능 손실이 있습니다. 3.14 시점 기준 약 5~10% 손실이 발생하며, 시간이 지남에 따라 줄어드는 추세입니다.

어떻게 쓰나 #

free-threaded 빌드 사용
# uv로 free-threaded 빌드 설치
uv python install 3.14t
# (t가 free-threaded 빌드)

# 프로젝트가 이걸 쓰게
uv init my-app --python 3.14t

라이브러리 호환성 #

C 확장 라이브러리들이 GIL을 가정한 코드가 많아서 호환성 마이그레이션이 진행 중입니다. NumPy, PyTorch, Pillow 등 주요 라이브러리는 free-threaded 호환을 진행했거나 진행 중입니다. 새 프로젝트라면 free-threaded부터 시도해볼 만하지만, 레거시 의존성이 있는 프로젝트는 호환 확인이 필수입니다.

sub-interpreter — PEP 734 (3.14) #

GIL 회피의 또 다른 방향입니다. 하나의 프로세스 안에 여러 인터프리터를 두고, 각 인터프리터가 자기 GIL을 가지는 모델입니다.

sub-interpreter (3.14+)
from concurrent.futures import InterpreterPoolExecutor

with InterpreterPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(cpu_heavy, [10**7] * 4))

multiprocessing보다 가볍고, free-threaded보다 호환성이 안전한 중간 지대입니다. 3.14 시점에는 아직 새로운 영역이지만 CPU 바운드 동시성의 중요한 옵션이 될 가능성이 큽니다.

결정 가이드 — 한 표 #

첫 시도
HTTP 요청 1000 개 동시asyncio (httpx)
HTTP 요청 50 개, 동기 라이브러리 사용 중ThreadPoolExecutor
무거운 수치 계산 8 코어 활용ProcessPoolExecutor
numpy가 GIL을 푸는 무거운 연산ThreadPoolExecutor (또는 numpy 자체 병렬화)
새 프로젝트, CPU + I/O 동시 활용3.14 free-threaded + threading
안정적인 격리, 큰 데이터 공유multiprocessing + shared_memory
단일 프로세스에서 CPU 바운드 다중InterpreterPoolExecutor (3.14+)

자주 만나는 함정 #

1) time.sleep vs asyncio.sleep 섞기 #

🚫 비동기 안에서 동기 sleep
async def fetch(url):
    time.sleep(1)    # GIL 보유 + 이벤트 루프 정지
    ...

중급 #7에서 본 것입니다. 항상 await asyncio.sleep을 씁니다.

2) print가 thread-safe 하지 않음 #

thread + print
def worker(i):
    print(f"start {i}")
    do_work()
    print(f"end {i}")

동시에 여러 스레드가 print하면 줄이 섞일 수 있습니다. logging 모듈은 thread-safe하니 그쪽을 쓰세요.

3) multiprocessing으로 NumPy 배열을 자주 보내기 #

🚫 큰 배열을 매번 pickle
with ProcessPoolExecutor() as pool:
    pool.map(process, [big_numpy_array] * 100)

배열을 pickle로 직렬화 → 자식에 전송 → 자식이 deserialize. 계산보다 IPC 비용이 더 큽니다. shared_memory 또는 numpy 자체 병렬화 (BLAS/LAPACK 멀티스레드)를 검토.

4) 데드락 #

여러 락을 다른 순서로 잡으면 데드락이 납니다.

🚫 데드락
def t1():
    with lock_a:
        with lock_b:    # t2가 b 잡고 a 기다리는 중이면 데드락
            ...

def t2():
    with lock_b:
        with lock_a:
            ...

룰: 락의 획득 순서를 전체 코드에서 일관되게 두기. 또는 threading.RLock으로 재진입 가능 락 사용.

실전 — 어떻게 시작할까 #

단계별 권장
1. 일단 동기 코드로 작동시킴
2. 병목이 어디인지 측정 (#7에서 다룸)
3. 병목이 I/O 바운드 → asyncio 또는 ThreadPoolExecutor
4. 병목이 CPU 바운드 → ProcessPoolExecutor 또는 free-threaded
5. 위 모두로 안 되면 라이브러리 자체를 더 빠른 것으로 바꾸기 (Cython, Rust 확장)

조기 최적화는 여기서도 적용됩니다. 동기 코드가 충분히 빠르면 동시성을 안 쓰는 게 정답입니다.

정리 #

이번 글에서 잡은 것:

  • GIL — CPython의 전역 락, 한 번에 한 스레드만 바이트코드 실행
  • I/O 동작 중에는 GIL이 풀려 멀티스레딩이 의미 있음, CPU 바운드는 무의미
  • asyncio (단일 스레드, 대규모 동시성), threading (I/O + 동기 코드), multiprocessing (CPU 바운드)
  • concurrent.futuresThreadPoolExecutor / ProcessPoolExecutor가 표준
  • multiprocessing은 IPC 비용 — 무거운 일을 묶어 보냄, if __name__ == "__main__":가드 필수
  • Free-threaded (3.13~3.14, PEP 703/779) — GIL 없는 빌드, CPU 동시성이 진짜로 동작
  • Sub-interpreter (3.14, PEP 734) — 프로세스 안 여러 인터프리터, 새 옵션
  • 함정: 동기 sleep, print 동시성, IPC 비용, 데드락
  • 시작은 동기 → 측정 → 병목 따라 선택

다음 글(#6 typing 고급)에서는 중급 #2의 다음 단계 — Variance, ParamSpec, Self, TypeGuard/TypeIs, overload까지 typing의 어려운 부분들을 다룹니다.

X