Skip to content

✍️ 필사 모드: Python GIL & CPython 내부 완전 가이드 2025: Global Interpreter Lock, Bytecode, No-GIL, Subinterpreters, Async 심층 분석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며: Python의 가장 큰 비밀

유명한 문제

"Python은 왜 멀티스레드에서 빠르지 않나요?"

답: GIL (Global Interpreter Lock) 때문입니다.

20년 넘게 Python 커뮤니티가 GIL로 싸웠다. 제거하려 했고, 우회하려 했고, 받아들이려 했다. 2024년 PEP 703이 승인되며 마침내 No-GIL Python이 현실이 되고 있다.

간단한 실험

import threading
import time

def cpu_bound():
    count = 0
    for _ in range(50_000_000):
        count += 1

# Single thread
start = time.time()
cpu_bound()
print(f"1 thread: {time.time() - start:.2f}s")

# Multi thread
start = time.time()
threads = [threading.Thread(target=cpu_bound) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"4 threads: {time.time() - start:.2f}s")

예상: 4 스레드는 4배 빨라야.

실제 (CPython 3.12):

1 thread: 3.2s
4 threads: 13.8s

4배 느려졌다. 왜?

답: GIL이 한 번에 하나의 스레드만 Python 바이트코드를 실행하도록 강제한다.

이 글에서 다룰 것

  1. CPython 아키텍처: Compiler, bytecode, VM.
  2. GIL의 정체: 왜 존재하는가.
  3. GIL의 구현: 내부 메커니즘.
  4. GIL의 영향: 어떤 작업이 느려지나.
  5. asyncio: GIL을 우회하는 법.
  6. Multiprocessing: 별도 프로세스.
  7. PEP 703 (No-GIL): 2024년의 혁명.
  8. Subinterpreters (PEP 684): 또 다른 접근.
  9. Alternative Pythons: PyPy, Jython, GraalPy.

1. CPython: 대부분 Python의 실제 모습

Python ≠ CPython

"Python"언어 스펙. 여러 구현체가 있다:

  • CPython: C로 작성. 표준, 참조 구현. 99%가 사용.
  • PyPy: Python으로 작성 (RPython). JIT 컴파일러. 빠름.
  • Jython: JVM 기반.
  • IronPython: .NET 기반.
  • GraalPy: GraalVM 기반.
  • MicroPython: 임베디드용.

이 글의 "Python GIL"은 CPython의 GIL이다. 다른 구현체는 다를 수 있다.

CPython 실행 흐름

Python 코드를 실행하면:

source code (.py)
Lexer (tokens)
Parser (AST)
Compiler (bytecode, .pyc)
Virtual Machine (interpreter loop)
출력

Bytecode

CPython은 bytecode를 실행한다. Python code를 중간 표현으로 컴파일:

def add(a, b):
    return a + b

Bytecode:

  0 LOAD_FAST        a
  2 LOAD_FAST        b
  4 BINARY_OP        0 (+)
  8 RETURN_VALUE

dis 모듈로 확인:

import dis

def add(a, b):
    return a + b

dis.dis(add)

Bytecode Cache

.pyc 파일로 저장됨 (__pycache__/ 디렉토리). 다음 실행 시 재컴파일 생략.

Stack-Based VM

CPython은 stack-based virtual machine. 연산은 stack에서 일어남:

LOAD_FAST a      # a를 stack에 push
LOAD_FAST b      # b를 stack에 push
BINARY_OP +      # a, b pop, a+b push
RETURN_VALUE     # stack top을 반환

간단하고 이식성 좋다. 단점: register-based VM보다 조금 느림 (JIT이 덜 유리).

Interpreter Loop

CPython의 심장: ceval.c_PyEval_EvalFrameDefault. 메인 루프:

for (;;) {
    opcode = NEXTOP();
    oparg = NEXTARG();
    
    switch (opcode) {
    case LOAD_FAST:
        // push frame->f_locals[oparg] to stack
        DISPATCH();
    
    case BINARY_OP:
        // pop two, apply op, push result
        DISPATCH();
    
    case RETURN_VALUE:
        // return top of stack
        ...
    }
}

