Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 703 – CPython에서 전역 인터프리터 잠금을 선택 사항으로 만들기

Author:
Sam Gross <colesbury at gmail.com>
Sponsor:
Łukasz Langa <lukasz at python.org>
Discussions-To:
Discourse thread
Status:
Final
Type:
Standards Track
Created:
09-Jan-2023
Python-Version:
3.13
Post-History:
09-Jan-2023, 04-May-2023
Resolution:
24-Oct-2023

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Note

운영 위원회는 PEP 703을 수용하지만, 도입은 점진적으로 진행하고 가능한 한 적은 부분만 중단시키며, 지나치게 큰 혼란을 일으키는 것으로 판명된 변경 사항은 되돌릴 수 있어야 한다는 명확한 조건을 제시합니다. 여기에는 필요한 경우 PEP 703 전체를 완전히 되돌리는 것도 포함됩니다(그러한 가능성이 매우 낮거나 바람직하지 않다고 예상하더라도).

Important

This PEP is a historical document. The up-to-date, canonical documentation can now be found at Python support for free threading.

×

See PEP 1 for how to propose changes.

초록

CPython의 전역 인터프리터 잠금(“GIL”)은 여러 스레드가 동시에 Python 코드를 실행하지 못하게 합니다. GIL은 Python에서 멀티코어 CPU를 효율적으로 사용하는 데 장애가 됩니다. 이 PEP는 CPython에 빌드 구성(--disable-gil)을 추가하여 전역 인터프리터 잠금 없이 Python 코드를 실행하고 인터프리터를 스레드 안전하게 만드는 데 필요한 변경 사항을 적용할 것을 제안합니다.

동기

GIL은 동시성에 대한 주요 장애물입니다. 과학 컴퓨팅 작업에서는 대부분의 프로세서 사이클이 최적화된 CPU 또는 GPU 커널에서 사용되므로, 이러한 동시성 부족이 Python 코드 실행 속도보다 더 큰 문제가 되는 경우가 많습니다. GIL은 전역 병목을 발생시켜 다른 스레드가 Python 코드를 호출할 경우 작업을 진행하지 못하게 할 수 있습니다. 오늘날 CPython에서 병렬 처리를 활성화하는 기존 방법이 있지만, 이러한 기법에는 상당한 제한이 따릅니다(Alternatives 참조).

이 절에서는 과학 컴퓨팅, 특히 이 작성자가 가장 많은 경험을 가진 분야인 AI/ML 워크로드에 대한 GIL의 영향을 중점적으로 다루지만, GIL은 Python의 다른 사용자에게도 영향을 줍니다.

GIL은 여러 유형의 병렬 처리를 표현하기 어렵게 만듭니다

신경망 기반 AI 모델은 병렬 처리를 위한 여러 기회를 제공합니다. 예를 들어 개별 연산은 내부적으로 병렬화될 수 있고(“연산 내 병렬 처리”), 여러 연산은 동시에 실행될 수 있으며(“연산 간 병렬 처리”), 여러 연산에 걸친 요청도 병렬화할 수 있습니다. 효율적인 실행을 위해서는 여러 유형의 병렬 처리를 활용해야 합니다 [1].

GIL로 인해 Python에서 연산 간 병렬 처리와 일부 형태의 요청 병렬 처리를 효율적으로 표현하기 어렵습니다. 다른 프로그래밍 언어에서는 시스템이 신경망의 서로 다른 부분을 별도의 CPU 코어에서 실행하기 위해 스레드를 사용할 수 있지만, Python에서는 GIL 때문에 비효율적입니다. 마찬가지로 지연 시간에 민감한 추론 워크로드는 요청 간 병렬 처리를 위해 스레드를 자주 사용하지만, Python에서는 동일한 확장성 병목에 직면합니다.

GIL이 Python에서 병렬 처리를 활용하는 데 초래하는 문제는 강화 학습에서 자주 드러납니다. NetHack Learning Environment의 작성자이자 Inflection AI의 기술 직원인 Heinrich Kuttler는 다음과 같이 씁니다:

Dota 2, StarCraft, NetHack에서와 같은 강화 학습의 최근 획기적인 성과는 비동기 액터-크리틱 방법을 사용하여 여러 환경(시뮬레이션된 게임)을 병렬로 실행하는 데 의존합니다. Python의 단순한 다중 스레드 구현은 GIL 경합 때문에 몇 개를 넘는 병렬 환경으로 확장되지 않습니다. 공유 메모리 또는 UNIX 소켓을 통한 통신을 사용하는 다중 처리는 복잡성을 크게 증가시키며, 사실상 서로 다른 워커에서 CUDA와 상호 작용하는 것을 불가능하게 만들어 설계 공간을 심각하게 제한합니다.

강화 학습 팀의 DeepMind 소프트웨어 엔지니어인 Manuel Kroiss는 GIL이 초래하는 병목으로 인해 Python 코드베이스를 C++로 다시 작성하게 되고 코드의 접근성이 낮아지는 과정을 다음과 같이 설명합니다:

DeepMind에서는 Python GIL과 관련된 문제와 자주 씨름합니다. 많은 애플리케이션에서 프로세스당 50~100개 정도의 스레드를 실행하고 싶습니다. 그러나 스레드가 10개 미만인 경우에도 GIL이 병목이 되는 상황을 자주 봅니다. 이 문제를 우회하기 위해 때때로 서브프로세스를 사용하지만, 많은 경우 프로세스 간 통신의 오버헤드가 지나치게 커집니다. GIL에 대처하기 위해 보통 Python 코드베이스의 상당 부분을 C++로 변환하게 됩니다. 이렇게 하면 연구자들이 코드에 접근하기 어려워지므로 바람직하지 않습니다.

여러 하드웨어 장치와의 인터페이스를 포함하는 프로젝트는 비슷한 과제에 직면합니다. 효율적인 통신을 위해서는 여러 CPU 코어를 사용해야 합니다. Dose-3D 프로젝트는 정밀한 선량 계획을 통해 암 방사선 치료를 개선하는 것을 목표로 합니다. 이 프로젝트는 맞춤형 하드웨어 및 Python으로 작성된 서버 애플리케이션과 함께 의료용 팬텀(인체 조직을 대신하는 물체)을 사용합니다. Dose-3D 프로젝트의 데이터 수집 시스템 수석 소프트웨어 아키텍트인 Paweł Jurgielewicz는 GIL로 인해 발생하는 확장성 문제와 GIL이 없는 Python 포크를 사용하여 프로젝트를 단순화한 방법을 설명합니다.

Dose-3D 프로젝트에서 핵심 과제는 1 Gbit/s UDP/IP 연결을 최대한 활용하면서 하드웨어 장치와 안정적이고 상당히 복잡한 동시 통신 링크를 유지하는 것이었습니다. 당연히 multiprocessing 패키지로 시작했지만, 어느 시점에 데이터 처리 자체가 아니라 데이터 처리 단계 간 데이터 전송에 대부분의 CPU 시간이 소비된다는 점이 분명해졌습니다. GIL을 기반으로 한 CPython 멀티스레딩 구현도 막다른 길이었습니다. Python의 “nogil” 포크에 대해 알게 되었을 때, 한 사람이 반나절도 안 되어 코드베이스를 이 포크에서 사용하도록 조정할 수 있었으며 그 결과는 놀라웠습니다. 이제 데이터 교환 알고리즘을 세밀하게 조정하는 대신 데이터 수집 시스템 개발에 집중할 수 있습니다.

CellProfiler의 저자이자 Prescient Design 및 Genentech의 스태프 엔지니어인 Allen Goodman은 GIL이 Python에서 생물학적 방법 연구를 더 어렵게 만드는 방식을 설명합니다.

Python의 전역 인터프리터 잠금 문제는 생물학적 방법 연구 전반에서 자주 좌절을 일으키는 원인입니다.

현재의 멀티스레딩 상황을 더 잘 이해하고자 다중 서열 정렬의 표준 방법인 HMMER의 일부를 다시 구현했습니다. 이 방법을 선택한 이유는 단일 스레드 성능(스코어링)과 멀티스레드 성능(서열 데이터베이스 검색)을 모두 집중적으로 시험하기 때문입니다. 단 8개의 스레드만 사용했을 때 GIL이 병목이 되었습니다. 이 방법의 현재 널리 사용되는 구현은 프로세스당 64개 또는 무려 128개의 스레드에 의존합니다. 서브프로세스로 전환하려 했지만 감당하기 어려운 IPC 비용 때문에 진행할 수 없었습니다. HMMER는 비교적 기본적인 생물정보학 방법이며, 더 새로운 방법은 훨씬 더 큰 멀티스레딩 요구 사항을 갖습니다.

방법 연구자들은 Python을 사용하기를 간절히 원합니다(저도 포함됩니다). 사용하기 쉽고 Python 생태계가 갖춰져 있으며, “사람들이 아는 것이기” 때문입니다. 많은 생물학자는 프로그래밍을 조금밖에 알지 못하며(거의 항상 Python입니다). Python의 멀티스레딩 상황이 해결될 때까지 C와 C++는 생물학적 방법 연구 커뮤니티의 공용어로 남을 것입니다.

GIL이 Python 라이브러리 사용성에 미치는 영향입니다

GIL은 멀티스레드 병렬성을 제한하는 CPython 구현 세부 사항이므로, 이를 사용성 문제로 보는 것이 직관에 어긋나 보일 수 있습니다. 그러나 라이브러리 작성자는 성능을 매우 중요하게 여기는 경우가 많으며 GIL을 우회하여 작업할 수 있도록 지원하는 API를 설계합니다. 이러한 우회 방법은 사용하기 더 어려운 API로 이어지는 경우가 많습니다. 결과적으로 이러한 API의 사용자는 GIL을 단순한 성능 문제가 아니라 사용성 문제로 경험할 수 있습니다.

예를 들어 PyTorch는 데이터 입력 파이프라인을 구축하기 위한 multiprocessing 기반 API인 DataLoader를 제공합니다. Linux에서는 일반적으로 spawn()보다 더 빠르고 메모리를 덜 사용하기 때문에 fork()를 사용하지만, 이로 인해 사용자에게 추가적인 문제가 발생합니다. GPU에 액세스한 후 DataLoader를 생성하면 이해하기 어려운 CUDA 오류가 발생할 수 있습니다. 프로세스는 CUDA 컨텍스트를 공유하지 않기 때문에(프로세스 내의 스레드와는 다릅니다) DataLoader작업자 내에서 GPU에 액세스하면 빠르게 메모리 부족 오류가 발생합니다.

Inria의 scikit-learn 개발자이자 소프트웨어 엔지니어인 Olivier Grisel은 scikit-learn 관련 라이브러리에서 GIL을 우회해야 하는 일이 어떻게 더 복잡하고 혼란스러운 사용자 경험으로 이어지는지 설명합니다.

수년에 걸쳐 scikit-learn 개발자는 multiprocessing의 일부 제한을 우회하기 위해 joblibloky와 같은 보조 라이브러리를 유지 관리해 왔습니다. 여기에는 대규모 데이터 버퍼의 반자동 메모리 매핑을 통한 추가 메모리 사용량의 부분적 완화, 장시간 실행되는 작업자 풀을 투명하게 재사용하여 느린 작업자 시작 문제 해결, 포크 전용 시작 방식을 사용하지 않음으로써 GNU OpenMP와 같은 서드 파티 네이티브 런타임 라이브러리의 포크 안전성 문제 해결, cloudpickle을 통해 노트북과 REPL에서 대화형으로 정의된 함수를 플랫폼 간 방식으로 병렬 호출할 수 있는 기능이 포함됩니다. 이러한 노력에도 불구하고 이 multiprocessing 기반 솔루션은 여전히 취약하고 유지 관리가 복잡하며, 시스템 수준 제약 조건에 대한 이해가 제한적인 데이터 과학자에게 혼란을 줍니다. 또한 프로세스 간 통신에 필요한 pickle 기반 직렬화 및 역직렬화 단계로 인해 발생하는 오버헤드와 같이 제거할 수 없는 제한도 여전히 존재합니다. 멀티코어 호스트(때로는 64개 이상의 물리적 코어를 갖춘 호스트)에서 경합 없이 스레드를 사용하여 Python 수준 작업과 네이티브 라이브러리 호출을 번갈아 수행하는 데이터 과학 파이프라인을 실행할 수 있다면, 이러한 추가 작업과 복잡성의 상당 부분은 더 이상 필요하지 않을 것입니다.

