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의 일부를 다시 구현했습니다. 이 방법을 선택한 이유는 단일 스레드 성능(점수 계산)과 멀티스레드 성능(서열 데이터베이스 검색)을 모두 강하게 시험하기 때문입니다. 스레드를 여덟 개만 사용했는데도 GIL이 병목이 되었습니다. 이 방법은 현재 널리 쓰이는 구현들이 프로세스당 64개, 심지어 128개의 스레드에 의존하는 방법입니다. 서브프로세스로 전환해 보았지만 감당할 수 없는 IPC 비용 때문에 가로막혔습니다. HMMER는 비교적 기초적인 생물정보학 방법이며, 더 새로운 방법들은 멀티스레딩을 훨씬 더 많이 요구합니다.

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

GIL은 Python 라이브러리의 사용성에 영향을 미칩니다

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

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

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

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

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

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

병렬성을 제어하기 위한 API와 설계 결정을 조율하는 일은 여전히 상당한 양의 작업이며, PyData 생태계 전반에서 비교적 어려운 과제 중 하나입니다. GIL이 없었다면 상황은 훨씬 달랐을 것입니다(더 좋고, 더 쉬웠을 것입니다).

GPU 중심 워크로드에는 멀티코어 처리가 필요합니다

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

PyTorch 코어 개발자이자 FAIR(Meta AI) 연구원인 Zachary DeVito는 계산의 대부분이 Python 외부에서 수행되는 경우에도 GIL 때문에 멀티스레드 확장이 비효율적이 되는 이유를 다음과 같이 설명합니다:

PyTorch에서 Python은 일반적으로 약 8개의 GPU와 약 64개의 CPU 스레드를 조율하는 데 사용되며, 대형 모델의 경우 4천 개의 GPU와 3만 2천 개의 CPU 스레드로까지 늘어납니다. 무거운 작업은 Python 외부에서 수행되지만, GPU의 속도 때문에 Python에서 하는 조율 작업만으로도 확장이 불가능해집니다. GIL 때문에 하나의 프로세스로 충분할 일을 72개의 프로세스로 처리하게 되는 경우가 많습니다. 이러한 환경에서는 로깅, 디버깅, 성능 튜닝이 수십 배 이상 더 어려워지며, 이는 지속적으로 개발자 생산성 저하를 초래합니다.

(스레드 대신) 여러 프로세스를 사용하면 일반적인 작업이 더 어려워집니다. Zachary DeVito는 다음과 같이 이어서 말합니다:

지난 두어 달 동안 세 차례에 걸쳐(데이터 로더의 중복 연산 줄이기, 모델 체크포인트를 비동기로 기록하기, 컴파일러 최적화 병렬화하기), 저는 실제로 해당 문제를 해결하는 것보다 GIL의 제약을 우회하는 방법을 알아내는 데 열 배는 더 많은 시간을 썼습니다.

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

개별 CPU 코어에 비해 GPU 성능이 향상됨에 따라 멀티코어 성능이 더욱 중요해지고 있습니다. GPU를 쉬지 않고 완전히 가동하기는 점점 더 어려워지고 있습니다. 그렇게 하려면 특히 멀티 GPU 시스템에서 여러 CPU 코어를 효율적으로 사용해야 합니다. 예를 들어, NVIDIA의 DGX-A100은 GPU에 데이터를 계속 “공급”하기 위해 8개의 GPU와 두 개의 64코어 CPU를 탑재하고 있습니다.

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 다운로드의 기본값으로 유지됩니다. 새로운 빌드 구성 플래그 --disable-gil이 configure 스크립트에 추가되며, 이 플래그를 사용하면 전역 인터프리터 잠금 없이 실행하는 것을 지원하는 CPython이 빌드됩니다.

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

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

CPython 변경 사항 개요

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

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

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

참조 카운팅

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