각 bytecode가 C 함수로 구현됨. 루프가 이를 디스패치.

Python 3.11의 Specializing Adaptive Interpreter

Python 3.11부터 JIT-like 최적화:

  • Hot loop 감지.
  • Specialized bytecode 생성: 타입 기반 최적화.
  • Quickening: 실행 중 bytecode 교체.

예시: BINARY_OP가 두 int를 더하는 경우, 특화된 BINARY_OP_ADD_INT 로 교체. 더 빠름.

이게 Python 3.11의 "25% 더 빠름"의 출처.

Python 3.13실험적 JIT 추가. 본격 JIT은 Python 3.14+ 예정.


2. GIL: 왜 존재하는가

GIL의 정의

GIL (Global Interpreter Lock): 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있게 하는 mutex.

Thread 1: GIL 획득 → Python code 실행 → GIL 해제
Thread 2: GIL 획득 대기 → ...GIL 획득 → 실행 → 해제

모든 Python thread는 GIL을 공유. 한 번에 하나만.

왜 GIL이 필요한가

이유 1: Reference Counting의 안전성

CPython의 메모리 관리는 reference counting:

x = [1, 2, 3]  # refcount = 1
y = x          # refcount = 2
del y          # refcount = 1
del x          # refcount = 0 → 메모리 해제

각 객체에 ob_refcnt 필드. 참조할 때 증가, 해제할 때 감소. 0이 되면 객체 삭제.

문제: Multi-thread에서 ob_refcnt 업데이트가 race condition. 두 스레드가 동시에 increment → 잘못된 값.

해결 옵션:

A. 모든 ob_refcnt를 atomic:

  • 매 참조마다 atomic 연산.
  • 수백 배 느려질 수 있음 (single-thread조차).

B. 각 객체마다 lock:

  • 엄청난 lock 오버헤드.
  • Deadlock 위험.

C. 하나의 전역 lock (GIL):

  • Single-thread는 영향 없음.
  • Multi-thread는 직렬화.
  • 구현 단순.

Python은 C를 선택. 이것이 GIL.

이유 2: C extension의 안전성

많은 C extension이 thread-safe하지 않다. NumPy, scikit-learn 등 초기 버전은 특히. GIL이 있으면:

  • C extension 개발자는 thread safety 걱정 안 해도.
  • Python 런타임이 보호.

이유 3: 단순성

Python의 설계 철학: "simple is better than complex".

Fine-grained locking은 복잡. GIL은 단순. Guido가 이를 선택한 이유 중 하나.

GIL의 역사

1992년: Guido가 Python 0.9.8에 GIL 도입. "임시 조치". 다시 안 뺐음.

2000년대 초: "GIL 없애자" 시도 여러 번. 모두 실패. 이유: Single-thread 성능 감소.

2010: Unladen Swallow (Google). JIT + no-GIL 시도. 실패.

2015: Larry Hastings의 "Gilectomy". 수년 작업. Single-thread가 2배 느려져 폐기.

2023: Sam Gross의 PEP 703. 새로운 접근. 승인됨. Python 3.13에 실험적 포함. Python 3.14에 기본 가능성.


3. GIL의 구현

3.12 이전: Check Intervals

Python 3.2 이전: N개의 bytecode마다 GIL 해제 시도.

Python 3.2+: 시간 기반. 기본 5 ms (sys.setswitchinterval(0.005)).

로직:

while True:
    execute_bytecode()
    if time_since_last_release > 5ms:
        release_GIL()
        yield_to_other_thread()
        acquire_GIL()

Voluntary vs Forced Release

Voluntary release:

  • I/O 작업 시: open(), socket.recv() 등. C 함수 호출 전에 GIL 해제.
  • time.sleep(): GIL 해제.
  • 긴 C 연산: NumPy가 이를 적극 활용.

