본문 바로가기
목차
Python

Lock and GIL

by ds31x 2026. 6. 1.
728x90
반응형

GIL (Global Interpreter Lock)

 

GIL을 이해하려면 먼저 lock의 개념부터 짚어야 하므로,

lock 에 대한 설명과 synchronous object에 대한 소개를 하고 나서 GIL을 다룸.


Lock이란

  • Lock (잠금):
    • 여러 thread가 동시에 공유 자원(shared resource)에 접근할 때 발생하는 문제를 막는
    • 동기화(synchronization) 기법.

예를 들어, 두 thread가 동시에 같은 변수를 수정하는 상황을 가정함.

counter = 0

# Thread A
counter += 1

# Thread B
counter += 1

결과가 2여야 할 것 같지만, 실제로는 1이 될 수 있음.

 

counter += 1원자적(atomic) 연산처럼 보이지만, 내부적으로는 세 단계로 분해되기 때문임.

  1. counter 값을 읽음 (READ)
  2. 읽은 값에 1을 더함 (ADD)
  3. 결과를 counter에 저장함 (WRITE)

두 thread가 동시에 이 과정을 수행하면, 서로의 WRITE 결과를 덮어씀.

이처럼 실행 순서에 따라 결과가 달라지는 문제를 race condition이라고 함.

 

하지만 CPython에서는 GIL 때문에 한 시점에 하나의 thread만 Python bytecode를 실행하므로,
이처럼 짧은 연산에서는 문제가 잘 드러나지 않음.
이는, lock이 필요 없다는 뜻은 아니지만 augmented assignment가 race condition을 재현하기에 약함을 의미함.

 

이를 방지하기 위해 lock을 사용함.

lock.acquire()   # lock 획득
counter += 1
lock.release()   # lock 반환
  • lock을 획득한 thread만 해당 구간을 실행 가능
  • 다른 thread는 lock이 반환될 때까지 대기(block)
  • lock으로 보호되는 이 구간을 critical section (임계 구역) 이라고 함

Lock을 오래 소유하면 생기는 문제

lock은 공유 자원을 보호하는 데 꼭 필요하지만, 필요 이상으로 오래 잡고 있으면 전체 성능이 나빠짐.

# 잘못된 예: lock을 소유한 채 CPU 작업 없는 I/O 등의 작업을 수행(sleep으로 모사)
lock.acquire()
time.sleep(5)    # CPU에서 아무 일도 안 하는데 lock을 계속 들고 있음
lock.release()

 

이 경우의 문제:

Thread A : lock 획득 → sleep (5초간 CPU를 쓰지 않는 작업 수행.)
Thread B : lock 필요 → 대기
Thread C : lock 필요 → 대기
  • CPU를 사용하지 않는 동안에도 lock은 계속 점유됨: 공유자원과 상관없는 긴 작업을 하면서 공유자원 점거.
  • 다른 thread들은 공유 자원(CPU)에 접근하지 못하고 전부 대기 상태가 됨
  • 전체 throughput(처리량)이 크게 감소함

올바른 방식:

lock.acquire()
counter += 1     # 공유 자원 수정만 lock으로 보호
lock.release()

time.sleep(5)    # 오래 걸리는 작업은 lock 밖에서 수행

lock은
공유 자원을 보호하는 최소 구간에서만
소유해야 함.


Python Threading Lock 예제

threading.Lock을 사용하여 공유 변수 counter를 안전하게 증가시키는 예제.

import threading
import time

counter = 0
lock = threading.Lock()


def worker():
    global counter
    for _ in range(100000):
        # lock 이 있는 경우
        with lock:        # lock 획득 → critical section 실행 → lock 반환
            temp = counter # READ 
            time.sleep(0) # 다른 thread에게 실행 기회 양보 
            counter = temp + 1 # WRITE
        # # lock이 없는 경우.
        # temp = counter # READ 
        # time.sleep(0) # 다른 thread에게 실행 기회 양보 
        # counter = temp + 1 # WRITE


threads = [threading.Thread(target=worker) for _ in range(4)]

for t in threads:
    t.start()

for t in threads:
    t.join()

print(counter)  # 정상 동작 시 400000 출력

 

with lock: 구문은 내부적으로 다음과 동일하게 동작함.

lock.acquire()
try:
    temp = counter
    time.sleep(0)
    counter = temp + 1
finally:
    lock.release()    # 예외가 발생해도 반드시 lock 반환

 

lock을 기다리는 동작은 acquire()가 수행함.
이 경우엔 timeout 지정도 가능함.

