https://peps.python.org/pep-0703/
[PEP 703 – Making the Global Interpreter Lock Optional in CPython | peps.python.org
In CPython, the global interpreter lock protects against corruption of internal interpreter states when multiple threads concurrently access or modify Python objects. For example, if multiple threads concurrently modify the same list, the GIL ensures that
peps.python.org](https://peps.python.org/pep-0703/)
PEP 703 – CPython에서 전역 인터프리터 락(GIL)
- Sam Gross
- Łukasz Langa
- Discourse thread
초안
- 2023년 1월 9일
- Python 버전 3.13
- 2023년 1월 9일부터 2023년 5월 4일까지
Abstract
CPython의 전역 인터프리터 락(GIL)은 여러 스레드가 동시에 Python 코드를 실행하는 것을 방지합니다. GIL은 Python에서 멀티코어 CPU를 효율적으로 사용하는 것을 방해합니다. 이 PEP은 CPython에 빌드 구성(--disable-gil)을 추가하여 전역 인터프리터 락 없이 Python 코드를 실행하고 인터프리터의 스레드 안전성을 보장하기 위한 필요한 변경 사항을 제안합니다.
동기
GIL은 동시성에 대한 주요 장애물입니다. 과학 계산 작업에서는 이러한 동시성의 부재가 Python 코드 실행 속도보다 더 큰 문제가 될 때가 많습니다. 왜냐하면 대부분의 프로세서 사이클은 최적화된 CPU 또는 GPU 커널에서 소비되기 때문입니다. GIL은 다른 스레드가 Python 코드를 호출할 경우 진행을 방해할 수 있는 전역 병목 현상을 도입합니다. 현재 CPython에서 병렬 처리를 가능하게 하는 방법이 있지만, 이러한 기술은 중요한 제한 사항을 가지고 있습니다(대안 참조).
이 섹션에서는 과학 계산을 위한 GIL의 영향에 초점을 맞추고 있으며, 특히 인공 지능/머신 러닝 작업에 대한 영향을 다루고 있습니다. 이 작성자는 이 분야에서 가장 많은 경험을 가지고 있기 때문입니다. 그러나 GIL은 Python의 다른 사용자들에게도 영향을 미칩니다.
GIL로 인해 다양한 유형의 병렬 처리가 어려워집니다.
신경망 기반의 AI 모델은 병렬 처리의 다양한 기회를 제공합니다. 예를 들어, 개별 작업은 내부적으로 병렬화될 수 있으며("intra-operator"), 여러 작업을 동시에 실행할 수 있으며("inter-operator"), 요청(여러 작업을 포함)은 병렬화될 수도 있습니다. 효율적인 실행을 위해서는 다양한 유형의 병렬 처리를 활용해야 합니다 [1].
GIL은 Python에서 inter-operator 병렬 처리 및 일부 형태의 요청 병렬 처리를 효율적으로 표현하기 어렵게 만듭니다. 다른 프로그래밍 언어에서는 시스템이 스레드를 사용하여 신경망의 각 부분을 별도의 CPU 코어에서 실행할 수 있지만, GIL로 인해 Python에서는 이것이 비효율적입니다. 마찬가지로, 지연 시간에 민감한 추론 작업은 종종 여러 요청에 걸쳐 병렬화를 위해 스레드를 사용하지만, Python에서는 동일한 확장성 병목 현상을 마주하게 됩니다.
GIL이 Python에서 병렬 처리를 활용하는 데 어려움을 일으키는 문제는 강화 학습에서 자주 발생합니다. NetHack Learning Environment의 저자이자 Inflection AI의 Technical Staff 멤버인 Heinrich Kuttler는 다음과 같이 말합니다:
"DotA 2, StarCraft, NetHack 등의 최근 강화 학습의 획기적인 발전은 비동기적인 액터-크리틱 방법을 사용하여 병렬로 여러 환경(시뮬레이션 게임)을 실행하는 데 의존합니다. Python의 단순한 멀티스레드 구현은 GIL 경합 때문에 몇 개 이상의 병렬 환경에서 확장되지 않습니다. 공유 메모리나 UNIX 소켓을 통해 통신하는 다중 프로세싱은 많은 복잡성을 동반하며, CUDA를 다른 워커에서 사용하는 것을 사실상 불가능하게 만들어 설계 공간을 심각하게 제한합니다."
GIL(Globel Interpreter Lock)로 인한 파이썬 코드의 접근성 저하
많은 애플리케이션에서 DeepMind에서 Python GIL과 관련된 문제에 직면하고 있습니다. 우리는 보통 하나의 프로세스에서 50-100개의 스레드를 실행하고 싶어하지만, 때로는 10개 이하의 스레드라도 GIL이 병목 현상을 일으킵니다. 이 문제를 해결하기 위해 때로는 서브프로세스를 사용하지만, 많은 경우에는 프로세스간 통신이 너무 큰 오버헤드가 됩니다. GIL과 관련된 문제를 해결하기 위해 우리는 보통 Python 코드 베이스의 큰 부분을 C++로 변환합니다. 이는 연구원들에게 코드 접근성을 떨어뜨리는 부작용을 일으킵니다.
여러 하드웨어 장치와의 인터페이스를 포함하는 프로젝트들은 유사한 도전을 직면하고 있습니다. Dose-3D 프로젝트는 정확한 용량 계획으로 암 방사선 치료를 개선하기 위한 목표를 가지고 있습니다. 이 프로젝트는 사람의 조직을 대신할 수 있는 의료 유사체(medical phantom)와 함께 Python으로 작성된 서버 애플리케이션과 커스텀 하드웨어를 사용합니다. Dose-3D 프로젝트의 데이터 수집 시스템의 주요 소프트웨어 아키텍트인 Paweł Jurgielewicz은 GIL에 의해 제기되는 확장 문제와 GIL이 없는 Python의 포크를 사용함으로써 프로젝트를 단순화한 경험에 대해 설명합니다.
Dose-3D 프로젝트에서 주요한 도전은 하드웨어 장치와 안정적이고 복잡한 동시 통신 링크를 유지하는 것이었습니다. 특히, 1 Gbit/s UDP/IP 연결을 최대한 활용해야 했습니다. 우리는 자연스럽게 multiprocessing 패키지를 사용해 시작했지만, 어느 순간에는 데이터 전송이 데이터 처리 단계보다 CPU 시간의 대부분을 소모한다는 것을 알게 되었습니다. GIL을 기반으로 한 CPython의 멀티스레딩 구현은 막다른 길이었습니다. 우리가 "nogil"이라는 Python 포크에 대해 알게 된 후에는 단 한 명의 개발자가 코드베이스를 이 포크를 사용하도록 조정하는 데에 절반 정도의 업무 시간이 걸렸고, 결과는 놀라웠습니다. 이제 우리는 데이터 교환 알고리즘을 세밀하게 조정하는 대신 데이터 수집 시스템 개발에 집중할 수 있습니다.
CellProfiler의 저자이자 Prescient Design 및 Genentech의 스태프 엔지니어인 Allen Goodman은 GIL로 인해 생물학적 방법 연구가 파이썬에서 더 어려워진다고 설명합니다.
Python의 글로벌 인터프리터 잠금(GIL)은 생물학적 방법 연구 전반에 걸쳐 빈번한 좌절의 원인이 됩니다.
저는 현재의 멀티스레딩 상황을 더 잘 이해하기 위해 HMMER, 여러 서열 정렬에 사용되는 표준 방법의 일부를 재구현해 보았습니다. 이 방법은 단일 스레드 성능(점수 매기기)과 멀티스레딩 성능(서열 데이터베이스 검색)을 모두 강조합니다. GIL은 8개의 스레드만 사용할 때에도 병목 현상이 발생했습니다. 현재 인기 있는 구현은 프로세스 당 64개 이상 또는 128개의 스레드에 의존합니다. 저는 서브프로세스로 이동해 보았지만, IPC cost 때문에 막혔습니다. HMMER는 상대적으로 기초적인 생물정보학 방법이며, 최신 방법들은 훨씬 더 큰 멀티스레딩 요구를 가지고 있습니다.
방법 연구자들은 사용하기 쉬우며, Python 생태계와 "사람들이 아는 언어"라는 이유로 Python을 사용하고자 합니다. 그러나 Python의 멀티스레딩 상황이 개선되지 않는 한, C와 C++은 생물학적 방법 연구 커뮤니티의 공용어로 남게 될 것입니다.
GIL이 파이썬 라이브러리의 사용성에 영향을 미칩니다.
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 개발자들은 joblib, loky, Ralf Gommers, Quansight Labs의 공동 디렉터이자 NumPy 및 SciPy 유지 관리자는 GIL이 NumPy 및 숫자 계산용 Python 라이브러리의 사용자 경험에 어떤 영향을 미치는지에 대해 설명합니다.
joblib
GIL이 NumPy와 Numeric Python 라이브러리의 사용자 경험에 미치는 영향
NumPy와 NumPy를 기반으로 한 패키지 스택에서의 주요 문제는 NumPy가 여전히 (대부분) 단일 스레드로 구성되어 있다는 것입니다. 이는 사용자 경험과 이를 기반으로 한 프로젝트의 중요한 부분을 형성해 왔습니다. NumPy는 내부 루프에서 GIL을 해제하지만 이는 충분하지 않습니다. NumPy는 단일 컴퓨터의 모든 CPU 코어를 효과적으로 활용하기 위한 해결책을 제공하지 않으며, 대신 Dask와 같은 다른 병렬 처리 솔루션에 이를 맡깁니다. 하지만 이러한 솔루션들은 효율적이지 않으며 사용하기도 더 어렵습니다. 이런 어려움은 주로 사용자가 사용할 때 추가적인 추상화와 계층을 고려해야 하는 부분에서 발생합니다.
관련 라이브러리
- dask.array
- numpy.ndarray
- threadpoolctl
병렬 처리를 제어하기 위한 API 및 디자인 결정을 조정하는 작업은 여전히 PyData 생태계 전반에서 가장 어려운 문제 중 하나입니다. GIL이 없었다면 이 작업은 훨씬 다르고 (더 좋고, 쉬웠을 것입니다).
GPU 중심의 작업은 멀티코어 처리를 필요로 합니다
고성능 컴퓨팅(HPC) 및 AI 작업은 GPU를 중심으로 많이 사용됩니다. 이러한 응용 프로그램은 일반적으로 대부분의 계산을 GPU에서 실행하지만 효율적인 멀티코어 CPU 실행이 필요합니다.
PyTorch 핵심 개발자이자 FAIR (Meta AI)의 연구원인 Zachary DeVito는 GIL로 인해 Python 외부에서 대부분의 계산이 수행되는 경우에도 멀티스레드 확장이 비효율적이라고 설명합니다.
PyTorch에서는 Python을 사용하여 대개 ~8개의 GPU와 ~64개의 CPU 스레드를 조정하며, 대규모 모델의 경우 4k개의 GPU와 32k개의 CPU 스레드까지 확장됩니다. 실제 작업은 Python 외부에서 수행되지만 GPU의 속도 때문에 Python에서의 조정만으로도 확장이 어렵습니다. GIL 때문에 하나 대신 72개의 프로세스가 필요한 경우가 종종 있습니다. 이로 인해 로깅, 디버깅 및 성능 튜닝이 어려워지며 개발자의 생산성이 크게 저하됩니다.
스레드 대신 많은 프로세스를 사용하는 것은 일반적인 작업을 더 어렵게 만듭니다. Zachary DeVito는 다음과 같이 말합니다.
"지난 몇 달 동안 중복 계산을 줄이는 데이터 로더, 모델 체크포인트를 비동기로 작성하는 것, 컴파일러 최적화를 병렬화하는 것과 같은 문제를 해결하는 데 GIL 제약을 어떻게 피할지에 대해 알아보는 데 시간을 소요한 것은 실제로 그 문제 자체를 해결하는 데보다도 한 차원 더 오랜 시간이 걸렸습니다."
심지어 GPU 중심의 작업들도 CPU 집약적인 부분이 자주 있습니다. 예를 들어 컴퓨터 비전 작업은 이미지 디코딩, 크롭 및 리사이징과 같은 "전처리" 단계를 여러 개 필요로 합니다. 이러한 작업은 일반적으로 CPU에서 수행되며 Pillow나 Pillow-SIMD와 같은 Python 라이브러리를 사용할 수도 있습니다. GPU를 데이터로 "공급"하기 위해 데이터 입력 파이프라인을 여러 CPU 코어에서 실행해야 합니다.
개별 CPU 코어 대비 GPU 성능의 증가로 인해 멀티코어 성능이 더 중요해졌습니다. GPU를 완전히 활용하기 위해서는 효율적인 여러 CPU 코어의 사용이 필요합니다. 특히 다중 GPU 시스템에서는 더욱 어려워졌습니다. 예를 들어, NVIDIA의 DGX-A100은 8개의 GPU와 2개의 64코어 CPU를 사용하여 GPU를 데이터로 "공급"합니다.
GIL로 인해 Python AI 모델 배포가 어려워집니다
Python은 신경망 기반 AI 모델을 개발하는 데 널리 사용됩니다. PyTorch에서 모델은 자주 멀티스레드로 된 주로 C++ 환경의 일부로 배포됩니다. GIL로 인해 효율적인 확장이 방해되어 Python은 종종 회의적으로 여겨집니다. 실제로 대부분의 계산이 GIL이 해제된 Python 외부에서 수행되기 때문입니다. torchdeploy 논문에서는 이러한 확장 병목 현상을 여러 모델 아키텍처에서 실험적으로 입증했습니다.
PyTorch는 GIL을 피하거나 해결하기 위한 여러 메커니즘을 제공하지만 모두 중요한 제한 사항을 가지고 있습니다. 예를 들어, 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에서의 조정과 통신에 대한 어려움에 대해 설명했습니다.## GIL(GLOBAL INTERPRETER LOCK) 제거
개요
GIL(GLOBAL INTERPRETER LOCK)은 CPython에서 사용되는 스레드 간 동기화 메커니즘으로, 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 제어합니다. 그러나 GIL은 현재의 과학 및 숫자 계산 라이브러리의 개발과 유지보수에 어려움을 줄 뿐만 아니라, 라이브러리 설계를 더 어렵게 만들기도 합니다.
이 PEP(파이썬 개선 제안)은 CPython에서 GIL을 제거하기 위한 변경 사항을 제시하고 있습니다.
빌드 구성 변경
GIL은 CPython 빌드 및 python.org 다운로드의 기본 설정으로 유지될 것입니다. 그러나 새로운 빌드 구성 플래그인 --disable-gil
이 configure 스크립트에 추가될 것이며, 이를 사용하여 GIL 없이 CPython을 실행할 수 있도록 빌드할 수 있습니다.
--disable-gil
로 빌드된 CPython은 Python/patchlevel.h에 있는 Py_NOGIL
매크로를 정의할 것입니다. ABI 태그에는 "n" (nogil을 의미)이 포함될 것입니다.
CPython의 --disable-gil
빌드는 여전히 런타임에서 GIL을 사용하여 실행할 수 있습니다(PYTHONGIL 환경 변수 및 Py_mod_gil 슬롯 참조).
CPython 변경 사항 개요
GIL 제거에는 CPython 내부에 상당한 변경 사항이 필요하지만, 공개 Python 및 C API에는 상대적으로 적은 변경 사항이 필요합니다. 이 섹션에서는 CPython 구현에 필요한 변경 사항을 설명한 다음, 제안된 API 변경 사항을 설명합니다.
구현 변경 사항은 다음 네 가지 범주로 그룹화될 수 있습니다:
- 참조 카운팅
- 메모리 관리
- 컨테이너 스레드 안전성
- 잠금 및 원자성 API
참조 카운팅
GIL 제거는 CPython의 참조 카운팅 구현에 대한 변경 사항을 필요로 합니다. 이를 통해 참조 카운팅이 스레드 안전하고, 실행 오버헤드가 낮으며, 여러 스레드에서 효율적으로 확장될 수 있도록 만들어야 합니다. 이 PEP은 이러한 제약 사항에 대응하기 위해 세 가지 기술의 조합을 제안합니다. 첫 번째는 일반 비원자적 참조 카운팅에서 스레드 안전한 참조 카운팅으로의 전환입니다. 이는 원자적 참조 카운팅보다 실행 오버헤드가 낮은 스레드 안전한 참조 카운팅 기술입니다. 나머지 두 가지 기술은 불멸화(immortalization)와 제한된 형태의 지연 참조 카운팅을 포함하며, 참조 카운팅의 멀티스레드 확장성 문제 중 일부를 해결합니다.
비원자적 참조 카운팅에서 스레드 안전한 참조 카운팅으로의 전환은 Jiho Choi, Thomas Shull, Josep Torrellas에 의해 2018년에 처음으로 소개된 기술입니다. 이 기술은 대부분의 객체가 다중 스레드 프로그램에서도 하나의 스레드에 의해서만 액세스되는 것을 관찰한 것에 기반합니다. 각 객체는 소유 스레드(생성한 스레드)와 연결됩니다. 소유 스레드에서의 참조 카운팅 작업은 "로컬" 참조 카운트를 수정하기 위해 원자적이지 않은 명령을 사용합니다. 다른 스레드는 "공유" 참조 카운트를 수정하기 위해 원자적 명령을 사용합니다. 이 설계는 현재 프로세서에서 비용이 많이 드는 원자적 읽기-수정-쓰기 연산을 피할 수 있도록 합니다.
이 PEP에서 제안하는 BRC(참조 카운팅)의 구현은 기본적으로 biased reference counting의 원래 설명과 매우 유사하지만, 참조 카운트 필드의 크기와 해당 필드의 특수 비트 등 세부 사항에서 차이가 있습니다. BRC는 각 객체의 헤더에 세 가지 정보를 저장해야 합니다: "로컬" 참조 카운트, "공유" 참조 카운트, 소유 스레드의 식별자. BRC 논문은 이러한 세 가지 요소를 하나의 64비트 필드로 압축합니다. 이 PEP은 참조 카운트 오버플로우로 인한 잠재적인 문제를 피하기 위해 각 객체의 헤더에 세 개의 별도 필드를 사용하는 것을 제안합니다. 또한 PEP은 일반적인 경우에 원자적 연산을 피하는 더 빠른 할당 해제 경로를 지원합니다.
제안된 PyObject 구조체(또는 struct _object)는 다음과 같습니다:
struct _object {
_PyObject_HEAD_EXTRA
uintptr_t ob_tid; // 소유 스레드 id (4-8바이트)
uint16_t __padding; // 미래에 사용하기 위한 예약 공간 (2바이트)
PyMutex ob_mutex; // 개별 객체의 뮤텍스 (1바이트)
uint8_t ob_gc_bits; // 가비지 컬렉션 필드 (1바이트)
uint32_t ob_ref_local; // 로컬 참조 카운트 (4바이트)
Py_ssize_t ob_ref_shared; // 공유 참조 카운트 및 상태 비트 (4-8바이트)
PyTypeObject ob_type;
};
불멸화
일부 객체들은 프로그램의 수명 동안 계속 유지되는 interned 문자열, 작은 정수, 정적으로 할당된 PyTypeObject 및 True, False, None 객체와 같은 객체입니다. 이러한 객체들은 로컬 참조 카운트 필드(ob_ref_local)를 UINT32_MAX로 설정하여 불멸로 표시됩니다.
불멸 객체에 대해 Py_INCREF 및 Py_DECREF 매크로는 작업을 수행하지 않습니다. 이로써 여러 스레드가 동시에 이러한 객체의 참조 카운트 필드에 접근할 때 발생하는 경합을 피할 수 있습니다.# Py_INCREF and Py_DECREF 매크로
Py_INCREF와 Py_DECREF 매크로는 불변 객체에 대해 작동하지 않습니다. 이렇게 함으로써 여러 스레드가 동시에 이러한 객체에 액세스할 때 참조 카운트 필드에 대한 경합을 피할 수 있습니다.
이 제안된 불변화 체계는 Python 3.12에서 채택된 PEP 683와 매우 유사하지만, 참조 카운트 필드의 불변 객체에 대한 약간 다른 비트 표현을 사용하여 편향 참조 카운팅과 지연 참조 카운팅과 함께 작동하도록 설계되었습니다. PEP 683 불변화를 사용하지 않는 이유도 참조하십시오.
편향 참조 카운팅
편향 참조 카운팅은 현재 스레드가 "소유"하는 객체에 대한 빠른 경로와 다른 객체에 대한 느린 경로를 가지고 있습니다. 소유권은 ob_tid 필드에 의해 표시됩니다. 스레드 ID를 결정하기 위해서는 플랫폼별 코드가 필요합니다. ob_tid에 0 값이 있는 경우, 해당 객체는 어떤 스레드도 소유하지 않음을 나타냅니다.
ob_ref_local 필드는 로컬 참조 카운트와 두 개의 플래그를 저장합니다. 가장 상위 2비트는 객체가 불변 객체인지 또는 지연 참조 카운팅을 사용하는지를 나타냅니다 (지연 참조 카운팅에 대해서는 Deferred reference counting을 참조하십시오).
ob_ref_shared 필드는 공유 참조 카운트를 저장합니다. 가장 하위 2비트는 참조 카운팅 상태를 저장하는 데 사용됩니다. 따라서 공유 참조 카운트는 왼쪽으로 2비트 시프트됩니다. 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_INCREF 및 Py_DECREF 작업의 동작을 설명하는 의사 코드(C 스타일)입니다:
// "ob_ref_shared"의 하위 2비트는 플래그로 사용됩니다.
define _Py_SHARED_SHIFT 2
void Py_INCREF(PyObject *op) {
uint32_t new_local = op->ob_ref_local + 1;
if (new_local == _Py_IMMORTAL_REFCNT)
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--;
else if (op->ob_ref_local == _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;
}# merge refcount
else
_Py_DecRefShared();
slow path
void _Py_MergeZeroRefcount(PyObject *op)
{
if (op->ob_ref_shared == 0)
{
// quick deallocation code path (common case)
_Py_Dealloc(op);
}
else
{
// slower merging path not shown
}
}
참조 구현체에는 _Py_MergeZeroRefcount와 _Py_DecRefShared의 구현이 포함되어 있습니다.
위의 코드는 의사 코드이며, 실제로는 C 및 C++에서 정의되지 않은 동작을 피하기 위해 "relaxed atomics"를 사용하여 ob_tid와 ob_ref_local에 액세스해야 합니다.
지연된 참조 카운팅
일부 유형의 객체(예: 최상위 함수, 코드 객체, 모듈 및 메서드)는 여러 스레드에서 동시에 자주 액세스될 수 있습니다. 이러한 객체는 프로그램의 수명 동안 존재하지 않을 수 있으므로 불멸화는 적합하지 않습니다. 이 PEP는 멀티 스레드 프로그램에서 이러한 객체의 참조 카운트 필드에 대한 경합을 피하기 위해 제한된 형태의 지연된 참조 카운팅을 제안합니다.
일반적으로 인터프리터는 객체를 인터프리터 스택에 푸시하고 팝할 때 객체의 참조 카운트를 수정합니다. 인터프리터는 지연된 참조 카운팅을 사용하는 객체에 대해 이러한 참조 카운팅 작업을 건너뜁니다. 지연된 참조 카운팅을 지원하는 객체는 로컬 참조 카운트 필드의 가장 상위 2비트를 1로 설정하여 표시됩니다.
참조 카운팅 작업이 건너뛰어지기 때문에 참조 카운트 필드는 이제 이러한 객체에 대한 실제 참조 수를 반영하지 않습니다. 실제 참조 카운트는 참조 카운트 필드의 합과 각 스레드의 인터프리터 스택에서 건너뛴 참조의 합입니다. 실제 참조 카운트는 순환 가비지 수집 중 모든 스레드가 일시 중지될 때만 안전하게 계산할 수 있습니다. 따라서 지연된 참조 카운팅을 사용하는 객체는 순환 가비지 수집 주기 동안에만 해제될 수 있습니다.
지연된 참조 카운팅을 사용하는 객체는 이미 CPython에서 자연스럽게 참조 사이클을 형성하므로 지연된 참조 카운팅 없이도 일반적으로 가비지 수집기에 의해 해제될 것입니다. 예를 들어, 최상위 함수와 모듈은 참조 사이클을 형성하며, 메서드와 타입 객체도 참조 사이클을 형성합니다.
지연된 참조 카운팅을 위한 가비지 수집기 수정
추적 가비지 수집기는 참조되지 않은 객체를 찾아 해제합니다. 현재 추적 가비지 수집기는 참조 사이클의 일부인 참조되지 않은 객체만 찾습니다. 지연된 참조 카운팅을 사용하면 추적 가비지 수집기는 참조 사이클에 속하지 않을 수도 있는 일부 참조되지 않은 객체를 찾아 수집합니다. 그러나 이는 지연된 참조 카운팅으로 인해 수집이 지연된 객체입니다. 이를 위해 지연된 참조 카운팅을 지원하는 모든 객체는 추적 가비지 수집을 지원하는 해당 유형 객체(through the Py_TPFLAGS_HAVE_GC 플래그를 통해)도 가져야 합니다. 또한 가비지 수집기는 각 스레드의 스택을 트래버스하여 각 컬렉션의 시작 시 GC 참조 카운트에 참조를 추가해야 합니다.
참조 카운팅 유형 객체
유형 객체(PyTypeObject)는 참조 카운팅 기술을 혼합하여 사용합니다. 정적으로 할당된 유형 객체는 이미 프로그램의 수명 동안 존재하기 때문에 불멸화됩니다. 힙 유형 객체는 지연된 참조 카운팅과 스레드별 참조 카운팅을 조합하여 사용합니다. 힙 유형의 멀티 스레드 확장 병목현상을 해결하기 위해 지연된 참조 카운팅만으로는 충분하지 않습니다. 왜냐하면 힙 유형에 대한 대부분의 참조는 인스턴스의 참조가 아닌 인터프리터 스택에 있는 참조입니다.
이를 해결하기 위해 힙 유형의 참조 카운트는 스레드별 배열에 분산하여 저장됩니다. 각 스레드는 각 힙 유형 객체에 대한 로컬 참조 카운트 배열을 저장합니다. 힙 유형 객체에는 고유한 번호가 할당되어 로컬 참조 카운트 배열에서의 위치를 결정합니다. 힙 유형의 실제 참조 카운트는 스레드별 배열의 항목들의 합, PyTypeObject의 참조 카운트 및 인터프리터 스택에서의 지연된 참조를 더한 값입니다.
스레드는 유형 객체의 로컬 참조 카운트를 증가 또는 감소할 때 필요에 따라 자체 유형 참조 카운트 배열을 확장할 수 있습니다.
스레드별 참조 카운트 배열의 사용은 몇 군데로 제한됩니다:
- PyType_GenericAlloc(PyTypeObject *type, Py_ssize_t nitems): 힙 유형인 경우 현재 스레드의 유형에 대한 로컬 참조 카운트를 증가시킵니다.
- 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 객체만 할당하는 데 사용되어야 하며 버퍼, 저장소 또는 PyObjects가 아닌 다른 데이터 구조를 할당하는 데 사용해서는 안 됩니다.
이 PEP은 플러그 가능한 할당자 API(PyMem_SetAllocator)에도 제한을 가합니다. GIL 없이 컴파일하는 경우 이 API를 사용하여 설정된 할당자는 Python 객체 할당을 위해 PyObject_Malloc과 같은 해당하는 하위 할당자로 할당을 위임해야 합니다. 이를 통해 Python의 tracemalloc과 디버그 할당자와 같이 하위 할당자를 "래핑"하는 할당자를 사용할 수 있지만, 할당자를 완전히 대체할 수는 없습니다.
CPython 자유 리스트
CPython은 튜플이나 숫자와 같이 자주 할당되는 작은 객체의 할당 속도를 높이기 위해 자유 리스트를 사용합니다. 이러한 자유 리스트는 개별 인터프리터 상태에서 PyThreadState로 이동합니다.
가비지 컬렉션 (Cycle Collection)
CPython 가비지 컬렉터는 이 제안과 함께 작동하기 위해 다음과 같은 변경 사항이 필요합니다.
GIL이 이전에 제공했던 스레드 안전성 보장을 위해 "stop-the-world" 사용
비 세대적인 컬렉터를 세대적인 컬렉터 대신 사용
지연된 참조 카운팅 및 편향된 참조 카운팅과의 통합
또한 위의 변경 사항은 GC 객체에서 _gc_prev 및 _gc_next 필드를 제거할 수 있도록 합니다. 추적된 상태, 완료된 상태 및 도달할 수 없는 상태를 저장하던 GC 비트는 PyObject 헤더의 ob_gc_bits 필드로 이동합니다.
Stop-the-World
현재 CPython 순환 가비지 컬렉터는 사이클을 찾는 동안 다른 스레드가 Python 객체에 액세스하지 못하도록 전역 인터프리터 락(GIL)을 사용합니다. GIL은 사이클 검색 루틴 중에 절대로 해제되지 않으므로 컬렉터는 안정적인(즉, 변경되지 않는) 참조 카운트와 참조를 사이클 검색 루틴의 지속 시간 동안 의존할 수 있습니다. 그러나 사이클 감지 이후에는 GIL이 일시적으로 해제될 수 있으며 객체의 최종화자와 clear(tp_clear) 함수를 호출하는 동안 다른 스레드가 교차로 실행될 수 있습니다.
GIL 없이 실행될 때 구현은 사이클 검출 중에 참조 카운트가 안정적인 상태를 유지할 수 있는 방법이 필요합니다. Python 코드를 실행하는 스레드는 참조와 참조 카운트가 안정적인 상태를 유지하기 위해 일시적으로 일시 정지되어야 합니다. 사이클이 식별되면 다른 스레드가 다시 시작됩니다.
현재 CPython 순환 가비지 컬렉터는 각 가비지 컬렉션 주기에서 두 개의 사이클 검출 패스를 수행합니다. 따라서 GIL 없이 가비지 컬렉터를 실행할 때는 두 개의 stop-the-world 일시 정지가 필요합니다. 첫 번째 사이클 검출 패스는 순환 쓰레기를 식별합니다. 두 번째 패스는 최종화자 이후에 실행되어 여전히 도달할 수 없는 객체를 식별합니다. 다른 스레드가 최종화자와 tp_clear 함수를 호출하기 전에 다시 시작되므로 현재 CPython 동작과 달리 잠재적인 데드락을 발생시키지 않도록 합니다.
스레드 상태
가비지 컬렉션을 위해 스레드를 일시 정지하기 위해 PyThreadState에 새로운 "status" 필드가 추가됩니다. PyThreadState의 다른 필드와 마찬가지로 status 필드는 공개 CPython API의 일부가 아닙니다. status 필드는 다음 세 가지 상태 중 하나를 가질 수 있습니다.
- ATTACHED
- DETACHED
- GC
ATTACHED와 DETACHED 상태는 이전과 동일하게 유지됩니다. GC 상태는 가비지 컬렉션 중인 스레드에 대해 설정됩니다. 이러한 상태는 동시에 여러 스레드가 가비지 컬렉션을 시작할 수 있도록 허용합니다. 이를 위해 PyEval_RestoreThread 함수가 수정됩니다.### ATTACHED와 DETACHED
- GC(Garbage Collection)를 수행하는 스레드는 다른 스레드가 Python 객체에 접근하거나 수정하지 않도록 보장해야 합니다.
- 다른 스레드는 "GC" 상태여야 합니다.
- GC 스레드는 상태 필드에 대해 원자적인 compare-and-swap 연산을 사용하여 DETACHED 상태에서 다른 스레드를 GC 상태로 전환할 수 있습니다.
- ATTACHED 상태에 있는 스레드는 기존의 "eval breaker" 메커니즘을 사용하여 스스로 일시 정지하고 상태를 "GC"로 설정해야 합니다.
- stop-the-world 일시 정지가 끝나면, "GC" 상태인 모든 스레드는 DETACHED로 설정되고 일시 정지된 경우 깨어납니다.
- 이전에 ATTACHED 상태였던 (즉, Python 바이트코드를 실행 중이던) 스레드는 다시 ATTACHED로 첨부하고 Python 코드를 계속 실행할 수 있습니다.
- 이전에 DETACHED 상태였던 스레드는 알림을 무시합니다.
Generations
- 기존의 Python 가비지 컬렉터는 세 가지 세대(generation)를 사용합니다.
- GIL 없이 컴파일하는 경우 가비지 컬렉터는 단일 세대를 사용합니다(즉, 세대적이지 않습니다).
- 이 변경의 주요 이유는 멀티스레드 애플리케이션에서 stop-the-world 일시 정지의 영향을 줄이기 위한 것입니다.
- 어린 세대를 수집하기 위한 빈번한 stop-the-world 일시 정지는 적은 빈도의 수집보다 멀티스레드 애플리케이션에 더 큰 영향을 미칠 것입니다.
Deferred와 Biased 참조 카운팅과의 통합
- 순환 가비지 컬렉터는 참조되지 않은 객체를 찾기 위해 참조 수와 객체의 참조 카운트의 차이를 계산합니다.
- 이 차이는 gc_refs라고 하며 _gc_prev 필드에 저장됩니다.
- gc_refs가 양수이면 객체가 살아있음(즉, 순환 쓰레기가 아님)이 보장됩니다.
- gc_refs가 0이면 객체는 다른 살아있는 객체에 의해 이전에 참조되었는지에 따라 살아있을 수도 있습니다.
- 이 차이를 계산할 때, 컬렉터는 각 스레드의 스택을 탐색하고 각 지연 참조에 대해 참조된 객체의 gc_refs를 증가시켜야 합니다.
- 제너레이터 객체도 지연 참조를 가진 스택을 가지고 있으므로 동일한 절차가 각 제너레이터의 스택에도 적용됩니다.
컨테이너 스레드 안전성
- CPython에서 글로벌 인터프리터 락(GIL)은 여러 스레드가 동시에 Python 객체에 접근하거나 수정할 때 내부 인터프리터 상태의 손상을 방지합니다.
- 예를 들어, 여러 스레드가 동시에 동일한 리스트를 수정하는 경우 GIL은 리스트의 길이(ob_size)가 정확하게 요소의 수와 일치하고 각 요소의 참조 카운트가 해당 요소에 대한 참조 수를 정확하게 반영하는 것을 보장합니다.
- GIL이 없으면서 다른 변경 없이 동시 수정이 발생하는 경우, 이러한 필드가 손상되고 프로그램 충돌이 발생할 수 있습니다.
- GIL은 연산이 원자적이거나 여러 연산이 동시에 발생할 때 정확성을 유지하지 않는다.
- 예를 들어, list.extend(iterable)는 iterable이 Python으로 구현된 반복자를 가지고 있는 경우 (또는 내부적으로 GIL을 해제하는 경우) 원자적으로 보이지 않을 수 있습니다.
- 마찬가지로, list.remove(x)는 리스트를 수정하는 다른 작업과 겹치는 경우 잘못된 객체를 제거할 수 있으며, 이는 동등성 연산자의 구현에 따라 달라집니다.
- 그럼에도 불구하고 GIL은 일부 연산이 사실상 원자적인 것처럼 보장합니다.
- 예를 들어, 생성자 list(set)은 set의 항목을 원자적으로 새로운 리스트로 복사하며, 일부 코드는 해당 복사가 원자적임(즉, set의 항목의 스냅샷을 가지고 있음)을 의존합니다.
- 이 PEP는 이러한 속성을 유지합니다.
- 이 PEP는 GIL이 제공하는 많은 보호 기능을 제공하기 위해 개별 객체 락을 사용하는 것을 제안합니다.
- 예를 들어, 모든 리스트, 사전 및 집합은 관련된 가벼운 락을 가지게 됩니다.
- 객체를 수정하는 모든 작업은 해당 객체의 락을 보유해야 합니다.
- 대부분의 객체에서 읽기 작업도 해당 객체의 락을 획득해야 하며, 락을 보유하지 않고 진행할 수 있는 몇 가지 읽기 작업은 아래에서 설명합니다.### Per-object Locking vs. GIL
각 객체에 대한 잠금과 크리티컬 섹션은 GIL(Global Interpreter Lock)보다 약한 보호를 제공합니다. 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의 구현은 한 객체 (list) 만 잠그면 됩니다. 다른 객체는 스레드 안전한 이터레이터 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_FetchItem(list, idx) : PyList_GetItem을 대체
- PyDict_FetchItem(dict, key) : PyDict_GetItem을 대체
- PyWeakref_FetchObject : PyWeakref_GetObject을 대체
일부 대여 참조를 반환하는 API (예 : PyTuple_GetItem)는 튜플이 불변이기 때문에 문제가되지 않습니다. 또한 위의 API의 모든 사용이 문제가되는 것은 아닙니다. 예를 들어, PyDict_GetItem은 함수 호출에서 키워드 인수 사전을 구문 분석하는 데 자주 사용됩니다. 해당 키워드 인수 사전은 사실상 개인적이며 (다른 스레드로부터 접근할 수 없음) 다른 스레드에서 접근할 수 없습니다.
Python 크리티컬 섹션
직관적인 개별 객체 잠금은 GIL로 실행할 때 존재하지 않았던 교착 상태를 도입할 수 있습니다. Python 작업은 중첩될 수 있기 때문에 스레드는 한 번에 하나의 작업에 대한 잠금 (또는 잠금) 만 보유 할 수 있도록 허용해야합니다. 중첩 작업을 시작하기 전에 외부 작업에 대한 잠금이 일시 중지되어야합니다. 중첩 작업이 완료되면 외부 작업에 대한 잠금을 다시 획득해야합니다.
또한 I/O와 같은 잠재적으로 블로킹 작업 (즉, GIL을 해제 한 작업) 주위에서도 활성 작업에 대한 잠금이 일시 중단되어야합니다. 이는 잠금과 블로킹 작업 간의 상호 작용이 여러 잠금간의 상호 작용과 동일한 방식으로 교착 상태를 유발할 수 있기 때문입니다.# 개요
이 PEP는 데드락을 피하기 위해 위의 방법과 다른 변형을 제안하여 성능을 향상시킵니다. 중첩된 작업이 시작될 때마다 즉시 잠금을 중지하는 대신, 스레드가 블록될 경우에만 잠금이 중지됩니다 (즉, GIL을 해제할 것입니다). 이렇게하면 중첩된 작업에 대한 잠금 획득 및 해제 횟수가 줄어들면서 데드락을 방지할 수 있습니다.
Python 임계 영역에 대한 제안된 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
또한, 사전과 목록의 반복자는 위의 함수를 사용하므로 다음 항목을 반환 할 때 잠금을 낙관적으로 회피합니다.
이러한 기능을 잠금 획득을 피하기 위해 사용하는 이유는 두 가지입니다. 주요 이유는 간단한 응용 프로그램에 대해서도 확장 가능한 멀티 스레드 성능에 필요하기 때문입니다. 사전은 모듈의 최상위 함수 및 클래스의 메서드를 보유합니다. 이러한 사전은 멀티 스레드 프로그램에서 많은 스레드에서 공유되는 특성이 있습니다. 멀티 스레드 프로그램에서 방법과 함수를로드하는 데 대한 이러한 잠금의 경쟁은 많은 기본 프로그램에서 효율적인 확장을 억제 할 것입니다.
잠금을 피하는 두 번째 동기는 오버헤드를 줄이고 단일 스레드 성능을 향상시키는 것입니다. 잠금 획득은 대부분의 작업에 비해 오버헤드가 낮지만 목록과 사전의 개별 요소에 액세스하는 것은 빠른 작업입니다 (따라서 잠금 오버헤드는 상대적으로 큽니다) 및 빈번합니다 (따라서 오버헤드는 더 큰 영향을 미칩니다).
이 섹션에서는 잠금 없이 사전 및 목록 액세스를 구현하는 데 어려움이있는 동시에이 PEP에서 이러한 도전에 대응하기 위해 Python 인터프리터에 필요한 변경 사항에 대해 설명합니다.
주요 도전은 목록이나 사전에서 항목을 검색하고 해당 항목의 참조 수를 증가시키는 것이 원자적인 작업이 아니라는 것입니다. 항목이 검색되고 참조 수가 증가하는 동안 다른 스레드가 목록이나 사전을 수정 할 수 있으며, 이전에 검색 된 항목의 메모리를 해제 할 수 있습니다.
이 문제를 해결하기 위한 부분적인 시도는 참조 수 증가를 조건부 증가로 변환하여 참조 수가 0이 아닌 경우에만 참조 수를 증가시키는 것입니다. 이 변경은 충분하지 않습니다. Python 객체의 참조 수가 0이 될 때 객체의 소멸자가 호출되고 객체를 저장하는 메모리가 다른 데이터 구조로 재사용되거나 운영 체제로 반환 될 수 있습니다. 대신,이 PEP는 조건부 참조 수 증가가 안전하도록 참조 수 필드가 액세스하는 동안 유효하게 유지되도록하는 기술을 제안합니다. 이 기술은 메모리 할당기 (mimalloc)의 협력 및 목록 및 사전 객체에 대한 변경 사항을 필요로합니다. 제안 된 기술은 리눅스 커널에서 널리 사용되는 동기화 메커니즘 인 read-copy update (RCU) [6]와 유사합니다.# list_item 함수 구현
현재 list.getitem을 구현한 C 함수인 list_item의 현재 구현 방식은 다음과 같습니다:
Py_INCREF(&ob_item[i]);
return ob_item[i];
제안된 구현 방식은 조건부 증가(_Py_TRY_INCREF)를 사용하고 추가적인 확인을 수행합니다:
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(&ob_item))
Py_DECREF(item);
goto retry;
return item;
위의 빠르고 락을 사용하지 않는 경로가 실패할 때 동시 수정으로 인해 발생하는 잠긴 대체 경로를 구현하는 "retry" 서브루틴입니다:
retry:
PyObject *item;
Py_BEGIN_CRITICAL_SECTION(&ob_mutex);
item = ob_item[i];
Py_INCREF(item);
Py_END_CRITICAL_SECTION(&ob_mutex);
return item;
dict 구현에 대한 수정은 유사합니다. 왜냐하면 list와 dictionary 검색의 관련 부분은 알려진 인덱스에서 배열의 항목/값을 로드하는 것을 포함하기 때문입니다.
조건부 증가를 따르는 추가적인 확인은 이전에 PyObject 구조체나 list나 dict 배열을 저장하던 메모리를 즉시 재사용할 수 있도록 허용하기 때문에 필요합니다. 이러한 추가적인 확인 없이 함수는 리스트에 이전에 있지 않았던 Python 객체를 반환할 수 있습니다. 왜냐하면 Python 객체가 차지하던 메모리는 이전에 리스트의 항목을 저장하던 다른 PyObject의 메모리를 저장하고 있을 수 있기 때문입니다.
낙관적 리스트와 딕셔너리 액세스를 위한 Mimalloc 변경 사항
이 구현은 메모리 할당자에 대한 추가 제약 조건을 요구하며, mimalloc 코드에 일부 변경 사항이 포함됩니다. mimalloc의 구현에 대한 배경 지식은 필요한 변경 사항을 이해하는 데 도움이 됩니다. mimalloc에서 개별 할당은 "블록"이라고 불리며, mimalloc "페이지"는 모두 같은 크기의 연속된 블록을 포함합니다. mimalloc "페이지"는 다른 할당기의 "슈퍼블록"과 유사하며, 운영 체제 페이지가 아닙니다. mimalloc "힙"은 여러 크기 클래스의 페이지를 포함하며, 각 페이지는 단일 힙에 속합니다. 페이지의 블록 중 하나도 할당되지 않은 경우 mimalloc은 페이지를 다른 크기 클래스나 다른 힙에 재사용할 수 있습니다(즉, 페이지를 다시 초기화할 수 있습니다).
리스트와 딕셔너리 액세스 방법은 mimalloc 페이지의 재사용을 부분적으로 제한하여 액세스 기간 동안 참조 카운트 필드를 유효한 상태로 유지하도록 합니다. mimalloc 페이지의 제한된 재사용은 Python 객체에 대한 별도의 힙을 가지고 있음으로써 강제됩니다. 이를 통해 액세스 중에 항목이 해제되고 메모리가 새 객체에 재사용되더라도 새 객체의 참조 카운트 필드가 메모리의 동일한 위치에 배치됩니다. 참조 카운트 필드는 할당을 통해 유효한 상태(또는 0)를 유지합니다.
Py_TPFLAGS_MANAGED_DICT를 지원하는 Python 객체는 PyObject 헤더 이전에 사전 및 약한 참조 필드를 갖기 때문에 참조 카운트 필드의 오프셋이 다릅니다. 이러한 객체는 별도의 mimalloc 힙에 저장됩니다. 또한, GC 객체가 아닌 객체는 자체 힙에 저장되므로 GC는 GC 객체만 검사하면 됩니다. 따라서 Python 객체에는 GC 객체가 아닌 객체용 힙, 관리되는 사전이 있는 GC 객체용 힙, 관리되는 사전이 없는 GC 객체용 힙의 세 가지 mimalloc 힙이 있습니다.
Mimalloc 페이지 재사용
전체 메모리 사용량을 증가시키지 않기 위해 mimalloc 페이지 재사용의 제한을 짧은 시간으로 유지하는 것이 유리합니다. 제한을 리스트 및 딕셔너리 액세스로만 한정하면 메모리 사용량을 최소화할 수 있지만, 비싼 동기화를 필요로 합니다. 다른 극단에서는 제한을 다음 GC 주기까지 유지하면 추가 동기화를 도입하지 않지만 메모리 사용량이 증가할 수 있습니다.
이 PEP는 FreeBSD의 "GUS"를 기반으로 한 두 가지 극단 사이에 있는 시스템을 제안합니다. 이 시스템은 전역 및 스레드별 카운터(또는 "시퀀스 번호")의 조합을 사용하여 빈 mimalloc 페이지를 다른 힙이나 다른 크기 클래스에 안전하게 재사용하거나 운영 체제에 반환할 수 있는 시기를 결정하는 조정을 수행합니다:
- 단조로 증가하는 전역 쓰기 시퀀스 번호가 있습니다.
- mimalloc 페이지가 비어 있으면 현재 쓰기 시퀀스 번호로 태그가 지정됩니다. 스레드는 전역 쓰기 시퀀스 번호를 원자적으로 증가시킬 수도 있습니다.
- 각 스레드는 스레드별 최신 쓰기 시퀀스 번호를 기록하는 로컬 읽기 시퀀스 번호를 갖습니다.
- 스레드는 리스트 또는 딕셔너리 액세스 상태가 아닐 때마다 쓰기 시퀀스 번호를 관찰할 수 있습니다. 참조 구현은 mimalloc의 느린 경로 할당 함수에서 이 작업을 수행합니다. 이는 유용한 정도로 정기적으로 호출되지만 상당한 오버헤드를 도입하지 않을만큼 충분히 자주 호출됩니다.### Global Read Sequence Number
전역 읽기 시퀀스 번호는 모든 활성 스레드의 읽기 시퀀스 번호 중 최솟값을 저장합니다. 스레드는 로컬 읽기 시퀀스 번호를 스캔하여 전역 읽기 시퀀스 번호를 업데이트할 수 있습니다. 새로운 mimalloc 페이지를 할당하기 전에 재사용할 수 있는 제한된 페이지가 있는 경우, 참조 구현은 이 작업을 수행합니다.
전역 읽기 시퀀스 번호가 페이지의 태그 번호보다 큰 경우, 비어있는 mimalloc 페이지는 다른 힙이나 크기 클래스에 재사용될 수 있습니다.
전역 읽기 시퀀스 번호가 페이지의 태그보다 큰 조건은, 동시적인 낙관적인 리스트나 사전 접근이 있는 스레드가 해당 접근을 완료했음을 보장하기 때문에 충분합니다. 즉, 해제된 페이지의 비어있는 블록에 접근하는 스레드가 없으므로 해당 페이지는 다른 용도로 사용하거나 운영 체제에 반환될 수 있습니다.
낙관적인 Dict와 List 접근 요약
이 PEP는 일반적으로 잠금을 획득하지 않고도 안전한 리스트와 사전 접근을 위한 기술을 제안합니다. 이를 통해 함수와 메서드 호출과 같은 일반적인 작업에서 실행 오버헤드를 줄이고 멀티 스레드 환경에서의 확장성 병목 현상을 피할 수 있습니다. 이 체계는 객체가 해제된 후에도 조건부 참조 카운트 증가 작업이 안전하도록 객체의 참조 카운트 필드가 유효하게 유지되도록 임시 제약 조건을 mimalloc 페이지 재사용에 적용하여 작동합니다. 이러한 제약 조건은 개별 객체 대신 mimalloc 페이지에 적용되어 메모리 재사용 기회를 개선합니다. 빈 mimalloc 페이지에 대한 누적된 액세스가 없음을 시스템이 판단할 수 있는 경우에는 제약 조건이 해제됩니다. 이를 확인하기 위해 시스템은 가벼운 스레드별 시퀀스 카운터와 빈 페이지가 되었을 때 페이지에 태그를 사용합니다. 각 스레드의 로컬 카운터가 페이지의 태그보다 크면 해당 페이지는 재사용될 수 있으며 다른 목적으로 사용하거나 운영 체제에 반환될 수 있습니다. 또한 제약 조건은 순환 가비지 수집기가 실행될 때마다 해제됩니다. 순환 가비지 수집기의 스톱-더-월드 일시 정지를 통해 스레드가 비어있는 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이 없어도 스레드 안전하지 않은 확장이 여전히 멀티 스레드 애플리케이션에서 유용할 수 있기 때문에 중요합니다. 예를 들어, 확장을 하나의 스레드에서만 사용하거나 잠금을 사용하여 액세스를 보호할 수 있을 수 있습니다. 이미 GIL이 있는 상태에서도 스레드 안전하지 않은 몇 가지 확장이 있으며, 사용자는 이미 이러한 유형의 조치를 취해야합니다.
PYTHONGIL=1 재정의는 때때로 디버깅에 유용합니다.
비세대 가비지 수집
이 PEP는 GIL이 없는 CPython에서 세대별 순환 가비지 수집기에서 비세대 수집기로 전환하는 것을 제안합니다. 이는 하나의 세대 (즉, "old" 세대)만 가지는 것과 동일합니다. 이 제안된 변경을 위해 두 가지 이유가 있습니다.### 사이클 가비지 컬렉션과 다중 스레드 프로그램의 확장성
사이클 가비지 컬렉션은 젊은 세대에서도 다른 스레드를 일시 중지해야한다. 저자는 젊은 세대의 빈번한 컬렉션으로 인해 멀티스레드 프로그램의 효율적인 확장성이 저하될 수 있다는 우려를 표명하고 있다. 이것은 젊은 세대에 해당하는 사이클 가비지 컬렉션 뿐만 아니라, 오래된 세대의 컬렉션에는 해당하지 않는다. 왜냐하면 젊은 세대는 일정한 할당 횟수 후에 컬렉션되기 때문에, 오래된 세대의 컬렉션은 힙에 있는 라이브 객체의 개수에 비례하여 예약된다. 또한, GIL 없이 각 세대의 객체를 효율적으로 추적하는 것은 어렵다. 예를 들어, 현재 CPython은 각 세대에 대한 객체들의 연결 리스트를 사용한다. CPython이 이러한 설계를 유지하려면, 해당 리스트들을 스레드 안전하게 만들어야하는데, 이를 효율적으로 수행하는 방법은 분명하지 않다.
다른 많은 언어 런타임에서는 세대별 가비지 컬렉션을 효과적으로 사용하고 있다. 예를 들어, 많은 Java HotSpot 가비지 컬렉터 구현은 여러 세대를 사용한다. 이런 런타임에서는 젊은 세대가 자주 처리되어 성능 향상을 이끌어낸다. 왜냐하면 젊은 세대의 많은 비율이 일반적으로 "죽은" 객체이기 때문에, GC는 수행한 작업에 비해 많은 양의 메모리를 회수할 수 있다. 예를 들어, 여러 Java 벤치마크는 "젊은" 객체 중 90% 이상이 일반적으로 수집된다고 보여준다. 이것은 일반적으로 "약한 세대 가설"이라고 불리며, 대부분의 객체가 어릴 때 죽는다는 관찰을 의미한다. CPython에서는 참조 카운팅을 사용하기 때문에 이러한 패턴이 반대로 나타난다. 대부분의 객체는 여전히 어릴 때 죽지만, 해당 객체의 참조 카운트가 0이 될 때 수집된다. 가비지 컬렉션 주기까지 살아남은 객체는 대부분 계속 살아있을 가능성이 높다. 이 차이로 인해 CPython에서는 다른 많은 언어 런타임보다 세대별 컬렉션이 훨씬 효과적이지 않다.
dict 및 list 접근에서의 낙관적인 락 회피
이 제안은 리스트와 딕셔너리의 개별 요소에 접근할 때 대부분의 경우 락(뮤텍스)을 획득하는 것을 피하는 방식에 의존한다. 이는 "락 프리(lock-free)" 알고리즘과 "웨이트 프리(wait-free)" 알고리즘의 의미에서는 "락 프리"가 아니다. 그저 일반적인 경우에 락(뮤텍스)을 획득하지 않도록 하여 병렬성을 향상시키고 오버헤드를 줄이기 위한 것이다.
훨씬 더 간단한 대안은 딕셔너리와 리스트 접근을 보호하기 위해 리더-라이터 락을 사용하는 것이다. 리더-라이터 락은 동시에 읽을 수는 있지만 업데이트는 할 수 없는 기능을 가지고 있어 딕셔너리와 리스트에 이상적으로 보일 수 있다. 그러나 리더-라이터 락은 상당한 오버헤드와 확장성이 떨어진다는 문제점이 있다. 특히 단일 요소에 대한 딕셔너리와 리스트 접근과 같이 크리티컬 섹션이 작은 경우에 그렇다. 리더의 확장성이 떨어지는 이유는 pthread_rwlock의 리더 수를 업데이트해야 하는 것과 같은 동일한 데이터 구조를 업데이트해야 하는데에서 비롯된다.
이 PEP에 설명된 기술은 RCU(Read-Copy-Update; 읽기-복사-갱신)와, 더 적은 정도로 하자드 포인터(hazard pointers)와 관련이 있다. RCU는 확장 가능한 방식으로 공유 데이터 구조를 보호하기 위해 리더가 동시에 업데이트를 수행하는 경우에 널리 사용된다. 이 PEP의 기술과 RCU는 모두 동시 데이터 구조에 접근하는 동안 회수를 연기하는 방식으로 작동한다. RCU는 일반적으로 해시 테이블이나 연결 리스트와 같은 개별 객체를 보호하는 데 사용되지만, 이 PEP에서는 mimalloc "페이지"와 같은 큰 메모리 블록을 보호하는 방법을 제안한다.
이러한 기술의 필요성은 주로 CPython에서 참조 카운팅을 사용하고 있기 때문이다. CPython이 추적 가비지 컬렉터만 사용하는 경우라면 이러한 기술이 필요하지 않을 수도 있다. 왜냐하면 추적 가비지 컬렉터는 이미 필요한 방식으로 회수를 연기하기 때문이다. 이러한 접근은 확장성 문제를 "해결"하지는 않지만, 많은 어려움을 가비지 컬렉터 구현으로 전환할 것이다.
역호환성
이 PEP는 CPython을 --disable-gil 플래그로 빌드할 때 일부 역호환성 문제를 야기하지만, 기본 빌드 구성을 사용할 때는 이러한 문제가 발생하지 않는다. 거의 모든 역호환성 문제는 C-API와 관련이 있다.
GIL 없이 CPython을 빌드하면 표준 CPython 빌드나 안정적인 ABI와 ABI 호환성이 없어지므로 biased reference counting을 지원하기 위해 Python 객체 헤더에 대한 변경이 필요하다.
이 버전에 맞는 C-API 확장을 독립적으로 다시 빌드해야 한다.
GIL을 사용하여 C 코드에서 전역 상태나 객체 상태를 보호하는 C-API 확장은 GIL 없이 실행될 때 스레드 안전성을 유지하기 위해 추가적인 명시적 락이 필요하다.
GIL 없이 안전하지 않은 방식으로 borrowed references를 사용하는 C-API 확장은 비대칭된 참조를 반환하는 새로운 동등한 API를 사용해야 한다. borrowed references의 사용 중 일부만 문제가 되는데, 다른 스레드에서 해제될 수 있는 객체에 대한 참조만 문제가 된다.# Custom Memory Allocators
요약
- 사용자 정의 메모리 할당기(PyMem_SetAllocator)는 실제 할당을 이전에 설정된 할당기에 위임해야한다.
- Python 객체는 PyType_GenericNew 또는 PyObject_Malloc과 같은 표준 API를 통해 할당되어야 한다.
- Python 코드의 역호환성 문제는 상대적으로 적다.
- Python 배포에 새로운 도전이 제기된다.
- 성능에 영향을 미친다.
- 빌드 봇에 포함된다.
- 사용 방법을 가르치는 방법이 포함된다.
- 참조 구현이 있다.
- 대체 방안이 있다.
사용자 정의 메모리 할당기
Python에서는 사용자 정의 메모리 할당기(PyMem_SetAllocator)를 사용하여 실제 할당을 이전에 설정된 할당기에 위임해야 한다. 예를 들어, Python 디버그 할당기와 추적 할당기는 할당을 기본 할당기에 위임하기 때문에 계속 작동한다. 반면에, 할당기를 완전히 대체하는 것(jemalloc 또는 tcmalloc과 같은)은 올바르게 작동하지 않을 것이다.
Python 객체는 PyType_GenericNew 또는 PyObject_Malloc와 같은 표준 API를 통해 할당되어야 한다. 비-Python 객체는 이러한 API를 통해 할당되어서는 안된다. 예를 들어, 현재는 버퍼(non-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와 협력하여 --disable-gil 버전의 Python을 conda 채널로부터 호환되는 패키지와 함께 배포할 계획이다. 이렇게 하면 확장 기능을 빌드하는 문제를 중앙집중화할 수 있으며, 저자는 이를 통해 사람들이 GIL 없이 더 빨리 Python을 사용할 수 있을 것이라고 믿는다.
성능
GIL 없이 CPython을 스레드 안전하게 만드는 변경 사항은 --disable-gil 빌드의 실행 오버헤드를 증가시킨다. 실행 오버헤드의 영향은 단일 스레드만 사용하는 프로그램과 여러 스레드를 사용하는 프로그램 사이에서 다르므로, 아래 표는 각각의 프로그램 유형에 대한 실행 오버헤드를 따로 보고한다.
pyperformance 1.0.6에서의 실행 오버헤드
- 하나의 스레드: 6%
- 여러 스레드: 8%
오버헤드를 측정하기 위해 3.12 버전의 Python에서 PR 19474의 018be4c를 기준으로 사용되었다. 실행 오버헤드의 가장 큰 기여는 편향 참조 카운팅이며, 그 다음으로는 개별 객체 잠금이 따른다. 스레드 안전성을 위해, 여러 스레드에서 실행되는 응용 프로그램은 특정 바이트 코드를 한 번만 특수화한다. 이것이 여러 스레드를 사용하는 프로그램의 오버헤드가 하나의 스레드만 사용하는 프로그램보다 큰 이유이다. 그러나 GIL이 비활성화된 상태에서 여러 스레드를 사용하는 프로그램은 CPU 코어를 더 효과적으로 사용할 수 있어야 한다.
이 PEP는 CPython의 기본(비 --disable-gil) 빌드의 성능에는 영향을 주지 않는다.
빌드 봇
안정적인 빌드 봇에는 --disable-gil 빌드가 포함된다.
사용 방법 가르치기
--disable-gil 모드를 구현하는 일환으로, 저자는 GIL 없이 Python을 실행할 때 호환되는 패키지를 만들기 위한 "HOWTO" 가이드를 작성할 것이다.
참조 구현
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# 대체 방법
파이썬은 현재 병렬성을 지원하기 위해 여러 가지 방법을 제공하지만 기존 기술에는 중요한 제약 사항이 있습니다.
Multiprocessing
multiprocessing
라이브러리를 사용하면 파이썬 프로그램에서 파이썬 서브프로세스를 시작하고 통신할 수 있습니다. 이를 통해 각 서브프로세스는 자체 파이썬 인터프리터를 가지기 때문에 병렬성이 가능합니다(즉, 프로세스마다 GIL이 있습니다). 그러나 multiprocessing
은 몇 가지 제약 사항이 있습니다. 프로세스 간 통신이 제한됩니다. 객체는 일반적으로 직렬화되어 공유 메모리로 복사되어야 합니다. 이로 인해 오버헤드(직렬화로 인한)가 발생하며 multiprocessing
위에 API를 구축하는 것을 복잡하게 만듭니다. 서브프로세스 시작 비용도 스레드 시작 비용보다 높습니다. 특히 "spawn" 구현에서는 스레드 시작에 약 100µs가 걸리지만, 서브프로세스 시작에는 약 50ms(50,000µs)가 걸립니다. 마지막으로, 많은 C 및 C++ 라이브러리는 여러 스레드에서의 접근을 지원하지만 여러 프로세스에서의 접근이나 사용은 지원하지 않습니다.
C-API 확장에서 GIL 해제
C-API 확장은 긴 실행 시간 함수 주위에서 GIL을 해제할 수 있습니다. 이는 GIL이 해제되면 여러 스레드가 동시에 실행될 수 있기 때문에 어느 정도의 병렬성이 가능하지만, GIL 획득 및 해제의 오버헤드로 인해 이는 몇 개의 스레드를 넘어서는 효율적으로 확장되지 않습니다. 많은 과학 계산 라이브러리는 계산 집약적인 함수에서 GIL을 해제하며, CPython 표준 라이브러리는 블로킹 I/O 주위에서 GIL을 해제합니다.
내부 병렬화
C로 구현된 함수는 내부적으로 여러 스레드를 사용할 수 있습니다. 예를 들어, 인텔의 NumPy 배포판, PyTorch 및 TensorFlow는 모두 이 기술을 사용하여 개별 작업을 내부적으로 병렬화합니다. 이는 기본 작업이 효율적으로 병렬화하기에 충분히 큰 경우에는 잘 작동하지만, 많은 작은 작업이 있는 경우나 작업이 일부 Python 코드에 의존하는 경우에는 작동하지 않습니다. C에서 Python을 호출하려면 GIL을 획득해야 하므로, Python 코드의 짧은 코드 스니펫조차 확장성을 제한할 수 있습니다.
관련된 작업
인터프리터별 GIL
최근 승인된 PEP 684은 멀티코어 병렬성을 해결하기 위해 인터프리터별 GIL을 제안합니다. 이를 통해 동일한 프로세스 내의 인터프리터 간에 병렬성이 가능하지만, 인터프리터 간에 파이썬 데이터를 공유하는 데는 상당한 제약 사항이 있습니다. 이 PEP과 PEP 684은 모두 멀티코어 병렬성을 다루지만, 다른 트레이드오프와 기술을 가지고 있습니다. CPython에서 동시에 이 두 가지 PEP을 구현하는 것이 가능합니다.
Gilectomy
Gilectomy [20]는 GIL을 CPython에서 제거하기 위한 Larry Hastings의 프로젝트였습니다. 이 PEP에서 제안된 디자인과 마찬가지로 Gilectomy는 동일한 인터프리터 내에서 여러 스레드가 병렬로 실행되는 것(즉, "free-threading")을 지원하며, 세밀하게 분할된 잠금을 사용합니다. 이 PEP의 참조 구현은 Gilectomy와 비교하여 단일 스레드 성능과 확장성을 개선했습니다.
PyParallel
PyParallel [21]은 Trent Nelson에 의해 Python 3.3의 개념 증명용 포크로, 단일 Python 프로세스에서 동시에 여러 스레드를 지원했습니다. 이 포크는 "병렬 스레드"라는 개념을 도입했습니다. 주 스레드가 일시 중지된 동안 병렬 스레드가 동시에 실행될 수 있었습니다. 병렬 스레드는 주 스레드에서 생성된 객체에 대해 읽기 전용 액세스 권한을 갖고 있었습니다. 병렬 스레드에서 생성된 객체는 생성한 스레드의 수명 동안 지속되었습니다. HTTP 서버의 경우, 이는 요청의 수명과 일치할 수 있습니다.
python-safethread
python-safethread [22] 프로젝트는 Adam Olsen에 의해 Python 3.0에 대한 패치로 GIL을 제거하는 것이었습니다. 이 프로젝트의 일부는 이 PEP에서 제안된 디자인과 유사합니다. 둘 다 세밀한 잠금을 사용하며, 개체가 동일한 스레드에서 생성되고 액세스되는 경우를 위해 참조 카운팅을 최적화합니다.
Greg Stein의 Free-Threading 패치
1996년, Greg Stein은 Python 1.4에 대한 GIL을 제거하는 패치를 게시했습니다 [23]. 이 패치는 Windows에서 원자적인 참조 카운팅과 Linux에서 전역 참조 카운트 잠금을 사용했습니다. 리스트와 딕셔너리 액세스는 뮤텍스로 보호되었습니다. 이 패치의 일부는 CPython에서 채택되었습니다. 특히, 이 패치는 PyThreadState 구조체와 올바른 스레드별 예외 처리를 도입했습니다.
Dave Beazley는 2011년 블로그 게시물에서 이 패치를 다시 살펴보았습니다 [24].
Jython 및 IronPython
Jython [25] 및 IronPython [26]과 같은 대체 파이썬 구현은 전역 인터프리터 잠금(GIL)을 갖고 있지 않습니다. 그러나 이들은 CPython 확장을 지원하지 않습니다. (이 구현은 Java 또는 C#으로 작성된 코드와 상호 작용할 수 있습니다).
PyPy-STM
pypy-stm [27] 인터프리터는 소프트웨어 트랜잭션 메모리를 사용하는 PyPy의 변형입니다. 저자들은 PyPy와의 단일 스레드 성능 오버헤드가 20%~50% 범위에 있다고 보고했습니다. 이는 CPython 확장과 호환되지 않습니다.
거절된 아이디어
왜 동시 가비지 수집기를 사용하지 않을까요?# 거부된 아이디어
동시성 가비지 수집기 사용하지 않는 이유
많은 최근 가비지 수집기는 주로 동시성을 지원합니다. 이러한 가비지 수집기는 애플리케이션과 동시에 가비지 수집기를 실행하여 긴 정지 시간을 피합니다. 그렇다면 왜 동시성 수집기를 사용하지 않을까요?
동시 수집은 쓰기 바리어(또는 읽기 바리어)가 필요합니다. CPython에 쓰기 바리어를 추가하는 방법은 C-API를 상당히 망가트리지 않고 추가하는 방법을 저자는 알지 못합니다.
PyDict_GetItem을 PyDict_FetchItem으로 대체하지 않는 이유
이 PEP는 PyDict_GetItem과 동일하게 동작하지만 대출된 참조 대신 새로운 참조를 반환하는 PyDict_FetchItem이라는 새로운 API를 제안합니다. 대출된 참조에 대한 기술에서 설명한 대로 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는 두 개의 참조 계수 필드 대신 하나의 참조 계수 필드를 가진 경향 있는 참조 계수에 의존하기 때문에 동일한 체계일 수 없습니다.
개선된 특수화
Python 3.11 릴리스에서 빠른 CPython 프로젝트의 일부로서 퀵닝과 특수화가 도입되어 성능이 크게 향상되었습니다. 특수화는 느린 바이트 코드 명령을 빠른 변형으로 대체합니다 [19]. 여러 스레드를 사용하는 응용 프로그램(및 GIL 없이 실행되는)의 스레드 안전성을 유지하기 위해 각 바이트 코드는 한 번만 특수화됩니다. 이는 일부 프로그램에서 성능을 저하시킬 수 있습니다. 여러 번 특수화하는 것을 지원할 수 있지만, 이는 추가 조사가 필요하며 이 PEP의 일부는 아닙니다.
Python 빌드 모드
이 PEP는 표준 빌드 모드와 ABI 호환되지 않는 새로운 빌드 모드(--disable-gil)를 도입합니다. 추가 빌드 모드는 Python 코어 개발자와 확장 개발자 양쪽에 복잡성을 추가합니다. 저자는 이러한 빌드 모드를 결합하고 전역 인터프리터 락을 런타임에서 제어하며 기본적으로 비활성화하는 것이 가치 있는 목표라고 생각합니다. 이 목표를 위한 경로는 여전히 열린 문제이지만, 가능한 경로는 다음과 같을 수 있습니다.
2024년, CPython 3.13이 --disable-gil 빌드 타임 플래그를 지원하는 것으로 출시됩니다. CPython에는 GIL이 있는 ABI와 GIL이 없는 ABI 두 가지가 있습니다. 확장 개발자는 두 가지 ABI를 대상으로 합니다.
2
3개의 릴리스 후(즉, 2026년2027년), CPython이 런타임 환경 변수나 플래그로 GIL을 제어하도록 출시됩니다. GIL은 기본적으로 활성화됩니다. 하나의 ABI만 있습니다.
추가로 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 703: Improving Python Concurrency
서론
PEP 703은 CPython 인터프리터에서 파이썬의 동시성을 개선하기 위한 목적으로 제안되었습니다. 이 PEP는 현재 GIL(Global Interpreter Lock)로 인해 CPython에서 발생하는 동시성 문제를 다루고, 이를 해결하기 위한 몇 가지 제안을 제시합니다.
GIL(Global Interpreter Lock)
GIL은 CPython 인터프리터의 특징 중 하나로, 파이썬 코드를 실행하는 동안에는 한 번에 하나의 스레드만이 파이썬 바이트코드를 실행할 수 있도록 제한하는 잠금 메커니즘입니다. 이로 인해 멀티스레드 환경에서도 단일 코어만 사용되는 문제가 발생하며, CPU 바운드 작업에서 성능 저하가 발생할 수 있습니다.
제안된 개선 방법
- GIL 제거: GIL을 완전히 제거하여 멀티스레드 환경에서의 동시성 문제를 해결할 수 있습니다. 이를 위해 Gilectomy와 PyParallel 등의 프로젝트가 제안되었습니다.
- GIL 로직 개선: GIL을 유지하면서 GIL 로직을 개선하여 GIL이 너무 자주 얻고 반납되지 않도록 제어할 수 있습니다. 이를 통해 GIL 경합 문제를 완화할 수 있습니다.
- 다중 인터프리터: 다중 인터프리터를 지원하여, 각각의 인터프리터가 독립적으로 GIL을 가질 수 있도록 합니다. 이를 통해 멀티코어 환경에서의 동시성을 개선할 수 있습니다.
- STM(소프트웨어 트랜잭션 메모리): STM을 도입하여 멀티스레드 환경에서의 데이터 일관성을 보장합니다. 이를 통해 GIL 없이도 안전한 동시성을 달성할 수 있습니다.
결론
PEP 703은 CPython에서의 동시성 문제를 해결하기 위한 몇 가지 제안을 제시하고 있습니다. 이러한 개선을 통해 파이썬의 멀티스레딩 환경에서의 성능과 동시성을 개선할 수 있을 것으로 기대됩니다.
참고 문헌
- "Exploiting Parallelism Opportunities with Deep Learning Frameworks" - 링크
- "Using Python for Model Inference in Deep Learning" - 링크
- "Biased reference counting: minimizing atomic operations in garbage collection" - 링크
- PEP 683 – Immortal Objects, Using a Fixed Refcount
- "What is RCU, Fundamentally?" - 링크
- "Global Unbounded Sequences (GUS)" - 링크
- "Is Parallel Programming Hard, And, If So, What Can You Do About It?" - 링크
- "HotSpot Virtual Machine Garbage Collection Tuning Guide" - 링크
- "The DaCapo Benchmarks: Java Benchmarking Development and Analysis"
- "Exploiting memory usage patterns to improve garbage collections in Java" - 링크
- "most things usually turn out to be reachable" - 링크
- "The Go team observed something similar in Go, but due to escape analysis and pass-by-value instead of reference counting. Recent versions of Go use a non-generational garbage collector" - 링크
- "https://github.com/colesbury/nogil"
- "https://github.com/colesbury/nogil-3.12"
- Python HOWTOs - 링크
- PEP 659 – Specializing Adaptive Interpreter
- Gilectomy - 링크
- PyParallel - 링크
- python-safethread - 링크
- "https://www.python.org/ftp/python/contrib-09-Dec-1999/System/threading.tar.gz"
- "An Inside Look at the GIL Removal Patch of Lore" - 링크
- Jython - 링크
- IronPython - 링크
- PyPy: Software Transactional Memory - 링크
'Python' 카테고리의 다른 글
Flask - secret_key (1) (0) | 2023.08.09 |
---|---|
Flask Error : AttributeError: 'Flask' object has no attribute 'login_manager' (0) | 2023.08.08 |
[DigitalOcean] Slack_bolt deploy runtime error (0) | 2023.06.26 |
On the amortized complexity of approximate counting (0) | 2023.06.20 |
[digitalOcean] digitalOcean + Slack ChatBot (0) | 2023.06.12 |