Forced release:

  • 5 ms 타이머 경과.
  • CPython이 강제로 GIL 해제.

GIL Switching 비용

Context switch 비용:

  • Thread state 저장/복원.
  • GIL 획득 대기.
  • Cache pollution.

~수 μs 오버헤드. 많이 switch하면 누적.

스케줄링의 함정

Python 3.2 이전의 문제:

CPU-bound 스레드 + I/O-bound 스레드:

  • I/O 스레드가 wake up.
  • CPU 스레드가 GIL 보유.
  • I/O 스레드가 100 bytecode 기다림 (~1 ms).
  • 응답성 저하.

Python 3.2 해결: 시간 기반 + GIL 요청 알림. I/O 스레드가 GIL을 요청하면 즉시 CPU 스레드가 양보.

완벽하진 않지만 크게 개선.


4. GIL의 영향

CPU-bound Multi-thread

가장 큰 피해자:

import threading

def heavy_computation():
    total = 0
    for i in range(10_000_000):
        total += i * i
    return total

threads = [threading.Thread(target=heavy_computation) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

4개의 스레드, 1개의 CPU만 사용. 직렬화된다. 멀티코어 이점 없음.

해결: multiprocessing 또는 C extension.

I/O-bound Multi-thread

거의 영향 없음:

import threading
import requests

def fetch(url):
    return requests.get(url)

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

I/O 대기 중엔 GIL 해제. 여러 HTTP 요청이 병렬로 진행.

이점:

  • I/O 대기 시간 활용.
  • N개 요청 ≈ 가장 느린 하나의 시간.

C Extension

GIL 외부에서 작업 가능:

NumPy:

import numpy as np

# 이 연산 중엔 GIL 해제됨
result = np.dot(large_matrix1, large_matrix2)

NumPy C 코드가 Py_BEGIN_ALLOW_THREADS 매크로로 GIL 해제. BLAS가 실행되는 동안 다른 Python 스레드도 작동.

이것이 NumPy가 Python에서 고성능 수치 계산을 가능하게 하는 이유.

scikit-learn, Pandas도 마찬가지.

Locking

GIL이 있어도 Python 객체는 thread-safe 아님:

counter = 0

def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1  # NOT atomic!

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)  # < 4_000_000 (race condition)

왜?: counter += 1은:

  1. LOAD_GLOBAL counter
  2. LOAD_CONST 1
  3. BINARY_OP +
  4. STORE_GLOBAL counter

4개 bytecode. GIL switching이 중간에 일어나면 lost update.

해결: threading.Lock() 사용.

결론: GIL은 interpreter를 보호하지만 사용자 코드를 보호하지 않는다. 여전히 락이 필요.


5. asyncio: GIL을 우회하는 우아한 방법

Event Loop Pattern

asyncio: Python의 동시성 라이브러리. 단일 스레드에서 여러 I/O 작업 동시 처리.

import asyncio

async def fetch(url):
    # ... I/O 작업 ...
    await asyncio.sleep(1)
    return url

async def main():
    results = await asyncio.gather(
        fetch("url1"),
        fetch("url2"),
        fetch("url3"),
    )

asyncio.run(main())

핵심: 하나의 스레드에서 여러 coroutine이 협력적으로 실행.

Coroutines

Coroutine: 일시 중단과 재개가 가능한 함수.

async def my_coro():
    print("start")
    await asyncio.sleep(1)  # 여기서 일시 중단
    print("end")

await: "여기서 멈추고 다른 일 해도 됨".

Event Loop의 작동

[ready queue]
  → task1
  → task2
  → task3

Event loop:
  while True:
    task = ready_queue.pop()
    resume task
    task runs until next `await`
    if task awaits I/O:
      register I/O with selector
      continue
    else:
      task done
  
  if ready queue empty:
    wait for I/O events (selector.select())
    when I/O ready, add corresponding tasks to ready queue

GIL과 asyncio