편향 참조 카운팅(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 필드는 단일 바이트 크기의 객체별 잠금을 제공합니다.

불멸화

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

Py_INCREF와 Py_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 - 기본(default)
  • 0b01 - 약한 참조(weakrefs)
  • 0b10 - 대기열 등록(queued)
  • 0b11 - 병합(merged)

상태는 순서대로 진행됩니다. 객체는 수명 주기 동안 수치상 더 높은 어떤 상태로든 전환될 수 있습니다. 객체는 “기본” 상태와 “병합” 상태에서만 할당 해제될 수 있습니다. 그 밖의 상태는 할당 해제 전에 “병합” 상태로 전환되어야 합니다. 상태를 전환하려면 ob_ref_shared 필드에 대한 원자적 비교 후 교환(compare-and-swap)이 필요합니다.

기본(0b00)

객체는 처음에 기본 상태로 생성됩니다. 빠른 할당 해제 코드 경로를 사용할 수 있는 상태는 이 상태뿐입니다. 그 밖의 경우에는 스레드가 로컬 참조 횟수 필드와 공유 참조 횟수 필드를 병합해야 하며, 이를 위해서는 원자적 비교 후 교환이 필요합니다.

이 빠른 할당 해제 코드 경로는 약한 참조의 동시 역참조와 함께 쓰이면 스레드 안전하지 않습니다. 따라서 약한 참조가 처음 생성될 때 객체가 현재 “기본” 상태라면 “약한 참조” 상태로 전환됩니다.

마찬가지로 빠른 할당 해제 코드 경로는 잠금 없는 리스트 및 딕셔너리 접근(Optimistically Avoiding Locking 참조)과 함께 쓰이면 스레드 안전하지 않습니다. 따라서 소유하지 않은 스레드가 “기본” 상태의 객체를 처음으로 가져오려고 하면, 더 느린 잠금 코드 경로로 대체하고 객체를 “약한 참조” 상태로 전환합니다.

약한 참조(0b01)

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

대기열 등록(0b10)

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

소유 스레드가 이미 종료되었다면, 이 작업을 수행하는 스레드가 즉시 로컬 참조 횟수 필드와 공유 참조 횟수 필드를 병합하고 병합 상태로 전환합니다.

병합(0b11)

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

참조 카운팅 의사 코드

제안된 Py_INCREF 및 Py_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++에서 정의되지 않은 동작을 피하기 위해 “relaxed atomics”를 사용하여 ob_tid와 ob_ref_local에 접근해야 합니다.

지연 참조 카운팅

최상위 함수, 코드 객체, 모듈, 메서드 같은 몇몇 종류의 객체는 여러 스레드에서 동시에 자주 접근되는 경향이 있습니다. 이러한 객체가 반드시 프로그램이 끝날 때까지 살아 있는 것은 아니므로 불멸화는 적합하지 않습니다. 이 PEP는 멀티스레드 프로그램에서 이러한 객체의 참조 횟수 필드에 대한 경합을 피하기 위해 제한된 형태의 지연 참조 카운팅을 제안합니다.

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

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

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

지연 참조 카운팅을 위한 가비지 컬렉터 수정 사항

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

타입 객체의 참조 카운팅

타입 객체(PyTypeObject)는 여러 참조 카운팅 기법을 혼합하여 사용합니다. 정적으로 할당된 타입 객체는 이미 프로그램이 끝날 때까지 살아 있으므로 불멸화됩니다. 힙 타입 객체는 지연 참조 카운팅을 스레드별 참조 카운팅과 함께 사용합니다. 힙 타입에 대한 참조 대부분은 인터프리터 스택의 참조가 아니라 객체 인스턴스에서 오기 때문에, 지연 참조 카운팅만으로는 힙 타입의 다중 스레드 확장성 병목을 해결하기에 충분하지 않습니다.

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

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

스레드별 참조 횟수 배열은 몇 군데에서만 사용됩니다:

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

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

메모리 관리

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

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

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”를 사용하여 제공합니다.
  • 세대별 가비지 컬렉션을 없애고 비세대별 컬렉터를 사용합니다.
  • 지연 참조 카운팅(deferred reference counting) 및 편향 참조 카운팅(biased reference counting)과 통합합니다.

또한 위의 변경 사항 덕분에 GC 객체에서 _gc_prev 및 _gc_next 필드를 제거할 수 있습니다. 추적됨(tracked), 종료자 실행됨(finalized), 도달 불가능(unreachable) 상태를 저장하던 GC 비트는 PyObject 헤더의 ob_gc_bits 필드로 옮겨집니다.

Stop-the-World

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

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

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

스레드 상태

가비지 컬렉션을 위한 스레드 일시 정지를 지원하기 위해 PyThreadState에 새로운 “status” 필드가 추가됩니다. PyThreadState의 다른 필드와 마찬가지로 status 필드는 공개 CPython API에 속하지 않습니다. status 필드는 다음 세 가지 상태 중 하나일 수 있습니다:

  • ATTACHED
  • DETACHED
  • GC

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

stop-the-world 일시 정지 중에 가비지 컬렉션을 수행하는 스레드는 다른 어떤 스레드도 Python 객체에 접근하거나 수정하고 있지 않음을 보장해야 합니다. 다른 모든 스레드는 “GC” 상태여야 합니다. 가비지 컬렉션 스레드는 상태 필드에 대한 원자적 비교-교환(compare-and-swap) 연산을 사용하여 다른 스레드를 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

일부 연산은 두 컨테이너의 내부 구조를 알고서 두 컨테이너 객체를 직접 다룹니다. 예를 들어, list.extend(iterable)에는 set과 같은 특정 이터러블 타입을 위한 내부 특수화 구현이 있습니다. 이러한 연산은 두 객체의 내부에 동시에 접근하므로 두 컨테이너 객체를 모두 잠금 처리해야 합니다. 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에는 포함되지 않습니다:

  • 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을 두 번 중첩하여 호출하는 것으로는 충분하지 않습니다. 안쪽 임계 영역이 바깥쪽 임계 영역의 잠금을 해제할 수 있기 때문입니다.

낙관적인 잠금 회피

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

낙관적인 빠른 경로가 있는 연산은 다음과 같습니다:

  • PyDict_FetchItem/GetItem 및 dict.__getitem__
  • PyList_FetchItem/GetItem 및 list.__getitem__

또한 dict와 list의 이터레이터는 위 함수들을 사용하므로, 다음 항목을 반환할 때 역시 낙관적으로 잠금을 회피합니다.

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

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

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

주요 과제는 리스트나 딕셔너리에서 항목을 가져오는 것과 그 항목의 참조 횟수를 증가시키는 것이 원자적 연산이 아니라는 점입니다. 항목을 가져온 시점과 참조 횟수를 증가시키는 시점 사이에 다른 스레드가 리스트나 딕셔너리를 수정할 수 있으며, 그 결과 앞서 가져온 항목의 메모리가 해제될 수도 있습니다.

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

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

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가 있었고 그 PyObject의 메모리에 더 이전에 리스트의 항목이 저장되어 있었던 경우, 함수가 리스트에 한 번도 들어 있지 않았던 Python 객체를 반환할 수 있습니다.

낙관적 list 및 dict 접근을 위한 mimalloc 변경 사항

이 구현에는 메모리 할당자에 대한 추가 제약이 필요하며, 여기에는 mimalloc 코드의 일부 변경도 포함됩니다. 필요한 변경 사항을 이해하려면 mimalloc의 구현 방식에 관한 배경지식이 어느 정도 도움이 됩니다. mimalloc이 수행하는 개별 할당을 “블록(block)”이라고 합니다. mimalloc의 “페이지(page)”에는 크기가 모두 같은 블록들이 연속으로 들어 있습니다. mimalloc의 “페이지”는 다른 할당자의 “슈퍼블록”과 비슷하며, 운영 체제 페이지가 결코 아닙니다. mimalloc의 “힙(heap)”에는 여러 크기 클래스의 페이지가 들어 있으며, 각 페이지는 하나의 힙에만 속합니다. 페이지의 블록이 하나도 할당되어 있지 않으면 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의 느린 경로(slow-path) 할당 함수에서 수행합니다. 이 함수는 쓸모가 있을 만큼 자주 호출되지만, 상당한 오버헤드를 일으킬 만큼 자주 호출되지는 않습니다.
  • 활성 상태인 모든 스레드의 읽기 시퀀스 번호 중 최솟값을 저장하는 전역 읽기 시퀀스 번호가 있습니다. 스레드는 각 스레드의 지역 읽기 시퀀스 번호를 훑어서 전역 읽기 시퀀스 번호를 갱신할 수 있습니다. 참조 구현은 재사용될 가능성이 있는 제한된 페이지가 있으면 새 mimalloc 페이지를 할당하기 전에 이 작업을 수행합니다.
  • 비어 있는 mimalloc 페이지는 전역 읽기 시퀀스 번호가 그 페이지의 태그 번호보다 클 때 다른 힙이나 크기 클래스에 재사용될 수 있습니다.

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

낙관적 dict 및 list 접근 요약

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

특수화 인터프리터

특수화 인터프리터가 GIL 없이 실행될 때 스레드 안전하려면 몇 가지 변경이 필요합니다.

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

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]. 이를 흔히 “약한 세대 가설(weak generational hypothesis)”이라고 부르며, 대부분의 객체가 젊어서 죽는다는 관찰에 근거합니다. CPython에서는 참조 카운팅을 사용하기 때문에 이 패턴이 반대로 나타납니다. 대부분의 객체가 여전히 일찍 죽기는 하지만, 이들은 참조 횟수가 0에 도달할 때 수거됩니다. 가비지 수집 주기까지 살아남은 객체는 계속 살아 있을 가능성이 가장 높습니다 [13]. 이러한 차이로 인해 CPython에서는 세대별 수집이 다른 많은 언어 런타임에서보다 훨씬 덜 효과적입니다 [14].

