부록: 구현
객체 상태
객체의 상태와 객체를 소유하는 ThreadGroup 또는 객체를 보호하는 뮤텍스의 ID를 기록하려면 객체 헤더에 공간이 필요합니다. 상태는 단일 바이트로 인코딩할 수 있습니다. ID는 모든 ThreadGroup ID와 뮤텍스 ID를 다룰 수 있어야 하므로 16비트로는 충분하지 않을 가능성이 높습니다. 32비트면 충분합니다.
이러한 필드를 추가하면 PyObject 헤더는 현재 PEP 703에 구현된 것보다는 작지만, 기본(GIL 사용) 빌드보다는 클 것입니다.
가능한 객체 헤더는 다음과 같습니다:
uint32_t owner_id;
uint32_t ref_count_shared;
PyTypeObject *ob_type;
uint8_t ref_count_local; // For biased reference counting
uint8_t state;
uint16_t flags;
uint32_t gc_info; // Additional info for the cycle GC
참조 카운팅
저자는 PEP 703의 편향 참조 카운팅(biased reference counting) 메커니즘이 사용될 것으로 예상합니다. PEP 703과 마찬가지로, 경합을 최소화하기 위해 필요한 곳에서는 스레드별 참조 카운팅과 지연 참조 카운팅도 사용합니다.
객체 상태 검사
CPython은 스택 머신입니다. 즉, 스레드가 객체에 대한 참조를 얻으려면 그 객체는 힙이나 API 호출에서 와서 스택에 푸시되어야 합니다. C 확장이 보아서는 안 되는 객체를 보지 못하도록 하려면 모든 C API 함수가 반환값을 검증해야 합니다. 또한 인터프리터는 힙에서 직접 가져온 모든 값을 스택에 푸시하기 전에 검사해야 합니다.
이렇게 하면 새로운 검사가 많이 추가될 수 있으므로, 성능에 큰 영향을 주지 않으려면 이러한 검사의 비용을 낮게 유지해야 합니다. 이는 다음과 같은 방법으로 달성할 수 있습니다:
- 검사를 저렴하게 만듭니다. 검사는 메모리 접근을 최소화하면서 한두 번의 간단한 비교만으로 이루어져야 합니다.
- 바이트코드 컴파일러와 JIT 컴파일러 모두에서 정적 분석을 통해 가능한 한 많은 검사를 제거합니다.
특수화를 통해 모든 유효한 상태를 검사하는 대신 가장 가능성이 높은 상태에 대해 한 번의 검사만 수행할 수 있습니다. 로컬 객체가 예상되는 경우에는 객체의 스레드 ID를 현재 ThreadGroup ID와 비교하기만 하면 됩니다. 반대로 불변 객체가 예상되는 경우에는 객체가 불변인지만 검사하면 됩니다.
JIT 컴파일러는 동일한 객체에 대한 중복 검사를 제거할 수도 있습니다.
접근 제어 함수
로컬 객체가 가장 흔할 것으로 가정하므로, 스레드 상태를 사용할 수 있는 경우에는 이를 먼저 검사합니다:
PyObject *PyObject_CheckAccessThread(PyObject *op, PyThread t)
{
PyThreadState *tstate = PyThreadStateFromThread(t);
if (op->owner_id == tstate->threadgroup_id) {
return op;
}
if (op->state >= SYNCHRONIZED) {
return op;
}
// Check for protected and stop the world cases...
}
반면 스레드를 그만큼 저렴하게 얻을 수 없는 경우에는 공유 가능한 경우를 먼저 검사합니다:
PyObject *PyObject_CheckAccess(PyObject *op)
{
if (op->state >= SYNCHRONIZED) {
return op;
}
PyThreadState *tstate = PyThreadState_GET();
if (op->owner_id == tstate->threadgroup_id) {
return op;
}
// Check for protected and stop the world cases...
}
다른 잠금을 이미 보유한 상태에서 여러 잠금을 획득하면 교착 상태에 빠지기가 너무 쉬우므로 그런 경우는 드물 것으로 보입니다. 따라서 보유 중인 뮤텍스 집합은 작을 것이며 LIFO 배열(스택)로 구현할 수 있습니다. 일반적으로 객체에 해당하는 뮤텍스는 첫 번째나 두 번째 항목에 있으므로 검사 비용은 저렴할 것입니다.
C API
예를 들어, 다음과 같은 가상의 API 함수를 생각해 봅시다: PyObject *PyObject_Foo(PyObject *op).
PyObject_Foo가 접근 제어를 지원하도록 바꾸려면, 먼저 현재 구현의 이름을 PyObject_FooUnchecked로 바꾼 다음 PyObject_Foo를 다음과 같이 구현합니다:
PyObject *
PyObject_Foo(PyObject *op)
{
PyObject *result = PyObject_FooUnchecked(op);
return _PyObject_CheckAccessNullable(result);
}
여기서 _PyObject_CheckAccessNullable은 접근 제어 검사를 제공하는 내부 함수입니다. 객체 참조가 NULL이 아님이 알려진 경우를 위해 _PyObject_CheckAccess 변형이 제공될 것입니다.
이 기계적인 변환은 코드베이스에 몇 가지 비효율을 남길 가능성이 높으므로, 나중에 다시 최적화하는 추가 작업이 필요할 것입니다.
모든 API 함수는 현재 스레드를 기준으로 검사해야 하므로, 호출할 때마다 스레드 참조를 가져오는 오버헤드를 줄이기 위해 스레드에 대한 참조를 받는 새로운 API가 추가될 것입니다. 예를 들어 PyObject_GetAttr에는 PyObject_GetAttrThread 변형이 추가될 것입니다.:
PyObject *PyObject_GetAttrThread(PyObject *v, PyObject *name, PyThread t);
스레드 포인터를 받는 _PyObject_CheckAccess 변형들이 추가될 것입니다.
많은 API 함수는 수정할 필요가 없습니다. 예를 들어 PyObject_Str은 항상 불변인 str을 반환하므로 추가적인 접근 검사가 필요하지 않습니다. PyObject_SetItem은 객체를 반환하지 않으므로 추가 검사가 필요하지 않습니다.
인터프리터
힙에서 값을 로드하는 모든 코드에는 접근 제어가 필요합니다. 또한 일부 지역 변수 로드에도 검사가 필요합니다.
지역 변수 접근을 느리게 만들고 싶지 않으므로, 필요한 곳에만 검사를 삽입하는 일은 바이트코드 컴파일러에 맡기고, 필요한 경우 LOAD_FAST 대신 LOAD_FAST_MAYBE_UNPROTECTED 명령어를 추가하도록 할 것입니다.
힙이나 C API에서 온 객체를 참조하는 참조를 스택에 푸시하는 명령어에는 검사를 추가해야 합니다. 이는 명령어 끝에 검사를 추가하는 것만큼 간단할 수 있으며, 마이크로 연산을 사용하면 추가 마이크로 연산 하나를 더하는 것만큼 간단할 수 있습니다. 예를 들면 다음과 같습니다.:
macro(LOAD_ATTR_MODULE) =
unused/1 +
_LOAD_ATTR_MODULE +
POP_TOP +
unused/5 +
_PUSH_NULL_CONDITIONAL;
이것이 다음과 같이 바뀝니다.:
macro(LOAD_ATTR_MODULE) =
unused/1 +
_LOAD_ATTR_MODULE +
POP_TOP +
TOS_ACCESS_CHECK +
unused/5 +
_PUSH_NULL_CONDITIONAL;
바이트코드 컴파일러
평가 스택의 모든 값은 안전하게 접근할 수 있어야 하고, 지역 변수에 값을 저장하는 유일한 방법은 평가 스택을 통하는 것이므로, 모든 지역 변수 접근이 안전한 것처럼 보일 수 있습니다. 그러나 꼭 그렇지는 않습니다. with 문 안에서 지역 변수에 값이 저장되면, 그 값은 with 문 밖에서 보호되지 않을 수 있습니다.
모든 지역 변수 읽기를 느리게 만들고 싶지 않으므로, 필요한 곳에 추가 검사를 삽입하기 위해 약간의 정적 분석을 수행해야 합니다. 이미 필요한 곳에서만 LOAD_FAST_CHECK를 사용하기 위해 이러한 검사를 수행하고 있으며, 여기서의 접근 방식도 매우 비슷합니다.
알고리즘은 다음과 같이 동작합니다:
with문 안에서 대입된 모든 지역 변수를 “보호되지 않음”으로 표시합니다- 데이터 흐름을 사용하여 이것이
LOAD_FAST로 흘러가는 곳을 찾아냅니다 - “보호되지 않은”
LOAD_FAST를 모두LOAD_FAST_MAYBE_UNPROTECTED로 교체합니다 LOAD_FAST_MAYBE_UNPROTECTED는 모두 해당 지역 변수를 다시 보호됨으로 표시합니다
with 문이 쓰이는 빈도와 LOAD_FAST를 LOAD_FAST_BORROW로 변환하는 것의 효과를 바탕으로 추정하면, LOAD_FAST_MAYBE_UNPROTECTED로 남는 LOAD_FAST의 수는 극히 적을 것입니다.
동기화된 컬렉션, 동결된 컬렉션, 로컬 컬렉션
기존 컬렉션과 매우 비슷한 새 클래스 서너 개를 추가하고, 기존 컬렉션 클래스의 코드를 수정합니다. 이 작업을 올바르게, 그리고 새 코드를 많이 추가하지 않고 수행하고자 합니다.
set을 예로 들어 보겠습니다(dict와 list도 비슷합니다). 기존 메서드에 접근 제어를 추가하고, 새 클래스인 SynchronizedSet을 추가해야 합니다.
- 세 클래스 모두 같은 레이아웃과, 그 레이아웃을 기술하는 같은 C 구조체를 사용해야 합니다.
- 변경하지 않는 메서드는 동기화 없이 접근 제어만 추가한 핵심 함수로 분리해야 합니다.
frozenset은 그 구현을 그대로 사용할 수 있습니다SynchronizedSet은 함수를 호출하기 전에 내부 뮤텍스를 획득하고, 호출한 뒤에는 이를 해제해야 합니다set은 지역적이므로 베이스 구현을 그대로 사용할 수도 있습니다
- 변경하는 메서드도 동기화 없이 접근 제어만 추가한 핵심 함수로 분리해야 합니다.
frozenset에는 변경 메서드의 구현이 없습니다SynchronizedSet은 함수를 호출하기 전에 뮤텍스를 획득하고, 호출한 뒤에는 이를 해제해야 합니다set은 지역적이므로 베이스 구현을 그대로 사용할 수 있습니다
- 다른 동기화된 객체를 인자로 받는
SynchronizedSet메서드는 교착 상태를 피하기 위해 내부 뮤텍스를 올바른 순서로 획득하도록 보장해야 합니다.
최적화
로컬 객체에 기존 최적화 재사용하기
로컬 객체는 하나의 ThreadGroup에서만 접근할 수 있으므로, 현재의 모든 최적화를 변경 없이 적용할 수 있습니다.
세계 정지를 통한 (거의) 불변성
함수와 같은 일부 객체는 하위 호환성을 위해 동기화되어 있지만, 변경되는 경우는 드뭅니다.
이러한 객체는 GIL 사용 빌드에 이미 구현된 것과 동일한 최적화를 JIT에서 적용하여 최적화할 수 있으며, 다만 세계 정지(stop-the-world) 잠금을 사용합니다. 이러한 객체 중 하나라도 변경되면, 다른 모든 스레드가 협력적으로 정지됩니다. 스레드가 정지되고 나면 변경이 일어납니다. 다른 스레드는 세계 정지 이벤트를 잠재적 탈출(escape)로 간주하므로, 해당 변경에 대비한 가드가 적용됩니다.
불변 객체를 위한 가드 없는 최적화
이미 일부 최적화에서 불변성을 활용하고 있지만, 이는 임시방편적인 방식으로 이루어지고 있습니다. 불변성이 VM에서 강제되는 속성이 되면, 알려진 불변성을 활용하여 JIT에서 더 많은 가드를 제거할 수 있습니다.
구현 전략
이 PEP를 구현할 때의 가장 큰 과제는 이 PEP의 기능을 추가하면서도 CPython의 기본 빌드가 계속 동작하도록 유지하는 것입니다. 추가해야 할 두 가지 핵심 기능은 ThreadGroups와 객체 소유권입니다. 둘 중 하나라도 없으면 어느 쪽도 쓸모가 없습니다. 소유권을 구현하려면 앞서 논의한 ABI 호환성 파괴가 필요합니다.
이를 염두에 두면, 가능한 구현 순서는 다음과 같습니다:
- ThreadGroups
- 일회성 ABI 호환성 파괴
- 자유 스레딩 빌드에서 편향 참조 카운팅과 지연 참조 카운팅 이식
- 단순 소유권. 로컬 및 불변 객체만
- 병렬 할당 및 순환 가비지 컬렉션 지원
__freeze__- 동기화된 객체
- 바이트코드 컴파일러 지원을 포함한 보호된 객체 상태
TransferBox및Channelsys.monitoring.StopTheWorld- 성능 작업
검증
정확성과 성능을 모두 확보하기 위해, 이 PEP는 건전하면서도 최적화할 수 있는 실행 모델을 제공합니다. JIT나 인터프리터의 최적화 과정에서도 이러한 건전성이 유지되는지 검증하기 위해, 디버그 빌드에서는 인터프리터가 스택에 참조를 푸시하는 모든 지점에 검증을 추가할 것입니다.