Quansight Labs의 공동 디렉터이자 NumPy 및 SciPy 유지 관리자인 Ralf Gommers가 GIL이 NumPy 및 수치 Python 라이브러리의 사용자 경험에 어떤 영향을 미치는지 설명합니다:

NumPy와 NumPy를 중심으로 구축된 패키지 스택의 핵심 문제는 NumPy가 여전히 (대부분) 단일 스레드라는 점이며, 이로 인해 사용자 경험과 NumPy를 중심으로 구축된 프로젝트의 상당 부분이 형성되었습니다. NumPy는 내부 루프(실제 계산 작업을 수행하는 부분)에서 GIL을 해제하지만, 그것만으로는 턱없이 부족합니다. NumPy는 단일 시스템의 모든 CPU 코어를 효율적으로 활용할 수 있는 해결책을 제공하지 않으며, 대신 이를 Dask 및 기타 멀티프로세싱 솔루션에 맡깁니다. 이러한 솔루션은 그다지 효율적이지 않고 사용하기도 더 번거롭습니다. 이러한 번거로움은 주로 사용자가 예를 들어 numpy.ndarray를 감싸는 dask.array를 사용할 때 직접 신경 써야 하는 추가 추상화와 계층에서 비롯됩니다. 또한 사용자가 명시적으로 인지하고 환경 변수나 서드파티 패키지인 threadpoolctl을 통해 관리해야 하는 과도한 구독 문제로도 나타납니다. 주된 이유는 NumPy가 선형 대수를 위해 BLAS를 호출하기 때문이며, 이러한 호출은 NumPy가 제어할 수 없고 pthreads 또는 OpenMP를 통해 기본적으로 모든 코어를 사용합니다.

병렬 처리를 제어하기 위한 API와 설계 결정을 조율하는 일은 여전히 상당한 작업량이며, PyData 생태계 전반에서 가장 어려운 과제 중 하나입니다. GIL이 없었다면 상황은 훨씬 달라졌을 것입니다(더 나아지고 더 쉬워졌을 것입니다).

GPU 집약적 워크로드에는 멀티코어 처리가 필요합니다

많은 고성능 컴퓨팅(HPC) 및 AI 워크로드는 GPU를 집중적으로 사용합니다. 이러한 애플리케이션은 계산의 대부분이 GPU에서 실행되더라도 효율적인 멀티코어 CPU 실행을 자주 필요로 합니다.

FAIR(Meta AI)의 PyTorch 핵심 개발자이자 연구원인 Zachary DeVito가 계산의 대부분이 Python 외부에서 수행되는 경우에도 GIL이 멀티스레드 확장을 비효율적으로 만드는 방식을 설명합니다:

PyTorch에서 Python은 일반적으로 ~8개의 GPU와 ~64개의 CPU 스레드를 조정하는 데 사용되며, 대형 모델에서는 4k개의 GPU와 32k개의 CPU 스레드까지 늘어납니다. 실제 작업은 Python 외부에서 수행되지만, GPU의 속도가 빠르기 때문에 Python에서 조정하는 작업만으로도 확장성이 떨어집니다. GIL 때문에 하나의 프로세스 대신 72개의 프로세스를 사용하게 되는 경우가 많습니다. 이러한 환경에서는 로깅, 디버깅 및 성능 조정이 몇 자릿수나 더 어려워지며, 그 결과 개발자 생산성이 지속적으로 낮아집니다.

스레드 대신 많은 프로세스를 사용하면 일반적인 작업이 더 어려워집니다. Zachary DeVito가 계속해서 설명합니다:

지난 몇 달 동안 세 번의 별도 사례(데이터 로더에서 중복 계산 줄이기, 모델 체크포인트를 비동기적으로 작성하기, 컴파일러 최적화를 병렬화하기)에서 특정 문제를 실제로 해결하는 것보다 GIL의 한계를 우회하는 방법을 알아내는 데 열 배나 더 많은 시간을 썼습니다.

GPU 집약적 워크로드에도 CPU를 집중적으로 사용하는 구성 요소가 있는 경우가 많습니다. 예를 들어 컴퓨터 비전 작업은 일반적으로 이미지 디코딩, 자르기 및 크기 조정과 같은 데이터 입력 파이프라인의 여러 “pre-processing” 단계를 필요로 합니다. 이러한 작업은 일반적으로 CPU에서 수행되며 PillowPillow-SIMD와 같은 Python 라이브러리를 사용할 수 있습니다. GPU에 데이터가 “fed”되도록 하려면 여러 CPU 코어에서 데이터 입력 파이프라인을 실행해야 합니다.

개별 CPU 코어와 비교한 GPU 성능의 향상으로 인해 멀티코어 성능이 더욱 중요해졌습니다. GPU를 완전히 활용 상태로 유지하기가 점점 더 어려워지고 있습니다. 이를 위해서는 특히 멀티 GPU 시스템에서 여러 CPU 코어를 효율적으로 사용해야 합니다. 예를 들어 NVIDIA의 DGX-A100에는 GPU에 데이터가 “fed”되도록 8개의 GPU와 64코어 CPU 2개가 탑재되어 있습니다.

GIL은 Python AI 모델의 배포를 어렵게 합니다.

Python은 신경망 기반 AI 모델을 개발하는 데 널리 사용됩니다. PyTorch에서 모델은 대개 멀티스레드 방식의, 대부분 C++로 구성된 환경의 일부로 배포됩니다. GIL이 전역 병목이 되어 효율적인 확장을 방해할 수 있기 때문에 Python은 종종 회의적으로 평가됩니다. 이는 GIL이 해제된 상태에서 계산의 압도적인 대부분이 Python “외부에서” 수행되는데도 그렇습니다. torchdeploy 논문 [2]은 여러 모델 아키텍처에서 이러한 확장 병목 현상을 보여 주는 실험적 증거를 제시합니다.

PyTorch는 GIL을 피하거나 우회하여 Python AI 모델을 배포할 수 있는 여러 메커니즘을 제공하지만, 모두 상당한 한계를 지닙니다. 예를 들어, TorchScript는 Python 의존성 없이 C++에서 실행할 수 있는 모델 표현을 캡처하지만, Python의 제한된 하위 집합만 지원하며 모델 코드의 일부를 다시 작성해야 하는 경우가 많습니다. torch::deploy API는 각자 고유한 GIL을 가진 여러 Python 인터프리터를 동일한 프로세스에서 사용할 수 있도록 합니다(PEP 684와 유사합니다). 그러나 torch::deploy는 C API 확장 기능을 사용하는 Python 모듈에 대한 지원이 제한적입니다.

동기 요약

Python의 전역 인터프리터 잠금으로 인해 많은 과학 및 수치 계산 애플리케이션에서 최신 멀티코어 CPU를 효율적으로 사용하기가 어렵습니다. Heinrich Kuttler, Manuel Kroiss 및 Paweł Jurgielewicz는 Python의 멀티스레드 구현이 자신들의 작업에서 잘 확장되지 않았으며, 여러 프로세스를 사용하는 것도 적절한 대안이 아니라고 판단했습니다.

확장 병목 현상은 핵심 수치 작업에만 존재하는 것이 아닙니다. Zachary DeVito와 Paweł Jurgielewicz는 모두 Python에서 조정 및 통신과 관련된 문제를 설명했습니다.

Olivier Grisel, Ralf Gommers 및 Zachary DeVito는 GIL에 대한 현재의 우회 방법이 “유지 관리하기 복잡”하고 “개발자 생산성 저하”를 초래한다고 설명했습니다. GIL은 과학 및 수치 계산 라이브러리의 개발과 유지 관리를 더욱 어렵게 하며, 그 결과 사용하기 더 어려운 라이브러리 설계로 이어집니다.

사양

빌드 구성 변경 사항

전역 인터프리터 잠금은 CPython 빌드와 python.org 다운로드에서 계속 기본값으로 유지됩니다. 전역 인터프리터 잠금 없이 실행할 수 있도록 CPython을 빌드하는 새로운 빌드 구성 플래그인 --disable-gil이 configure 스크립트에 추가됩니다.

--disable-gil로 빌드하면 CPython은 Python/patchlevel.h에서 Py_GIL_DISABLED 매크로를 정의합니다. ABI 태그에는 “threading”을 의미하는 문자 “t”가 포함됩니다.

CPython의 --disable-gil 빌드는 런타임에 GIL을 활성화한 상태로 선택적으로 실행하는 것도 계속 지원합니다(PYTHONGIL Environment VariablePy_mod_gil Slot 참조).

CPython 변경 사항 개요

전역 인터프리터 잠금을 제거하려면 CPython 내부 구현을 상당히 변경해야 하지만, 공개 Python 및 C API는 비교적 적게 변경하면 됩니다. 이 절에서는 필요한 CPython 구현 변경 사항에 이어 제안된 API 변경 사항을 설명합니다.

구현 변경 사항은 다음 네 가지 범주로 묶을 수 있습니다.

  • 참조 카운팅
  • 메모리 관리
  • 컨테이너 스레드 안전성
  • 잠금 및 원자적 API

참조 카운팅

GIL을 제거하려면 CPython의 참조 카운팅 구현을 변경하여 스레드 안전하게 만들어야 합니다. 또한 실행 오버헤드가 낮고 여러 스레드로 효율적으로 확장할 수 있어야 합니다. 이 PEP는 이러한 제약을 해결하기 위해 세 가지 기법을 조합할 것을 제안합니다. 첫 번째는 일반 비원자적 참조 카운팅에서 편향된 참조 카운팅으로 전환하는 것입니다. 편향된 참조 카운팅은 일반 원자적 참조 카운팅보다 실행 오버헤드가 낮은 스레드 안전 참조 카운팅 기법입니다. 나머지 두 기법은 불멸화와 제한된 형태의 지연 참조 카운팅입니다. 이러한 기법은 일부 참조 카운트 수정 작업을 피하여 참조 카운팅의 멀티스레드 확장성 문제 중 일부를 해결합니다.

편향된 참조 카운팅(BRC)은 Jiho Choi, Thomas Shull, Josep Torrellas가 2018년에 처음 설명한 기법입니다 [3]. 이는 멀티스레드 프로그램에서도 대부분의 객체가 단일 스레드에서만 접근된다는 관찰에 기반합니다. 각 객체는 소유 스레드(해당 객체를 생성한 스레드)와 연결됩니다. 소유 스레드의 참조 카운팅 연산은 비원자적 명령을 사용하여 “로컬” 참조 카운트를 수정합니다. 다른 스레드는 원자적 명령을 사용하여 “공유” 참조 카운트를 수정합니다. 이 설계는 최신 프로세서에서 비용이 많이 드는 원자적 읽기-수정-쓰기 연산을 많이 피합니다.

이 PEP에서 제안하는 BRC 구현은 편향된 참조 카운팅의 원래 설명과 대체로 일치하지만, 참조 카운팅 필드의 크기와 해당 필드의 특수 비트 같은 세부 사항에서는 차이가 있습니다. BRC를 사용하려면 각 객체의 헤더에 세 가지 정보를 저장해야 합니다. “로컬” 참조 카운트, “공유” 참조 카운트, 그리고 소유 스레드의 식별자입니다. BRC 논문에서는 이 세 가지를 하나의 64비트 필드에 패킹합니다. 이 PEP는 참조 카운트 오버플로로 인해 발생할 수 있는 문제를 피하기 위해 각 객체의 헤더에 세 개의 별도 필드를 사용할 것을 제안합니다. 또한 이 PEP는 일반적인 경우에 원자적 연산을 피하는 더 빠른 할당 해제 경로를 지원합니다.

제안된 PyObject 구조체(또는 struct _object라고도 함)는 아래와 같습니다.

struct _object {
  _PyObject_HEAD_EXTRA
  uintptr_t ob_tid;         // owning thread id (4-8 bytes)
  uint16_t __padding;       // reserved for future use (2 bytes)
  PyMutex ob_mutex;         // per-object mutex (1 byte)
  uint8_t ob_gc_bits;       // GC fields (1 byte)
  uint32_t ob_ref_local;    // local reference count (4 bytes)
  Py_ssize_t ob_ref_shared; // shared reference count and state bits (4-8 bytes)
  PyTypeObject *ob_type;
};

ob_tid, ob_ref_local, ob_ref_shared는 편향된 참조 카운팅 구현에 사용됩니다. ob_gc_bits 필드는 이전에 PyGC_Head에 저장되었던 가비지 컬렉션 플래그를 저장하는 데 사용됩니다(Garbage Collection (Cycle Collection) 참조). ob_mutex 필드는 1바이트로 객체별 잠금을 제공합니다.

