Python GIL 알아보기

Python GIL 알아보기
Photo by Elias Maurer / Unsplash

이번 시간에는 파이썬의 GIL (Global Interpreter Lock)에 대해 알아봅니다. GIL은 파이썬 프로그램의 성능을 이해하고 최적화 할 때 빼놓을 수 없는 중요한 개념 중 하나입니다. GIL이란 무엇이고, 왜 이것이 도입되었는지 함께 살펴봅시다. 😊

GIL이란 무엇인가?

파이썬의 GIL (Global Interpreter Lock)은 "CPython 인터프리터에서 동시에 하나의 Thread만 실행하도록 강제하는 Lock"입니다. Multi-Thread를 사용하더라도 하나의 Thread만 파이썬 바이트코드를 실행할 수 있으며, 나머지 스레드는 대기하는 방식으로 동작합니다.

위 정의를 이해하기 위해서는 두 가지 질문을 해결해야 합니다.

  1. CPython 인터프리터란 무엇인가?
  2. 왜 인터프리터를 동시에 하나의 스레드만 실행되도록 강제하는가?

Cpython 인터프리터란?

파이썬은 프로그래밍 언어이고, CPython은 그 언어를 실행하는 가장 널리 사용되는 구현체(인터프리터)입니다.

우리가 파이썬 코드를 작성하면 아래와 같은 과정을 통해 프로그램이 실행됩니다.

  1. 파이썬 소스 코드가 Byte Code로 변환 (컴파일 과정)
  2. CPython의 PVM(Python Virtual Machine)에서 Byte Code를 한 줄씩 해석하여 실행 (인터프리팅)

다시 말해서, GIL은 해당 과정에서 CPython 인터프리터는 동시에 여러 개의 Thread를 실행할 수 없는 것이죠.

CPython Intrepreter에 대한 자세한 설명은 아래 링크를 참고해주세요 😊

Python은 인터프리터 언어인가?
파이썬은 흔히 ‘인터프리터 언어’라고 불린다. 🤔: ”파이썬은 코드를 한 줄씩 읽고 실행하는 인터프리터 언어이다.” C++이나 Go 같은 언어는 소스 코드를 기계어로 번역하는 컴파일 과정을 거치지만, 파이썬은 코드를 한 줄씩 읽어 실행된다는 설명이다. 하지만 과연 Python을 단순히 ”인터프리터 언어”라고 정의할 수 있을까? 이 질문에 대한 답을 찾기 위해, Python이 실제로

왜 GIL이 필요한가?

파이썬이 GIL을 도입한 가장 큰 이유는, 파이썬의 Garbage Collection 방식인 Reference Counting에 있습니다.

파이썬은 크게 두 가지 방식으로 Garbage Collection이 동작합니다.

  • Reference Counting
    • 객체 자신을 참조하는 참조(reference)의 개수를 바탕으로 GC 결정
  • Circular Detection
    • Reference Counting만으로는 Circular Reference가 발생할 수 있음
    • Circular Detection을 통해 해당 객체를 탐지하고 GC 수행

여기서 Reference Counting의 동작 방식은 다음과 같습니다.

  1. 파이썬의 모든 객체 (Object)는 Reference Count를 가짐
  2. 객체가 새로운 변수에 할당되면 Reference Count가 증가함
  3. 변수가 소멸하거나 del을 호출하면 Reference Count가 감소함
  4. Reference Count가 0이 되면 Garbage Collection을 수행
import sys

a = []  # Reference Count: 1
# print(sys.getrefcount(a))
b = a   # Reference Count: 2
# print(sys.getrefcount(b))
del a   # Reference Count: 1
# print(sys.getrefcount(b))
del b   # Reference Count: 0 -> Garbage Collection!

Reference Counting과 Race Condition

Reference Counting을 저장하는 refcnt도 일종의 변수입니다. 따라서 해당 변수를 다루는 연산이 Atomic하게 처리되지 않아, Critical Section이 됩니다. 멀티스레드 환경에서 여러 스레드가 동시에 같은 객체의 참조 카운트를 변경하려고 할 경우 Race Condition이 발생하게 됩니다.

참조 횟수
The functions and macros in this section are used for managing reference counts of Python objects.

참조 카운트 증가/감소 연산은 CPU 레벨에서 다음과 같이 실행됩니다:

(A += 1)
mov eax, num  # LOAD: 변수값을 레지스터로 가져옴
inc eax       # INC: 레지스터 값 증가
mov num, eax  # STORE: 증가된 값을 다시 변수에 저장

만약 두 스레드가 동시에 같은 객체의 참조 카운트를 증가시키려고 한다면:

Thread 1: LOAD (ref_cnt = 1)
Thread 2: LOAD (ref_cnt = 1)
Thread 1: INC (eax = 2)
Thread 2: INC (eax = 2)
Thread 1: STORE (ref_cnt = 2)
Thread 2: STORE (ref_cnt = 2)  # 두 번 증가해야 하는데 한 번만 증가됨!