단일 스레드라서 GIL 문제 없음. 모든 coroutine이 같은 스레드에서 순차 실행. 하지만 I/O 대기는 효율적.

장점:

  • 단일 스레드: GIL 무관.
  • 저 비용: 스레드보다 coroutine이 훨씬 가벼움.
  • 수천 concurrent I/O.

단점:

  • CPU-bound 불가: 여전히 단일 스레드.
  • async 전염: 한 번 async면 전파.
  • 복잡도: Sync보다 어려움.

실전 예시

100,000 HTTP 요청:

Sync (threads):

  • 수백 스레드.
  • GIL 영향 (약간).
  • 수 분.

Async (asyncio):

  • 1 스레드.
  • 수만 coroutine.
  • 수 초~수십 초.

수십 배 빠름. I/O-bound 작업의 표준.

라이브러리

aiohttp: HTTP client/server (async).

asyncpg: PostgreSQL async.

aioboto3: AWS async.

FastAPI: Web framework.

Uvicorn: ASGI server.

현재 Python async 생태계는 매우 성숙하다.


6. Multiprocessing: 별도 프로세스

기본 아이디어

Multiprocessing: Python을 여러 프로세스로 실행. 각 프로세스가 자기 GIL.

from multiprocessing import Process

def heavy(x):
    return sum(i*i for i in range(x))

processes = [Process(target=heavy, args=(10_000_000,)) for _ in range(4)]
for p in processes: p.start()
for p in processes: p.join()

4 프로세스 → 4 CPU 완전 활용. 스레드와 달리 GIL 영향 없음.

프로세스 vs 스레드

항목ThreadProcess
GIL영향 있음영향 없음
Memory공유격리
Overhead작음
CommunicationShared memoryIPC, pipes, queues
장애공유 (프로세스 크래시)격리

IPC (Inter-Process Communication)

프로세스 간 데이터 교환:

Queue:

from multiprocessing import Process, Queue

q = Queue()

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

p = Process(target=worker, args=(q,))
p.start()
print(q.get())  # "hello"
p.join()

Pipe: 1:1. Manager: 동기화된 데이터 구조. SharedMemory (3.8+): Numpy 배열 등 대용량 공유.

Multiprocessing Pool

from multiprocessing import Pool

def process_item(item):
    return item * 2

with Pool(4) as pool:
    results = pool.map(process_item, range(1000))

4개 프로세스가 작업 분배.

함정

1. Pickle 문제:

Process 간 데이터는 pickle로 직렬화. Lambda, nested function 등은 pickle 불가.

p = Pool(4)
p.map(lambda x: x*2, data)  # ERROR: can't pickle lambda

해결: 모듈 레벨 함수 사용.

2. 메모리:

각 프로세스가 자기 메모리. 4 프로세스 = 4배 메모리.

대용량 데이터는 SharedMemory 사용.

3. 시작 오버헤드:

Fork (Linux): 빠름. Spawn (Windows, macOS 기본): 느림. 새 Python 시작.

from multiprocessing import set_start_method
set_start_method('fork')

4. 디버깅:

멀티 프로세스 디버깅 어려움. 로그가 섞임.


7. PEP 703: No-GIL Python

역사적 전환점

2022년: Facebook의 Sam Gross가 "nogil" 프로토타입 공개. 성공적.

2023년 7월: PEP 703 승인. Python steering council이 동의.

2024년 Python 3.13: 실험적 빌드 (--disable-gil).

Python 3.14+: Default 전환 계획.

접근 방식

Sam Gross의 nogil은 다른 전략:

1. Immortal Objects:

  • True, False, None, 작은 정수 등은 영원히 존재.
  • Refcount 변경 불필요.

2. Deferred Reference Counting:

  • 지역 변수의 참조는 deferred.
  • 함수 종료 시 한 번에 처리.

3. Biased Reference Counting:

  • 각 객체에 소유자 스레드.
  • 같은 스레드면 atomic 아님 (빠름).
  • 다른 스레드면 atomic.