불멸화

인터닝된 문자열, 작은 정수, 정적으로 할당된 PyTypeObject, 그리고 True, False, None 객체와 같은 일부 객체는 프로그램의 수명 동안 계속 살아 있습니다. 이러한 객체는 로컬 참조 카운트 필드(ob_ref_local)를 UINT32_MAX로 설정하여 불멸 객체로 표시합니다.

Py_INCREFPy_DECREF 매크로는 불멸 객체에 대해 아무 작업도 수행하지 않습니다. 이를 통해 여러 스레드가 이러한 객체에 동시에 접근할 때 해당 객체의 참조 카운트 필드에 대한 경합을 피할 수 있습니다.

이 제안된 불멸화 방식은 Python 3.12에 채택된 PEP 683과 매우 유사하지만, 편향된 참조 카운팅 및 지연 참조 카운팅과 함께 작동하도록 불멸 객체의 참조 카운트 필드에서 비트 표현이 약간 다릅니다. 또한 Why Not Use PEP 683 Immortalization?을 참조하십시오.

편향된 참조 카운팅

편향된 참조 카운팅에는 현재 스레드가 “소유한” 객체를 위한 빠른 경로와 다른 객체를 위한 느린 경로가 있습니다. 소유권은 ob_tid 필드로 표시됩니다. 스레드 ID를 확인하려면 플랫폼별 코드가 필요합니다 [4]. ob_tid의 값이 0이면 해당 객체가 어떤 스레드에도 소유되지 않았음을 나타냅니다.

ob_ref_local필드는 로컬 참조 횟수와 두 개의 플래그를 저장합니다. 최상위 두 비트는 객체가 불멸 객체인지 또는 지연 참조 횟수 계산을 사용하는지를 나타내는 데 사용됩니다(Deferred Reference Counting 참조).

ob_ref_shared필드는 공유 참조 횟수를 저장합니다. 최하위 두 비트는 참조 횟수 계산 상태를 저장하는 데 사용됩니다. 따라서 공유 참조 횟수는 왼쪽으로 두 비트 시프트됩니다. 공유 참조 횟수는 일시적으로 음수가 될 수 있으므로 ob_ref_shared필드는 최하위 비트를 사용합니다. 스레드 간에 incref와 decref의 균형이 맞지 않을 수 있습니다.

가능한 참조 횟수 계산 상태는 다음과 같이 나열됩니다.

  • 0b00 - 기본
  • 0b01 - 약한 참조
  • 0b10 - 대기열 등록
  • 0b11 - 병합

상태는 진행 단계를 형성합니다. 객체는 수명 주기 동안 수치적으로 더 높은 상태로 전환될 수 있습니다. 객체는 “기본” 상태와 “병합” 상태에서만 할당 해제될 수 있습니다. 다른 상태는 할당 해제되기 전에 “병합” 상태로 전환되어야 합니다. 상태를 전환하려면 ob_ref_shared필드에 대해 원자적 비교 및 교환을 수행해야 합니다.

기본(0b00)

객체는 처음에 기본 상태로 생성됩니다. 이 상태만 빠른 할당 해제 코드 경로를 허용합니다. 그렇지 않으면 스레드가 로컬 참조 횟수 필드와 공유 참조 횟수 필드를 병합해야 하며, 이 작업에는 원자적 비교 및 교환이 필요합니다.

이 빠른 할당 해제 코드 경로는 약한 참조를 동시에 역참조할 때 스레드 안전하지 않으므로, 약한 참조가 처음 생성될 때 객체가 현재 “기본” 상태라면 “약한 참조” 상태로 전환됩니다.

마찬가지로 이 빠른 할당 해제 코드 경로는 잠금 없는 리스트 및 딕셔너리 액세스(Optimistically Avoiding Locking 참조)와 함께 사용할 때 스레드 안전하지 않으므로, 소유하지 않는 스레드가 “기본” 상태의 객체를 처음 검색하려고 시도하면 더 느린 잠금 코드 경로로 대체되고 객체가 “약한 참조” 상태로 전환됩니다.

약한 참조(0b01)

약한 참조 및 그보다 높은 상태의 객체는 약한 참조의 역참조뿐 아니라 소유하지 않는 스레드의 잠금 없는 리스트 및 딕셔너리 액세스도 지원합니다. 이러한 객체는 할당 해제 전에 병합 상태로 전환되어야 하며, 이는 “기본” 상태에서 지원되는 빠른 할당 해제 코드 경로보다 비용이 많이 듭니다.

대기열 등록(0b10)

대기열 등록 상태는 소유하지 않는 스레드가 참조 횟수 필드를 병합하도록 요청했음을 나타냅니다. 이는 공유 참조 횟수가 음수가 될 때 발생할 수 있습니다(스레드 간 incref와 decref의 불균형 때문입니다). 객체는 병합할 객체를 보관하는 소유 스레드의 대기열에 삽입됩니다. 소유 스레드는 eval_breaker메커니즘을 통해 알림을 받습니다. 실제로 이 작업은 드뭅니다. 대부분의 객체는 단일 스레드에서만 액세스되며, 여러 스레드에서 액세스되는 객체의 공유 참조 횟수가 음수가 되는 경우도 드뭅니다.

소유 스레드가 종료된 경우, 작업 중인 스레드가 로컬 참조 횟수 필드와 공유 참조 횟수 필드를 즉시 병합하고 병합 상태로 전환합니다.

병합(0b11)

병합 상태는 객체가 어떤 스레드에도 소유되지 않았음을 나타냅니다. 이 상태에서 ob_tid필드는 0이며 ob_ref_local은 사용되지 않습니다. 공유 참조 횟수가 0에 도달하면 객체를 병합 상태에서 할당 해제할 수 있습니다.

참조 횟수 계산 의사 코드

제안된 Py_INCREFPy_DECREF 연산은 다음과 같이 동작해야 합니다(C와 유사한 의사 코드 사용).

// low two bits of "ob_ref_shared" are used for flags
#define _Py_SHARED_SHIFT 2

void Py_INCREF(PyObject *op)
{
  uint32_t new_local = op->ob_ref_local + 1;
  if (new_local == 0)
    return;  // object is immortal
  if (op->ob_tid == _Py_ThreadId())
    op->ob_ref_local = new_local;
  else
    atomic_add(&op->ob_ref_shared, 1 << _Py_SHARED_SHIFT);
}

void Py_DECREF(PyObject *op)
{
  if (op->ob_ref_local == _Py_IMMORTAL_REFCNT) {
    return;  // object is immortal
  }
  if (op->ob_tid == _Py_ThreadId()) {
    op->ob_ref_local -= 1;
    if (op->ob_ref_local == 0) {
      _Py_MergeZeroRefcount(); // merge refcount
    }
  }
  else {
    _Py_DecRefShared(); // slow path
  }
}

void _Py_MergeZeroRefcount(PyObject *op)
{
  if (op->ob_ref_shared == 0) {
    // quick deallocation code path (common case)
    op->ob_tid = 0;
    _Py_Dealloc(op);
  }
  else {
    // slower merging path not shown
  }
}

참조 구현 [15]에는 _Py_MergeZeroRefcount_Py_DecRefShared의 구현이 포함되어 있습니다.

위 내용은 의사 코드라는 점에 유의하십시오. 실제로는 C 및 C++에서 정의되지 않은 동작을 방지하기 위해 구현에서 ob_tidob_ref_local에 접근할 때 “relaxed atomics”를 사용해야 합니다.

지연 참조 횟수 계산

최상위 함수, 코드 객체, 모듈 및 메서드와 같은 일부 객체 유형은 여러 스레드에서 동시에 자주 액세스되는 경향이 있습니다. 이러한 객체는 프로그램의 수명 동안 반드시 존재하는 것은 아니므로, 불멸화는 적합하지 않습니다. 이 PEP에서는 다중 스레드 프로그램에서 이러한 객체의 참조 횟수 필드에 대한 경합을 피하기 위해 제한적인 형태의 지연 참조 횟수 계산을 제안합니다.

일반적으로 인터프리터는 객체를 인터프리터 스택에 푸시하거나 스택에서 팝할 때 객체의 참조 횟수를 수정합니다. 인터프리터는 지연 참조 횟수 계산을 사용하는 객체에 대해서는 이러한 참조 횟수 계산 연산을 건너뜁니다. 지연 참조 횟수 계산을 지원하는 객체는 로컬 참조 횟수 필드의 최상위 비트 두 개를 1로 설정하여 표시합니다.

일부 참조 횟수 계산 연산을 건너뛰므로 참조 횟수 필드는 더 이상 이러한 객체에 대한 실제 참조 수를 반영하지 않습니다. 실제 참조 횟수는 참조 횟수 필드의 합에 각 스레드의 인터프리터 스택에서 건너뛴 참조를 더한 값입니다. 실제 참조 횟수는 순환 가비지 컬렉션 중 모든 스레드가 일시 중지된 경우에만 안전하게 계산할 수 있습니다. 따라서 지연 참조 횟수 계산을 사용하는 객체는 가비지 컬렉션 주기 중에만 할당 해제할 수 있습니다.

지연 참조 횟수 계산을 사용하는 객체는 CPython에서 이미 자연스럽게 참조 순환을 형성하므로, 지연 참조 횟수 계산이 없더라도 일반적으로 가비지 컬렉터에 의해 할당 해제된다는 점에 유의하십시오. 예를 들어 최상위 함수와 모듈은 참조 순환을 형성하며, 메서드와 타입 객체도 마찬가지입니다.

지연 참조 횟수 계산을 위한 가비지 컬렉터 수정

추적 가비지 컬렉터는 참조되지 않는 객체를 찾아 할당 해제합니다. 현재 추적 가비지 컬렉터는 참조 순환의 일부인 참조되지 않는 객체만 찾습니다. 지연 참조 횟수 계산을 사용하면 추적 가비지 컬렉터는 어떤 참조 순환에도 속하지 않을 수 있지만 지연 참조 횟수 계산으로 인해 수집이 지연된 일부 참조되지 않는 객체도 찾아 수집합니다. 이를 위해서는 지연 참조 횟수 계산을 지원하는 모든 객체에 추적 가비지 컬렉션을 지원하는 해당 타입 객체가 있어야 합니다(Py_TPFLAGS_HAVE_GC플래그를 통해 지원). 또한 가비지 컬렉터는 각 컬렉션 시작 시 각 스레드의 스택을 순회하여 GC 참조 횟수에 참조를 추가해야 합니다.

참조 횟수 계산 타입 객체

타입 객체(PyTypeObject)는 여러 참조 횟수 계산 기법을 혼합하여 사용합니다. 정적으로 할당된 타입 객체는 이미 프로그램의 수명 동안 존재하므로 불멸화됩니다. 힙 타입 객체는 스레드별 참조 횟수 계산과 함께 지연 참조 횟수 계산을 사용합니다. 힙 타입에 대한 대부분의 참조가 인터프리터 스택의 참조가 아니라 객체 인스턴스에서 발생하므로, 지연 참조 횟수 계산만으로는 다중 스레드 환경에서 힙 타입의 확장성 병목을 해결하기에 충분하지 않습니다.

이를 해결하기 위해 힙 타입 참조 횟수의 일부를 스레드별 배열에 분산하여 저장합니다. 각 스레드는 모든 힙 타입 객체에 대한 로컬 참조 횟수 배열을 저장합니다. 힙 타입 객체에는 고유 번호가 할당되며, 이 번호가 로컬 참조 횟수 배열에서의 위치를 결정합니다. 힙 타입의 실제 참조 횟수는 스레드별 배열에 있는 해당 항목들의 합계에 PyTypeObject의 참조 횟수와 인터프리터 스택의 지연된 참조를 더한 값입니다.

스레드는 타입 객체의 로컬 참조 횟수를 증가시키거나 감소시킬 때 필요에 따라 자체 타입 참조 횟수 배열을 확장할 수 있습니다.

스레드별 참조 횟수 배열의 사용은 몇몇 위치로 제한됩니다.

  • PyType_GenericAlloc(PyTypeObject *type, Py_ssize_t nitems): type이 힙 타입인 경우 현재 스레드의 로컬 참조 횟수를 증가시킵니다.
  • subtype_dealloc(PyObject *self): 현재 스레드의 self->ob_type에 대한 로컬 참조 횟수를 감소시키며, 해당 타입이 힙 타입인 경우에만 수행합니다.
  • gcmodule.c: 각 스레드의 로컬 참조 횟수를 해당 힙 타입 객체의 gc_refs 횟수에 더합니다.