dict와 list 접근에서의 낙관적 잠금 회피

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

훨씬 더 단순한 대안은 읽기-쓰기 잠금(reader-writer lock)을 사용하여 딕셔너리와 리스트 접근을 보호하는 것입니다. 읽기-쓰기 잠금은 동시 읽기는 허용하지만 갱신은 허용하지 않으므로, 리스트와 딕셔너리에 이상적인 것처럼 보일 수 있습니다. 문제는 읽기-쓰기 잠금의 오버헤드가 상당하고 확장성이 떨어진다는 점이며, 단일 요소 딕셔너리 및 리스트 접근처럼 임계 구역이 작을 때 특히 그렇습니다 [8]. 읽기 측 확장성이 낮은 이유는 pthread_rwlocks의 읽기 스레드 수와 같은 동일한 자료 구조를 모든 읽기 스레드가 갱신해야 하기 때문입니다.

이 PEP에서 설명하는 기법은 RCU(“read-copy-update”) [5]와 관련이 있고, 정도는 덜하지만 해저드 포인터와도 관련이 있습니다. 이 둘은 동시적이며 읽기 위주인 자료 구조를 최적화하기 위한 잘 알려진 두 가지 기법입니다. RCU는 Linux 커널에서 공유 자료 구조를 확장 가능한 방식으로 보호하기 위해 널리 사용됩니다. 이 PEP의 기법과 RCU는 모두 읽는 쪽이 동시 자료 구조에 접근하고 있을 수 있는 동안 회수를 지연하는 방식으로 동작합니다. RCU는 주로 개별 객체(해시 테이블이나 연결 리스트 등)를 보호하는 데 사용되는 반면, 이 PEP는 더 큰 메모리 블록(mimalloc “페이지”)을 보호하는 기법을 제안합니다 [9].

