PEP 683 – 고정 참조 횟수를 사용하는 불멸 객체
- Author:
- Eric Snow <ericsnowcurrently at gmail.com>, Eddie Elizondo <eduardo.elizondorueda at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 10-Feb-2022
- Python-Version:
- 3.12
- Post-History:
- 16-Feb-2022, 19-Feb-2022, 28-Feb-2022, 12-Aug-2022
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
PEP 수락 조건
이 PEP는 다음 조건을 전제로 수락되었습니다:
- Solutions for Accidental De-Immortalization의 주요 제안(
tp_dealloc()에서 불멸 참조 횟수를 재설정)이 적용되었습니다 - 이것이 없는 타입은 (CPython 코드에서) 불멸화되지 않았습니다
- 구현이 확정된 후 최종 벤치마크 결과로 PEP가 갱신되었습니다(변경이 가치 있음을 확인했습니다)
초록
현재 CPython 런타임은 각 객체에 할당된 메모리에 소량의 가변 상태를 유지합니다. 이 때문에 원래는 불변이어야 할 객체가 실제로는 가변입니다. 이는 CPU 및 메모리 성능에 큰 부정적 영향을 미칠 수 있으며, 특히 Python의 확장성을 높이려는 접근 방식에서 그렇습니다.
이 제안은 CPython이 내부적으로 객체를 해당 런타임 상태가 더 이상 변하지 않는 객체로 표시하는 기능을 지원하도록 규정합니다. 따라서 이러한 객체의 참조 횟수는 결코 0에 도달하지 않으며, 그 결과 객체는 결코 정리되지 않습니다(런타임 종료 처리 중처럼 런타임이 그렇게 해도 안전하다고 아는 경우는 예외입니다). 이러한 객체를 “불멸(immortal)” 객체라고 부릅니다. (일반적으로 비교적 적은 수의 내부 객체만이 불멸 객체가 됩니다.) 여기서의 근본적인 개선점은 이제 객체가 진정으로 불변일 수 있다는 것입니다.
범위
객체 불멸성은 내부 전용 기능으로 의도된 것이므로, 이 제안은 공개 API나 동작에 대한 어떠한 변경도 포함하지 않습니다(한 가지 예외가 있습니다). 평소와 같이, 객체를 불멸화하거나 객체가 불멸인지 확인하는 등의 작업을 위한 비공개(그러나 공개적으로 접근 가능한) API를 추가할 수는 있습니다. 이 기능을 사용자에게 노출하려는 모든 시도는 별도로 제안되어야 합니다.
“동작 변경 없음”에는 한 가지 예외가 있습니다: 불멸 객체의 참조 횟수 계산 의미론은 일부 경우에 사용자의 기대와 다릅니다. 이 예외와 그 해결책은 아래에서 논의합니다.
이 PEP의 대부분은 위의 규정을 충족하는 내부 구현에 초점을 맞춥니다. 그러나 이러한 구현 세부 사항은 엄격하게 규범적인 것으로 의도된 것이 아닙니다. 오히려 이들은 최소한 규정이 요구하는 기술적 고려 사항을 설명하는 데 도움을 주기 위해 포함되었습니다. 실제 구현은 아래에 설명된 제약 조건을 충족하는 한 다소 달라질 수 있습니다. 또한 아래에 설명된 특정 구현 세부 사항의 수용 여부는 명시적으로 언급되지 않는 한 이 PEP의 상태에 좌우되지 않습니다.
예를 들어, 다음의 구체적인 세부 사항은:
- 무언가를 불멸로 표시하는 방법
- 무언가를 불멸로 인식하는 방법
- 기능적으로 불멸인 객체 중 어떤 부분 집합을 불멸로 표시할지
- 불멸 객체에 대해 어떤 메모리 관리 작업을 건너뛰거나 수정할지
CPython에 한정될 뿐만 아니라, 이후 버전에서 변경될 것으로 예상되는 비공개 구현 세부 사항이기도 합니다.
구현 요약
다음은 구현을 개괄적으로 살펴본 것입니다:
객체의 참조 횟수가 매우 특정한 값(아래에서 정의합니다)과 일치하면 그 객체는 불멸로 취급됩니다. CPython C-API와 런타임은 불멸 객체의 참조 횟수(또는 다른 런타임 상태)를 수정하지 않습니다. 이제 런타임은 정적으로 할당된 경우가 아니라면 종료 처리(finalization) 중에 모든 불멸 객체를 해제할 책임을 명시적으로 지게 됩니다. (아래의 Object Cleanup을 참조하십시오.)
참조 카운팅 의미 체계의 변경 외에도 고려해야 할 부정적인 영향이 한 가지 더 있을 수 있습니다. 불멸 객체에 대해 “허용 가능한” 성능 저하의 기준은 2%입니다(2022년 Language Summit에서의 합의). 아래에서 설명하는 접근 방식을 단순하게 구현하면 CPython이 약 4% 느려집니다. 그러나 알려진 완화책을 적용하면 이 구현은 ~성능 중립적~입니다.
TODO: 최신 브랜치에 대한 성능 영향을 갱신하십시오(GCC와 clang 모두).
동기
앞서 언급했듯이 현재 모든 객체는 사실상 가변적입니다. 여기에는 str 인스턴스와 같은 “불변” 객체도 포함됩니다. 이는 모든 객체의 참조 횟수가 실행 중에 객체가 사용될 때마다 빈번하게 수정되기 때문입니다. 이는 None과 같이 흔히 사용되는 여러 전역(내장) 객체에서 특히 두드러집니다. 이러한 객체는 Python 코드에서도, 내부적으로도 매우 많이 사용됩니다. 이것이 쌓여 지속적으로 대량의 참조 횟수 변경이 발생합니다.
모든 Python 객체가 사실상 가변적이라는 점은 Instagram처럼 확장성을 목표로 하는 프로젝트나 GIL을 인터프리터별로 만들려는 노력 등 Python 커뮤니티의 일부에 구체적인 영향을 미칩니다. 아래에서는 참조 횟수 수정이 이러한 프로젝트에 실질적인 악영향을 미치는 여러 방식을 설명합니다. 진정으로 불변인 객체에서는 이러한 일이 전혀 일어나지 않을 것입니다.
CPU 캐시 무효화 줄이기
참조 횟수를 수정할 때마다 해당 CPU 캐시 라인이 무효화됩니다. 이는 여러 가지 영향을 미칩니다.
우선 쓰기 내용이 다른 캐시 수준과 주 메모리로 전파되어야 합니다. 이는 모든 Python 프로그램에 작은 영향을 미칩니다. 불멸 객체는 이 점에서 약간의 부담을 덜어 줄 것입니다.
게다가 멀티코어 애플리케이션은 그 대가를 치릅니다. (서로 다른 코어에서 동시에 실행되는) 두 스레드가 같은 객체(예: None)를 다루고 있다면, incref와 decref를 할 때마다 서로의 캐시를 무효화하게 됩니다. 이는 True, 0, str 인스턴스처럼 그 밖의 면에서는 불변인 객체에서도 마찬가지입니다. CPython의 GIL은 한 번에 하나의 스레드만 실행되므로 이 영향을 줄이는 데 도움이 되지만, 그 비용을 완전히 없애지는 못합니다.
데이터 경쟁 피하기
멀티코어 이야기가 나온 김에 말씀드리면, 저희는 GIL을 인터프리터별 잠금으로 만드는 것을 검토하고 있으며, 이렇게 하면 진정한 멀티코어 병렬성이 가능해집니다. 무엇보다도 현재 GIL은 같은 객체를 incref하거나 decref할 수 있는 여러 동시 스레드 사이의 경쟁을 방지합니다. 공유 GIL이 없으면 실행 중인 두 인터프리터는 None처럼 그 밖의 면에서는 불변인 객체조차 안전하게 공유할 수 없습니다.
이는 인터프리터별 GIL을 사용하려면 각 인터프리터가 모든 객체의 사본을 각자 가져야 한다는 뜻입니다. 여기에는 싱글턴과 정적 타입도 포함됩니다. 이를 위한 실행 가능한 전략은 있지만, 상당한 추가 노력이 들고 복잡성도 더해집니다.
대안은 모든 공유 객체가 진정으로 불변이도록 보장하는 것입니다. 수정이 없으므로 경쟁 상태도 없을 것입니다. 여기서 제안하는 불멸성은 불멸성 외의 측면에서는 불변인 객체에 대해 바로 이것을 가능하게 합니다. 불멸 객체가 있으면 인터프리터별 GIL 지원이 훨씬 간단해집니다.
쓰기 시 복사 피하기
일부 애플리케이션에서는 애플리케이션을 원하는 초기 상태로 만든 다음 각 워커마다 프로세스를 포크하는 것이 합리적입니다. 이를 통해 성능을 크게 개선할 수 있으며, 특히 메모리 사용량 측면에서 그렇습니다. 여러 기업의 Python 사용자(예: Instagram, YouTube)가 이를 활용해 왔습니다. 그러나 위에서 설명한 참조 횟수 의미 체계는 그 이점을 크게 줄이며, 몇 가지 최적이 아닌 우회책으로 이어졌습니다.
또한 쓰기 시 복사 의미 체계를 사용하는 운영 체제 메커니즘이 “fork”만 있는 것은 아니라는 점에 유의하십시오. 또 다른 예는 mmap입니다. 이러한 유틸리티는 모두 “필멸” 객체만 사용할 때와 비교하여, 불멸 객체가 관련될 때 쓰기 시 복사가 줄어드는 이점을 얻을 수 있습니다.
근거
제안된 해결책은 충분히 명백하여, 이 제안의 두 저자가 각자 독립적으로 같은 결론(그리고 대체로 같은 구현)에 도달했습니다. Pyston 프로젝트는 비슷한 접근 방식을 사용합니다. 다른 설계도 검토되었습니다. 지난 몇 년간 python-dev에서도 여러 가능성이 논의되었습니다.
대안은 다음과 같습니다.
- 상위 비트를 사용해 “불멸”을 표시하되
Py_INCREF()는 변경하지 않음 - 객체에 명시적 플래그 추가
- 타입을 통해 구현(
tp_dealloc()은 아무 동작도 하지 않음) - 객체의 타입 객체를 통해 추적
- 별도의 테이블로 추적
위의 각 방법은 객체를 불멸로 만들지만, 어느 것도 위에서 설명한 참조 횟수 수정으로 인한 성능 저하를 해결하지 못합니다.
인터프리터별 GIL의 경우, 유일하게 현실적인 대안은 모든 전역 객체를 PyInterpreterState로 옮기고 이에 접근하기 위한 조회 함수를 하나 이상 추가하는 것입니다. 그런 다음 C-API에 노출된 많은 객체의 호환성을 유지하기 위해 C-API에 몇 가지 편법을 추가해야 할 것입니다. 불멸 객체를 사용하면 상황이 훨씬, 훨씬 간단해집니다.
영향
이점
특히 위 예시에서 설명한 사례들은 불멸 객체로부터 큰 이점을 얻을 수 있습니다. pre-fork를 사용하는 프로젝트는 기존의 우회 방법을 없앨 수 있습니다. 인터프리터별 GIL 프로젝트에서는 불멸 객체 덕분에 기존 정적 타입과 공개 C-API가 노출하는 객체에 대한 해결책이 크게 단순해집니다.
일반적으로 객체의 불변성을 강력하게 보장하면 Python 애플리케이션의 확장성이 향상되며, 특히 다중 프로세스 배포에서 그렇습니다. 이는 애플리케이션이 현재처럼 메모리 사용량 면에서 큰 대가를 치르지 않고도 멀티코어 병렬 처리를 활용할 수 있게 되기 때문입니다. 방금 설명한 사례들과 앞의 Motivation에서 설명한 사례들이 이러한 개선을 보여 줍니다.
성능
단순한 구현에서는 a 2% slowdown이 나타납니다(MSVC에서는 3%). 몇 가지 기본적인 완화 조치를 적용하면 ~performance-neutral~ 상태로 돌아온다는 것을 입증했습니다. 아래의 mitigations 절을 참조하십시오.
긍정적인 측면으로, 불멸 객체를 pre-fork 모델과 함께 사용하면 상당한 양의 메모리를 절약할 수 있습니다. 또한 불멸 객체는 eval 루프에서 성능을 향상할 수 있는 특수화의 기회를 제공합니다.
하위 호환성
이상적으로는 이 내부 전용 기능이 완전히 호환될 것입니다. 하지만 일부 경우에는 참조 횟수 의미의 변경을 수반합니다. 영향을 받는 것은 불멸 객체뿐이지만, 여기에는 None, True, False와 같이 사용 빈도가 높은 객체가 포함됩니다.
구체적으로, 불멸 객체가 관련된 경우에는 다음과 같습니다.
- 참조 횟수를 검사하는 코드는 정말, 정말 큰 값을 보게 됩니다
- 새로운 noop 동작으로 인해 다음과 같은 코드가 동작하지 않을 수 있습니다.
- 참조 횟수가 항상 증가하거나 감소하는 것(또는
Py_SET_REFCNT()로 설정한 특정 값을 갖는 것)에 특별히 의존하는 코드 - 0이나 1 이외의 특정 참조 횟수 값에 의존하는 코드
- 참조 횟수를 직접 조작하여 그곳에 추가 정보를 저장하는 코드
- 참조 횟수가 항상 증가하거나 감소하는 것(또는
- 3.12 이전의 32비트 Stable ABI 확장에서는 Accidental Immortality로 인해 객체가 누수될 수 있습니다.
- 이러한 확장은 Accidental De-Immortalizing으로 인해 크래시가 발생할 수 있습니다.
다시 말하지만, 이러한 동작 변경은 불멸 객체에만 적용되며 사용자가 사용하는 대다수의 객체에는 적용되지 않습니다. 게다가 사용자는 객체를 불멸로 표시할 수 없으므로, 사용자가 생성한 객체의 동작이 이렇게 바뀌는 일은 결코 없습니다. 전역(내장) 객체에서 바뀌는 동작 중 어느 하나에라도 의존하는 사용자는 이미 문제를 안고 있습니다. 따라서 전반적인 영향은 작을 것입니다.
또한 참조 누수를 검사하는 코드는 어떤 불멸 객체를 기준으로 하드코딩된 작은 값을 검사하지 않는 한 계속 정상적으로 동작할 것이라는 점에 유의하십시오. 우리는 참조 횟수를 수정하지 않으므로 Pyston이 발견한 문제는 여기에 해당하지 않을 것입니다.
자세한 논의는 아래의 Public Refcount Details를 참조하십시오.
우발적 불멸화
가정해 보면, 불멸이 아닌 객체가 너무 많이 incref되어 불멸로 간주되는 데 필요한 매직 값에 도달할 수 있습니다. 그러면 그 객체는 결코 0까지 decref되지 않으므로 의도치 않게 누수됩니다(결코 정리되지 않습니다).
64비트 참조 횟수에서는 이러한 우발적인 시나리오가 일어날 가능성이 매우 낮아서 걱정할 필요가 없습니다. 좁은 루프에서 Py_INCREF()를 사용하여 의도적으로 수행하고 각 반복이 CPU 1사이클만 걸린다고 해도, (불멸 비트가 2^60이라면) 2^60 사이클이 걸립니다. 5 GHz라는 빠른 속도에서도 거의 250,000,000초(2,500일 이상)가 걸립니다!
또한 참조 횟수가 다시 0으로 돌아가 객체가 정리되기 전까지는 아무런 문제가 되지 않으므로, 문제가 될 가능성은 이중으로 낮습니다. 따라서 그 마법 같은 “불멸” 참조 횟수 값에 도달한 객체는 동작 변화가 드러나기 전에 다시 그만큼 많은 횟수로 decref되어야 합니다.
다시 말하지만, 마법 같은 참조 횟수에 도달하는(그리고 이를 되돌리는) 유일하게 현실적인 방법은 의도적으로 그렇게 하는 것입니다. (물론 같은 일을 Py_SET_REFCNT()를 사용하여 효율적으로 할 수도 있지만, 그것은 더더욱 우연이라고 보기 어렵습니다.) 그런 경우는 이 제안이 고려할 사항이 아니라고 봅니다.
32비트 플랫폼처럼 최대 참조 횟수가 훨씬 작은 빌드에서는 그 결과가 그렇게 명확하지 않습니다. 마법 같은 참조 횟수가 2^30이라고 가정해 봅시다. 위와 같은 사양을 사용하면, 객체를 우발적으로 불멸화하는 데 대략 4초가 걸립니다. 합리적인 조건에서는 객체가 우발적으로 불멸화될 가능성이 여전히 매우 낮습니다. 이를 위해서는 다음 조건을 충족해야 합니다:
- 불멸이 아닌 객체를 대상으로 함 (따라서 많이 사용되는 내장 객체 중 하나가 아님)
- 확장이 대응하는 decref 없이 incref함 (예: 함수나 메서드에서 반환하는 경우)
- 그동안 다른 어떤 코드도 그 객체를 decref하지 않음
훨씬 낮은 빈도로 일어나더라도 (32비트에서는) 우발적 불멸에 도달하는 데 오래 걸리지 않습니다. 그러나 그다음에는 그 객체 하나가 실질적으로 누수되기 전에 같은 횟수만큼의 (이제는 아무 일도 하지 않는) decref를 거쳐야 합니다. 특히 이 계산은 decref가 전혀 없다고 가정하므로, 이런 일은 일어날 가능성이 매우 낮습니다.
게다가 이는 그러한 32비트 확장이 이미 객체를 2^31 넘게 incref하여 참조 횟수를 음수로 만들 수 있는 것과 크게 다르지 않습니다. 그것이 실제 문제였다면 이미 알려졌을 것입니다.
위의 모든 경우를 종합하여, 이 제안은 우발적 불멸을 문제로 보지 않습니다.
안정 ABI
이 PEP에서 설명하는 구현 방식은 안정 ABI로 컴파일된 확장과 호환됩니다(Accidental Immortality와 Accidental De-Immortalizing은 예외입니다). 안타깝게도 안정 ABI의 특성상, 그러한 확장은 객체의 ob_refcnt 필드를 직접 수정하는 버전의 Py_INCREF() 등을 사용합니다. 이로 인해 불멸 객체의 성능상 이점이 모두 사라집니다.
그러나 그런 상황에서도 불멸 객체가 (대부분) 불멸 상태를 유지하도록 보장합니다. 불멸 객체의 초기 참조 횟수는 그 객체를 불멸로 식별할 수 있고, 확장이 참조 횟수를 수정하더라도 계속 그렇게 식별할 수 있는 값으로 설정합니다. (예를 들어, 참조 횟수의 상위 비트 중 하나를 사용하여 객체가 불멸임을 나타낸다고 가정해 봅시다. 그러면 초기 참조 횟수를 여전히 그 비트와 일치하는 더 높은 값, 예컨대 다음 비트까지의 중간 지점으로 설정할 것입니다. _Py_IMMORTAL_REFCNT를 참고하십시오.) 최악의 경우라도 그런 상황의 객체는 Motivation 절에서 설명한 영향을 받는 정도입니다. 그렇더라도 전체적인 영향은 크지 않을 것입니다.
우발적 불멸 해제
이전 안정 ABI 확장의 32비트 빌드는 Accidental Immortality를 한층 더 심각한 수준으로 끌어올릴 수 있습니다.
가정해 보면, 그러한 확장은 객체의 참조 횟수를 incref하여 매직 참조 횟수 값보다 한 단계 위의 비트에 해당하는 값까지 올릴 수 있습니다. 예를 들어 매직 값이 2^30이고 그에 따라 초기 불멸 참조 횟수가 2^30 + 2^29라면, 확장이 2^29번 incref하면 2^31 값에 도달하여 객체가 더 이상 불멸이 아니게 됩니다. (물론 참조 횟수가 그렇게 높다면 불멸 객체와 관계없이 이미 크래시가 발생할 가능성이 높습니다.)
더 문제가 되는 경우는 그러한 32비트 안정 ABI 확장이 이미 불멸인 객체를 마구 decref하는 경우입니다. 위의 예를 이어 가면, 매직 불멸 참조 횟수 값 아래로 떨어지려면 2^29번의 비대칭 decref가 필요합니다. 따라서 None 같은 객체가 불멸이 아니게 되어 decref의 대상이 될 수 있습니다. 그렇더라도 어떤 이유로 그 객체에 대한 decref가 계속되어 참조 횟수가 0에 도달하기 전까지는 여전히 문제가 되지 않습니다. None과 같이 정적으로 할당된 불멸 객체의 경우, 확장이 그 객체를 해제하려고 하면 프로세스가 크래시됩니다. 그 밖의 불멸 객체의 경우에는 해제해도 괜찮을 수 있습니다. 그러나 이전에 불멸이었던 객체가 영원히 존재할 것으로 기대하는 런타임 코드가 있을 수 있습니다. 그러한 코드는 아마 크래시될 것입니다.
다시 말하지만, 32비트 빌드에서도 이런 일이 일어날 가능성은 극히 낮습니다. 그 하나의 객체에 대해 대응하는 incref 없이 대략 10억 번의 decref가 일어나야 합니다. 가장 가능성이 높은 시나리오는 다음과 같습니다:
많은 함수와 메서드가 None에 대한 “새” 참조를 반환합니다. 불멸이 아닌 객체와 달리, 3.12 런타임은 None을 확장에 넘겨주기 전에 사실상 절대 incref하지 않습니다. 그러나 확장은 사용을 마치면 그것을 반드시 decref합니다(반환하는 경우는 제외). 하나의 객체에 대해 그러한 주고받기가 일어날 때마다 크래시에 한 걸음씩 가까워집니다.
32비트에서 Python 프로세스가 살아 있는 동안 (하나의 객체에 대해) 어떤 형태로든 그러한 주고받기가 10억 번 일어나는 것이 얼마나 현실적입니까? 그것이 문제라면 어떻게 해결할 수 있습니까?
얼마나 현실적인지에 대해서는 현재로서는 답이 명확하지 않습니다. 그러나 완화책이 충분히 간단하므로, 문제가 되지 않을 것이라는 가정하에 안전하게 진행할 수 있습니다.
가능한 해결책은 뒤에서 살펴봅니다.
대체 Python 구현
이 제안은 CPython에 한정됩니다. 그러나 이 제안은 C-API의 동작과 관련이 있으며, 이는 다른 Python 구현에 영향을 줄 수 있습니다. 따라서 앞의 Backward Compatibility에서 설명한 동작 변경의 영향은 여기에도 적용됩니다(예: 다른 구현이 0 이외의 특정 참조 횟수 값이나 참조 횟수가 변하는 정확한 방식에 강하게 결합되어 있다면 영향을 받을 수 있습니다).
보안 영향
이 기능이 보안에 미치는 것으로 알려진 영향은 없습니다.
유지 보수성
이것은 복잡한 기능이 아니므로 유지 관리자에게 큰 정신적 부담을 주지 않을 것입니다. 기본 구현은 많은 코드를 건드리지 않으므로 유지보수성에 큰 영향을 미칠 것입니다. 성능 저하를 완화하는 과정에서 약간의 추가 복잡성이 생길 수 있습니다. 하지만 그것은 초기화 후에 모든 객체를 불멸화하고 나중에 런타임 종료 처리 중에 명시적으로 할당 해제하는 부분으로 한정되어야 합니다. 이를 위한 코드는 비교적 한곳에 모여 있을 것입니다.
명세
이 접근 방식에는 다음과 같은 근본적인 변경 사항이 포함됩니다.
- 내부 C-API에 _Py_IMMORTAL_REFCNT (매직 값)를 추가합니다.
- 매직 참조 횟수와 일치하는 객체에 대해서는
Py_INCREF()와Py_DECREF()가 아무 동작도 하지 않도록(no-op) 수정합니다. - 참조 횟수를 수정하는 다른 모든 API에도 똑같이 적용합니다.
- 불멸 GC 객체(“컨테이너”)에 대해서는
PyGC_Head를 더 이상 수정하지 않습니다. - 런타임 종료 처리 중에 모든 불멸 객체가 정리되도록 보장합니다
그러면 어떤 객체든 참조 횟수를 _Py_IMMORTAL_REFCNT로 설정하면 해당 객체는 불멸 객체가 됩니다.
(여기에서 설명하지 않는 사소한 내부 변경 사항도 있습니다.)
다음 하위 절에서는 가장 중요한 세부 사항을 깊이 살펴봅니다. 먼저 몇 가지 개념적인 주제를 다루고, 이어서 영향을 받는 구체적인 API와 같은 보다 구체적인 측면을 다룹니다.
공개 참조 횟수 세부 사항
Backward Compatibility에서 이 제안의 변경으로 인해 사용자 코드가 망가질 수 있는 경우들을 소개했습니다. 이에 기여하는 사용자의 오해는 대부분 참조 횟수 관련 API의 이름과, 문서가 그 API(그리고 참조 카운팅 전반)를 설명하는 방식에서 비롯되었을 가능성이 큽니다.
이름과 문서를 종합해 보면 다음 질문들에 대한 답을 분명히 알 수 있습니다.
- 사용자는 어떤 동작을 기대합니까?
- 우리는 무엇을 보장합니까?
- 사용자가 받는 참조 횟수 값을 어떻게 해석해야 하는지 우리가 알려 줍니까?
- 사용자가 객체의 참조 횟수를 특정 값으로 설정하게 되는 사용 사례는 무엇입니까?
- 사용자가 자신이 생성하지 않은 객체의 참조 횟수를 설정하고 있습니까?
이 제안의 일부로서, 우리는 사용자가 참조 횟수 동작 중 어느 부분에 의존할 수 있고 어느 부분이 구현 세부 사항으로 간주되는지를 명확하게 이해할 수 있도록 해야 합니다. 구체적으로, 사용자는 기존의 공개 참조 횟수 관련 API를 사용해야 하며, 의미가 있는 참조 횟수 값은 0과 1뿐입니다. (일부 코드는 객체를 안전하게 수정할 수 있다는 표시로 1에 의존합니다.) 그 밖의 모든 값은 “0 또는 1이 아닌 값”으로 간주됩니다.
이 정보는 문서에서 명확히 설명할 것입니다.
기존의 참조 횟수 관련 API를 우리가 사용자에게 기대하게 하려는 바를 반영하도록 수정해야 한다고 주장할 수도 있습니다. 예를 들면 다음과 같습니다.
Py_INCREF()->Py_ACQUIRE_REF()(또는Py_NewRef()만 지원)Py_DECREF()->Py_RELEASE_REF()Py_REFCNT()->Py_HAS_REFS()Py_SET_REFCNT()->Py_RESET_REFS()및Py_SET_NO_REFS()
하지만 이러한 변경은 이 제안에 포함되지 않습니다. 이 변경에 도움이 될 수 있도록 사용자 기대치를 더 좁게 한정하는 방향을 보여 주기 위해 여기에 포함했습니다.
제약 조건
- 그 외에는 불변인 객체가 진정으로 불변일 수 있도록 보장합니다.
- 일반적인 Python 사용 사례에서 성능 저하를 최소화합니다.
- 실제로 런타임 종료 처리 시점까지 유지될 것으로 예상하지 않는 객체를 불멸화할 때는 주의하십시오.
- 그 외에도 불변이 아닌 객체를 불멸화할 때는 주의해야 합니다.
__del__과 약한 참조(weakref)는 계속 올바르게 동작해야 합니다.
“진정으로” 불변인 객체와 관련하여, 이 PEP는 객체별 런타임 상태(예: 참조 횟수)를 제외하면 어떤 객체의 실질적인 불변성에도 영향을 주지 않습니다. 따라서 어떤 불멸 객체가 진정으로(또는 실질적으로라도) 불변인지 여부는 이 제안과 별개로만 결정될 수 있습니다. 예를 들어, str 객체는 일반적으로 불변으로 간주되지만, PyUnicodeObject는 지연 캐시되는 데이터를 일부 보유합니다. 이 PEP는 그 상태가 str의 불변성에 어떤 영향을 미치는지에 아무런 영향을 주지 않습니다.
불멸 가변 객체
어떤 객체든 불멸로 표시할 수 있습니다. 저희는 어떠한 제한이나 검사도 제안하지 않습니다. 하지만 실제로 객체를 불멸로 만드는 것의 가치는 그 객체의 가변성과 관련이 있으며, 그 객체가 애플리케이션 수명의 충분한 부분 동안 사용될 가능성에 달려 있습니다. 상황에 따라서는 가변 객체를 불멸로 표시하는 것이 타당할 수 있습니다.
불멸 객체의 사용 사례 중 상당수는 불변성을 중심으로 하며, 이를 통해 스레드가 잠금 없이 그러한 객체를 안전하고 효율적으로 공유할 수 있습니다. 이러한 이유로 dict나 list 같은 가변 객체는 결코 공유되지 않을 것입니다(따라서 불멸성도 없습니다). 하지만 일반적으로 가변인 객체가 실제로는 수정되지 않는다는 충분한 보장이 있다면 불멸성이 적절할 수 있습니다.
한편, 일부 가변 객체는 스레드 간에 결코 공유되지 않습니다(적어도 GIL 같은 잠금 없이는 공유되지 않습니다). 경우에 따라 그중 일부도 불멸로 만드는 것이 실용적일 수 있습니다. 예를 들어, sys.modules는 인터프리터별 dict로, 해당 인터프리터가 종료될 때까지 해제되리라고 전혀 예상하지 않는 객체입니다(교체되지 않는다고 가정할 때). 이를 불멸로 만들면 incref/decref 중에 발생하는 추가 오버헤드를 더 이상 부담하지 않게 됩니다.
이 아이디어는 아래의 mitigations 절에서 더 자세히 살펴봅니다.
암묵적으로 불멸인 객체
불멸 객체가 일반(필멸) 객체에 대한 참조를 보유하고 있다면, 그 참조되는 객체는 실질적으로 불멸입니다. 이는 불멸 객체가 그 객체를 해제할 때까지 그 객체의 참조 횟수가 결코 0에 도달할 수 없기 때문입니다.
예시:
dict및list와 같은 컨테이너tp_subclasses와tp_weaklist를 가진PyTypeObject처럼 내부적으로 참조를 보유하는 객체- 객체의 타입(
ob_type에 보유됨)
따라서 이렇게 보유된 객체는 보유되는 동안 암묵적으로 불멸입니다. 실제로는 동작이 바뀌는 것이 아니므로 실질적인 영향은 없을 것입니다. 유일한 차이는 (참조를 보유한) 불멸 객체가 결코 정리되지 않는다는 점입니다.
이러한 암묵적 불멸 객체를 어떤 식으로든 변경하자고 제안하지는 않습니다. 불멸 객체가 보유하고 있다는 이유만으로 이들을 명시적으로 불멸로 표시해서는 안 됩니다. 그렇게 해도 아무것도 하지 않는 것보다 나은 점이 없습니다.
객체의 불멸 해제
이 제안에는 불멸 객체를 가져와 “정상” 상태로 되돌리는 메커니즘이 포함되어 있지 않습니다. 현재로서는 그러한 기능이 필요하지 않습니다.
게다가 가장 확실한 방법은 참조 횟수를 단순히 작은 값으로 설정하는 것입니다. 하지만 그 시점에서는 어떤 값이 안전한지 알 방법이 없습니다. 이상적으로는 불멸이 되지 않았다면 가졌을 값으로 설정해야 합니다. 하지만 그 값은 이미 오래전에 사라졌을 것입니다. 따라서 그에 따르는 복잡성 때문에, 설령 그렇게 할 타당한 이유가 있더라도 객체의 불멸을 안전하게 해제할 수 있을 가능성은 낮습니다.
_Py_IMMORTAL_REFCNT
두 개의 내부 상수를 추가합니다:
_Py_IMMORTAL_BIT - has the top-most available bit set (e.g. 2^62)
_Py_IMMORTAL_REFCNT - has the two top-most available bits set
실제 최상위 비트는 부호 비트나 일부 GC 용도처럼 참조 횟수 비트의 기존 용도에 따라 달라집니다. 기존 용도를 고려한 후 가능한 가장 높은 비트를 사용합니다.
불멸 객체의 참조 횟수는 _Py_IMMORTAL_REFCNT로 설정됩니다(즉, 그 값은 _Py_IMMORTAL_BIT와 그다음 상위 비트 값의 중간이 됩니다). 하지만 객체가 불멸인지 확인할 때는 참조 횟수를 _Py_IMMORTAL_BIT와만 비교(비트 AND)합니다.
이 차이 덕분에 어떤 이유로 참조 횟수가 수정되더라도(예를 들어 오래된 안정 ABI 확장에 의해) 불멸 객체는 여전히 불멸로 간주됩니다.
참조 횟수의 최상위 두 비트는 이미 다른 용도로 예약되어 있다는 점에 유의하십시오. 그래서 최상위에서 세 번째 비트를 사용합니다.
구현에서는 부호 비트나 2^31(64비트에서 포화된 참조 횟수용) 같은 다른 값을 불멸 비트로 사용하는 것도 열어 두고 있습니다.
영향을 받는 API
이제 불멸 객체를 무시하게 될 API:
- (공개)
Py_INCREF() - (공개)
Py_DECREF() - (공개)
Py_SET_REFCNT() - (비공개)
_Py_NewReference()
참조 횟수를 노출하는 API(변경되지 않지만 이제 큰 값을 반환할 수 있습니다):
- (공개)
Py_REFCNT() - (공개)
sys.getrefcount()
(_Py_RefTotal, 그리고 그에 따라 sys.gettotalrefcount()는 영향을 받지 않는다는 점에 유의하십시오.)
TODO: _Py_RefTotal의 상태를 명확히 해야 합니다.
또한 불멸 객체는 GC에 참여하지 않습니다.
불멸 전역 객체
모든 런타임 전역(내장) 객체는 불멸 객체가 됩니다. 여기에는 다음이 포함됩니다.
- 싱글턴(
None,True,False,Ellipsis,NotImplemented) - 모든 정적 타입(예:
PyLong_Type,PyExc_Exception) _PyRuntimeState.global_objects에 있는 모든 정적 객체(예: 식별자, 작은 정수)
객체 전체를 실제로 불변으로 만드는 문제(예: 인터프리터별 GIL을 위한 것)는 이 PEP의 범위에 포함되지 않습니다.
객체 정리
런타임 종료 처리 중에 모든 불멸 객체를 정리하려면 이들을 추적해야 합니다.
GC 객체(“컨테이너”)의 경우, 불멸화된 모든 컨테이너를 GC의 영구 세대(permanent generation)에 넣는 방식으로 영구 세대를 활용합니다. 런타임 종료 시에는 먼저 런타임이 이러한 인스턴스를 정상적으로 할당 해제하도록 최선을 다하게 하는 전략을 취합니다. 이제 대부분의 모듈 할당 해제는 pylifecycle.c:finalize_modules()에서 처리되며, 여기서 남은 모듈을 최대한 정리합니다. 이로 인해 __del__ 실행 중에 사용할 수 있는 모듈이 달라지지만, 이는 이미 문서에서 명시적으로 정의되지 않은 동작으로 규정되어 있습니다. 선택적으로, 사용자 모듈이 표준 라이브러리 모듈보다 먼저 할당 해제되도록 보장하기 위해 위상 정렬을 수행할 수도 있습니다. 마지막으로, 남은 것이 있다면 영구 세대 GC 리스트를 통해 찾을 수 있으며, finalize_modules()가 완료된 후 이 리스트를 비울 수 있습니다.
컨테이너가 아닌 객체의 경우, 추적 방식은 사례별로 달라집니다. 거의 모든 경우에 이러한 객체는 각각 런타임 상태에서, 예를 들어 _PyRuntimeState 또는 PyInterpreterState 필드에서 직접 접근할 수 있습니다. 소수의 객체에 대해서는 런타임 상태에 추적 메커니즘을 추가해야 할 수도 있습니다.
이러한 정리 작업은 성능에 큰 영향을 미치지 않습니다.
성능 저하 완화 방안
명확성을 위해, 불멸 객체를 단순하게 구현했을 때 잃는 4% 성능 중 일부를 되찾기 위해 시도할 몇 가지 방법을 소개합니다.
이 절의 내용은 실제로는 제안의 일부가 아니라는 점에 유의하십시오.
런타임 초기화가 끝날 때 모든 객체를 불멸로 표시
불멸 객체를 단순하게 구현했을 때 잃는 4% 성능 중 일부를 되찾기 위해 Immortal Mutable Objects의 개념을 적용할 수 있습니다. 런타임 초기화가 끝날 때 모든 객체를 불멸로 표시하여 incref/decref의 추가 비용을 피할 수 있습니다. 불변성은 GIL 없이 스레드 간에 공유할 계획인 객체에 대해서만 신경 쓰면 됩니다.
불필요하게 하드코딩된 참조 횟수 연산을 제거합니다
C-API의 일부는 Py_RETURN_NONE처럼 불멸임을 알고 있는 객체를 특정하여 다룹니다. 이러한 함수와 매크로는 참조 횟수 연산을 모두 제거하도록 갱신할 수 있습니다.
평가 루프에서 불멸 객체에 대한 특수화
평가 루프에는 이미 알려진 특정 불멸 객체(예: None)와 관련된 연산을 최적화할 여지가 있습니다. 일반적인 메커니즘은 PEP 659에 설명되어 있습니다. Pyston도 참고하십시오.
그 밖의 가능성
- 인턴된 모든 문자열을 불멸로 표시
- “interned” 딕셔너리가 공유된다면 이를 불멸로 표시하고, 그렇지 않다면 인턴된 모든 문자열을 공유
- (Larry,MAL) 모듈에 대해 언마셜된 모든 상수를 불멸로 표시
- (Larry,MAL) (불변) 불멸 객체를 전용 메모리 페이지에 할당
- 최하위 32비트를 사용하는 포화 참조 횟수
우발적 불멸 해제에 대한 해결책
Accidental De-Immortalizing 절에서 불멸 객체가 초래할 수 있는 부정적 결과를 개략적으로 설명했습니다. 여기서는 이를 처리하기 위한 몇 가지 방안을 살펴봅니다.
여기서 해결책을 열거하는 것은 만족스러운 방안이 있음을 보여 주기 위해서이지, 문제를 어떻게 해결할지 정하기 위해서가 아니라는 점에 유의하십시오.
다음 사항에도 유의하십시오.
- 이는 32비트 안정 ABI의 경우에만 문제가 됩니다
- 불멸 객체에만 영향을 미칩니다
- 사용자 정의 불멸 객체는 없으며, 내장 타입만 있습니다
- 대부분의 불멸 객체는 정적으로 할당됩니다(따라서
tp_dealloc()이 호출되면 이미 실패해야 합니다) - 실제로 이 문제를 겪을 만큼 자주 사용되는 불멸 객체는 극소수뿐입니다(예:
None) - 해결해야 할 주된 문제는
tp_dealloc()에서 비롯되는 크래시입니다
해결책을 위한 근본적인 관찰 중 하나는 어떤 조건이 충족되면 불멸 객체의 참조 횟수를 _Py_IMMORTAL_REFCNT로 재설정할 수 있다는 점입니다.
이 모든 점을 고려하면, 간단하면서도 효과적인 해결책은 tp_dealloc()에서 불멸 객체의 참조 횟수를 재설정하는 것입니다. NoneType과 bool에는 이미 호출되면 Py_FatalError()를 호출하는 tp_dealloc()이 있습니다. PyUnicodeObject(unicode_is_singleton()에 따라), PyTupleObject, PyTypeObject처럼 특정 조건에 따르는 다른 타입도 마찬가지입니다. 사실, 정적으로 선언된 모든 객체에 대해 같은 검사가 중요합니다. 이러한 타입에서는 대신 참조 횟수를 재설정할 것입니다. 나머지 경우에는 검사를 도입합니다. 어느 경우든 tp_dealloc()에서 수행하는 검사의 오버헤드는 무시할 수 있을 만큼 작을 것입니다.
그 밖의 (덜 실용적인) 해결책:
- 불멸 객체의 참조 횟수를 주기적으로 재설정합니다.
- 사용 빈도가 높은 객체에 대해서만 그렇게 합니다.
- 안정 ABI 확장이 임포트된 경우에만 그렇게 합니다.
- 불멸성을 비활성화하는 런타임 플래그를 제공합니다.
(자세한 내용은 토론 스레드에 있습니다.)
최종적으로 어떤 해결책을 택하든, 필요하다면 나중에 다른 방법을 적용할 수 있습니다.
TODO: 구현된 해결책이 전반적인 ~성능 중립~ 결과에 영향을 주지 않는다는 점을 알리는 메모를 추가하십시오.
문서
불멸 객체의 동작과 API는 내부 구현 세부 사항이므로 문서에 추가하지 않습니다.
다만, 참조 횟수 동작에 대한 공개적인 보장을 더 명확히 하도록 문서를 갱신할 것입니다. 구체적으로는 다음과 같습니다:
Py_INCREF()- “객체 o의 참조 횟수를 증가시킵니다.”를 “객체 o에 대한 새 참조를 얻었음을 나타냅니다.”로 변경합니다.Py_DECREF()- “객체 o의 참조 횟수를 감소시킵니다.”를 “이전에 얻은 객체 o에 대한 참조를 더 이상 사용하지 않음을 나타냅니다.”로 변경합니다.Py_XINCREF(),Py_XDECREF(),Py_NewRef(),Py_XNewRef(),Py_Clear()에도 마찬가지로 적용합니다.Py_REFCNT()- “참조 횟수 0과 1은 특정한 의미를 가지며, 그 밖의 모든 값은 값에 관계없이 어딘가의 코드가 해당 객체를 사용하고 있다는 뜻일 뿐입니다. 0은 객체가 사용되지 않으며 정리될 것임을 뜻합니다. 1은 코드가 정확히 하나의 참조를 가지고 있음을 뜻합니다.”를 추가합니다.Py_SET_REFCNT()- 1보다 큰 값이 다른 값으로 대체될 수 있다는 점에 관해서는Py_REFCNT()를 참조하도록 안내합니다.
또한 이 변경으로 사용자가 겪을 수 있는 당혹감을 줄이기 위해, 다음 항목에 불멸 객체에 관한 메모를 추가할 수도 있습니다:
Py_SET_REFCNT()(불멸 객체에 대해서는 아무 동작도 하지 않습니다)Py_REFCNT()(값이 예상외로 클 수 있습니다)sys.getrefcount()(값이 예상외로 클 수 있습니다)
이러한 메모가 도움이 될 수 있는 다른 API는 현재 문서화되어 있지 않습니다. 이 기능은 그 외에는 사용자에게 투명하므로, 다른 곳에는 (Py_INCREF()와 Py_DECREF()를 포함하여) 이러한 메모를 추가하지 않을 것입니다.
참조 구현
구현은 GitHub에 제안되어 있습니다:
미해결 문제
- Accidental De-Immortalizing 우려는 얼마나 현실적입니까?
참고 문헌
선행 사례
논의
이 내용은 2021년 12월 python-dev에서 논의되었습니다:
런타임 객체 상태
다음은 CPython 런타임이 각 Python 객체마다 유지하는 내부 상태입니다:
- PyObject.ob_refcnt: 객체의 참조 카운트
- _PyGC_Head: (선택 사항) “GC” 객체 리스트 안에서 해당 객체가 차지하는 노드
- _PyObject_HEAD_EXTRA: (선택 사항) 힙 객체 리스트 안에서 해당 객체가 차지하는 노드
ob_refcnt는 모든 객체에 할당되는 메모리의 일부입니다. 그러나 _PyObject_HEAD_EXTRA는 CPython이 Py_TRACE_REFS를 정의한 상태로 빌드된 경우에만 할당됩니다. PyGC_Head는 객체의 타입에 Py_TPFLAGS_HAVE_GC가 설정된 경우에만 할당됩니다. 일반적으로 여기에 해당하는 것은 컨테이너 타입(예: list)뿐입니다. 또한 PyObject.ob_refcnt와 _PyObject_HEAD_EXTRA는 PyObject_HEAD의 일부라는 점에도 유의하십시오.
참조 카운팅과 순환 가비지 컬렉션
가비지 컬렉션은 일부 프로그래밍 언어가 제공하는 메모리 관리 기능입니다. 이는 객체가 더 이상 사용되지 않으면 정리된다(예: 메모리 해제)는 뜻입니다.
참조 카운팅은 가비지 컬렉션을 구현하는 한 가지 방식입니다. 언어 런타임은 객체에 대한 참조가 몇 개 유지되고 있는지 추적합니다. 코드가 객체에 대한 참조의 소유권을 가져오거나 해제하면 런타임은 이를 통보받고 그에 따라 참조 횟수를 증가시키거나 감소시킵니다. 참조 횟수가 0에 도달하면 런타임은 해당 객체를 정리합니다.
CPython에서는 코드가 C-API의 Py_INCREF()와 Py_DECREF()를 사용하여 참조를 명시적으로 얻거나 해제해야 합니다. 이 매크로들은 객체의 참조 횟수를 직접 수정합니다(가비지 컬렉션 방식을 변경하려 할 때 ABI 호환성 문제를 일으키므로 안타까운 일입니다). 또한 CPython에서 객체가 정리될 때, 그 객체는 (메모리가 해제되기 전에) 자신이 소유한 모든 참조(및 자원)도 해제합니다.
때로는 객체들이 참조 순환에 얽힐 수 있습니다. 예를 들어 객체 A가 객체 B에 대한 참조를 가지고 있고, 객체 B가 객체 A에 대한 참조를 가지고 있는 경우입니다. 그 결과, 다른 참조가 전혀 없더라도 두 객체 모두 결코 정리되지 않습니다(즉, 메모리 누수). 순환에 가장 흔히 얽히는 객체는 컨테이너입니다.
CPython에는 참조 순환을 처리하기 위한 전용 장치가 있으며, 이를 “순환 가비지 컬렉터”, 또는 흔히 줄여서 “가비지 컬렉터”나 “GC”라고 부릅니다. 이름 때문에 혼동하지 마십시오. 이 장치는 참조 순환을 끊는 일만 담당합니다.
참조 카운팅과 순환 가비지 컬렉션에 대한 더 자세한 설명은 다음 문서를 참고하십시오:
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.