동시성·병렬성·GIL

src/content/documents/python/python-concurrency-parallelism-gil.json

공공 API 100개를 순서대로 호출하면 100초가 걸린다. 그런데 스레드를 4개 만들어 CPU 연산을 나눠도 속도가 거의 그대로인 경우가 있다. 두 상황은 반대로 보이지만 원인은 하나다 — Python(CPython)에는 GIL(Global Interpreter Lock) 이라는 제약이 있고, 이 제약이 언제 문제가 되는지는 작업의 성격에 달려 있다.

한 줄 정의

동시성(concurrency) 은 여러 작업을 번갈아 진행해 기다리는 시간을 겹쳐 쓰는 것이고, 병렬성(parallelism) 은 여러 작업을 물리적으로 동시에 실행하는 것이다. CPython은 GIL 때문에 한 프로세스 안에서 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있어, 이 둘을 구현하는 도구가 갈린다.

동시성 vs 병렬성 — 작업 성격으로 나눈다

먼저 작업을 두 종류로 구분한다.

구분

I/O-bound

CPU-bound

병목

네트워크 응답, 디스크 읽기 등 대기 시간

연산 자체에 걸리는 시간

API 호출, 파일 다운로드, DB 쿼리

수치 계산, 이미지 처리, 대용량 전처리

적합한 도구

threading, asyncio

multiprocessing

GIL 영향

대기 중 GIL을 놓으므로 영향이 작다

연산 내내 GIL을 쥐고 있어 영향이 크다

데이터 분석에서는 두 성격이 자주 섞인다. 공공 API를 100번 호출해 수집하는 작업은 I/O-bound, 수집한 수십 GB CSV를 전처리하는 작업은 CPU-bound다. 같은 파이프라인 안에서도 단계마다 맞는 도구가 다르다는 뜻이다.

GIL이 하는 일

CPython 객체는 참조 횟수(reference count)로 메모리를 관리한다. 여러 스레드가 동시에 같은 객체의 참조 횟수를 늘리고 줄이면 경쟁 상태(race condition)로 값이 깨질 수 있다. GIL은 이 문제를 막기 위해 한 번에 하나의 스레드만 Python 바이트코드를 실행하도록 강제하는 락이다.

그 결과 스레드를 여러 개 만들어도 CPU 연산 자체는 번갈아 조금씩 실행될 뿐 동시에 진행되지 않는다.

import threading

def cpu_task():
    return sum(range(10**7))