이 기법이 필요한 주된 이유는 CPython이 참조 카운팅을 사용하기 때문입니다. CPython이 추적 가비지 컬렉터에만 의존한다면, 추적 가비지 컬렉터는 이미 필요한 방식으로 메모리 회수를 지연시키므로 이 기법은 아마 필요하지 않을 것입니다. 그렇다고 해서 확장성 문제가 “해결”되는 것은 아니며, 많은 과제가 가비지 컬렉터 구현 쪽으로 옮겨갈 뿐입니다.

하위 호환성

이 PEP는 --disable-gil 플래그로 CPython을 빌드할 때 여러 가지 하위 호환성 문제를 일으키지만, 기본 빌드 구성을 사용할 때는 이러한 문제가 발생하지 않습니다. 하위 호환성 문제는 거의 모두 C-API와 관련이 있습니다:

  • GIL 없이 빌드한 CPython은 편향 참조 카운팅을 지원하는 데 필요한 Python 객체 헤더 변경으로 인해 표준 CPython 빌드나 안정 ABI와 ABI 호환되지 않습니다. C-API 확장은 이 버전에 맞게 별도로 다시 빌드해야 합니다.
  • C 코드에서 전역 상태나 객체 상태를 보호하기 위해 GIL에 의존하는 C-API 확장은 GIL 없이 실행될 때 스레드 안전성을 유지하려면 추가로 명시적인 잠금이 필요합니다.
  • GIL 없이는 안전하지 않은 방식으로 빌린 참조(borrowed reference)를 사용하는 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 코드에서는 잠재적인 하위 호환성 문제가 더 적습니다.

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