또한 스레드가 종료되면 0이 아닌 로컬 참조 횟수를 각 타입 객체 자체의 참조 횟수 필드에 더합니다.

메모리 관리

CPython은 현재 소형 객체 할당에 최적화된 내부 할당자인 pymalloc을 사용합니다. pymalloc 구현은 GIL 없이는 스레드 안전하지 않습니다. 이 PEP는 pymalloc을 소형 할당을 포함하여 성능이 우수한 범용 스레드 안전 할당자인 mimalloc으로 교체할 것을 제안합니다.

일부 수정 사항과 함께 mimalloc을 사용하면 GIL 제거와 관련된 두 가지 다른 문제도 해결할 수 있습니다. 첫째, 내부 mimalloc 구조를 순회하면 연결 리스트를 유지하지 않고도 가비지 컬렉터가 모든 Python 객체를 찾을 수 있습니다. 이에 대해서는 가비지 컬렉션 절에서 더 자세히 설명합니다. 둘째, mimalloc 힙과 크기 클래스에 기반한 할당을 사용하면 딕셔너리와 같은 컬렉션이 일반적으로 읽기 전용 작업 중에 잠금을 획득하지 않아도 됩니다. 이에 대해서는 컬렉션 스레드 안전성 절에서 더 자세히 설명합니다.

CPython은 이미 가비지 컬렉션을 지원하는 객체가 GC 할당자 API를 사용하도록 요구하며, 일반적으로 PyType_GenericAlloc을 간접적으로 호출하여 사용합니다. 이 PEP는 Python 할당자 API 사용에 추가 요구 사항을 부과합니다. 첫째, Python 객체는 PyType_GenericAlloc, PyObject_Malloc 또는 이러한 호출을 래핑하는 다른 Python API와 같은 객체 할당 API를 통해 할당해야 합니다. Python 객체는 C의 malloc을 직접 호출하거나 C++의 new 연산자를 사용하는 것과 같은 다른 API를 통해 할당해서는 안 됩니다. 또한 PyObject_Malloc은 Python 객체를 할당하는 데만 사용해야 하며, PyObject가 아닌 버퍼, 저장 공간 또는 기타 데이터 구조를 할당하는 데 사용해서는 안 됩니다.

이 PEP는 플러그형 할당자 API(PyMem_SetAllocator)에도 제한을 부과합니다. GIL 없이 컴파일할 때 이 API를 사용하여 설정한 할당자는 Python 객체를 할당하는 경우 PyObject_Malloc과 같은 해당 기본 할당자에 최종적으로 할당을 위임해야 합니다. 이를 통해 Python의 tracemalloc 및 디버그 할당자와 같이 기본 할당자를 “래핑”하는 할당자를 사용할 수 있지만, 할당자를 완전히 대체할 수는 없습니다.

CPython 프리 리스트

CPython은 튜플과 숫자처럼 작고 자주 할당되는 객체의 할당 속도를 높이기 위해 프리 리스트를 사용합니다. 이러한 프리 리스트는 인터프리터별 상태에서 PyThreadState로 이동합니다.

가비지 컬렉션(순환 참조 수집)

CPython 가비지 컬렉터가 이 제안과 함께 작동하려면 다음과 같은 변경이 필요합니다.

  • GIL이 이전에 제공하던 스레드 안전성 보장을 제공하기 위해 “stop-the-world”를 사용합니다.
  • 세대별 가비지 컬렉션을 제거하고 비세대별 컬렉터를 사용합니다.
  • 지연 참조 카운팅 및 편향 참조 카운팅과 통합합니다.

또한 위의 변경으로 GC 객체에서 _gc_prev_gc_next 필드를 제거할 수 있습니다. 추적됨, 종료됨 및 도달 불가능함 상태를 저장하던 GC 비트가 PyObject 헤더의 ob_gc_bits 필드로 이동합니다.

Stop-the-World

현재 CPython 순환 가비지 컬렉터는 컬렉터가 순환을 찾는 동안 다른 스레드가 Python 객체에 액세스하지 못하도록 전역 인터프리터 잠금에 의존합니다. 순환 탐색 루틴 중에는 GIL이 절대 해제되지 않으므로, 컬렉터는 해당 루틴이 진행되는 동안 참조 횟수와 참조가 안정적(즉, 변하지 않음)이라고 가정할 수 있습니다. 그러나 순환 탐색 후에는 객체의 종료자와 clear (tp_clear) 함수를 호출하는 동안 GIL이 일시적으로 해제될 수 있으며, 이에 따라 다른 스레드가 서로 교차하는 방식으로 실행될 수 있습니다.

GIL 없이 실행할 때는 순환 탐색 중 참조 횟수가 안정적으로 유지되도록 보장하는 방법이 구현에 필요합니다. 참조와 참조 횟수가 안정적으로 유지되도록 Python 코드를 실행 중인 스레드를 일시 중지해야 합니다. 순환이 식별되면 다른 스레드를 재개합니다.

현재 CPython 순환 가비지 컬렉터는 각 가비지 컬렉션 주기마다 두 번의 순환 탐색 패스를 수행합니다. 따라서 GIL 없이 가비지 컬렉터를 실행할 때는 두 번의 stop-the-world 일시 중지가 필요합니다. 첫 번째 순환 탐색 패스는 순환 쓰레기를 식별합니다. 두 번째 패스는 종료자 실행 후에도 도달 불가능한 상태로 남아 있는 객체를 식별하기 위해 실행됩니다. 현재 CPython 동작에는 없는 잠재적 교착 상태가 발생하지 않도록 종료자와 tp_clear 함수가 호출되기 전에 다른 스레드를 재개한다는 점에 유의하십시오.

스레드 상태

가비지 컬렉션을 위해 스레드를 일시 중지할 수 있도록 PyThreadState에 새로운 “status” 필드가 추가됩니다. PyThreadState의 다른 필드와 마찬가지로 status 필드는 공개 CPython API의 일부가 아닙니다. status 필드는 세 가지 상태 중 하나일 수 있습니다.

  • ATTACHED
  • DETACHED
  • GC

ATTACHEDDETACHED 상태는 전역 인터프리터 잠금을 획득하고 해제하는 것에 각각 밀접하게 대응합니다. GIL 없이 컴파일할 때 이전에 GIL을 획득하던 함수는 대신 스레드 상태를 ATTACHED로 전환하고, 이전에 GIL을 해제하던 함수는 스레드 상태를 DETACHED로 전환합니다. 이전에 스레드가 Python 객체에 액세스하거나 객체를 수정하기 전에 GIL을 획득해야 했던 것처럼, 이제는 Python 객체에 액세스하거나 객체를 수정하기 전에 ATTACHED 상태에 있어야 합니다. 이전에 GIL을 획득했던 것과 동일한 공개 C-API 함수가 스레드를 “attach”하므로(예: PyEval_RestoreThread), 확장에서 스레드를 초기화하는 데 필요한 사항은 동일하게 유지됩니다. 중요한 차이점은 이제 여러 스레드가 동시에 attached 상태에 있을 수 있다는 점이며, 이전에는 한 번에 하나의 스레드만 GIL을 획득할 수 있었습니다.

세계 정지 일시 중지 중에는 가비지 컬렉션을 수행하는 스레드가 다른 스레드가 Python 객체에 액세스하거나 이를 수정하고 있지 않은지 확인해야 합니다. 다른 모든 스레드는 “GC” 상태에 있어야 합니다. 가비지 컬렉션 스레드는 상태 필드에 대한 원자적 비교 및 교환 연산을 사용하여 다른 스레드를 DETACHED 상태에서 GC 상태로 전환할 수 있습니다. ATTACHED 상태의 스레드는 기존 “eval breaker” 메커니즘을 사용하여 스스로 일시 중지하고 상태를 “GC”로 설정하도록 요청됩니다. 세계 정지 일시 중지가 끝나면 “GC” 상태의 모든 스레드가 DETACHED로 설정되며, 일시 중지되어 있는 경우 깨어납니다. 이전에 연결되어 있던 스레드(즉, Python 바이트코드를 실행 중이던 스레드)는 다시 연결하여(스레드 상태를 ATTACHED로 설정하여) Python 코드 실행을 재개할 수 있습니다. 이전에 DETACHED였던 스레드는 알림을 무시합니다.

세대

기존 Python 가비지 컬렉터는 세 세대를 사용합니다. GIL 없이 컴파일하는 경우 가비지 컬렉터는 단일 세대만 사용합니다(즉, 세대 구분을 사용하지 않습니다). 이러한 변경의 주된 이유는 멀티스레드 애플리케이션에서 세계 정지 일시 중지의 영향을 줄이기 위한 것입니다. 젊은 세대를 수집하기 위한 잦은 세계 정지 일시 중지는 빈도가 낮은 수집보다 멀티스레드 애플리케이션에 더 큰 영향을 미칩니다.

지연 및 편향 참조 카운팅과의 통합

순환 가비지 컬렉터는 참조되지 않는 객체를 찾기 위해 유입 참조 수와 객체의 참조 횟수 간의 차이를 계산합니다. 이 차이를 gc_refs라고 하며 _gc_prev 필드에 저장합니다. gc_refs가 0보다 크면 객체가 살아 있음(즉, 순환 쓰레기가 아님)이 보장됩니다. gc_refs가 0이면 객체는 다른 살아 있는 객체가 전이적으로 참조하는 경우에만 살아 있습니다. 이 차이를 계산할 때 컬렉터는 각 스레드의 스택을 순회하고, 지연된 각 참조에 대해 해당 참조가 가리키는 객체의 gc_refs를 증가시켜야 합니다. 제너레이터 객체에도 지연된 참조가 있는 스택이 있으므로, 동일한 절차를 각 제너레이터의 스택에 적용합니다.

Python 단위 테스트에서는 참조되지 않는 객체가 소멸되고 해당 종료자가 실행되도록 보장하기 위해 일반적으로 gc.collect()를 사용합니다. 편향 참조 카운팅은 여러 스레드가 참조하는 일부 객체의 소멸을 지연시킬 수 있으므로, 해당 객체가 참조 순환의 일부가 아니더라도 가비지 컬렉션 중에 소멸되도록 보장하는 것이 편리합니다. 다른 스레드가 일시 중지된 동안 가비지 컬렉션 스레드는 대기 중인 객체의 참조 횟수를 병합해야 하지만, 병합된 참조 횟수가 0인 경우에도 소멸자를 호출해서는 안 됩니다. (다른 스레드가 일시 중지된 동안 소멸자를 호출하면 교착 상태가 발생할 위험이 있습니다.) 다른 스레드가 재개된 후 GC 스레드는 병합된 참조 횟수가 0인 객체에 대해 _Py_Dealloc을 호출해야 합니다.

컨테이너 스레드 안전성

CPython에서 전역 인터프리터 잠금은 여러 스레드가 동시에 Python 객체에 액세스하거나 이를 수정할 때 내부 인터프리터 상태가 손상되지 않도록 보호합니다. 예를 들어 여러 스레드가 동시에 동일한 리스트를 수정하는 경우, GIL은 리스트의 길이(ob_size)가 요소 수와 정확히 일치하고 각 요소의 참조 횟수가 해당 요소에 대한 참조 수를 정확히 반영하도록 보장합니다. GIL이 없고 다른 변경 사항도 없다면 동시 수정으로 인해 이러한 필드가 손상되고 프로그램 충돌이 발생할 가능성이 높습니다.

GIL이 연산이 원자적이거나 여러 연산이 동시에 발생할 때에도 올바른 상태로 유지되도록 반드시 보장하는 것은 아닙니다. 예를 들어 이터러블에 Python으로 구현된 이터레이터가 있거나 내부적으로 GIL을 해제하는 경우 list.extend(iterable)는 원자적으로 보이지 않을 수 있습니다. 마찬가지로 동등성 연산자의 구현에 따라 list.remove(x)는 리스트를 수정하는 다른 연산과 겹칠 경우 잘못된 객체를 제거할 수 있습니다. 그럼에도 GIL은 일부 연산이 사실상 원자적이도록 보장합니다. 예를 들어 생성자 list(set)은 집합의 항목을 새 리스트에 원자적으로 복사하며, 일부 코드는 해당 복사가 원자적이라는 사실에 의존합니다(즉, 집합 항목의 스냅샷을 얻는다는 의미입니다). 이 PEP는 해당 속성을 보존합니다.