4. Per-Object Locks:

  • 객체마다 자체 lock.
  • Critical section만 lock.

5. Fine-grained Locking:

  • 기존 GIL 대신 세밀한 lock.

성능 영향

Single-thread:

  • 기존 대비 ~5% 느림.
  • 이전 nogil 시도 (Gilectomy)의 2배 느림보다 훨씬 낫다.

Multi-thread:

  • 진짜 병렬.
  • CPU 수에 비례한 확장.

벤치마크 예시:

# CPU-bound, 4 threads
# GIL: 4 threads → 1x
# No-GIL: 4 threads → 3.8x

API 영향

대부분 호환:

  • 기존 Python 코드 그대로 작동.
  • threading 모듈 동일.

C extension의 영향:

  • Thread safety 확인 필요.
  • 기존 extension은 재검증 해야.
  • NumPy, Pandas 등이 작업 중.

전환 일정

2024 (Python 3.13):

  • 실험적 빌드.
  • PYTHONGIL=0 환경 변수.
  • Opt-in.

2025 (Python 3.14):

  • 성숙.
  • 일부 배포판에서 기본.

2027+ (Python 3.16+):

  • 전면 기본.
  • GIL 버전 유지 (호환성).

8. Subinterpreters (PEP 684)

또 다른 접근

Subinterpreters: Python 내부에 여러 interpreter.

각 subinterpreter는:

  • 자기 메모리 영역.
  • 자기 GIL (Python 3.12+).
  • 완전 격리.

기존 기능: Python 3.1부터 있었음. C API만.

PEP 684: 각 subinterpreter가 독립 GIL.

PEP 734: Python API (3.12+).

사용

# Python 3.13+
from concurrent import interpreters

def run_in_sub():
    return 42

interp = interpreters.create()
result = interp.run(run_in_sub)

각 subinterpreter가 독립적으로 실행. 진정한 병렬.

Multiprocessing과의 차이

Multiprocessing:

  • 별도 OS 프로세스.
  • IPC로 통신.
  • 느린 시작.
  • 높은 메모리.

Subinterpreters:

  • 같은 OS 프로세스.
  • 공유 C 런타임.
  • 빠른 시작.
  • 낮은 메모리.
  • Python 객체 공유 불가 (격리).

Subinterpreters는 multiprocessing과 threading의 중간.

데이터 교환

Shared memory:

interpreters.get_default().channels  # 통신 채널

제약: Python 객체 직접 공유 불가. pickle 필요 (multiprocessing과 유사).

언제 유용한가

  • 웹 서버: 각 요청이 subinterpreter.
  • 플러그인: 격리된 실행.
  • No-GIL 대안: 기존 extension과의 호환성.

아직 실험적. 생태계가 적응 중.


9. 실전 성능 가이드

언제 무엇을 쓸 것인가

CPU-bound + 단일 코어 충분:

  • Sync Python. 단순.

CPU-bound + 멀티코어 필요:

  • Multiprocessing (지금).
  • No-GIL (미래).
  • C extension (NumPy 등).

I/O-bound + 작은 규모:

  • Threading + sync 라이브러리. 간단.

I/O-bound + 대규모:

  • asyncio. 수천~수만 동시.

Mixed (I/O + CPU):

  • asyncio + multiprocessing.
  • CPU 작업은 ProcessPoolExecutor로.

성능 비교

같은 작업, 다른 접근:

100만 숫자 제곱 합:

# Sync
def sync_sum():
    return sum(i*i for i in range(1_000_000))
# ~100 ms

# Threads (GIL 영향)
# 4 threads: ~100 ms (not faster)

# Multiprocessing
# 4 processes: ~30 ms (~3.3x speedup)

# NumPy
import numpy as np
def numpy_sum():
    arr = np.arange(1_000_000)
    return (arr * arr).sum()
# ~5 ms (20x)