배포

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

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

성능

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

pyperformance 1.0.6에서의 실행 오버헤드
Intel Skylake AMD Zen 3
단일 스레드 6% 5%
다중 스레드 8% 7%

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

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

빌드 봇

안정 빌드 봇에는 --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은 현재 병렬 처리를 가능하게 하는 여러 방법을 지원하지만, 기존 기법에는 상당한 한계가 있습니다.

멀티프로세싱

multiprocessing 라이브러리를 사용하면 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는 모두 멀티코어 병렬성 문제를 다루지만, 트레이드오프와 기법은 서로 다릅니다. 두 PEP를 CPython에 동시에 구현하는 것도 가능합니다.

Gilectomy

Gilectomy [18]는 CPython에서 GIL을 제거하려고 Larry Hastings가 진행한 프로젝트였습니다. 이 PEP에서 제안하는 설계와 마찬가지로, Gilectomy는 같은 인터프리터 안에서 여러 스레드가 병렬로 실행되는 것(즉, “자유 스레딩”)을 지원했으며 세분화된 잠금을 사용했습니다. 이 PEP의 참조 구현은 Gilectomy보다 단일 스레드 성능과 확장성이 뛰어납니다.

PyParallel

PyParallel [19]은 Trent Nelson이 만든 Python 3.3의 개념 증명용 포크로, 단일 Python 프로세스에서 여러 스레드가 동시에 실행되는 것을 지원했습니다. 이 포크는 “병렬 스레드(parallel threads)”라는 개념을 도입했습니다. 병렬 스레드는 메인 Python 스레드가 일시 중단된 동안 동시에 실행될 수 있는 스레드입니다. 병렬 스레드는 메인 스레드가 생성한 객체에 읽기 전용으로 접근할 수 있었습니다. 병렬 스레드 안에서 생성된 객체는 그 객체를 생성한 스레드가 살아 있는 동안 유지되었습니다. HTTP 서버라면 이는 요청 하나의 수명에 해당할 수 있습니다.

python-safethread

python-safethread [20] 프로젝트는 GIL을 제거하려고 Adam Olsen이 만든 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 확장과 호환되지 않습니다.

기각된 아이디어

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

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

동시 수집에는 쓰기 배리어(또는 읽기 배리어)가 필요합니다. 저자는 C-API를 크게 깨뜨리지 않으면서 CPython에 쓰기 배리어를 추가하는 방법을 알지 못합니다.

PyDict_FetchItem을 위해 PyDict_GetItem을 폐기하지 않는 이유는 무엇입니까?

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

이 PEP는 몇 가지 이유로 PyDict_GetItem 및 빌린 참조를 반환하는 유사한 함수들을 폐기하는 것을 제안하지 않습니다:

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

PEP 683 불멸화를 사용하지 않는 이유는 무엇입니까?

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

미해결 문제

개선된 특수화

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

Python 빌드 모드

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

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

이 PEP는 첫 번째 단계를 다루며, 나머지 단계는 미해결 과제로 남겨 둡니다. 이 시나리오에서는 확장 모듈 작성자가 지원하는 CPU 아키텍처와 OS마다 CPython 빌드를 하나씩 더 대상으로 삼아야 하는 기간이 2~3년 동안 이어집니다.

통합

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

단일 스레드 성능 저하 완화 방안

이 PEP에서 제안하는 변경 사항은 GIL이 있는 Python 빌드에 비해 --disable-gil 빌드의 실행 오버헤드를 증가시킵니다. 다시 말해, 단일 스레드 성능이 더 느려집니다. 실행 오버헤드를 줄일 수 있는 몇 가지 최적화 방안이 있으며, 특히 단일 스레드만 사용하는 --disable-gil 빌드에서 그렇습니다. 장기적인 목표가 단일 빌드 모드를 갖추는 것이라면 이러한 최적화는 가치가 있을 수 있지만, 어떤 최적화를 선택할지와 그에 따른 트레이드오프는 여전히 미해결 과제로 남아 있습니다.

참고 문헌

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

감사의 글

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