PEP 684 – 인터프리터별 GIL
- Author:
- Eric Snow <ericsnowcurrently at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Requires:
- 683
- Created:
- 08-Mar-2022
- Python-Version:
- 3.12
- Post-History:
- 08-Mar-2022, 29-Sep-2022, 28-Oct-2022
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
Python 1.5(1997년)부터 CPython 사용자는 같은 프로세스에서 여러 인터프리터를 실행할 수 있습니다. 그러나 같은 프로세스 안의 인터프리터들은 항상 상당한 양의 전역 상태를 공유해 왔습니다. 이는 버그의 원인이 되며, 이 기능을 사용하는 사람이 늘어날수록 그 영향도 커지고 있습니다. 또한 충분히 격리되면 인터프리터들이 더 이상 GIL을 공유하지 않는 진정한 멀티코어 병렬 처리가 쉬워집니다. 이 제안에서 설명하는 변경 사항으로 인터프리터를 그 수준까지 격리할 수 있습니다.
개괄 요약
크게 보면 이 제안은 CPython을 다음과 같이 변경합니다:
- 충분히 격리된 경우 인터프리터 간에 GIL을 더 이상 공유하지 않습니다.
- 격리 설정을 위한 새 인터프리터 구성 옵션을 여러 개 추가합니다.
- 호환되지 않는 확장 모듈이 문제를 일으키지 못하게 합니다.
GIL
GIL은 CPython 런타임 상태 대부분에 대한 동시 접근을 보호합니다. 따라서 GIL이 각 인터프리터로 옮겨 가려면 먼저 GIL이 보호하는 전역 상태가 모두 각 인터프리터로 옮겨 가야 합니다.
(일부 경우에는 대신 잠금이나 “불멸(immortal)” 객체 같은 다른 메커니즘을 사용하여 스레드 안전한 공유를 보장할 수 있습니다.)
CPython 런타임 상태
인터프리터를 제대로 격리하려면 CPython 런타임 상태 대부분이 PyInterpreterState 구조체에 저장되어야 합니다. 현재는 그중 일부만 그곳에 저장되어 있고, 나머지는 C 전역 변수나 _PyRuntimeState에 있습니다. 그 대부분을 옮겨야 합니다.
이는 전역 변수의 내부 사용을 크게 줄이고 런타임 상태를 _PyRuntimeState와 PyInterpreterState로 통합하려고 (수년째) 진행 중인 작업과 직접 맞물려 있습니다. (아래 Consolidating Runtime Global State를 참고하십시오.) 그 프로젝트는 그 자체로 상당한 가치가 있으며 논란도 거의 없었습니다. 따라서 인터프리터별 GIL은 그 작업의 완료에 의존하지만, 그 프로젝트를 이 제안의 일부로 간주해서는 안 되며 의존성으로만 보아야 합니다.
그 밖의 격리 관련 고려 사항
CPython의 인터프리터들은 몇 가지 예외를 제외하고 서로 엄격하게 격리되어야 합니다. 상당 부분은 이미 그렇게 격리되어 있습니다. 각 인터프리터는 모든 모듈, 클래스, 함수, 변수의 복사본을 각자 가지고 있습니다. CPython C-API 문서에서 더 자세히 설명합니다.
그러나 이미 언급한 것(예: GIL) 외에도 인터프리터들이 여전히 일부 상태를 공유하는 경우가 몇 가지 있습니다.
우선 일부 프로세스 전역 자원(예: 메모리, 파일 디스크립터, 환경 변수)이 공유됩니다. 이를 변경할 계획은 없습니다.
둘째, 일부 격리는 버그나 여러 인터프리터를 고려하지 않은 구현 때문에 결함이 있습니다. 여기에는 CPython 런타임과 표준 라이브러리뿐만 아니라 전역 변수에 의존하는 확장 모듈도 포함됩니다. 이러한 경우에는 버그를 등록해야 하며, 일부는 이미 등록되어 있습니다.
불멸 객체에 대한 의존
PEP 683은 CPython 내부 기능으로 불멸 객체를 도입합니다. 불멸 객체를 사용하면 불멸성 외에는 불변인 모든 전역 객체를 모든 인터프리터 간에 공유할 수 있습니다. 따라서 이 PEP는 공개 C-API로 노출된 다양한 객체를 처리하는 방법을 다룰 필요가 없습니다. 또한 내장 정적 타입을 어떻게 처리할지에 대한 문제도 단순해집니다. (아래 Global Objects를 참조하십시오.)
두 문제 모두 다른 해결책이 있지만, 불멸 객체를 사용하면 모든 것이 더 단순해집니다. PEP 683이 수락되지 않으면 이 PEP는 대안을 반영하여 갱신될 것입니다. 이를 통해 이 제안의 불필요한 내용을 줄일 수 있습니다.
동기
여기서 해결하려는 근본적인 문제는 CPython 런타임에 (Python 코드를 위한) 진정한 멀티 코어 병렬성이 없다는 점입니다. 그 원인은 GIL입니다. 실제로는 대개 문제가 되지 않지만, 적어도 Python의 멀티 코어 지원 상황을 불분명하게 만들며, 이 때문에 GIL은 끊임없이 논란거리가 됩니다.
격리된 인터프리터는 특정 동시성 모델을 지원하는 효과적인 메커니즘이기도 합니다. PEP 554에서 이를 더 자세히 논의합니다.
간접적인 이점
인터프리터별 GIL에 필요한 작업 대부분에는 어차피 그 작업을 할 가치가 있게 만드는 이점이 있습니다:
- 다중 인터프리터 동작을 더 신뢰할 수 있게 합니다
- 그렇지 않았다면 우선순위에서 밀렸을 오래된 런타임 버그의 수정으로 이어졌습니다
- 이전에 알려지지 않았던 런타임 버그를 드러내 왔습니다 (그리고 그 수정을 이끌어 왔습니다)
- 더 깔끔한 런타임 초기화를 이끌었습니다 (PEP 432, PEP 587)
- 더 깔끔하고 완전한 런타임 종료 처리를 이끌었습니다
- C-API의 구조적 계층화로 이어졌습니다 (예:
Include/internal) - 아래 Benefits to Consolidation도 참조하십시오
또한 그 작업의 상당 부분은 CPython과 관련된 다른 프로젝트에도 도움이 됩니다:
- 성능 개선 (”faster-cpython”)
- pre-fork 애플리케이션 배포 (예: Instagram server)
- 확장 모듈 격리 (PEP 630 등 참조)
- CPython 임베딩
여러 인터프리터의 기존 사용
여러 인터프리터를 위한 C-API는 수년 동안 사용되어 왔습니다. 그러나 비교적 최근까지 이 기능은 널리 알려지지 않았고, 폭넓게 사용되지도 않았습니다(mod_wsgi는 예외입니다).
지난 몇 년 동안 여러 인터프리터의 사용이 늘어나고 있습니다. 다음은 현재 이 기능을 사용하는 공개 프로젝트 중 일부입니다.
PEP 554가 도입되면 (C-API가 아닌 Python 코드를 통해) 여러 인터프리터의 사용이 크게 늘어날 가능성이 높다는 점에 유의하십시오.
PEP 554 (표준 라이브러리의 여러 인터프리터)
PEP 554는 사용자가 Python 코드에서 여러 인터프리터에 접근할 수 있도록 최소한의 표준 라이브러리 모듈을 제공하는 것만을 다룹니다. 실제로 이 PEP는 GIL과 관련된 변경을 제안하는 일을 의도적으로 피합니다. 그러나 해당 모듈의 사용자는 인터프리터별 GIL의 이점을 누릴 수 있으며, 이 점이 PEP 554를 더욱 매력적으로 만든다는 점을 생각해 보십시오.
근거
2014년의 초기 조사에서 멀티코어 Python을 위한 다양한 해결책을 검토했지만, 모두 간단히 해결할 수 없는 단점이 있었습니다.
- 확장 모듈에서 GIL을 해제하는 기존 관행
- Python 코드에는 도움이 되지 않습니다.
- 다른 Python 구현 (예: Jython, IronPython)
- 커뮤니티에서 CPython이 압도적인 위치를 차지하고 있습니다.
- GIL 제거 (예: gilectomy, “no-gil”)
- (당시에는) 기술적 위험이 너무 컸습니다.
- Trent Nelson의 “PyParallel” 프로젝트
- 미완성이었고, 당시에는 Windows 전용이었습니다.
multiprocessing- 충분히 효과적으로 만들려면 작업량이 너무 많습니다. 일부 상황(대규모 환경, Windows)에서는 불이익이 큽니다.
- 기타 병렬 처리 도구 (예: dask, ray, MPI)
- 런타임/표준 라이브러리에 적합하지 않습니다.
- 멀티코어 포기 (예: async, 아무것도 하지 않기)
- 결국 눈물로 끝날 수밖에 없습니다.
2014년에도 이미 격리된 인터프리터를 사용하는 해결책은 기술적 위험이 높지 않으며, 작업 대부분이 어차피 할 만한 가치가 있다는 점이 꽤 분명했습니다. (단점은 해야 할 작업의 양이었습니다.)
명세
위에서 요약한 것처럼, 이 제안에는 다음 변경 사항이 포함되며, 아래 순서대로 이루어져야 합니다.
- 전역 런타임 상태를 통합합니다(객체 포함). 통합 대상은
_PyRuntimeState입니다 - 거의 모든 상태를
PyInterpreterState로 내려보냅니다 - 마지막으로 GIL을
PyInterpreterState로 내려보냅니다 - 그 밖의 모든 것
- C-API를 갱신합니다
- 확장 모듈 제약을 구현합니다
- 널리 쓰이는 확장 모듈의 유지 관리자들과 협력하여 다중 인터프리터 지원을 돕습니다
인터프리터별 상태
다음 런타임 상태는 PyInterpreterState로 옮겨집니다:
- 안전하게 공유할 수 없는(완전히 불변이 아닌) 모든 전역 객체
- GIL
- 현재 GIL로 보호되는 가변 데이터의 대부분
- 현재 다른 인터프리터별 잠금으로 보호되는 가변 데이터
- 서로 다른 인터프리터에서 독립적으로 사용될 수 있는 가변 데이터(다단계 초기화를 사용하는 확장 모듈을 포함하여 확장 모듈에도 적용됩니다)
- 아래에서 달리 제외되지 않은 그 밖의 모든 가변 데이터
또한 전체 전역 상태 중 GC, 경고, atexit 훅을 포함한 일부는 이미 인터프리터로 옮겨졌습니다.
다음 런타임 상태는 옮겨지지 않습니다:
- 안전하게 공유할 수 있는 전역 객체(있는 경우)
- 불변 데이터(주로
const) - 사실상 불변인 데이터(불변으로 취급됨), 예를 들면 다음과 같습니다:
- 일찍 초기화된 뒤 다시는 수정되지 않는 일부 상태
- 문자열(
PyUnicodeObject)의 해시는 처음 필요할 때 멱등적으로 계산된 뒤 캐시됩니다
- 메인 스레드에서만 수정되는 것이 보장되는 모든 데이터로, 다음을 포함합니다:
- CPython의
main()에서만 사용되는 상태 - REPL의 상태
- 런타임 초기화 중에만 수정되는 데이터(이후에는 사실상 불변)
- CPython의
- GIL 이외의 어떤 전역 잠금으로 보호되는 가변 데이터
- 원자적 변수에 있는 전역 상태
- 원자적 변수로 (합리적으로) 바꿀 수 있는 가변 전역 상태
메모리 할당자
이것은 인터프리터를 격리하는 작업에서 가장 민감한 부분 중 하나입니다. 가장 간단한 해결책은 다른 거의 모든 런타임 상태에 대해 하고 있는 것처럼, 내부 “small block” 할당자의 전역 상태를 PyInterpreterState로 옮기는 것입니다. 아래에서 세부 사항과 근거를 자세히 설명합니다.
CPython은 three allocator domains를 갖춘 메모리 관리 C-API를 제공합니다. 세 도메인은 “raw”, “mem”, “object”(객체)입니다. 각 도메인은 malloc(), calloc(), realloc(), free()에 해당하는 기능을 제공합니다. 각 도메인의 사용자 정의 할당자는 런타임 초기화 중에 설정할 수 있으며, 같은 API로 현재 할당자를 훅으로 감쌀 수 있습니다(예: 표준 라이브러리의 tracemalloc 모듈). 현재 할당자는 런타임 전역이며 모든 인터프리터가 공유합니다.
“raw” 할당자는 스레드 안전해야 하며, 기본값은 glibc의 할당자(malloc() 등)입니다. 그러나 “mem” 할당자와 “object”(객체) 할당자는 스레드 안전할 것으로 기대되지 않으며, 현재 스레드 안전성을 GIL에 의존할 수 있습니다. 그 이유 중 하나는 두 도메인의 기본 할당자인 “pyobject”가 스레드 안전하지 않기 때문입니다(is not thread-safe). 이는 해당 할당자의 모든 상태가 C 전역 변수에 저장되기 때문입니다. (Objects/obmalloc.c를 참조하십시오.)
따라서 런타임 상태를 격리하는 문제로 다시 돌아오게 됩니다. 인터프리터들이 GIL을 더 이상 공유하지 않으려면 할당자의 스레드 안전성 문제를 해결해야 합니다. 인터프리터들이 할당자를 계속 공유한다면 스레드 안전성을 확보할 다른 방법이 필요합니다. 그렇지 않으면 인터프리터들이 할당자 공유를 중단해야 합니다. 두 경우 모두 여러 가지 해결책이 가능하며, 각각 잠재적인 단점이 있습니다.
할당자를 계속 공유하기 위한 가장 간단한 해결책은 PyMem_Malloc(), PyObject_Malloc() 등에서 “mem” 할당자와 “object”(객체) 할당자를 호출하는 부분을 세분화된 런타임 전역 잠금으로 감싸는 것입니다. 이 방법은 성능에 영향을 주겠지만, 이를 완화할 방법이 몇 가지 있습니다(예: 첫 번째 서브인터프리터가 생성된 이후에만 잠금을 시작하는 방법).
할당자를 계속 공유하는 또 다른 방법은 “mem” 할당자와 “object”(객체) 할당자가 스레드 안전하도록 요구하는 것입니다. 이렇게 하려면 pyobject 할당자 구현을 스레드 안전하게 만들어야 합니다. mimalloc과 같은 확장 가능한 할당자를 사용해 pyobject 할당자를 다시 구현해야 할 수도 있습니다. 이 방식의 잠재적인 단점은 할당자를 다시 구현하는 비용과, 그러한 작업에 본질적으로 따르는 결함의 위험입니다.
어느 쪽이든, 스레드 안전한 할당자를 요구하는 방식으로 전환하면 CPython을 임베드하면서 현재 스레드 안전하지 않은 할당자를 설정하는 모든 사람에게 영향을 줍니다. 누가 영향을 받을 수 있는지, 그리고 부정적인 영향을 어떻게 줄일지 고려해야 합니다(예: 할당자를 스레드 안전하게 만드는 데 도움이 되는 기본 C-API 추가).
인터프리터 간 할당자 공유를 중단한다면, “mem” 할당자와 “object”(객체) 할당자에 대해서만 그렇게 해야 합니다. 또한 특정 런타임 수준의 용도를 위해 전역 할당자 전체 세트를 유지해야 할 수도 있습니다. 현재 인터프리터를 조회한 다음 포인터 간접 참조를 거쳐 할당자를 얻어야 하므로 어느 정도 성능 저하가 있을 것입니다. 또한 임베드하는 쪽에서 각 인터프리터마다 새로운 할당자 컨텍스트를 제공해야 할 가능성이 높습니다. 장점은 할당자 훅(예: tracemalloc)이 영향을 받지 않는다는 것입니다.
결론적으로, 가장 간단한 방안을 택하겠습니다:
- 할당자를 전역 런타임 상태에 유지합니다
- 할당자가 스레드 안전하도록 요구합니다
- 기본 객체 할당자(“small block” 할당자라고도 함)의 상태를
PyInterpreterState로 옮깁니다
a rough implementation으로 실험해 본 결과 상당히 간단했으며, 성능 저하는 사실상 전혀 없었습니다.
C-API
이제 인터프리터 상태는 내부적으로, 여러 인터프리터에서의 사용을 지원하지 않는 확장 모듈을 임포트 시스템이 어떻게 처리해야 하는지를 추적합니다. 아래의 Restricting Extension Modules를 참조하십시오. 여기서는 이 설정을 “PyInterpreterState.strict_extension_compat”라고 부르겠습니다.
다음 API는 아직 공개되지 않았다면 공개될 것입니다:
PyInterpreterConfig(구조체)PyInterpreterConfig_INIT(매크로)PyInterpreterConfig_LEGACY_INIT(매크로)PyThreadState * Py_NewInterpreterFromConfig(PyInterpreterConfig *)
PyInterpreterConfig에 두 개의 새 필드를 추가할 것입니다:
int own_gilint strict_extensions_compat
필요에 따라 시간이 지나면서 다른 필드를 추가할 수도 있습니다(예: “own_initial_thread”).
초기화 매크로와 관련하여, PyInterpreterConfig_INIT은 서브인터프리터에 친화적이지 않은 기능도 피하는 격리된 인터프리터를 얻는 데 사용될 것입니다. 이는 PEP 554를 통해 생성된 인터프리터의 기본값이 될 것입니다. 제한 없는 방식(현상 유지)은 PyInterpreterConfig_LEGACY_INIT을 통해 계속 사용할 수 있으며, 이는 이미 메인 인터프리터와 Py_NewInterpreter()에 사용되고 있습니다. 이 점은 변경되지 않습니다.
“메인” 인터프리터에 대한 참고 사항:
아래에서는 “메인” 인터프리터를 여러 번 언급합니다. 이는 런타임 초기화 중에 생성되는 인터프리터를 가리키며, 이 인터프리터의 초기 PyThreadState는 프로세스의 메인 스레드에 대응합니다. 이 인터프리터는 여러 고유한 책임(예: 시그널 처리)을 가지며, 런타임 초기화/종료 처리 중에 특별한 역할도 수행합니다. 또한 (현재로서는) 보통 유일한 인터프리터이기도 합니다. (다음도 참조하십시오: https://docs.python.org/3/c-api/init.html#sub-interpreter-support.)
PyInterpreterConfig.own_gil
true (1)이면 새 인터프리터는 자체 “전역” 인터프리터 잠금을 갖게 됩니다. 이는 새 인터프리터가 다른 인터프리터에 의해 중단되지 않고 실행될 수 있음을 의미합니다. 이로써 여러 코어를 온전히 활용하는 데 걸림돌이 사실상 사라집니다. 이것이 이 PEP의 근본적인 목표입니다.
false (0)이면 새 인터프리터는 메인 인터프리터의 잠금을 사용합니다. 이는 CPython의 레거시(3.12 이전) 동작으로, 모든 인터프리터가 하나의 GIL을 공유합니다. 스레드 안전성을 여전히 GIL에 의존하는 확장 모듈을 사용할 때는 이처럼 GIL을 공유하는 것이 바람직할 수 있습니다.
PyInterpreterConfig_INIT에서는 이 값이 true가 됩니다. PyInterpreterConfig_LEGACY_INIT에서는 이 값이 false가 됩니다.
또한 안전을 위해, 런타임 초기화 중에 사용자 정의 할당자가 설정된 경우에는 당분간 own_gil이 참이 되는 것을 허용하지 않을 것입니다. tracemalloc처럼 할당자를 감싸는 것은 여전히 괜찮습니다.
PyInterpreterConfig.strict_extensions_compat
PyInterpreterConfig.strict_extension_compat은 기본적으로 “PyInterpreterState.strict_extension_compat”에 사용되는 초기값입니다.
확장 모듈 제한하기
상태가 전역 변수에 저장될 때 확장 모듈은 런타임과 같은 문제를 상당수 겪습니다. PEP 630은 확장이 격리를 지원하고, 그리하여 여러 인터프리터에서 동시에 안전하게 실행되기 위해 무엇을 해야 하는지에 대한 모든 세부 사항을 다룹니다. 여기에는 확장의 전역 변수를 처리하는 것도 포함됩니다.
확장이 다단계 초기화(PEP 489 참조)를 구현하면 여러 인터프리터와 호환되는 것으로 간주됩니다. 그 외의 모든 확장은 호환되지 않는 것으로 간주됩니다. (인터프리터별 GIL이 이 분류에 어떤 영향을 줄 수 있는지에 대한 자세한 내용은 Extension Module Thread Safety를 참조하십시오.)
호환되지 않는 확장을 임포트할 때 현재 “PyInterpreterState.strict_extension_compat” 값이 true이면 임포트 시스템은 ImportError를 발생시킵니다. (false인 경우에는 아예 검사하지 않습니다.) 이는 importlib._bootstrap_external.ExtensionFileLoader를 통해 이루어집니다(실제로는 _imp.create_dynamic(), _PyImport_LoadDynamicModuleWithSpec(), PyModule_FromDefAndSpec2()를 통해서입니다).
메인 인터프리터(또는 Py_NewInterpreter()를 통해 생성된 인터프리터)에서는 두 경우 모두 “PyInterpreterState.strict_extension_compat”가 false로 초기화되므로 이러한 임포트가 절대 실패하지 않습니다. 따라서 기존(3.12 이전) 동작이 유지됩니다.
우리는 널리 쓰이는 확장들이 여러 인터프리터에서의 사용을 지원할 수 있도록 함께 작업할 것입니다. 여기에는 CPython의 공개 C-API에 무언가를 추가하는 일이 포함될 수 있으며, 이는 사안별로 다룰 것입니다.
확장 모듈 호환성
Extension Modules에서 언급했듯이, 많은 확장은 아무런 변경 없이도 여러 인터프리터에서(그리고 인터프리터별 GIL 하에서도) 잘 동작합니다. 그러나 그러한 모듈이 지원 여부를 명시적으로 표시하지 않으면 임포트 시스템은 여전히 실패합니다. 처음에는 이를 표시하는 확장 모듈이 많지 않을 것이므로, 이는 잠재적인 불만의 원인이 됩니다.
우리는 여러 인터프리터 지원 여부에 대한 검사를 일시적으로 비활성화하는 컨텍스트 관리자를 추가하여 이 문제를 해결할 것입니다: importlib.util.allow_all_extensions(). 대략적으로, 이는 (예를 들어 비공개 sys 함수를 통해) 현재 “PyInterpreterState.strict_extension_compat” 값을 수정합니다.
확장 모듈 스레드 안전성
모듈이 여러 인터프리터에서의 사용을 지원한다면, 이는 대체로 해당 인터프리터들이 GIL을 공유하지 않더라도 모듈이 동작한다는 것을 의미합니다. 한 가지 주의할 점은 모듈이 스레드 안전하지 않은 내부 전역 상태를 가진 라이브러리에 링크되는 경우입니다. (임시 버퍼로 쓰이는 정적 지역 변수처럼 무해해 보이는 것조차 문제가 될 수 있습니다.) GIL을 공유하면 그 상태가 보호됩니다. 공유 GIL이 없다면, 그러한 모듈은 해당 상태를 사용하는 모든 곳(예를 들어 호출)을 잠금으로 감싸야 합니다.
현재로서는 여러 인터프리터 지원(supports-multiple-interpreters)이 인터프리터별 GIL 지원(supports-per-interpreter-gil)과 충분히 동등하여 특별한 조치를 피할 수 있는지 여부가 명확하지 않습니다. 이는 여전히 의미 있는 논의와 조사가 이루어지고 있는 사안입니다. (Python 커뮤니티, 예를 들어 PyPI에서) 둘 사이의 실질적인 차이는 아직 이 문제를 결론 내릴 만큼 충분히 파악되지 않았습니다. 마찬가지로, 확장 유지 관리자가 이 문제(문제라고 가정할 때)를 완화하도록 돕기 위해 우리가 무엇을 할 수 있을지도 명확하지 않습니다.
그동안에는 그 차이가 현재 존재하는 확장 모듈 중 상당수에 문제를 일으킬 만큼 크다고 가정하고 진행해야 합니다. 우리가 적용할 해결책은 다음과 같습니다:
- 확장을 인터프리터별 GIL 아래에서 임포트할 수 있음을 나타내는(즉, 옵트인하는)
PyModuleDef슬롯을 추가합니다 - 앞서 논의한 대로, 그 슬롯을 “호환되는” 확장의 정의에 포함합니다
단점은 모듈 유지 관리자가 추가로 노력하지 않으면, 그 노력이 아무리 사소하더라도, 단 하나의 확장 모듈도 인터프리터별 GIL을 활용할 수 없다는 것입니다. 이는 Extension Module Compatibility에서 설명한 문제를 가중하며, 같은 우회 방법이 적용됩니다. 이상적으로는 문제가 될 만큼 큰 차이가 없다고 판단하게 될 것입니다.
결국 인터프리터별 GIL 아래에서의 임포트에 옵트인을 요구하게 되었다가 나중에 그것이 필요하지 않다고 판단하게 되면, 그 시점에 기본값을 바꾸고, 기존 옵트인 슬롯은 아무 동작도 하지 않도록 만들고, 명시적으로 옵트아웃하기 위한 새 PyModuleDef 슬롯을 추가할 수 있습니다. 사실 그 옵트아웃 슬롯은 처음부터 추가하는 것이 합리적입니다.
문서
- C-API:
Doc/c-api/init.rst의 “서브인터프리터 지원(Sub-interpreter support)” 절에서 갱신된 API를 자세히 설명할 것입니다 - C-API: 그 절에서 인터프리터별 GIL이 가져오는 결과에 대해 설명합니다
- importlib:
ExtensionFileLoader항목에 서브인터프리터에서 임포트가 실패할 수 있다는 점을 명시합니다 - importlib:
importlib.util.allow_all_extensions()에 대한 새 항목이 추가됩니다
영향
하위 호환성
이 제안으로 인해 동작이나 API를 변경할 의도는 없지만, 두 가지 예외가 있습니다:
- 일부 확장은 일부 서브인터프리터에서 임포트에 실패합니다(다음 절을 참조하십시오)
- 현재 스레드 안전하지 않은 “mem” 할당자와 “object”(객체) 할당자는 여러 인터프리터와 함께 사용될 때 데이터 경쟁에 취약해질 수 있습니다
인터프리터를 관리하기 위한 기존 C-API는 현재 동작을 유지하며, 새로운 동작은 새 API를 통해 노출됩니다. 안정 ABI와의 호환성을 포함하여, 그 밖의 API나 런타임 동작은 변경할 의도가 없습니다.
관련 논의는 아래의 Objects Exposed in the C-API를 참조하십시오.
확장 모듈
현재 Python의 가장 일반적인 사용 방식은 단연코 메인 인터프리터가 단독으로 실행되는 것입니다. 그 시나리오에서 이 제안은 확장 모듈에 아무런 영향도 미치지 않습니다. 마찬가지로, 좋든 나쁘든, 기존 Py_NewInterpreter()를 사용하여 생성한 여러 인터프리터에서의 동작에도 변화가 없습니다.
일부 확장은 모듈 상태를 전역 변수에 보관하기 때문에(또는 internal state of linked libraries 때문에) 여러 인터프리터에서 사용될 때 이미 제대로 동작하지 않는다는 점을 명심하십시오. 그런 확장은 충돌하거나, 더 나쁘게는 일관되지 않은 동작을 보일 수 있습니다. 이는 PEP 630과 관련 PEP들의 동기 중 일부였으므로, 새로운 상황도 아니고 이 제안의 결과도 아닙니다.
반면, 제안된 API를 적절한 설정과 함께 사용하여 여러 인터프리터를 생성하면, 호환되지 않는 확장의 동작이 바뀝니다. 그 경우, Restricting Extension Modules에서 설명한 대로 그러한 확장의 임포트는 (메인 인터프리터 밖에서) 실패합니다. 여러 인터프리터에서 이미 제대로 동작하지 않는 확장에게는 이것이 개선이 됩니다.
또한 일부 확장 모듈은 스레드 안전하지 않은 내부 전역 상태를 가진 라이브러리에 링크됩니다. (Extension Module Thread Safety를 참조하십시오.) 그러한 모듈은 그 상태를 직접 또는 간접적으로 사용하는 모든 부분을 잠금으로 감싸기 시작해야 합니다. 이것이 마찬가지로 다단계 초기화를 구현하여 여러 인터프리터 지원(즉, 격리)을 나타내는 다른 모듈과의 핵심적인 차이입니다.
이제 앞에서 언급한 호환성 단절 문제를 살펴봅니다. 일부 확장은 그렇다고 표시하지 않았더라도 여러 인터프리터(및 인터프리터별 GIL) 환경에서 안전합니다. 안타깝게도 임포트 시스템이 그러한 확장이 안전하다고 추론할 수 있는 신뢰할 만한 방법이 없으므로, 이러한 확장을 임포트하면 여전히 실패합니다. 이 경우는 위의 Extension Module Compatibility에서 다룹니다.
확장 모듈 유지 관리자
관련된 고려 사항 하나는 인터프리터별 GIL이 특히 PEP 554가 채택될 경우 여러 인터프리터의 사용 증가를 이끌 가능성이 높다는 점입니다. 일부 대형 확장 모듈의 유지 관리자들은 여러 인터프리터의 사용 증가로 인해 예상되는 부담 증가에 대해 우려를 표명했습니다.
구체적으로, 여러 인터프리터 지원을 활성화하려면 일부 확장 모듈에서는 (많지는 않겠지만) 상당한 작업이 필요합니다. 그러한 지원을 추가하려면 해당 모듈의 유지 관리자(대개 자원봉사자)는 평소의 우선순위와 관심사를 제쳐 두고 호환성에 집중해야 합니다(PEP 630 참고).
물론 확장 유지 관리자는 여러 인터프리터에서의 사용 지원을 추가하지 않을 자유가 있습니다. 그러나 특히 이 기능의 인기가 높아진다면 사용자들은 점점 더 그러한 지원을 요구할 것입니다.
어느 쪽이든, 이 상황은 그러한 확장의 유지 관리자에게, 특히 여가 시간에 작업하는 경우 스트레스가 될 수 있습니다. 이들이 표명한 우려는 충분히 이해할 만하며, Restricting Extension Modules 및 Extension Module Compatibility 절에서 부분적인 해결책을 다룹니다.
대체 Python 구현
다른 Python 구현은 같은 프로세스에서 여러 인터프리터를 지원할 필요가 없습니다(일부는 이미 지원하고 있지만).
보안 영향
이 제안이 보안에 미치는 것으로 알려진 영향은 없습니다.
유지 보수성
한편으로, 이 제안은 이미 CPython을 더 유지보수하기 쉽게 만드는 여러 개선을 이끌어 냈습니다. 이러한 흐름은 계속될 것으로 예상됩니다. 다른 한편으로, 기반 작업을 통해 런타임에 이미 존재하던 여러 결함이 드러났고, 이를 수정해야 했습니다. 이 역시 여러 인터프리터가 더 많이 사용됨에 따라 계속될 것으로 예상됩니다. 그 밖에는 유지보수성에 큰 영향이 없을 것이므로, 전체적인 효과는 긍정적일 것입니다.
성능
전역 변수를 통합하는 작업은 이미 CPython의 성능을 여러모로 개선하여 속도를 높이고 메모리 사용량을 줄였으며, 이러한 개선은 계속될 것입니다. 인터프리터별 GIL 자체의 성능상 이점은 아직 탐구되지 않았습니다. 적어도 (인터프리터가 충분히 격리되어 있는 한) CPython을 더 느리게 만들지는 않을 것으로 예상됩니다. 그리고 당연히 Python 코드에서 다양한 멀티코어 병렬 처리를 가능하게 합니다.
교육 방법
PEP 554와 달리, 이는 C-API 사용자 중 일부만을 대상으로 하는 고급 기능입니다. API의 세부 사항이나 그 직접적인 활용 방법을 가르칠 것으로 예상하지는 않습니다.
그렇기는 하지만, 만약 가르친다면 다음과 같이 요약할 수 있습니다:
Py_NewInterpreter() 외에도 Py_NewInterpreterFromConfig()를 사용하여 인터프리터를 생성할 수 있습니다. 여기에 전달하는 config는 해당 인터프리터가 어떻게 동작하기를 원하는지를 나타냅니다.
또한 격리된 인터프리터를 생성하는 확장 모듈의 유지 관리자는 인터프리터별 GIL이 가져오는 결과를 사용자에게 설명해야 할 가능성이 높습니다. 먼저 설명해야 할 것은 격리된 인터프리터가 가능하게 하는 동시성 모델에 대해 PEP 554가 알려 주는 내용입니다. 이는 해당 동시성 모델을 사용하여 작성된 Python 소프트웨어가 현재 GIL 때문에 불가능한 멀티 코어 병렬성을 활용할 수 있게 된다는 점으로 이어집니다.
참조 구현
<TBD>
미해결 문제
- “mem” 할당자와 “object”(객체) 할당자가 스레드 안전하도록 요구해도 괜찮겠습니까?
- 인터프리터별 tracemalloc 모듈은 전역 할당자와 어떤 관계를 가지게 됩니까?
- faulthandler 모듈을 (signal 모듈처럼) 메인 인터프리터로 제한하겠습니까, 아니면 (세분화된 잠금으로 보호되는) 해당 전역 상태가 인터프리터 사이에 새어 나가도록 두겠습니까?
- “런타임 전역 상태 통합” 섹션을 바탕으로 관련 정보를 모두 담은 정보성 PEP를 별도로 분리하겠습니까?
- 모듈이 여러 인터프리터(격리) 환경에서는 동작하지만 인터프리터별 GIL 환경에서는 동작하지 않을 가능성은 얼마나 됩니까? (Extension Module Thread Safety를 참고하십시오.)
- 그 가능성이 충분히 높다면, 확장 모듈 유지 관리자가 문제를 완화하고 인터프리터별 GIL 환경에서 모듈을 활용할 수 있도록 우리가 무엇을 할 수 있겠습니까?
allow_all_extensions에 더 나은 (더 무섭게 들리는) 이름은 무엇이겠습니까?
연기된 기능
- 인터프리터를 항상 새 스레드에서 실행하는
PyInterpreterConfig옵션 - 인터프리터에 “메인” 스레드를 할당하고 해당 스레드에서만 실행하는
PyInterpreterConfig옵션
기각된 아이디어
<TBD>
추가 맥락
전역 객체 공유
우리는 일부 전역 객체를 인터프리터 사이에 공유하고 있습니다. 이는 구현 세부 사항이며 이 제안보다는 전역 상태 통합과 더 관련이 있지만, 여기에서 설명할 만큼 중요한 세부 사항입니다.
대안은 인터프리터 사이에 어떤 객체도 절대 공유하지 않는 것입니다. 이를 달성하려면 모든 정적 타입의 처리 방안을 정리해야 할 뿐만 아니라, 공개 C-API에 노출된 많은 객체의 호환성 문제도 처리해야 합니다.
프로토타이핑을 통해 유효한 해결책이 입증되기는 했지만, 이 접근 방식은 상당한 추가 복잡성과 더 높은 위험을 초래합니다. 또한 성능 저하로 이어질 가능성이 높습니다.
불멸 객체를 사용하면 그 밖의 면에서는 불변인 전역 객체를 공유할 수 있습니다. 이렇게 하면 추가 비용을 피할 수 있습니다.
C-API에 노출된 객체
C-API(제한된 API 포함)는 내장 예외를 포함한 모든 내장 타입과 내장 싱글톤을 노출합니다. 예외는 PyObject *로 노출되지만, 나머지는 포인터가 아닌 정적 값으로 노출됩니다. 이는 인터프리터별 GIL을 위해 해결해야 했던, 몇 안 되는 까다로운 문제 중 하나였습니다.
불멸 객체가 있으면 이는 문제가 되지 않습니다.
런타임 전역 상태 통합
위의 CPython Runtime State에서 언급했듯이, CPython의 전역 상태를 _PyRuntimeState 구조체로 통합하려는 작업이 (이 PEP와는 별개로) 활발히 진행되고 있습니다. 이 작업의 거의 전부는 해당 상태를 전역 변수에서 옮기는 일입니다. 이 프로젝트는 이 제안과 특히 관련이 깊으므로, 아래에서 추가로 자세히 설명합니다.
통합의 이점
전역 변수를 통합하면 다음과 같은 다양한 이점이 있습니다:
- C 전역 변수의 수를 크게 줄입니다(C 코드의 모범 사례)
- 옮기는 과정에서 불안정하거나 망가진 런타임 상태에 주의를 기울이게 됩니다
- 런타임 상태를 사용하는 방식의 일관성을 높입니다
- CPython의 런타임 상태를 찾아내고 식별하기 쉬워집니다
- 런타임 상태를 일관된 방식으로 정적 할당하기 쉬워집니다
- 런타임 상태의 메모리 지역성이 향상됩니다
또한 위의 Indirect Benefits에 나열된 모든 이점이 여기에도 적용되며, 그곳에 나열된 프로젝트들도 똑같이 이점을 얻습니다.
작업 규모
옮겨야 할 전역 변수의 수는 무시할 수 없을 만큼 많지만, 대부분은 (Py_IDENTIFIER처럼) 큰 그룹 단위로 처리할 수 있는 Python 객체입니다. 거의 모든 경우에 이러한 전역 변수를 인터프리터로 옮기는 작업은 매우 기계적입니다. 이 작업에는 영리함이 아니라 누군가가 시간을 들이는 것이 필요합니다.
옮겨야 할 상태
나머지 전역 변수는 다음과 같이 분류할 수 있습니다:
- 전역 객체
- 정적 타입(예외 타입 포함)
- 비정적 타입(힙 타입, structseq 타입 포함)
- 싱글톤(정적)
- 싱글톤(한 번만 초기화됨)
- 캐시된 객체
- 객체가 아닌 것
- 초기화 후 변경되지 않음(또는 변경될 가능성이 낮음)
- 메인 스레드에서만 사용됨
- 지연 초기화됨
- 미리 할당된 버퍼
- 상태
이러한 전역 변수는 코어 런타임, 내장 모듈, 표준 라이브러리 확장 모듈에 걸쳐 흩어져 있습니다.
남아 있는 전역 변수의 세부 내역을 보려면 다음을 실행하십시오:
./python Tools/c-analyzer/table-file.py Tools/c-analyzer/cpython/globals-to-fix.tsv
이미 완료된 작업
앞서 언급했듯이, 이 작업은 수년간 진행되어 왔습니다. 다음은 이미 완료된 작업 중 일부입니다:
도구
앞서 밝혔듯이, 전역 변수를 식별하고 분석하는 데 도움이 되는 여러 도구가 있습니다.
Tools/c-analyzer/cpython/globals-to-fix.tsv- 남아 있는 전역 변수 목록Tools/c-analyzer/c-analyzer.pyanalyze- 모든 전역 변수를 식별check- 무시 대상이 아닌 미지원 전역 변수가 있으면 실패
Tools/c-analyzer/table-file.py- 알려진 전역 변수를 요약
또한 새로운 전역 변수가 실수로 추가되지 않도록 미지원 전역 변수 검사가 CI에 통합되어 있습니다.
전역 객체
(GIL 없이) 인터프리터 간에 안전하게 공유할 수 있는 전역 객체는 _PyRuntimeState에 그대로 둘 수 있습니다. 객체가 사실상 불변(예: 싱글턴, 문자열)이어야 할 뿐만 아니라, 안전하려면 참조 횟수조차 변경되어서는 안 됩니다. 불멸성(PEP 683)이 이를 제공합니다. (대안은 어떤 객체도 공유하지 않는 것인데, 이는 해법에 상당한 복잡성을 더하며, 특히 공개 C-API에 노출된 객체의 경우에 그렇습니다.)
내장 정적 타입은 공유될 전역 객체의 특수한 경우입니다. 이들은 한 부분, 즉 __subclasses__ (일명 tp_subclasses)를 제외하면 사실상 불변입니다. 내장 타입에서 그 밖의 어떤 것도, 심지어 __dict__ (일명 tp_dict)의 내용조차 바뀌지 않을 것으로 예상합니다.
내장 타입의 __subclasses__는 현재 PyInterpreterState에서 해당 타입을 조회하는 게터로 만들어 처리할 것입니다.
참고 문헌
관련 문서:
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.