# Numba (JIT)
from numba import jit
@jit
def numba_sum():
    total = 0
    for i in range(1_000_000):
        total += i * i
    return total
# ~2 ms (50x)

교훈: Python의 "느림"은 사용 방식에 달려 있음.

프로파일링

cProfile: 내장 프로파일러.

python -m cProfile -o profile.stats script.py

py-spy: Rust로 작성. 프로덕션 안전.

py-spy record -o profile.svg -- python script.py

scalene: CPU, memory, GPU 프로파일.

line_profiler: 줄 단위.


10. 대안 Python 구현체

PyPy

PyPy: CPython의 대안. JIT 컴파일러.

장점:

  • CPU 집약적 코드에서 10-100배 빠름.
  • API 호환 (대부분).

단점:

  • C extension 호환성 제한.
  • 메모리 사용 약간 많음.
  • 시작 시 warm-up.

언제 사용: 순수 Python 계산.

Cython

Cython: Python + C 타입.

# cython
def sum_cython(int n):
    cdef int total = 0
    cdef int i
    for i in range(n):
        total += i * i
    return total

C로 컴파일. C 속도. NumPy, Pandas 등이 내부적으로 사용.

Numba

Numba: JIT 데코레이터. 간단.

from numba import jit

@jit(nopython=True)
def fast_function(x):
    # ...

NumPy 친화적. 과학 계산에 인기.

Mojo (미래)

Mojo: Modular (Chris Lattner)가 개발 중인 새 언어.

  • Python과 호환.
  • 수천 배 빠름 (주장).
  • ML/AI 타겟.

아직 preview. 주목할 만.


퀴즈로 복습하기

Q1. 왜 NumPy는 Python의 GIL에서 자유로운가?

A.

: NumPy의 계산 집약적 연산C 코드로 작성되어 있고, 그 C 코드 내에서 GIL을 해제하기 때문이다.

상세 설명:

CPython의 GIL 규칙:

  • Python bytecode 실행 중엔 GIL 필요.
  • C 함수 실행 중엔 C 코드가 결정:
    • GIL 유지: 짧은 연산, Python 객체 직접 조작.
    • GIL 해제: 긴 연산, Python 객체 건드리지 않음.

NumPy의 전략:

numpy.dot(A, B) 같은 매트릭스 연산은:

  1. Python에서 numpy.dot 호출.
  2. NumPy의 C 함수 진입.
  3. Py_BEGIN_ALLOW_THREADS 매크로로 GIL 해제.
  4. 순수 C/BLAS 코드로 계산 수행.
  5. Py_END_ALLOW_THREADS 로 GIL 재획득.
  6. Python 결과 객체로 반환.

핵심: 3-5 단계 동안 다른 Python 스레드가 자유롭게 실행.

예시:

import numpy as np
import threading

def heavy_numpy():
    A = np.random.rand(1000, 1000)
    B = np.random.rand(1000, 1000)
    C = np.dot(A, B)  # GIL 해제됨