이 PEP는 GIL이 제공하는 보호 기능과 동일한 보호 기능을 여러 가지 제공하기 위해 객체별 잠금을 사용할 것을 제안합니다. 예를 들어 모든 리스트, 딕셔너리 및 집합에는 연결된 경량 잠금이 있습니다. 객체를 수정하는 모든 연산은 해당 객체의 잠금을 보유해야 합니다. 객체에서 읽는 대부분의 연산도 객체의 잠금을 획득해야 합니다. 잠금을 보유하지 않고 진행할 수 있는 몇 안 되는 읽기 연산은 아래에 설명합니다.

임계 구역과 함께 사용하는 객체별 잠금은 GIL보다 약한 보호 기능을 제공합니다. GIL이 동시 연산의 원자성이나 정확성을 반드시 보장하지는 않으므로, 객체별 잠금 방식 역시 동시 연산의 원자성이나 정확성을 보장할 수 없습니다. 대신 객체별 잠금은 GIL과 유사한 보호 기능을 목표로 하지만, 상호 배제를 개별 객체로 제한합니다.

컨테이너 형식의 인스턴스에 대한 대부분의 연산에는 해당 객체를 잠그는 작업이 필요합니다. 예를 들면 다음과 같습니다.

  • list.append, list.insert, list.repeat, PyList_SetItem
  • dict.__setitem__, PyDict_SetItem
  • list.clear, dict.clear
  • list.__repr__, dict.__repr__
  • list.extend(iterable)
  • setiter_iternext

일부 연산은 두 컨테이너 객체의 내부 구조를 모두 알고 두 컨테이너 객체에서 직접 작동합니다. 예를 들어 set과 같은 특정 이터러블 형식에 대해 list.extend(iterable)의 내부 특수화가 있습니다. 이러한 연산은 두 객체의 내부에 동시에 액세스하므로 두 컨테이너 객체를 모두 잠가야 합니다. list.extend의 일반 구현은 다른 객체에 스레드 안전 이터레이터 API를 통해 간접적으로 액세스하므로 한 객체, 즉 리스트만 잠그면 됩니다. 두 컨테이너를 잠그는 연산은 다음과 같습니다.

  • list.extend(list), list.extend(set), list.extend (dictitems) 및 인자 형식에 맞게 구현이 특수화된 기타 특수화
  • list.concat(list)
  • list.__eq__(list), dict.__eq__(dict)

일부 단순한 연산은 단일 필드에만 액세스하므로 원자적 접근을 통해 직접 구현할 수 있으며 잠금이 필요하지 않습니다. 이러한 연산에는 다음이 포함됩니다.

  • len(list), 즉 list_length(PyListObject *a)
  • len(dict)
  • len(set)

일부 특정 연산은 성능을 향상하기 위해 낙관적으로 잠금을 피합니다. 이러한 연산에는 특수한 구현과 메모리 할당자의 협력이 필요합니다.

  • list[idx] (list_subscript)
  • dict[key] (dict_subscript)
  • listiter_next, dictiter_iternextkey/value/item
  • list.contains

빌린 참조

객체별 잠금은 GIL이 제공하는 중요한 보호 기능을 대부분 제공하지만, 충분하지 않은 경우도 몇 가지 있습니다. 예를 들어, 빌린 참조를 “소유된” 참조로 승격하는 데 의존하는 코드는 특정 상황에서 안전하지 않을 수 있습니다.

PyObject *item = PyList_GetItem(list, idx);
Py_INCREF(item);

GIL은 접근과 Py_INCREF 호출 사이에 다른 스레드가 리스트를 수정할 수 없도록 보장합니다. GIL이 없으면 – 객체별 잠금이 있더라도 – 다른 스레드가 리스트를 수정하여 접근과 Py_INCREF 호출 사이에 item이 해제될 수 있습니다.

문제가 되는 빌린 참조 API에는 “새 참조”를 반환하지만 그 밖에는 동일한 기능을 하는 함수가 추가로 제공됩니다.

  • PyList_GetItem에 해당하는 PyList_FetchItem(list, idx)
  • PyDict_GetItem에 해당하는 PyDict_FetchItem(dict, key)
  • PyWeakref_GetObject에 해당하는 PyWeakref_FetchObject

PyTuple_GetItem과 같이 빌린 참조를 반환하는 일부 API는 튜플이 변경 불가능하므로 문제가 되지 않는다는 점에 유의하십시오. 마찬가지로 위 API의 모든 사용이 문제가 되는 것은 아닙니다. 예를 들어, PyDict_GetItem은 함수 호출에서 키워드 인자 딕셔너리를 구문 분석하는 데 자주 사용되며, 이러한 키워드 인자 딕셔너리는 사실상 비공개입니다(다른 스레드에서 접근할 수 없습니다).

Python 임계 구역

단순한 객체별 잠금은 GIL을 사용하여 실행할 때는 존재하지 않았던 교착 상태를 일으킬 수 있습니다. Python 연산은 중첩될 수 있으므로 스레드는 여러 객체에 대한 잠금을 동시에 보유할 수 있습니다. 객체에 대한 연산은 다른 객체에 대한 연산을 호출하여 여러 객체별 잠금을 획득할 수 있습니다. 스레드가 동일한 잠금을 서로 다른 순서로 획득하려고 하면 교착 상태가 발생합니다.

이 PEP는 교착 상태를 방지하기 위해 객체별 잠금을 암묵적으로 해제하는 “Python 임계 구역”이라는 방식을 제안합니다. 이 방식을 이해하기 위해 먼저 교착 상태를 방지하는 일반적인 접근법을 소개한 다음, 더 나은 성능을 제공하도록 그 접근법을 개선한 방법을 제안합니다.

교착 상태를 방지하는 한 가지 방법은 스레드가 한 번에 하나의 연산에 대한 잠금(또는 잠금들)만 보유하도록 허용하는 것입니다(일반적으로는 단일 잠금이지만, 위에서 설명한 것처럼 일부 연산에는 두 개의 잠금이 필요합니다). 스레드가 중첩된 연산을 시작할 때는 바깥쪽 연산에 대한 잠금을 일시 중지해야 합니다. 중첩된 연산을 시작하기 전에 바깥쪽 연산의 잠금을 해제하고, 중첩된 연산이 완료되면 바깥쪽 연산의 잠금을 다시 획득합니다.

또한 I/O와 같이 차단될 가능성이 있는 연산(즉, GIL을 해제했을 연산)을 수행하는 동안에는 활성 연산에 대한 잠금도 일시 중지해야 합니다. 잠금과 차단 연산의 상호 작용은 여러 잠금 간의 상호 작용과 마찬가지로 교착 상태를 일으킬 수 있기 때문입니다.

성능을 개선하기 위해 이 PEP는 교착 상태를 방지하면서도 위 방식의 변형을 제안합니다. 중첩된 연산이 시작될 때마다 즉시 잠금을 일시 중지하는 대신, 스레드가 차단될 경우에만(즉, GIL을 해제했을 경우에만) 잠금을 일시 중지합니다. 이렇게 하면 교착 상태를 방지하면서 중첩된 연산에 필요한 잠금 획득 및 해제 횟수를 줄일 수 있습니다.

제안된 Python 임계 구역 API는 다음 네 매크로입니다. 이러한 매크로는 공개 API(C API 확장에서 사용할 수 있는 API)로 제공하도록 의도되었지만, 제한 API의 일부는 아닙니다.

  • Py_BEGIN_CRITICAL_SECTION(PyObject *op);: 참조된 객체의 뮤텍스를 획득하여 임계 구역을 시작합니다. 객체가 이미 잠겨 있으면, 이 스레드가 참조된 객체의 잠금이 해제될 때까지 기다리기 전에 현재 완료되지 않은 임계 구역의 잠금을 해제합니다.
  • Py_END_CRITICAL_SECTION;: 가장 최근 작업을 종료하고 뮤텍스의 잠금을 해제합니다. 그다음으로 최근의 이전 임계 영역이 있는 경우 현재 일시 중단되어 있으면 재개합니다.
  • Py_BEGIN_CRITICAL_SECTION2(PyObject *a, PyObject *b);: 두 객체의 뮤텍스를 획득하여 임계 영역을 시작합니다. 일관된 잠금 순서를 보장하기 위해 획득 순서는 메모리 주소로 결정됩니다(즉, 메모리 주소가 더 낮은 뮤텍스를 먼저 획득합니다). 어느 한 뮤텍스라도 이미 잠겨 있으면, 이 스레드가 참조된 객체의 잠금이 해제되기를 기다리기 전에 보류 중인 모든 임계 영역의 잠금이 해제됩니다.
  • Py_END_CRITICAL_SECTION2;: Py_END_CRITICAL_SECTION과 동일하게 동작하지만 두 객체의 잠금을 해제합니다.

또한 스레드가 ATTACHED 상태에서 DETACHED 상태로 전환할 때는 활성 상태인 임계 영역을 모두 일시 중단해야 합니다. DETACHED 상태에서 ATTACHED 상태로 전환할 때는 일시 중단된 임계 영역 중 가장 최근의 것이 있으면 재개해야 합니다.

두 컨테이너를 동시에 잠그는 연산에서는 Py_BEGIN_CRITICAL_SECTION2매크로를 사용해야 한다는 점에 유의하십시오. Py_BEGIN_CRITICAL_SECTION을 두 번 호출하여 중첩하는 것만으로는 충분하지 않습니다. 내부 임계 영역이 외부 임계 영역의 잠금을 해제할 수 있기 때문입니다.

낙관적으로 잠금 피하기

dictlist의 일부 연산은 객체별 잠금 획득을 낙관적으로 피합니다. 이러한 연산에는 잠금을 획득하지 않는 빠른 경로가 있지만, 다른 스레드가 해당 컨테이너를 동시에 수정하는 경우 딕셔너리 또는 리스트의 잠금을 획득하는 느린 연산으로 대체될 수 있습니다.

낙관적인 빠른 경로를 사용하는 연산은 다음과 같습니다.

  • PyDict_FetchItem/GetItemdict.__getitem__
  • PyList_FetchItem/GetItemlist.__getitem__

또한 dictlist의 이터레이터는 위 함수를 사용하므로 다음 항목을 반환할 때 잠금 획득을 낙관적으로 피합니다.

이러한 함수에서 잠금 획득을 피하는 데에는 두 가지 동기가 있습니다. 주된 이유는 단순한 애플리케이션에서도 확장 가능한 멀티스레드 성능을 위해 이것이 필요하기 때문입니다. 딕셔너리에는 모듈의 최상위 함수와 클래스의 메서드가 저장됩니다. 이러한 딕셔너리는 멀티스레드 프로그램에서 본질적으로 여러 스레드가 광범위하게 공유합니다. 멀티스레드 프로그램에서 메서드와 함수를 로드할 때 이러한 잠금에 대한 경합이 발생하면 많은 기본 프로그램에서 효율적인 확장이 저해됩니다.

잠금 획득을 피하는 두 번째 동기는 오버헤드를 줄이고 단일 스레드 성능을 향상하는 것입니다. 잠금 획득은 대부분의 연산에 비해 오버헤드가 작지만, 리스트와 딕셔너리의 개별 요소에 대한 접근은 빠른 연산이므로 잠금 오버헤드가 상대적으로 더 크고, 이러한 접근이 빈번하게 수행되므로 오버헤드의 영향도 더 큽니다.

이 절에서는 잠금 없이 딕셔너리와 리스트에 접근할 때의 과제를 설명한 다음, 이러한 과제를 해결하는 데 필요한 이 PEP의 Python 인터프리터 변경 사항을 설명합니다.

가장 큰 과제는 리스트나 딕셔너리에서 항목을 가져오고 해당 항목의 참조 횟수를 증가시키는 작업이 원자적 연산이 아니라는 점입니다. 항목을 가져온 시점과 참조 횟수를 증가시키는 시점 사이에 다른 스레드가 리스트나 딕셔너리를 수정하여, 이전에 가져온 항목의 메모리를 해제할 가능성이 있습니다.

이 문제를 해결하기 위한 부분적인 시도는 참조 횟수 증가를 조건부 증가로 변경하여, 참조 횟수가 0이 아닌 경우에만 증가시키는 것입니다. 그러나 Python 객체의 참조 횟수가 0에 도달하면 객체의 소멸자가 호출되고, 객체를 저장하던 메모리가 다른 데이터 구조에 재사용되거나 운영 체제에 반환될 수 있으므로 이 변경만으로는 충분하지 않습니다. 대신 이 PEP에서는 접근하는 동안 참조 횟수 필드가 유효한 상태로 유지되도록 하여 조건부 참조 횟수 증가가 안전하도록 보장하는 기법을 제안합니다. 이 기법을 사용하려면 메모리 할당자(mimalloc)의 협력뿐 아니라 리스트 및 딕셔너리 객체의 변경도 필요합니다. 제안된 기법은 Linux 커널에서 널리 사용되는 동기화 메커니즘인 read-copy update (RCU) [5]와 유사합니다.