acquired = lock.acquire(timeout=3)  # 최대 3초 대기, 실패 시 False 반환

 


Synchronization Object란

Synchronization object는
여러 thread 사이에서 공유 자원 접근을 안전하게 제어하거나,
특정 조건이 만족될 때까지 thread의 진행을 조율하기 위한 동기화 객체임.

 

lock은 "누가 공유 자원에 접근하는가"를 제어하지만,
"언제 작업을 진행할 수 있는가"는 알려주지 않음.

 

예를 들어, producer–consumer 구조를 생각해 보자.

  • Producer : data를 생성
  • Consumer : data가 준비될 때까지 기다렸다가 처리

consumer가 해야 하는 일은 두 가지임.

  1. lock을 획득해서 공유 자원에 안전하게 접근하는 것
  2. "data가 준비되었는가?"라는 조건(condition) 이 만족될 때까지 기다리는 것

이처럼

단순한 lock만으로는 부족한 상황에서 사용하는 것이
다른 synchronization object (동기화 객체) 임.

 

Python에서 제공하는 주요 synchronization object는 다음과 같음.

객체 역할
threading.Lock 하나의 thread만 critical section에 진입하도록 제어
threading.Condition 특정 조건이 만족될 때까지 대기 + lock 관리
threading.Event 특정 이벤트 발생 여부를 thread 간에 알림
threading.Barrier 지정된 수의 thread가 같은 지점에 도달할 때까지 대기
threading.Semaphore 동시에 접근 가능한 thread 수를 제한

 

이 중에서 Condition 의 사용법을 좀 더 자세히 살펴보고, Synchronization Object의 용도를 이해해 보자:


Condition 사용법

threading.Condition

  • lock 기능과
  • "조건 만족 대기" 기능을 함께 제공하는 synchronization object임.
import threading
condition = threading.Condition()   # 내부적으로 lock을 소유함

Condition.wait()

condition.wait()

  • 특정 조건이 만족될 때까지
  • 현재 thread를 대기 상태로 만드는 method임.
with condition:
    condition.wait()

 

핵심:

wait()를 호출하면 condition의 lock을 잠시 반환(release)함.

 

동작 순서:

  1. condition의 lock 획득
  2. condition.wait() 호출
    • condition의 lock 반환  ← 이것이 핵심
    • 현재 thread는 sleep 상태로 대기
  3.  다른 thread가 notify() 또는 notify_all() 호출
  4. 깨어난 thread가 다시 lock 획득
  5. wait() 종료

 

이 동작은 lock을 소유한 채 쉬는 time.sleep()과 근본적으로 다름.

time.sleep(5)    # lock을 소유한 채 쉼: 다른 thread가 lock을 얻을 수 없음
condition.wait() # lock을 반환하고 쉼: 다른 thread가 lock을 얻어 조건을 변경할 수 있음

notify()와 notify_all()

다른 thread가 조건이 만족되었음을 알릴 때 사용함.

with condition:
    # 공유 상태 변경
    condition.notify()      # 대기 중인 thread 하나를 깨움
    # 또는
    condition.notify_all()  # 대기 중인 thread 전부를 깨움

 

주의: notify()를 호출해도 대기 중인 thread가 즉시 실행되는 것은 아님.

  • notify()는 "잠든 thread를 깨우는 신호"일 뿐임.
  • lock은 with condition: 블록이 종료될 때 반환됨.
with condition:          # → lock 획득
    data = "Hello"
    condition.notify()   # → 대기 thread를 깨우지만, lock은 아직 소유 중
                         #   깨어난 thread는 lock을 얻으려 하지만 대기 상태
# ← with 블록 종료 → lock 반환
# 이제서야 깨어난 thread가 lock을 획득하고 wait()에서 빠져나옴

즉, notify() 호출 시점과 대기 thread가 실제로 실행되는 시점은 다름.


Producer–Consumer 예제 (Condition 예제)

import threading
import time

condition = threading.Condition()
data = None


def consumer():
    global data
    with condition:
        while data is None:          # while로 반복 확인 (spurious wakeup 방지)
            print("Consumer: data 대기 중")
            condition.wait()         # lock 반환 후 대기
        print(f"Consumer: data 수신 = {data}")


def producer():
    global data
    time.sleep(2)
    with condition:
        data = "Hello"
        print("Producer: data 생성 완료")
        condition.notify()           # consumer 깨우기


t1 = threading.Thread(target=consumer)
t2 = threading.Thread(target=producer)
t1.start()
t2.start()
t1.join()
t2.join()

실행 흐름을 시간 순서로 정리하면 다음과 같음.