threads = [threading.Thread(target=cpu_task) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
# 스레드 4개를 만들어도 GIL 때문에 실질적으로는 한 번에 하나씩 실행된다

I/O 대기(time.sleep, 네트워크 응답 등)에 들어가는 순간에는 스레드가 GIL을 놓는다. 그래서 I/O-bound 작업에는 GIL이 병목이 되지 않는다.

실측: threading은 CPU-bound에서 왜 소용없는가

연산 20,000,000개짜리 합을 4번 반복하는 작업을 순차 실행, threading, multiprocessing 세 가지 방식으로 걸리는 시간을 재본다.

import threading
import time
from multiprocessing import Pool


def cpu_task(n):
    return sum(range(n))


def run_sequential(n, times):
    start = time.perf_counter()
    for _ in range(times):
        cpu_task(n)
    return time.perf_counter() - start


def run_threaded(n, times):
    start = time.perf_counter()
    threads = [threading.Thread(target=cpu_task, args=(n,)) for _ in range(times)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return time.perf_counter() - start


def run_multiprocess(n, times):
    start = time.perf_counter()
    with Pool(processes=times) as pool:
        pool.map(cpu_task, [n] * times)
    return time.perf_counter() - start


if __name__ == "__main__":
    N, TIMES = 40_000_000, 4
    print(f"순차 실행:       {run_sequential(N, TIMES):.2f}초")
    print(f"threading:       {run_threaded(N, TIMES):.2f}초")
    print(f"multiprocessing: {run_multiprocess(N, TIMES):.2f}초")
순차 실행:       0.75초
threading:       0.74초
multiprocessing: 0.22초

threading 은 순차 실행과 시간이 거의 같다 — GIL 때문에 스레드 4개가 번갈아 실행됐을 뿐 동시에 계산하지 못했다. multiprocessing 은 프로세스마다 별도의 Python 인터프리터와 GIL을 갖기 때문에 실제로 네 개의 연산이 동시에 진행되어 약 3배 이상 빨라졌다.

같은 조건, I/O-bound에서는 정반대

이번에는 네트워크 응답을 흉내 낸 0.5초 대기를 4번 반복한다.

def io_task():
    time.sleep(0.5)  # 네트워크 응답을 기다리는 상황을 흉내

# 순차 실행: 4 × 0.5초 = 2초
# threading: 4개가 동시에 대기 → 약 0.5초
순차 실행: 2.01초
threading: 0.50초

대기하는 동안에는 GIL을 붙잡고 있지 않으므로 스레드 4개가 동시에 기다릴 수 있다. 같은 threading 모듈인데도 작업 성격에 따라 결과가 정반대로 나오는 이유다.

multiprocessing으로 큰 데이터를 나눠 처리하기

실제 전처리는 리스트 하나를 코어 수만큼 나누고, ProcessPoolExecutor 로 각 조각을 병렬로 처리하는 형태가 흔하다.

from concurrent.futures import ProcessPoolExecutor
import multiprocessing as mp


def transform(row):
    return row * row


def process_chunk(chunk):
    return [transform(row) for row in chunk]


def split_chunks(data, n):
    size = len(data) // n
    return [data[i * size:(i + 1) * size] for i in range(n)]


if __name__ == "__main__":
    all_data = list(range(12))
    n_cores = min(4, mp.cpu_count())
    chunks = split_chunks(all_data, n_cores)
    with ProcessPoolExecutor(max_workers=n_cores) as exe:
        results = list(exe.map(process_chunk, chunks))
    print(results)
[[0, 1, 4], [9, 16, 25], [36, 49, 64], [81, 100, 121]]

max_workers 는 보통 mp.cpu_count() 를 상한으로 잡는다. 코어 수보다 많은 프로세스를 띄워도 실제로 동시에 실행되는 개수는 코어 수를 넘지 못하고, 프로세스를 만들고 데이터를 주고받는 비용만 늘어난다.

예외: NumPy는 GIL 밖에서 계산한다

행렬 곱셈처럼 NumPy가 내부적으로 BLAS 같은 C/Fortran 라이브러리를 호출하는 연산은 계산 도중 GIL을 놓는다. 그래서 순수 Python 반복문과 달리 NumPy 연산은 스레드로 병렬 이득을 볼 수 있다.

import numpy as np

A = np.random.rand(1200, 1200)
B = np.random.rand(1200, 1200)
C = np.dot(A, B)  # BLAS가 내부적으로 멀티스레드로 계산, GIL은 그 동안 풀려 있다

이 성질 때문에 "Python은 느리다"는 말은 순수 Python 반복문에만 해당한다. pandas·NumPy로 벡터화한 연산은 이미 GIL의 제약 밖에서 돈다.

선택 기준

상황

선택

API를 여러 개 동시에 호출한다

asyncio + httpx (다음 편)

레거시 코드에서 I/O 여러 개를 동시에 처리한다

threading

대용량 데이터를 CPU로 나눠 계산한다

multiprocessing / ProcessPoolExecutor

pandas·NumPy 벡터 연산으로 충분하다

그대로 사용 (이미 GIL 밖에서 돈다)

자주 발생하는 문제

지역 함수를 multiprocessing에 넘긴다. 각 프로세스는 별도 메모리를 쓰므로 작업 함수를 pickle로 직렬화해 전달한다. 함수 안에 정의한 지역 함수는 pickle이 이름으로 찾아갈 수 없어 실패한다.

def make_pool_call():
    def local_square(n):  # 지역 함수 -- 피클링 불가
        return n * n
    with Pool(processes=2) as pool:
        return pool.map(local_square, [1, 2, 3])
AttributeError: Can't pickle local object 'make_pool_call.<locals>.local_square'

모듈 최상위에 정의한 함수만 넘긴다. 클래스 메서드나 람다도 같은 이유로 문제가 될 수 있다.

if __name__ == "__main__": 을 빼먹는다. multiprocessing 은 새 프로세스를 만들 때 현재 모듈을 다시 import한다. 이 보호구문이 없으면 자식 프로세스가 같은 코드를 실행하다가 또 자식을 만드는 무한 생성으로 이어질 수 있다. macOS와 Windows의 기본 시작 방식(spawn)에서는 특히 필수다.

스레드 수를 늘릴수록 빨라질 거라 기대한다. CPU-bound 작업에서는 스레드를 늘려도 GIL 때문에 한계가 있고, 오히려 컨텍스트 전환 비용만 늘어 더 느려지는 경우도 있다. 위 실측처럼 먼저 작업 성격을 CPU-bound/I/O-bound로 나눈 뒤 도구를 고른다.

관련 문서

threading·multiprocessing 은 둘 다 표준 라이브러리 모듈이다. import와 모듈 구조 자체가 낯설다면 모듈과 표준 라이브러리 사용법 을 먼저 본다. I/O-bound 작업을 실제 코드로 옮기는 방법은 다음 편 asyncio·httpx 문서에서 이어진다.

참고 자료

댓글 0

댓글을 불러오는 중…