threads = [threading.Thread(target=heavy_numpy) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

실제 측정:

  • 1 thread: 1.0 초.
  • 4 threads: 0.3 초 (거의 3배 빠름).

일반 Python 코드는 같은 조건에서 4배 느려지는 것과 대조적.

왜 이것이 가능한가:

NumPy 연산의 특징:

  • C 배열 직접 조작 (Python 객체 아님).
  • BLAS (Basic Linear Algebra Subprograms) 호출.
  • MKL, OpenBLAS 등이 실제 계산.
  • 멀티 스레드 안전 (BLAS 자체가 그렇게 설계).

GIL 해제 조건이 완벽히 충족:

  1. Python 객체 안 건드림 (C 배열만).
  2. 긴 연산 (수 ms+).
  3. Re-entrant.

어떤 NumPy 연산이 GIL 해제:

GIL 해제:

  • np.dot, np.matmul: 행렬 곱.
  • np.add, np.multiply: 큰 배열 연산.
  • np.linalg.solve, np.linalg.eig: 선형대수.
  • np.fft: FFT.
  • np.sort: 정렬 (큰 배열).

GIL 유지 (짧음):

  • 스칼라 연산.
  • 작은 배열 (수백 개 이하).
  • 인덱싱 arr[i].
  • 타입 변환.

판단 기준: 대략 50 μs 이상 걸리는 연산은 GIL 해제.

BLAS와 병렬성:

BLAS 자체가 멀티스레드. 예를 들어 MKL은 OMP (OpenMP)로 여러 CPU 코어 사용.

import os
os.environ['OMP_NUM_THREADS'] = '4'

이중 병렬성:

  • Python 스레드 레벨: 4개.
  • BLAS 스레드 레벨: 각 Python 스레드 × 4 BLAS 스레드 = 16.

주의: Over-subscription 위험. CPU 16 core인데 16 × 16 = 256 스레드.

관리 방법:

from threadpoolctl import threadpool_limits

with threadpool_limits(limits=1, user_api='blas'):
    # BLAS는 1 스레드만 사용
    np.dot(A, B)

다른 예:

scikit-learn: 대부분의 알고리즘이 NumPy/Cython 기반. GIL 해제.

Pandas: DataFrame 연산도 마찬가지.

SciPy: NumPy 기반.

PIL/Pillow: 이미지 처리. 일부 GIL 해제.

OpenCV Python: 대부분 GIL 해제.

TensorFlow, PyTorch: 자체 C++ 런타임. GIL 해제.

중요한 구분:

GIL-friendly Python 코드:

# Pure Python: GIL 영향
for i in range(1_000_000):
    total += i * i

GIL-free NumPy 코드:

# NumPy: GIL 해제
arr = np.arange(1_000_000)
total = (arr * arr).sum()

두 번째가 GIL 관점에서도, 순수 성능에서도 훨씬 빠르다.

"Python이 느리다"의 진짜 의미:

느림:

  • 순수 Python 루프.
  • 객체 생성 많음.
  • 동적 타입 검사.

빠름 (C 속도):

  • NumPy 벡터 연산.
  • bytes, str 연산.
  • C extension 일반.

함정: NumPy를 쓰되 안 쓰는 방식으로 쓰면 느림:

# 느림: 루프
result = np.zeros(1_000_000)
for i in range(1_000_000):
    result[i] = arr[i] * 2

# 빠름: 벡터화
result = arr * 2

NumPy는 벡터화가 핵심. 루프로 쓰면 Python의 느림 + NumPy 오버헤드.

고급 기술: Py_BEGIN_ALLOW_THREADS:

Custom C extension을 작성할 때:

static PyObject *
my_long_func(PyObject *self, PyObject *args) {
    // Python 인자 파싱
    long n;
    if (!PyArg_ParseTuple(args, "l", &n)) return NULL;
    
    long result = 0;
    
    Py_BEGIN_ALLOW_THREADS  // GIL 해제
    
    // 긴 C 계산
    for (long i = 0; i < n; i++) {
        result += heavy_computation(i);
    }
    
    Py_END_ALLOW_THREADS  // GIL 재획득
    
    return PyLong_FromLong(result);
}

중요:

  • GIL 해제 중엔 Python 객체 접근 금지.
  • 로컬 변수, C 함수 호출만.
  • GIL 없이 PyLong_FromLong 등 호출하면 크래시.

성능 지침:

CPU-bound 작업이 많다면:

  1. 순수 Python: 느림. GIL 문제.
  2. NumPy: 빠름. GIL 자유.
  3. Cython: 더 빠름. GIL 제어 가능.
  4. Numba: JIT. GIL 옵션.
  5. C extension: 완전 제어.
  6. Rust + PyO3: 최신 트렌드.
  7. Multiprocessing: 별도 프로세스. GIL 없음.

NumPy가 첫 번째 선택인 이유:

  • 가장 단순.
  • 거의 모든 곳에서 빠름.
  • GIL도 해결.

교훈:

"Python의 GIL이 싫으면 Python을 덜 써라".

모순적이지만 사실이다. CPU 집약적 작업은:

  • NumPy 벡터화.
  • C 확장.
  • 또는 Python 밖 (Rust, Go).

"Python은 glue language". 고수준 로직은 Python, 성능 중요한 부분은 C/C++. 이것이 Python 생태계의 철학이며, GIL이 큰 문제 안 되는 이유다.

NumPy, Pandas, TensorFlow, PyTorch 등 주요 과학/AI 라이브러리는 모두 이 패턴을 따른다. 그래서 Python이 GIL 있어도 데이터 과학의 표준이 되었다.

당신의 Python 코드가 느리다면, 먼저 물어보라: "이걸 NumPy로 쓸 수 있나?". 대부분 답은 "예"이고, 10-100배 빨라진다. GIL은 부차적 문제가 된다.

당신의 I/O 코드가 느리다면, asyncio로. 이것도 GIL 영향 없음.

진짜 순수 Python CPU 병렬성이 필요하다면 (드물다), multiprocessing 또는 No-GIL Python (미래). 그때까지는 대부분의 경우 NumPy + asyncio 조합이 답이다.


마치며: Python의 미래

핵심 정리

  1. CPython: Bytecode interpreter. Stack-based VM.
  2. GIL: 한 번에 한 스레드. Reference counting 보호.
  3. asyncio: GIL 우회. I/O-bound 강함.
  4. Multiprocessing: 별도 프로세스. CPU-bound 해결.
  5. No-GIL (PEP 703): 진짜 멀티스레드. Python 3.14+.
  6. Subinterpreters: 격리된 interpreter 여러 개.
  7. NumPy 등: C extension이 GIL 해제.

Python이 살아남은 이유

Python은 1991년에 시작. 2025년 현재 가장 인기 있는 프로그래밍 언어 중 하나. 왜?

철학:

  • 간단함.
  • 가독성.
  • "There should be one obvious way".

생태계:

  • 수만 개의 라이브러리.
  • 모든 도메인 커버.
  • 활발한 커뮤니티.

데이터 과학 + AI의 표준:

  • NumPy, Pandas, scikit-learn.
  • TensorFlow, PyTorch, JAX.
  • Jupyter notebooks.

GIL에도 불구하고 성공한 것은, GIL이 실제로 큰 문제가 되지 않았기 때문이다:

  • I/O-bound: asyncio, threading 충분.
  • CPU-bound: NumPy, multiprocessing.
  • 대부분의 앱: 단일 스레드 충분.

마지막 교훈

Python은 완벽한 언어가 아니다. 느리고 복잡하고 GIL이 있다. 하지만 가장 접근 가능한 언어 중 하나다. 초보자도 수 분 만에 의미 있는 코드를 쓴다. 전문가는 C extension으로 극한 성능을 낸다.

유연성이 Python의 진짜 강점이다. 모든 수준의 개발자가 쓸 수 있고, 모든 종류의 문제를 풀 수 있다.

GIL은 오랜 논란의 대상이었지만, Python의 성공을 막지 못했다. No-GIL이 와도, asyncio와 NumPy가 여전히 주류일 것이다. 도구는 문제에 맞게 쓰는 것이 중요하다.

당신이 Python을 쓸 때, 이 글의 지식이 다음 질문에 답할 수 있게 도와줄 것이다:

  • 왜 내 멀티스레드 코드가 느린가?
  • asyncio를 써야 할까?
  • Multiprocessing이 필요한가?
  • NumPy가 왜 빠른가?
  • No-GIL Python은 언제 써야 하나?

이 답들을 알면 Python을 훨씬 효과적으로 사용할 수 있다. 언어의 한계를 알면, 그 안에서 최대로 활용할 수 있다.


참고 자료

현재 단락 (1/635)

"Python은 왜 멀티스레드에서 빠르지 않나요?"

작성 글자: 0원문 글자: 17,066작성 단락: 0/635