이런 상황에서 데이터 손실이 발생하고, 메모리 누수(memory leak)조기 해제(premature deallocation) 같은 문제가 발생할 수 있습니다.

GIL의 등장

위와 같은 Race Condition을 해결하기 위해 다음과 같은 방법을 고려할 수 있습니다:

  1. 모든 객체의 참조 카운트 연산에 개별 Mutex Lock을 적용
  2. 아토믹(Atomic) 연산 사용
  3. 전체 인터프리터에 하나의 Lock 적용 (GIL)

당시 CPython 개발자들은 세 번째 방법인 GIL을 선택했습니다. 그 이유는 아래와 같습니다.

  1. 단순성: 구현이 간단하고 버그 발생 가능성이 낮음
  2. 성능 (당시 기준): 1990년대 초 파이썬이 개발될 당시에는 단일 프로세서 시스템이 일반적이었으므로 멀티스레딩의 이점이 제한적이었음
  3. 확장 모듈과의 호환성: C로 작성된 확장 모듈이 thread-safe하지 않아도 안전하게 동작할 수 있음

하지만 최근에는 Multi-Threading이 보편화됨에 따라 GIL이 파이썬 프로그램의 속도 향상에 큰 장애물이 되고 있습니다.

GIL과 Performance

GIL이 Python 프로그램의 성능에 어떤 영향을 미치는지 간단한 예제로 살펴봅시다.

import time
import threading

def cpu_bound_task():
    for i in range(10**7):
        _ = i * i

# 1. Single-Thread
start = time.time()
cpu_bound_task()
cpu_bound_task()
end = time.time()
print(f"Single Thread: {end - start:.2f}")

# 2. Multi-Thread
start = time.time()
t1 = threading.Thread(target=cpu_bound_task)
t2 = threading.Thread(target=cpu_bound_task)
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"Multi Thread: {end - start:.2f}")

실행 결과는 아래와 같습니다. (컴퓨팅 환경에 따라 결과는 달라질 수 있습니다.)

Single Thread: 0.71
Multi Thread: 0.75

Multi-Thread 버전이 Single-Thread 버전보다 GIL로 인해 Thread를 여러 개를 동시에 활용하지 못하고, 오히려 작업 중 Context-Switching으로 인해 느려진 것을 확인할 수 있습니다.

아래의 경우는 어떨까요?

import time
import threading
import requests

def io_bound_task():
    response = requests.get('https://facerain.me')
    return len(response.content)

# 1. Single-Thread
start = time.time()
for _ in range(5):
    io_bound_task()
end = time.time()
print(f"Single-Thread: {end - start:.2f}")

# 2. Multi-Thread
start = time.time()
threads = []
for _ in range(5):
    t = threading.Thread(target=io_bound_task)
    threads.append(t)
    t.start()
for t in threads:
    t.join()
end = time.time()
print(f"Multi-Thread: {end - start:.2f}")

실행 결과는 아래와 같습니다.

Single-Thread: 2.08
Multi-Thread: 1.56

위 결과와는 반대로 Multi-Thread 버전이 더욱 좋은 성능을 보였습니다.

이유는 무엇일까요? CPU-Bound가 아닌 I/O Bound 작업에서는 GIL이 I/O 대기 중에 해제가 됩니다. 따라서 Multi-Thread를 통해 성능 향상을 얻을 수 있습니다.

정리하면 아래와 같습니다.

  • CPU Bound Task: GIL로 인해 멀티 스레드의 이점이 없음
  • I/O Bound Task: GIL이 I/O 작업 중에 해제되므로 멀티 스레드가 효과적

GIL 극복하기

그렇다면 파이썬에서 GIL로 인한 성능 제약을 어떻게 극복할 수 있을까요? 몇 가지 방법을 살펴보겠습니다.

1. 멀티 프로세싱 사용 (multiprocessing)

GIL은 Thread Level에서만 적용되고, 각 Process는 자체 GIL을 가지게 됩니다. 따라서 Multi-Process를 활용하면 동시에 여러 Process를 활용할 수 있습니다.

실제 예시로 Pytorch에서 데이터를 불러오는 DataLoader에서도 GIL로 인해 multi-processing을 이용한다고 밝히고 있습니다.

Within a Python process, the Global Interpreter Lock (GIL) prevents true fully parallelizing Python code across threads. To avoid blocking computation code with data loading, PyTorch provides an easy switch to perform multi-process data loading by simply setting the argument num_workers to a positive integer.

torch.utils.data — PyTorch 2.6 documentation

멀티 프로세싱을 활용한 예시 코드는 아래와 같습니다.

import multiprocessing
import time

def compute():
    for _ in range(10**7):
        pass

if __name__ == "__main__":
    start = time.time()
    
    p1 = multiprocessing.Process(target=compute)
    p2 = multiprocessing.Process(target=compute)
    
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    
    end = time.time()
    print("Execution Time:", end - start)

2.비동기 사용 (Async)