시간   Consumer thread                       Producer thread
────────────────────────────────────────────────────────────
t=0    with condition: → lock 획득
       data is None → True
       condition.wait() 호출
         → lock 반환                         (lock 획득 가능해짐)
         → sleep 상태 진입
                                             time.sleep(2) 실행 중
t=2                                          with condition: → lock 획득
                                             data = "Hello"
                                             condition.notify()
                                               → Consumer를 깨울 준비
                                               → 단, lock은 아직 소유 중
                                             with 블록 종료 → lock 반환

       (lock 획득 대기 중이었음)
       lock 획득 → wait() 종료
       data is None → False → while 탈출
       data 처리
       with 블록 종료 → lock 반환
────────────────────────────────────────────────────────────

핵심 포인트:

  • condition.wait() 호출 시점에 Consumer가 lock을 반환하므로 Producer가 lock을 얻을 수 있음
  • condition.notify() 호출 시점과 Consumer가 실제로 실행되는 시점은 다름
  • Consumer는 Producer의 with 블록이 끝나 lock이 반환된 뒤에야 실행됨

if가 아니라 while을 쓰는 이유:

  • spurious wakeup: 조건이 만족되지 않았는데도 thread가 깨어나는 경우가 OS 수준에서 발생할 수 있
  • wait()에서 깨어난 뒤 반드시 조건을 다시 확인해야 안전함

대기 method 정리

Python에서 "기다린다"는 동작을 하는 method는 여러 종류가 있으며, 각자 역할이 다름.

lock.acquire()
    → lock이 반환될 때까지 대기

thread.join()
    → 특정 thread의 실행 종료를 대기
    → 사용자가 획득한 lock을 자동으로 반환하지 않음

event.wait()
    → Event가 set() 상태가 될 때까지 대기

condition.wait()
    → notify() / notify_all() 호출을 대기
    → 대기하는 동안 condition의 lock을 반환함  ← GIL 이해에 중요

barrier.wait()
    → 지정된 수의 thread가 같은 지점에 도달할 때까지 대기

wait()
"thread 종료를 기다리는 method"로 오해하면 안 됨.
thread 종료 대기는 join()이 담당함.

Condition.wait()를 특별히 강조하는 이유:

  • 단순히 "기다린다"를 넘어서 "기다리는 동안 lock을 반환한다" 는 특성이 있음
  • 이 특성이 뒤에서 설명할 GIL의 I/O 동작과 직접 연결됨

GIL도 결국 Lock이다

GIL (Global Interpreter Lock):
CPython interpreter 자체와
Python object의 내부 상태를
보호하기 위한 전역 lock.

 

일반적인 lock이 특정 변수나 data structure를 보호한다면, GIL은 Python interpreter 전체를 보호함.

Thread A : GIL 획득 → Python bytecode 실행
Thread B : GIL 대기
Thread C : GIL 대기

결과: 여러 thread가 존재하더라도 한 시점에 하나의 thread만 Python bytecode를 실행할 수 있음.


왜 GIL이 필요한가

CPython은 memory 관리를 위해 reference counting (참조 카운팅) 을 사용함.

  • 각 object는 자신을 참조하는 횟수(reference count)를 내부적으로 저장함
  • reference count가 0이 되면 memory에서 해제됨

문제:
여러 thread가 동시에 같은 object의 reference count를 수정하면 race condition이 발생함.

 

GIL을 사용하면:

  • Python object를 조작하는 동안 다른 thread가 개입할 수 없음
  • interpreter 구현이 단순해짐
  • reference count 조작이 안전해짐

CPU-bound vs I/O-bound 작업에서의 차이

CPU-bound 작업

GIL 때문에 기존 CPython의 threading은 CPU-bound 작업의 병렬 처리에 부적합함.

  • 큰 반복 계산
  • 순수 Python 기반 수치 연산
  • Python loop로 작성된 이미지 처리

thread를 여러 개 만들어도
GIL을 가진 하나의 thread만 Python bytecode를 실행하므로,
여러 CPU core를 동시에 활용하는 효과를 얻기 어려움.


I/O-bound 작업

blocking I/O 작업 시 CPython은 일반적으로 GIL을 잠시 해제(release)함.

Thread A:
    GIL 획득 → Python code 실행
    → socket.recv() 호출
    → GIL 반환         ← 네트워크 응답을 기다리는 동안 GIL을 놓아줌
    → 네트워크 응답 대기

Thread B:
    GIL 획득 → Python code 실행   ← Thread A가 대기하는 동안 실행 가능

 

