PEP 556 – 스레드 기반 가비지 컬렉션
- Author:
- Antoine Pitrou <solipsis at pitrou.net>
- Status:
- Deferred
- Type:
- Standards Track
- Created:
- 08-Sep-2017
- Python-Version:
- 3.7
- Post-History:
- 08-Sep-2017
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
보류 공지
이 PEP는 현재 활발하게 작업이 진행되고 있지 않습니다. 향후 다시 추진될 수 있습니다. 주요 미완료 단계는 다음과 같습니다:
- 구현을 다듬고, 필요한 경우 테스트 스위트를 조정합니다;
- 스레드 기반 가비지 컬렉션을 설정하더라도 기존 코드가 예기치 않은 방식으로 망가지지 않도록 보장합니다(예상되는 영향으로는 참조 순환에 속한 객체의 수명이 길어지는 것이 있습니다).
초록
이 PEP는 CPython의 순환 가비지 컬렉터(GC)를 위한 새로운 선택적 동작 모드를 제안합니다. 이 모드에서는 암시적(즉, 기회주의적) 수집이 동기적으로 수행되는 대신 전용 스레드에서 수행됩니다.
용어
“암시적” GC 실행(또는 “암시적” 수집)이란 새로운 할당이 요청될 때마다 할당 통계를 바탕으로 계산된 특정 휴리스틱에 따라 기회주의적으로 촉발되는 실행을 말합니다. 이 PEP는 해당 휴리스틱을 변경하자고 제안하지 않으므로, 휴리스틱의 세부 사항은 이 PEP와 관련이 없습니다.
“명시적” GC 실행(또는 “명시적” 수집)이란 gc.collect와 같은 API 호출을 통해 프로그램에서 요청하는 실행을 말합니다.
“스레드 기반”이란 GC 실행이 애플리케이션 코드의 순차적 실행과 분리된 전용 스레드에서 일어난다는 사실을 가리킵니다. 이는 “동시적”이라는 의미도 아니고(전역 인터프리터 잠금, 즉 GIL은 여전히 전용 GC 스레드를 포함한 Python 스레드들 사이의 실행을 직렬화합니다), “병렬적”이라는 의미도 아닙니다(GC는 GC 실행의 실제 경과 시간 지연을 줄이기 위해 작업을 여러 스레드에 동시에 분산할 수 없습니다).
근거
GC의 동작 모드는 언제나 암시적 수집을 동기적으로 수행하는 것이었습니다. 즉, 앞서 언급한 휴리스틱이 활성화될 때마다 현재 스레드에서 애플리케이션 코드의 실행이 일시 중단되고, 죽은 참조 순환을 회수하기 위해 GC가 실행됩니다.
하지만 여기에는 함정이 있습니다. 죽은 참조 순환(및 그 순환에 매달린 모든 부수적인 객체)을 회수하는 과정에서, GC는 __del__ 메서드와 weakref 콜백 형태로 된 임의의 종료 처리 코드를 실행할 수 있습니다. 수년에 걸쳐 Python은 점점 더 정교한 용도로 사용되어 왔으며, 종료 처리 코드가 복잡한 작업을 수행하는 경우가 점점 더 흔해지고 있습니다. 예를 들어 분산 시스템에서는 객체가 사라지면 다른 (논리적 또는 물리적) 노드에 이를 알려야 할 수도 있습니다.
일관된 내부 상태 및/또는 동기화 기본 요소의 획득에 의존할 수 있는 종료 처리 코드를 실행하기 위해 애플리케이션 코드를 임의의 지점에서 중단하면 재진입 문제가 발생하며, 이러한 문제는 가장 노련한 전문가조차 제대로 고치는 데 어려움을 겪습니다 [1].
이 PEP는 겉보기에는 비슷해 보이지만 동일 스레드 재진입이 다중 스레드 동기화보다 근본적으로 더 어려운 문제라는 관찰에 기반합니다. 이 PEP는 각 개발자나 라이브러리 작성자가 극도로 어려운 재진입 문제와 하나하나 씨름하도록 내버려 두는 대신, 잘 알려진 다중 스레드 동기화 기법만으로 충분한 별도의 스레드에서 GC가 실행될 수 있도록 할 것을 제안합니다.
제안
이 PEP에 따르면 GC는 두 가지 동작 모드를 가집니다:
- “serial”은 기본이자 기존 모드로, (앞서 언급한 할당 휴리스틱에 따라) 암시적 실행이 필요하다고 감지한 스레드에서 암시적 GC 실행이 즉시 수행됩니다.
- “threaded”는 런타임에 프로세스 단위로 명시적으로 활성화할 수 있는 모드로, 할당 휴리스틱이 촉발될 때마다 암시적 GC 실행이 예약되지만, 실제 실행은 전용 백그라운드 스레드에서 이루어집니다.
“serial” 모드에서 종료 처리 콜백을 정교하게 사용할 때 골칫거리가 되는 어려운 재진입 문제는 “threaded” 동작 모드에서 비교적 쉬운 다중 스레드 동기화 문제가 됩니다.
GC는 또한 전통적으로 Python API gc.collect와 C API PyGC_Collect를 사용한 명시적 GC 실행을 허용합니다. 이 두 API의 겉으로 드러나는 의미는 변경되지 않습니다: 호출되면 즉시 GC 실행을 수행하며, GC 실행이 끝난 후에만 반환합니다.
새로운 공개 API
gc 모듈에 새로운 Python API 두 개가 추가됩니다:
gc.set_mode(mode)는 현재 동작 모드(“serial” 또는 “threaded”)를 설정합니다. “serial”로 설정할 때 현재 모드가 “threaded”이면, 이 함수는 GC 스레드가 종료될 때까지 기다리기도 합니다.gc.get_mode()는 현재 동작 모드를 반환합니다.
동작 모드는 몇 번이든 서로 전환할 수 있습니다.
의도된 용도
이 스위치는 프로세스 단위로 적용되며 모든 종료 처리 콜백의 의미에 영향을 미치므로, 애플리케이션 코드의 시작 부분에서(그리고/또는 multiprocessing을 사용할 때와 같이 자식 프로세스의 초기화 함수에서) 설정할 것을 권장합니다. 라이브러리 함수가 gc.enable이나 gc.disable을 호출하지 않아야 하는 것과 마찬가지로 이 설정도 건드리지 않는 편이 좋겠지만, 그렇게 하는 것을 막는 장치는 없습니다.
목표가 아닌 것
이 PEP는 다른 종류의 비동기 코드 실행(예를 들어 signal 모듈로 등록한 시그널 핸들러)에서 발생하는 재진입 문제는 다루지 않습니다. 저자는 골치 아픈 재진입 문제의 압도적 다수가 종료자에서 발생한다고 생각합니다. 대부분의 경우 시그널 핸들러는 플래그 하나를 설정하거나 파일 디스크립터를 깨워서, 또는 둘 다 해서 메인 프로그램이 알아차리게 할 수 있습니다. 예외를 발생시키는 시그널 핸들러는 반드시 해당 스레드 안에서 실행되어야 합니다.
또한 이 PEP는 종료 처리 콜백이 일반적인 참조 카운팅의 일부로 호출될 때, 즉 보이는 참조를 해제하여 객체의 참조 횟수가 0이 될 때의 콜백 실행 방식도 변경하지 않습니다. 이러한 실행은 코드의 결정적인 지점에서 일어나므로 보통 문제가 되지 않습니다.
내부 세부 사항
TODO: 이 절을 현재 구현에 맞게 갱신하십시오.
gc 모듈
GC가 직렬 방식인지 스레드 방식인지를 나타내는 내부 플래그 gc_is_threaded가 추가됩니다.
두 GC 실행이 동시에 일어나지 않도록 내부 구조체 gc_mutex가 추가됩니다:
static struct {
PyThread_type_lock lock; /* taken when collecting */
PyThreadState *owner; /* whichever thread is currently collecting
(NULL if no collection is taking place) */
} gc_mutex;
GC 스레드와의 동기화를 처리하기 위해 내부 구조체 gc_thread가 추가됩니다:
static struct {
PyThread_type_lock wakeup; /* acts as an event
to wake up the GC thread */
int collection_requested; /* non-zero if collection requested */
PyThread_type_lock done; /* acts as an event signaling
the GC thread has exited */
} gc_thread;
threading 모듈
threading 모듈에 비공개 함수 두 개가 추가됩니다:
threading._ensure_dummy_thread(name)은 주어진 name으로 현재 스레드에 대한Thread인스턴스를 생성하고 등록한 뒤 이를 반환합니다.threading._remove_dummy_thread(thread)는 주어진 thread(_ensure_dummy_thread가 반환한 것)를 threading 모듈의 내부 상태에서 제거합니다.
이 두 함수의 목적은 GC 스레드의 종료 처리 콜백 안에서 threading.current_thread()가 호출될 때 더 의미 있는 이름을 가진 객체를 반환하도록 하여 디버깅과 인트로스펙션을 개선하는 것입니다.
의사 코드
다음은 이 PEP를 구현하는 데 필요한 공개 및 내부 주요 프리미티브에 대해 제안하는 의사 코드입니다. 달리 언급하지 않는 한, 이들은 모두 C로 구현되며 gc 모듈 안에 위치합니다:
def collect_with_callback(generation):
"""
Collect up to the given *generation*.
"""
# Same code as currently (see collect_with_callback() in gcmodule.c)
def collect_generations():
"""
Collect as many generations as desired by the heuristic.
"""
# Same code as currently (see collect_generations() in gcmodule.c)
def lock_and_collect(generation=-1):
"""
Perform a collection with thread safety.
"""
me = PyThreadState_GET()
if gc_mutex.owner == me:
# reentrant GC collection request, bail out
return
Py_BEGIN_ALLOW_THREADS
gc_mutex.lock.acquire()
Py_END_ALLOW_THREADS
gc_mutex.owner = me
try:
if generation >= 0:
return collect_with_callback(generation)
else:
return collect_generations()
finally:
gc_mutex.owner = NULL
gc_mutex.lock.release()
def schedule_gc_request():
"""
Ask the GC thread to run an implicit collection.
"""
assert gc_is_threaded == True
# Note this is extremely fast if a collection is already requested
if gc_thread.collection_requested == False:
gc_thread.collection_requested = True
gc_thread.wakeup.release()
def is_implicit_gc_desired():
"""
Whether an implicit GC run is currently desired based on allocation
stats. Return a generation number, or -1 if none desired.
"""
# Same heuristic as currently (see _PyObject_GC_Alloc in gcmodule.c)
def PyGC_Malloc():
"""
Allocate a GC-enabled object.
"""
# Update allocation statistics (same code as currently, omitted for brevity)
if is_implicit_gc_desired():
if gc_is_threaded:
schedule_gc_request()
else:
lock_and_collect()
# Go ahead with allocation (same code as currently, omitted for brevity)
def gc_thread(interp_state):
"""
Dedicated loop for threaded GC.
"""
# Init Python thread state (omitted, see t_bootstrap in _threadmodule.c)
# Optional: init thread in Python threading module, for better introspection
me = threading._ensure_dummy_thread(name="GC thread")
while gc_is_threaded == True:
Py_BEGIN_ALLOW_THREADS
gc_thread.wakeup.acquire()
Py_END_ALLOW_THREADS
if gc_thread.collection_requested != 0:
gc_thread.collection_requested = 0
lock_and_collect(generation=-1)
threading._remove_dummy_thread(me)
# Signal we're exiting
gc_thread.done.release()
# Free Python thread state (omitted)
def gc.set_mode(mode):
"""
Set current GC mode. This is a process-global setting.
"""
if mode == "threaded":
if not gc_is_threaded == False:
# Launch thread
gc_thread.done.acquire(block=False) # should not fail
gc_is_threaded = True
PyThread_start_new_thread(gc_thread)
elif mode == "serial":
if gc_is_threaded == True:
# Wake up thread, asking it to end
gc_is_threaded = False
gc_thread.wakeup.release()
# Wait for thread exit
Py_BEGIN_ALLOW_THREADS
gc_thread.done.acquire()
Py_END_ALLOW_THREADS
gc_thread.done.release()
else:
raise ValueError("unsupported mode %r" % (mode,))
def gc.get_mode(mode):
"""
Get current GC mode.
"""
return "threaded" if gc_is_threaded else "serial"
def gc.collect(generation=2):
"""
Schedule collection of the given generation and wait for it to
finish.
"""
return lock_and_collect(generation)
논의
기본 모드
기본 모드를 아예 “threaded”로 바꾸면 되지 않느냐고 생각할 수도 있습니다. 멀티스레드 애플리케이션이라면 아마 문제가 되지 않을 것입니다. 그런 애플리케이션은 종료 처리 핸들러가 임의의 스레드에서 실행될 수 있다는 점에 이미 대비하고 있어야 하기 때문입니다. 그러나 단일 스레드 애플리케이션에서는 현재 파이널라이저가 항상 메인 스레드에서 호출된다는 것이 보장됩니다. 이 특성이 깨지면 미묘한 동작 변화나 버그가 생길 수 있습니다. 예를 들어 파이널라이저가 스레드 로컬 값에 의존하는 경우가 그렇습니다.
또 다른 문제는 프로그램이 동시성을 위해 fork()를 사용하는 경우입니다. 단일 스레드 프로그램에서 fork()를 호출하는 것은 안전하지만, 프로그램이 멀티스레드라면 (좋게 말해도) 불안정합니다.
명시적 수집
명시적 수집도 백그라운드 스레드에 위임해야 하느냐고 물을 수 있습니다. 답은 크게 상관없다는 것입니다. gc.collect와 PyGC_Collect는 실제로 수집이 끝날 때까지 기다리므로 (이 특성을 깨면 호환성이 깨집니다), 실제 작업을 백그라운드 스레드에 위임한다고 해서 명시적 수집을 요청한 스레드와의 동기화가 쉬워지지는 않습니다.
결국 이 PEP는 위의 의사 코드를 바탕으로 구현하기 더 간단해 보이는 동작을 선택합니다.
메모리 사용에 미치는 영향
“threaded” 모드는 기본 “serial” 모드에 비해 암묵적 수집에 약간의 지연을 발생시킵니다. 물론 이로 인해 일부 애플리케이션의 메모리 프로파일이 달라질 수 있습니다. 얼마나 달라지는지는 실제 사용 환경에서 측정해 보아야 하지만, 영향은 작고 감당할 만한 수준에 머물 것으로 예상합니다. 첫째, 암묵적 수집은 휴리스틱에 기반하며, 그 효과는 어차피 결정론적으로 드러나는 동작으로 이어지지 않기 때문입니다. 둘째, GC는 참조 순환을 다루는 반면, 많은 객체는 마지막으로 보이는 참조가 사라지는 즉시 회수되기 때문입니다.
CPU 소비에 미치는 영향
위의 의사 코드는 “threaded” 모드에서 암묵적 수집 요청마다 잠금 연산을 두 번 추가합니다. 하나는 요청하는 스레드에서(release 호출), 다른 하나는 GC 스레드에서(acquire 호출) 일어납니다. 또한 현재 모드와 관계없이 실제 수집마다 그 앞뒤로 잠금 연산을 두 번 더 추가합니다.
현대 시스템에서는 이러한 잠금 연산의 비용이 수집 과정 자체에서 포인터 사슬을 따라가는 실제 비용에 비해 매우 작을 것으로 예상합니다(“포인터 추적”은 추측 실행과 슈퍼스칼라 실행에 잘 맞지 않아 현대 CPU에서 가장 까다로운 작업 부하 중 하나입니다).
최악의 경우를 다루는 미니 벤치마크로 실제로 측정해 보면 안심할 수 있는 상한을 제시하는 데 도움이 될 것입니다.
GC 일시 정지에 미치는 영향
이 PEP는 GC 일시 정지 자체를 다루지는 않지만, 암묵적 수집 도중 어느 시점에 GIL을 해제하면(예를 들어 순수 Python 종료자를 실행하는 경우) 그 사이에 애플리케이션 코드가 실행될 수 있으므로, 일부 애플리케이션에서는 눈에 보이는 GC 일시 정지 시간이 줄어들 가능성이 실제로 있습니다.
이 PEP가 채택되면 향후 작업에서 수집 중에 GIL을 추측적으로 해제하여 이 가능성을 더 잘 살려 볼 수 있겠지만, 그것이 얼마나 실현 가능한지는 분명하지 않습니다.
미해결 문제
gc.set_mode는 여러 번의 동시 호출로부터 보호되어야 할 것입니다. 또한 GC 실행 내부에서(즉, 파이널라이저에서) 호출되면 예외를 발생시켜야 합니다.- 종료 시에는 어떻게 됩니까? GC 스레드는
_PyGC_Fini()가 호출될 때까지 실행됩니까?
구현
초안 구현은 작성자의 Github 포크 [3]에 있는 threaded_gc 브랜치 [2]에서 볼 수 있습니다.
참고 문헌
Copyright
This document has been placed in the public domain.