현재 list.__getitem__을 구현하는 C 함수인 list_item의 구현은 다음과 같습니다:

Py_INCREF(a->ob_item[i]);
return a->ob_item[i];

제안된 구현은 조건부 증가(_Py_TRY_INCREF)를 사용하며 추가 검사를 수행합니다:

 PyObject **ob_item = atomic_load(&a->ob_item);
 PyObject *item = atomic_load(&ob_item[i]);
 if (!item || !_Py_TRY_INCREF(item)) goto retry;
 if (item != atomic_load(&ob_item[i])) {
   Py_DECREF(item);
   goto retry;
 }
 if (ob_item != atomic_load(&a->ob_item)) {
   Py_DECREF(item);
   goto retry;
}
return item;

“retry” 서브루틴은 리스트에 대한 동시 수정으로 인해 위의 빠른 비잠금 경로가 실패할 때 잠금된 대체 경로를 구현합니다:

retry:
  PyObject *item;
  Py_BEGIN_CRITICAL_SECTION(a->ob_mutex);
  item = a->ob_item[i];
  Py_INCREF(item);
  Py_END_CRITICAL_SECTION(a->ob_mutex);
  return item;

dict의 구현 수정 사항도 유사합니다. 리스트와 딕셔너리 검색의 관련 부분이 모두 알려진 인덱스의 배열에서 항목/값을 로드하기 때문입니다.

조건부 증가 이후의 추가 검사는 이 방식이 메모리를 즉시 재사용하도록 허용하기 때문에 필요합니다. 여기에는 이전에 PyObject 구조체나 list 또는 dict 배열을 저장했던 메모리도 포함됩니다. 이러한 추가 검사가 없으면, Python 객체가 차지하는 메모리가 이전에 리스트의 항목을 저장했던 다른 PyObject의 메모리를 보유하고 있었던 경우, 함수가 리스트에 한 번도 없었던 Python 객체를 반환할 수 있습니다.

낙관적 listdict 액세스를 위한 Mimalloc 변경 사항

이 구현에는 메모리 할당자에 대한 추가 제약이 필요하며, mimalloc 코드의 일부 변경도 필요합니다. 필요한 변경 사항을 이해하려면 mimalloc 구현에 관한 몇 가지 배경지식이 도움이 됩니다. mimalloc에서 개별 할당은 “블록”이라고 합니다. Mimalloc의 “페이지”에는 모두 크기가 같은 연속된 블록이 포함됩니다. Mimalloc의 “페이지”는 다른 할당자의 “슈퍼블록”과 유사하지만 운영 체제 페이지는 아닙니다. Mimalloc의 “힙”에는 다양한 크기 클래스의 페이지가 포함되며, 각 페이지는 하나의 힙에만 속합니다. 페이지의 블록 중 할당된 것이 하나도 없으면 mimalloc은 해당 페이지를 다른 크기 클래스나 다른 힙에 재사용할 수 있습니다(즉, 페이지를 다시 초기화할 수 있습니다).

리스트와 딕셔너리 액세스 방식은 액세스 기간 동안 참조 횟수 필드가 유효하게 유지되도록 mimalloc 페이지의 재사용을 부분적으로 제한하는 방식으로 작동합니다. mimalloc 페이지의 제한된 재사용은 Python 객체를 위한 별도의 힙을 사용하여 시행됩니다 [6]. 이를 통해 액세스 중에 항목이 해제되고 해당 메모리가 새 객체에 재사용되더라도 새 객체의 참조 횟수 필드가 메모리의 동일한 위치에 배치되도록 합니다. 참조 횟수 필드는 할당 전반에서 유효하게 유지되거나 0으로 유지됩니다.

Py_TPFLAGS_MANAGED_DICT를 지원하는 Python 객체는 PyObject 헤더 앞에 딕셔너리 및 약한 참조 필드를 가지므로, 해당 참조 횟수 필드는 할당 시작 지점에서 다른 오프셋에 있습니다. 이러한 객체는 별도의 mimalloc 힙에 저장됩니다. 또한 비-GC 객체는 자체 힙에 저장되므로 GC는 GC 객체만 확인하면 됩니다. 따라서 Python 객체를 위한 mimalloc 힙은 세 개입니다. 하나는 비-GC 객체용이고, 하나는 관리형 딕셔너리가 있는 GC 객체용이며, 하나는 관리형 딕셔너리가 없는 GC 객체용입니다.

Mimalloc 페이지 재사용

전체 메모리 사용량이 증가하는 것을 방지하려면 mimalloc 페이지 재사용에 대한 제한을 짧은 시간 동안만 유지하는 것이 유용합니다. 제한을 리스트 및 딕셔너리 액세스에만 정확히 적용하면 메모리 사용량을 최소화할 수 있지만, 비용이 큰 동기화가 필요합니다. 반대로 다음 GC 주기까지 제한을 유지하면 추가 동기화를 도입하지 않아도 되지만, 메모리 사용량이 잠재적으로 증가할 수 있습니다.

이 PEP는 FreeBSD의 “GUS” [7]를 기반으로 이러한 두 극단의 중간에 해당하는 시스템을 제안합니다. 이 시스템은 전역 카운터와 스레드별 카운터(또는 “시퀀스 번호”)를 조합하여, 빈 mimalloc 페이지를 다른 힙이나 다른 크기 클래스에 재사용하거나 운영 체제에 반환해도 안전한 시점을 결정하는 작업을 조정합니다:

  • 단조롭게 증가하는 전역 쓰기 시퀀스 번호가 있습니다.
  • mimalloc 페이지가 비면 현재 쓰기 시퀀스 번호로 태그됩니다. 스레드는 전역 쓰기 시퀀스 번호를 원자적으로 증가시킬 수도 있습니다.
  • 각 스레드에는 자신이 관찰한 가장 최근의 쓰기 시퀀스 번호를 기록하는 로컬 읽기 시퀀스 번호가 있습니다.
  • 스레드는 리스트 또는 딕셔너리 접근 중이 아닐 때마다 쓰기 시퀀스 번호를 관찰할 수 있습니다. 참조 구현은 mimalloc의 느린 경로 할당 함수에서 이 작업을 수행합니다. 이는 유용할 만큼 충분히 정기적으로 호출되지만, 상당한 오버헤드를 유발할 정도로 자주 호출되지는 않습니다.
  • 모든 활성 스레드의 읽기 시퀀스 번호 중 최솟값을 저장하는 전역 읽기 시퀀스 번호가 있습니다. 스레드는 각 스레드의 로컬 읽기 시퀀스 번호를 검색하여 전역 읽기 시퀀스 번호를 갱신할 수 있습니다. 재사용될 가능성이 있는 제한된 페이지가 있으면 참조 구현은 새로운 mimalloc 페이지를 할당하기 전에 이 작업을 수행합니다.
  • 전역 읽기 시퀀스 번호가 페이지의 태그 번호보다 클 때 빈 mimalloc 페이지를 다른 힙 또는 크기 클래스를 위해 재사용할 수 있습니다.

전역 읽기 시퀀스 번호가 페이지의 태그보다 크다는 조건으로 충분한 이유는, 동시에 낙관적 리스트 또는 딕셔너리 접근을 수행하던 모든 스레드가 해당 접근을 완료했음을 보장하기 때문입니다. 다시 말해 해제된 페이지의 빈 블록에 접근하는 스레드가 없으므로, 해당 페이지를 다른 용도로 사용하거나 운영 체제에 반환할 수도 있습니다.

낙관적 dictlist 접근 요약

이 PEP는 일반적으로 잠금 획득을 피하는 스레드 안전 리스트 및 딕셔너리 접근 기법을 제안합니다. 이를 통해 실행 오버헤드를 줄이고, 함수 및 메서드 호출과 같은 일반적인 작업에서 발생하는 일부 멀티스레드 확장성 병목 현상을 피할 수 있습니다. 이 방식은 객체가 해제된 후에도 객체의 참조 횟수 필드가 유효하게 유지되도록 mimalloc 페이지 재사용에 일시적인 제한을 설정하여 조건부 참조 횟수 증가 연산이 안전하게 수행되도록 합니다. 메모리 재사용 기회를 개선하기 위해 제한을 개별 객체가 아니라 mimalloc 페이지에 설정합니다. 시스템이 빈 mimalloc 페이지와 관련된 미완료 접근이 없음을 확인할 수 있게 되는 즉시 제한이 해제됩니다. 이를 확인하기 위해 시스템은 가벼운 스레드별 시퀀스 카운터를 조합하여 사용하고, 페이지가 비어 있을 때 페이지에 태그도 지정합니다. 각 스레드의 로컬 카운터가 페이지의 태그보다 커지면 해당 페이지를 어떤 용도로든 재사용하거나 운영 체제에 반환할 수 있습니다. 순환 가비지 수집기가 실행될 때마다 제한도 해제됩니다. 세계 중지 일시 중지가 스레드에 빈 mimalloc 페이지에 대한 미완료 참조가 없음을 보장하기 때문입니다.

특수화 인터프리터

GIL 없이 실행할 때 스레드 안전성을 확보하려면 특수화 인터프리터를 일부 변경해야 합니다.

  • 뮤텍스를 사용하여 동시 특수화를 방지합니다. 이를 통해 여러 스레드가 동일한 인라인 캐시에 쓰는 것을 방지합니다.
  • GIL 없이 실행되는 멀티스레드 프로그램에서는 각 바이트코드가 한 번만 특수화됩니다. 이를 통해 스레드가 부분적으로 작성된 인라인 캐시를 읽는 것을 방지합니다.
  • 잠금을 사용하면 tp_version_tagkeys_version의 캐시된 값이 캐시된 디스크립터 및 기타 값과 일치하도록 보장됩니다.
  • 인라인 카운터의 수정에는 “완화된 원자 연산”을 사용합니다. 다시 말해 일부 카운터 감소가 누락되거나 덮어써질 수 있지만, 이는 정확성에 영향을 주지 않습니다.

Py_mod_gil 슬롯

--disable-gil 빌드에서 확장을 로드할 때 CPython은 새로운 PEP 489-스타일 Py_mod_gil 슬롯을 확인합니다. 슬롯이 Py_mod_gil_not_used로 설정되어 있으면 확장 로딩은 평소와 같이 진행됩니다. 슬롯이 설정되어 있지 않으면 인터프리터는 모든 스레드를 일시 중지하고 GIL을 활성화한 후 계속 진행합니다. 또한 인터프리터는 확장의 이름, GIL이 활성화되었다는 사실과 그 이유, 사용자가 이를 재정의하기 위해 취할 수 있는 단계를 명시한 눈에 띄는 경고를 표시합니다.

PYTHONGIL 환경 변수

--disable-gil 빌드에서는 사용자가 PYTHONGIL 환경 변수를 설정하여 런타임에 동작을 재정의할 수도 있습니다. PYTHONGIL=0을 설정하면 모듈 슬롯 로직을 재정의하여 GIL을 비활성화합니다. PYTHONGIL=1을 설정하면 GIL을 활성화합니다.

PYTHONGIL=0재정의는 스레드 안전하지 않은 확장도 다중 스레드 애플리케이션에서 여전히 유용할 수 있기 때문에 중요합니다. 예를 들어, 확장을 단일 스레드에서만 사용하거나 잠금으로 접근을 보호할 수 있습니다. 참고로 GIL이 있어도 이미 스레드 안전하지 않은 확장이 일부 있으며, 사용자는 이미 이러한 종류의 조치를 취해야 합니다.

PYTHONGIL=1재정의는 디버깅에 유용한 경우가 있습니다.

근거

세대 구분이 없는 가비지 컬렉션

이 PEP는 (CPython이 GIL 없이 빌드되는 경우) 세대 구분 순환 가비지 컬렉터에서 세대 구분이 없는 컬렉터로 전환할 것을 제안합니다. 이는 세대가 하나(“이전” 세대)만 있는 것과 같습니다. 이 변경을 제안하는 이유는 두 가지입니다.