두 번째 방법은 Async Programming를 활용하는 것입니다.

언뜻 보기에는 Multi-Processing과 Async는 같은 개념처럼 보입니다. 하지만 내부 동작 원리는 완전히 다릅니다. Async에서는 GIL 제약 없이 여러 작업을 동시에 진행하는 것처럼 (Concurrency) 진행할 수 있습니다.

  • Multi-Processing: 여러 스레드가 동시에 실행됨. 각 스레드는 독립적인 실행 흐름을 가짐 (Parallelism)
  • Async: 단일 스레드에서 이벤트 루프를 사용하여 여러 작업을 번갈아 실행 (Concurrency)
import asyncio
import aiohttp
import time

async def fetch(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    urls = ["https://facerain.me"] * 10
    tasks = [asyncio.create_task(fetch(url)) for url in urls]
    await asyncio.gather(*tasks)

start = time.time()
asyncio.run(main())
end = time.time()
print("Execution Time:", end - start)

3.Cython, Numba, Jython 활용

사실 GIL은 CPython 구현체에만 존재하는 제약으로, 다른 파이썬 구현체 (Jython, IronPython) 혹은 Cython, Numba 등을 활용하여 GIL 제약을 우회할 수도 있습니다. 실제로 계산 집약적인 라이브러리 (Numpy, Pandas) 등에 이러한 기법들이 활용되고 있습니다.

GIL의 미래

앞서 살펴본 대안이 있음에도, 최근처럼 대규모 요청과 트래픽을 처리해야 하는 시대에 Python의 GIL은 여전히 큰 제약으로 작용합니다. 이러한 한계를 극복하고자, PEP 703에서 GIL을 비활성화할 수 있는 옵션이 제안되었고 Python 3.14에 도입될 예정입니다.

PEP 703 – Making the Global Interpreter Lock Optional in CPython | peps.python.org
CPython’s global interpreter lock (“GIL”) prevents multiple threads from executing Python code at the same time. The GIL is an obstacle to using multi-core CPUs from Python efficiently. This PEP proposes adding a build configuration (--disable-gil) to…
파이썬에서 GIL 삭제된다⋯“병렬 처리의 혁신적 진전”
많은 논란 끝에 파이썬 운영 위원회(Python Steering Council)가 “C파이썬에서 전역 인터프리터 잠금(Global Interpreter Lock)을 선택 사항으로 두자”는 PEF 703 제안을 승인하는 쪽으로 가닥을 잡았다. 이 제안은 파이썬의 전역 인터프리터 잠금, 즉 GIL을 제거하기 위한 몇 년에 걸친 노력의 결실이다. GIL을 제거하면 멀티 스레딩을 가로막는 큰 장애물이 사라지면서 파이썬은 진정한 멀티코어 언어가 되고 병렬성을 활용하는 워크로드의 성능이 크게 향상된다. 이 제안으로 파이썬의 멀티스레딩 및 동시성에 대한 일급(first-class) 지원이 현실에 한 걸음 더 다가섰다.

마치며

이번 시간에는 파이썬의 GIL에 대해서 알아보았습니다. 최근에는 파이썬을 이용하여 높은 수준의 성능이 필요한 백엔드 서버나 데이터 파이프라인 등을 개발하는 일이 많아지고 있습니다. 이때 GIL에 대한 이해를 통해, 더 효율적인 파이썬 프로그램을 설계하고 상황에 맞는 최적의 병렬 처리 방법을 선택하는데 도움이 되었으면 좋겠습니다. 감사합니다 😊

Reference

Read more

[논문 리뷰] Search-R1: Training LLMs to Reason and Leverage Search Engines with Reinforcement Learning

[논문 리뷰] Search-R1: Training LLMs to Reason and Leverage Search Engines with Reinforcement Learning

들어가며 이번 시간에는 LLM이 검색 엔진과 상호작용하며 추론(Reasoning)을 수행할 수 있는 강화 학습 프레임워크 Search-R1을 소개합니다. 최근 OpenAI의 Deep Research나 여러 최신 연구에서 알 수 있듯, LLM의 추론 능력뿐 아니라 실시간 검색과 결합된 Reasoning이 큰 주목을 받고 있습니다. 하지만 기존의 RAG(Retrieval-Augmented Generation)이나 Tool-Use 방식은 * 복잡한 다단계

By Yongwoo Song
[독서] 게으른 완벽주의자를 위한 심리학

[독서] 게으른 완벽주의자를 위한 심리학

✒️일을 시작할 수 있는 비결은 그것이 쉽지 않을 것임을 인정하는 것이다. 운이 좋게도 침실을 만드는 일 외에 아무런 할 일도 없는 날은 오지 않는다. 솔직하게 인정하자. 그리고 어려운 일을 할 때 떠오르는 생각과 감정을 통제할 수 있는 전략을 짜자. 시작을 어려워하는 이유는 힘든 감정과 생각에 대응할 전략을 구상하는 대신 이를

By Yongwoo Song