Condition.wait()가 lock을 반환하고 기다리는 것처럼,

blocking I/O도 GIL을 반환하고 기다림:

  • Condition.wait() : condition lock 반환 후 대기
  • Blocking I/O     : GIL 반환 후 대기

따라서 I/O-bound 작업에서는 GIL이 큰 문제가 되지 않음.

 

threading이 효과적으로 사용되는 I/O-bound 상황 예시:

  • 웹 서버 (동시 요청 처리)
  • 웹 크롤러 (다수 URL 동시 요청)
  • REST API 클라이언트 (다수 API 동시 호출)
  • DB 요청이 많은 프로그램

이 경우
각 thread가 CPU를 동시에 사용하는 것은 아니지만,

서로의 I/O 대기 시간을 겹쳐 처리할 수 있으므로 전체 throughput이 향상됨.


최신 CPython의 변화: Free-threaded Python

Python 3.13부터 GIL을 비활성화할 수 있는 free-threaded build가 추가됨.

기존 CPython : 한 번에 하나의 thread만 Python bytecode 실행
Free-threaded CPython: 여러 thread가 여러 CPU core에서 동시 실행 가능
  • Python 3.13: free-threaded build 도입 (실험적)
  • Python 3.14: 공식적으로 지원되는 선택 가능한 build로 격상
  • 기본 Python 실행 파일은 여전히 GIL을 가짐
  • GIL 없이 사용하려면 free-threaded build를 명시적으로 설치해야 함

일부 환경에서는 t suffix가 붙은 interpreter를 사용함.

python3.14t    # t = free-threaded build

Free-threaded Python 사용 시 주의점

GIL이 없다고 해서 기존 코드가 자동으로 안전해지거나 빨라지는 것은 아님.

  • 모든 third-party package가 free-threaded build를 완전히 지원하지는 않음
  • C extension module은 thread-safe하게 작성되어야 함
  • 일부 extension은 호환성 문제로 내부적으로 GIL을 다시 활성화할 수 있음
  • list, dict, set 등의 built-in type은 내부적으로 thread safety를 고려하지만, 전체 program logic의 정확성까지 보장하지는 않음
  • lock contention이 심하면 CPU-bound 작업에서도 기대만큼의 성능이 나오지 않을 수 있음

Free-threaded Python은
"GIL이 없으니 thread를 아무렇게나 써도 된다"는 의미가 아님.
race condition, deadlock, data corruption
가능성을 더 신경 써야 함.


GIL을 우회하는 방법

CPU-bound 병렬 처리가 필요한 경우 다음 방법을 고려할 수 있음.

방법 desc.
multiprocessing 사용 process마다 Python interpreter와 GIL을 각각 가짐.
기존 CPython에서 가장 안정적인 CPU-bound 병렬 처리 방법
Free-threaded CPython Python 3.13+에서 사용 가능.
thread 기반 parallel execution이 필요한 경우 고려
NumPy / OpenCV / PyTorch 등 native library 내부 C/C++ 코드에서 GIL을 release하고 연산함.
기존 CPython에서도 효과적
C/C++ extension 직접 작성 오래 걸리는 native code 구간에서
GIL을 명시적으로 release 가능

정리

GIL (Global Interpreter Lock):

  • CPython에서 Python interpreter 전체와 Python object의 내부 상태를 보호하는 전역 lock
  • 기존 CPython에서 여러 thread가 있어도 한 시점에 하나의 thread만 Python bytecode를 실행할 수 있음

I/O에서의 GIL 동작:

  • blocking I/O 발생 시 CPython은 일반적으로 GIL을 잠시 release함
  • Condition.wait()가 기다리는 동안 lock을 반환하는 것과 같은 원리
CPU-bound 작업:
    GIL을 가진 하나의 thread만 Python bytecode 실행
    → threading으로 CPU 병렬 처리 효과를 얻기 어려움

I/O-bound 작업:
    I/O 대기 중 GIL을 release
    → 다른 thread가 실행 가능
    → threading이 효과적으로 사용될 수 있음

 

최신 Python에서 GIL을 넘어서려면:

  • 기존 방식: multiprocessing 사용
  • 최신 방식: free-threaded CPython (Python 3.13+) 선택
728x90

'Python' 카테고리의 다른 글

Dynamic Scope 란?  (0) 2026.05.24
Python String Literal Concatenation  (0) 2026.05.23
tqdm 간단 사용법  (0) 2026.05.23
uv 를 통한 wheel 빌드하기-uv_build, hatchling  (0) 2026.05.04
pip install 옵션 정리  (0) 2026.05.03