순환 가비지 컬렉션은 젊은 세대만 대상으로 하더라도 프로그램의 다른 스레드를 일시 중지해야 합니다. 작성자는 젊은 세대의 빈번한 컬렉션이 다중 스레드 프로그램의 효율적인 확장을 방해할 것을 우려합니다. 이는 젊은 세대에는 해당하지만 이전 세대에는 해당하지 않는 우려입니다. 젊은 세대는 고정된 할당 횟수 후에 수집되는 반면, 이전 세대의 컬렉션은 힙에 존재하는 객체 수에 비례하여 예약되기 때문입니다. 또한 GIL 없이 각 세대의 객체를 효율적으로 추적하기는 어렵습니다. 예를 들어, CPython은 현재 각 세대의 객체에 연결 리스트를 사용합니다. CPython이 해당 설계를 유지한다면 이러한 리스트를 스레드 안전하게 만들어야 하지만, 이를 효율적으로 수행하는 방법은 분명하지 않습니다.

세대 구분 가비지 컬렉션은 다른 많은 언어 런타임에서 효과적으로 사용됩니다. 예를 들어, 많은 Java HotSpot 가비지 컬렉터 구현은 여러 세대를 사용합니다 [10]. 이러한 런타임에서는 젊은 세대가 처리량을 향상하는 경우가 많습니다. 젊은 세대의 상당 부분이 일반적으로 “죽은” 상태이므로, GC는 수행한 작업량에 비해 많은 양의 메모리를 회수할 수 있기 때문입니다. 예를 들어, 여러 Java 벤치마크에서 “젊은” 객체의 90% 이상이 일반적으로 수집되는 것으로 나타납니다 [11] [12]. 이를 흔히 “약한 세대 가설”이라고 부릅니다. 대부분의 객체가 젊을 때 소멸한다는 관찰에 따른 것입니다. 참조 카운팅을 사용하기 때문에 CPython에서는 이 패턴이 반대로 나타납니다. 대부분의 객체가 여전히 젊을 때 소멸하지만, 참조 카운트가 0에 도달할 때 수집됩니다. 가비지 컬렉션 주기까지 살아남은 객체는 계속 살아 있을 가능성이 가장 높습니다 [13]. 이러한 차이로 인해 CPython의 세대 구분 컬렉션은 다른 많은 언어 런타임에서보다 훨씬 덜 효과적입니다 [14].

dictlist 접근에서 낙관적으로 잠금 피하기

이 제안은 리스트와 딕셔너리의 개별 요소에 접근할 때 대부분 잠금 획득을 피하는 방식을 기반으로 합니다. 이는 실행의 진전을 보장하는 “lock-free” 및 “wait-free” 알고리즘의 의미에서 “lock free”인 것은 아니라는 점에 유의하십시오. 일반적인 경우에 잠금(뮤텍스)을 획득하는 것을 피하여 병렬성을 향상하고 오버헤드를 줄일 뿐입니다.

훨씬 간단한 대안은 딕셔너리와 리스트 접근을 보호하기 위해 읽기-쓰기 잠금을 사용하는 것입니다. 읽기-쓰기 잠금은 동시 읽기를 허용하지만 갱신은 허용하지 않으므로, 리스트와 딕셔너리에 이상적인 방식처럼 보일 수 있습니다. 문제는 읽기-쓰기 잠금에 상당한 오버헤드와 낮은 확장성이 있다는 점이며, 특히 단일 요소 딕셔너리 및 리스트 접근처럼 임계 구역이 작을 때 그러합니다 [8]. 읽기 확장성이 낮은 이유는 읽기 작업을 수행하는 주체들이 모두 pthread_rwlocks의 읽기 작업자 수와 같은 동일한 데이터 구조를 갱신해야 하기 때문입니다.

이 PEP에서 설명하는 기법은 RCU (“read-copy-update”) [5] 및 그보다 관련성이 낮은 hazard pointer와 관련되어 있으며, 이들은 동시 접근이 가능하고 읽기가 대부분인 데이터 구조를 최적화하기 위한 잘 알려진 두 가지 방식입니다. RCU는 공유 데이터 구조를 확장 가능한 방식으로 보호하기 위해 Linux 커널에서 널리 사용됩니다. 이 PEP의 기법과 RCU는 모두 읽기 작업이 동시 데이터 구조에 접근할 수 있는 동안 회수를 지연하는 방식으로 동작합니다. RCU는 일반적으로 개별 객체(해시 테이블이나 연결 리스트 등)를 보호하는 데 사용되는 반면, 이 PEP는 더 큰 메모리 블록(mimalloc “pages”) [9]을 보호하는 방식을 제안합니다.

이 방식이 필요한 이유는 주로 CPython에서 참조 카운팅을 사용하기 때문입니다. CPython이 추적 가비지 수집기에만 의존한다면 이 방식은 아마 필요하지 않을 것입니다. 추적 가비지 수집기는 이미 필요한 방식으로 회수를 지연하기 때문입니다. 이는 확장성 문제를 “해결”하지는 않지만, 많은 문제를 가비지 수집기 구현으로 옮기게 됩니다.

하위 호환성

--disable-gil플래그를 사용하여 CPython을 빌드할 때 이 PEP는 여러 하위 호환성 문제를 야기하지만, 기본 빌드 구성에서는 이러한 문제가 발생하지 않습니다. 하위 호환성과 관련된 우려는 거의 모두 C-API와 관련됩니다.

  • CPython을 GIL 없이 빌드하면 편향 참조 카운팅을 지원하는 데 필요한 Python 객체 헤더 변경으로 인해 표준 CPython 빌드 또는 안정 ABI와 ABI 호환되지 않습니다. C-API 확장은 이 버전에 맞게 특별히 다시 빌드해야 합니다.
  • C 코드에서 전역 상태 또는 객체 상태를 보호하기 위해 GIL에 의존하는 C-API 확장은 GIL 없이 실행될 때 스레드 안전성을 유지하려면 추가적인 명시적 잠금이 필요합니다.
  • GIL 없이 안전하지 않은 방식으로 빌린 참조를 사용하는 C-API 확장은 빌리지 않은 참조를 반환하는 동등한 새 API를 사용해야 합니다. 빌린 참조의 사용 중 일부만 문제가 된다는 점에 유의하십시오. 다른 스레드에서 해제될 수 있는 객체에 대한 참조만 문제가 됩니다.
  • 사용자 지정 메모리 할당자(PyMem_SetAllocator)는 실제 할당을 이전에 설정된 할당자에 위임해야 합니다. 예를 들어 Python 디버그 할당자와 추적 할당자는 할당을 기반 할당자에 위임하므로 계속 작동합니다. 반면 할당자를 통째로 교체하는 방식(예: jemalloc 또는 tcmalloc 사용)은 올바르게 작동하지 않습니다.
  • Python 객체는 PyType_GenericNew 또는 PyObject_Malloc과 같은 표준 API를 통해 할당해야 합니다. Python 객체가 아닌 객체는 해당 API를 통해 할당해서는 안 됩니다. 예를 들어 현재는 버퍼(Python 객체가 아닌 객체)를 PyObject_Malloc을 통해 할당해도 되지만, 더 이상 허용되지 않으며 버퍼는 대신 PyMem_Malloc, PyMem_RawMalloc 또는 malloc을 통해 할당해야 합니다.

Python 코드에서는 잠재적인 하위 호환성 문제가 더 적습니다.

  • 코드 객체와 최상위 함수 객체의 소멸자 및 약한 참조 콜백은 지연 참조 카운팅의 사용으로 인해 다음 순환 가비지 수집이 수행될 때까지 지연됩니다.
  • 여러 스레드에서 접근하는 일부 객체의 소멸자는 편향 참조 카운팅으로 인해 약간 지연될 수 있습니다. 이는 드문 경우입니다. 여러 스레드에서 접근하는 객체를 포함한 대부분의 객체는 참조 카운트가 0이 되는 즉시 소멸됩니다. Python 표준 라이브러리 테스트의 두 곳에서는 계속 통과하기 위해 gc.collect() 호출이 필요했습니다.

배포

이 PEP는 Python 배포에 새로운 과제를 제기합니다. 적어도 한동안은 별도로 컴파일해야 하는 C-API 확장을 요구하는 두 가지 Python 버전이 존재하게 됩니다. C-API 확장 작성자가 --disable-gil 호환 패키지를 빌드하여 PyPI에 업로드하려면 시간이 걸릴 수 있습니다. 또한 일부 작성자는 --disable-gil 모드가 널리 채택될 때까지 지원을 주저할 수 있지만, 채택 여부는 Python의 풍부한 확장 기능 집합을 사용할 수 있는지에 따라 달라질 가능성이 큽니다.

이를 완화하기 위해 작성자는 Anaconda와 협력하여 conda 채널의 호환 패키지와 함께 --disable-gil 버전의 Python을 배포할 것입니다. 이렇게 하면 확장 기능 빌드 과제가 한곳에 모이며, 작성자는 이를 통해 그렇지 않았다면 가능했을 때보다 더 일찍 더 많은 사람이 GIL 없이 Python을 사용할 수 있게 될 것이라고 믿습니다.

성능

GIL 없이 CPython을 스레드 안전하게 만들기 위한 변경 사항은 --disable-gil 빌드의 실행 오버헤드를 증가시킵니다. 단일 스레드만 사용하는 프로그램과 다중 스레드를 사용하는 프로그램은 성능에 미치는 영향이 서로 다르므로, 아래 표는 이러한 유형의 프로그램에 대한 실행 오버헤드를 각각 별도로 보여줍니다.

pyperformance 1.0.6에서의 실행 오버헤드
인텔 Skylake AMD Zen 3
스레드 1개 6% 5%
다중 스레드 8% 7%

오버헤드를 측정하는 데 사용된 기준선은 Python 3.12를 위한 불멸 객체(immortal object)를 구현하는 PR 19474018be4c입니다. 실행 오버헤드에 가장 크게 기여하는 요소는 편향된 참조 카운팅이며, 그다음은 객체별 잠금입니다. 스레드 안전성을 위해 여러 스레드로 실행되는 애플리케이션은 주어진 바이트코드를 한 번만 특수화합니다. 따라서 여러 스레드를 사용하는 프로그램의 오버헤드가 하나의 스레드만 사용하는 프로그램보다 큽니다. 그러나 GIL이 비활성화되면 여러 스레드를 사용하는 프로그램은 여러 CPU 코어를 더 효과적으로 사용할 수 있어야 합니다.

이 PEP는 기본(--disable-gil이 아닌) CPython 빌드의 성능에는 영향을 주지 않는다는 점에 유의하십시오.

빌드 봇

안정 버전 빌드 봇에도 --disable-gil 빌드가 포함됩니다.

이를 가르치는 방법

--disable-gil 모드 구현의 일환으로, 작성자는 GIL 없이 Python을 실행할 때 패키지를 호환되게 만드는 방법에 관한 “HOWTO” 안내서 [16]를 작성할 것입니다.

참조 구현

GIL 없이 CPython 버전을 구현하는 GitHub 저장소는 두 개입니다.

nogil-3.12은 Python 3.12.0a4를 기반으로 합니다. 이는 단일 스레드 실행 오버헤드를 평가하고 이 PEP의 참조 구현으로 사용하는 데 유용합니다. 많은 확장 기능이 현재 Python 3.12와 호환되지 않으므로, C-API 확장 호환성을 평가하는 데는 유용성이 떨어집니다. 3.12 포트 작업에 사용할 수 있는 시간이 제한되어 있어 nogil-3.12구현은 모든 지연된 참조 카운트를 건너뛰지 않습니다. 임시 해결책으로, 이 구현은 여러 스레드를 생성하는 프로그램에서 지연된 참조 카운팅을 사용하는 객체를 불멸화합니다.

nogil 저장소는 Python 3.9.10을 기반으로 합니다. 실제 환경의 애플리케이션에서 멀티스레딩 확장성과 확장 기능 호환성을 평가하는 데 유용합니다. nogil-3.12 저장소보다 안정적이고 더 많이 테스트되었습니다.

대안

Python은 현재 병렬성을 활성화하는 여러 방법을 지원하지만, 기존 기법에는 상당한 한계가 따릅니다.

멀티프로세싱

멀티프로세싱 라이브러리를 사용하면 Python 프로그램에서 Python 서브프로세스를 시작하고 서로 통신할 수 있습니다. 각 서브프로세스가 자체 Python 인터프리터를 가지므로 병렬 처리가 가능합니다(즉, 프로세스마다 GIL이 하나씩 있습니다). 멀티프로세싱에는 몇 가지 상당한 제한이 있습니다. 프로세스 간 통신에는 제한이 있습니다. 객체는 일반적으로 직렬화하거나 공유 메모리에 복사해야 합니다. 이로 인해 직렬화에 따른 오버헤드가 발생하고 멀티프로세싱을 기반으로 API를 구축하기가 복잡해집니다. 특히 “spawn” 구현을 사용하는 경우 서브프로세스를 시작하는 작업은 스레드를 시작하는 것보다 비용이 더 많이 듭니다. 스레드를 시작하는 데는 약 ~100 µs가 걸리는 반면, 서브프로세스를 생성하는 데는 Python 재초기화로 인해 ~50 ms(50,000 µs)가 걸립니다.

마지막으로, 많은 C 및 C++ 라이브러리는 여러 스레드에서의 접근을 지원하지만 여러 프로세스에 걸친 접근이나 사용은 지원하지 않습니다.

C-API 확장에서 GIL 해제

C-API 확장은 실행 시간이 긴 함수 주변에서 GIL을 해제할 수 있습니다. GIL이 해제되면 여러 스레드가 동시에 실행될 수 있으므로 어느 정도의 병렬 처리가 가능하지만, 일반적으로 GIL을 획득하고 해제하는 데 따른 오버헤드 때문에 몇 개를 넘는 스레드로 효율적으로 확장하기 어렵습니다. 많은 과학 계산 라이브러리는 계산량이 많은 함수에서 GIL을 해제하며, CPython 표준 라이브러리는 블로킹 I/O 주변에서 GIL을 해제합니다.

내부 병렬 처리

C로 구현된 함수는 내부적으로 여러 스레드를 사용할 수 있습니다. 예를 들어 Intel의 NumPy 배포판, PyTorch, TensorFlow는 모두 이 기법을 사용하여 개별 연산을 내부적으로 병렬화합니다. 기본 연산이 효율적으로 병렬화할 수 있을 만큼 충분히 큰 경우에는 잘 작동하지만, 작은 연산이 많거나 연산이 일부 Python 코드에 의존하는 경우에는 그렇지 않습니다. C에서 Python으로 호출하려면 GIL을 획득해야 하므로, 짧은 Python 코드 조각조차 확장을 방해할 수 있습니다.

관련 작업

인터프리터별 GIL

최근 승인된 PEP 684는 멀티코어 병렬 처리를 해결하기 위해 인터프리터별 GIL을 제안합니다. 이를 통해 동일한 프로세스 내 인터프리터 간 병렬 처리가 가능해지지만, 인터프리터 간 Python 데이터 공유에는 상당한 제한이 적용됩니다. 이 PEP와 PEP 684는 모두 멀티코어 병렬 처리를 다루지만, 절충점과 기법은 서로 다릅니다. CPython에서 두 PEP를 동시에 구현하는 것은 실현 가능합니다.

Gilectomy

Gilectomy [18]는 CPython에서 GIL을 제거하기 위해 Larry Hastings가 진행한 프로젝트입니다. 이 PEP에서 제안한 설계와 마찬가지로 Gilectomy는 동일한 인터프리터 내에서 여러 스레드가 병렬로 실행되는 것을 지원했으며(즉, “free-threading”), 세분화된 잠금을 사용했습니다. 이 PEP의 참조 구현은 Gilectomy에 비해 단일 스레드 성능과 확장성을 개선합니다.

PyParallel

PyParallel [19]은 트렌트 넬슨이 만든 Python 3.3의 개념 증명용 포크로, 단일 Python 프로세스에서 여러 스레드를 동시에 실행할 수 있도록 지원했습니다. 이 포크는 “병렬 스레드”라는 개념을 도입했습니다 – 주 Python 스레드가 일시 중단된 동안 동시에 실행할 수 있는 스레드입니다. 병렬 스레드는 주 스레드가 생성한 객체에 읽기 전용으로 접근할 수 있었습니다. 병렬 스레드 내에서 생성된 객체는 객체를 생성한 스레드의 수명 동안 존재했습니다. HTTP 서버의 경우 이는 요청의 수명에 해당할 수 있습니다.

python-safethread

python-safethread [20]프로젝트는 Adam Olsen이 GIL을 제거하기 위해 Python 3.0에 적용한 패치였습니다. 이 프로젝트의 일부 측면은 이 PEP에서 제안한 설계와 유사합니다. 두 설계 모두 세밀한 잠금을 사용하며, 객체가 동일한 스레드에서 생성되고 접근되는 경우에 맞게 참조 카운팅을 최적화합니다.

Greg Stein의 프리 스레딩 패치

1996년에 Greg Stein은 GIL을 제거한 Python 1.4용 패치를 발표했습니다 [21]. 이 패치는 Windows에서 원자적 참조 카운팅을 사용하고 Linux에서 전역 참조 카운트 잠금을 사용했습니다. 리스트와 딕셔너리 접근은 뮤텍스로 보호되었습니다. 이 패치의 일부는 CPython에 채택되었습니다. 특히 이 패치는 PyThreadState 구조체와 올바른 스레드별 예외 처리를 도입했습니다.

Dave Beazley는 2011년 블로그 게시물에서 이 패치를 다시 다루었습니다 [22].

Jython과 IronPython

Jython [23] 및 IronPython [24]과 같은 일부 대체 Python 구현에는 전역 인터프리터 잠금이 없습니다. 그러나 이러한 구현은 CPython 확장을 지원하지 않습니다. (이러한 구현은 Java 또는 C#으로 작성된 코드와 인터페이스할 수 있습니다.)

PyPy-STM

pypy-stm [25]인터프리터는 소프트웨어 트랜잭션 메모리를 사용하는 PyPy의 변형입니다. 작성자들은 PyPy와 비교할 때 단일 스레드 성능 오버헤드가 20%-50% 범위라고 보고합니다. CPython 확장과 호환되지 않습니다.

거부된 아이디어

동시 가비지 컬렉터를 사용하지 않는 이유는 무엇입니까?

최근의 많은 가비지 컬렉터는 대부분 동시 방식입니다 – 가비지 컬렉터가 애플리케이션과 동시에 실행되도록 하여 긴 월드 스톱 일시 중지를 방지합니다. 그렇다면 동시 컬렉터를 사용하지 않는 이유는 무엇입니까?

동시 수집에는 쓰기 배리어(또는 읽기 배리어)가 필요합니다. 작성자는 C-API를 상당히 손상하지 않고 CPython에 쓰기 배리어를 추가할 방법을 알지 못합니다.

PyDict_GetItemPyDict_FetchItem을 대신하여 사용 중단하지 않는 이유는 무엇입니까?

이 PEP는 PyDict_GetItem처럼 동작하지만 빌린 참조 대신 새 참조를 반환하는 새로운 API인 PyDict_FetchItem을 제안합니다. Borrowed References 에서 설명한 대로, GIL과 함께 실행할 때 안전했던 빌린 참조의 일부 사용은 GIL 없이 실행할 때 안전하지 않으므로, 새 참조를 반환하는 PyDict_FetchItem과 같은 함수로 대체해야 합니다.

이 PEP은 몇 가지 이유로 PyDict_GetItem 및 이와 유사하게 빌린 참조를 반환하는 함수의 사용 중단을 제안하지 않습니다:

  • GIL 없이 실행하는 경우에도 빌린 참조의 많은 사용 사례는 안전합니다. 예를 들어 C API 함수는 키워드 인자 딕셔너리에서 항목을 가져오기 위해 자주 PyDict_GetItem을 사용합니다. 키워드 인자 딕셔너리는 단일 스레드에만 표시되므로 이러한 호출은 안전합니다.
  • 초기에 이 접근 방식을 시도했을 때, PyDict_GetItemPyDict_FetchItem으로 일괄 교체하면 새로운 참조 카운팅 버그가 자주 발생한다는 사실을 발견했습니다. 제 의견으로는 새로운 참조 카운팅 버그가 발생할 위험이 일반적으로 GIL 없이 안전하지 않은 PyDict_GetItem호출을 놓칠 위험보다 큽니다.

PEP 683의 불멸화를 사용하지 않는 이유

이 PEP은 PEP 683과 마찬가지로 Python 객체를 불멸화하는 방식을 제안하지만, 두 PEP은 불멸 객체를 표시하는 데 서로 다른 비트 표현을 사용합니다. 이 방식들은 동일할 수 없습니다. 이 PEP가 편향된 참조 카운팅에 의존하며, 참조 카운트 필드가 하나가 아니라 두 개이기 때문입니다.

미해결 문제

개선된 특수화

Python 3.11 릴리스는 더 빠른 CPython 프로젝트의 일환으로 퀵닝과 특수화를 도입하여 성능을 크게 향상했습니다. 특수화는 느린 바이트코드 명령어를 더 빠른 변형으로 대체합니다 [17]. 스레드 안전성을 유지하기 위해 여러 스레드를 사용하고 GIL 없이 실행되는 애플리케이션은 각 바이트코드 명령어를 한 번만 특수화하므로, 일부 프로그램에서는 성능이 저하될 수 있습니다. 여러 번 특수화를 수행하도록 지원하는 것은 가능하지만, 이를 위해서는 추가 조사가 필요하며 이 PEP의 범위에 포함되지 않습니다.

Python 빌드 모드

이 PEP는 표준 빌드 모드와 ABI 호환되지 않는 새로운 빌드 모드(--disable-gil)를 도입합니다. 이 추가 빌드 모드는 Python 핵심 개발자와 확장 개발자 모두에게 복잡성을 더합니다. 작성자는 이러한 빌드 모드를 통합하고 전역 인터프리터 잠금을 런타임에 제어하며, 기본적으로 비활성화할 수도 있도록 하는 것이 가치 있는 목표라고 생각합니다. 이 목표에 이르는 경로는 여전히 미해결 문제이지만, 가능한 경로는 다음과 같을 수 있습니다:

  1. 2024년에 CPython 3.13은 --disable-gil 빌드 시점 플래그를 지원하는 상태로 릴리스됩니다. CPython에는 GIL을 사용하는 ABI와 사용하지 않는 ABI, 두 가지 ABI가 존재합니다. 확장 작성자는 두 ABI를 모두 대상으로 합니다.
  2. 2–3번의 릴리스 후, (즉, 2026–2027년에) CPython은 런타임 환경 변수나 플래그로 GIL을 제어할 수 있는 상태로 릴리스됩니다. GIL은 기본적으로 활성화됩니다. ABI는 하나만 존재합니다.
  3. 다시 2–3번의 릴리스 후, (즉, 2028–2030년에) CPython은 GIL이 기본적으로 비활성화된 상태로 전환됩니다. 런타임에 환경 변수나 명령줄 플래그를 통해 GIL을 활성화할 수는 있습니다.

이 PEP는 첫 번째 단계를 다루며, 나머지 단계는 미해결 문제로 남겨 둡니다. 이 시나리오에서는 2~3년 동안 확장 작성자가 지원되는 각 CPU 아키텍처와 운영 체제에 대해 추가 CPython 빌드를 대상으로 하게 됩니다.

통합

참조 구현은 CPython에서 약 15,000줄의 코드를 변경하며, 약 15,000줄의 코드로 이루어진 mimalloc도 포함합니다. 대부분의 변경 사항은 성능에 민감하지 않으므로 --disable-gil 빌드와 기본 빌드 모두에 포함할 수 있습니다. Py_BEGIN_CRITICAL_SECTION과 같은 일부 매크로는 기본 빌드에서 아무 작업도 하지 않습니다. 저자는 --disable-gil빌드를 지원하기 위해 엄청나게 많은 #ifdef 문이 필요할 것으로 예상하지 않습니다.

단일 스레드 성능을 위한 완화책

PEP에서 제안하는 변경 사항은 GIL이 활성화된 Python 빌드와 비교할 때 --disable-gil 빌드의 실행 오버헤드를 증가시킵니다. 즉, 단일 스레드 성능이 더 느려집니다. 실행 오버헤드를 줄일 수 있는 몇 가지 최적화가 가능하며, 특히 단일 스레드만 사용하는 --disable-gil 빌드에서 그러합니다. 장기적인 목표가 단일 빌드 모드를 갖는 것이라면 이러한 최적화가 가치 있을 수 있지만, 최적화의 선택과 그 상충 관계는 여전히 미해결 문제입니다.

참고 자료

  • PEP 683 – 고정된 참조 횟수를 사용하는 불멸 객체입니다.

감사의 말

이 PEP의 초안에 의견을 주신 Hugh Leather, Łukasz Langa, Eric Snow께 감사드립니다.