Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

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에는 두 가지 작동 모드가 있습니다.

  • “직렬” 모드는 기본값이자 레거시 모드이며, 앞서 언급한 할당 휴리스틱에 따라 암시적 GC 실행이 필요하다고 감지한 스레드에서 암시적 GC 실행을 즉시 수행합니다.
  • “스레드형” 모드는 프로세스별로 런타임에 명시적으로 활성화할 수 있으며, 할당 휴리스틱이 트리거될 때마다 암시적 GC 실행을 예약하지만 전용 백그라운드 스레드에서 실행합니다.

“직렬” 모드에서 종료 처리 콜백을 정교하게 사용하는 경우 발생하는 심각한 재진입성 문제가 “스레드형” 작동 모드에서는 비교적 쉬운 멀티스레드 동기화 문제가 됩니다.

GC는 전통적으로 Python API gc.collect와 C API PyGC_Collect를 사용한 명시적 GC 실행도 허용합니다. 이 두 API의 관찰 가능한 의미는 변경되지 않습니다. 호출되면 즉시 GC 실행을 수행하며, GC 실행이 완료될 때까지 반환하지 않습니다.

새로운 공개 API

두 개의 새로운 Python API가 gc 모듈에 추가됩니다:

  • 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)은 현재 스레드에 대해 주어진 이름으로 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.collectPyGC_Collect는 실제로 컬렉션이 끝날 때까지 wait하기 때문입니다(이 속성을 깨뜨리면 호환성이 깨집니다). 실제 작업을 백그라운드 스레드에 위임하더라도 명시적 컬렉션을 요청한 스레드와의 동기화가 더 쉬워지지는 않습니다.

결국 이 PEP는 위의 의사 코드에 기반하여 구현하기 더 간단해 보이는 동작을 선택합니다.

메모리 사용량에 미치는 영향

“threaded” 모드는 기본 “serial” 모드에 비해 암시적 컬렉션을 약간 지연시킵니다. 이는 분명 일부 애플리케이션의 메모리 프로파일을 변경할 수 있습니다. 실제 환경에서 얼마나 영향을 미치는지는 측정해야 하지만, 그 영향은 작고 감당할 수 있는 수준으로 유지될 것으로 예상합니다. 첫째, 암시적 컬렉션은 그 효과가 어차피 결정론적인 가시적 동작으로 나타나지 않는 heuristic에 기반하기 때문입니다. 둘째, GC는 참조 순환을 처리하는 반면 많은 객체는 마지막으로 관찰 가능한 참조가 사라지는 즉시 회수되기 때문입니다.

CPU 사용량에 미치는 영향

위의 의사 코드는 “threaded” 모드에서 각 암시적 컬렉션 요청마다 두 개의 잠금 연산을 추가합니다. 하나는 요청을 만드는 스레드의 release 호출이고, 다른 하나는 GC 스레드의 acquire 호출입니다. 또한 현재 모드와 관계없이 각 실제 컬렉션 전후에 두 개의 잠금 연산을 추가합니다.

최신 시스템에서는 컬렉션 자체에서 포인터 체인을 따라가는 실제 비용에 비해 이러한 잠금 연산의 비용이 매우 작을 것으로 예상합니다. (“pointer chasing”은 추측 실행과 슈퍼스칼라 실행에 적합하지 않아 최신 CPU에서 가장 어려운 작업 부하 중 하나입니다.)

최악의 경우를 대상으로 한 실제 미니 벤치마크 측정은 안심할 수 있는 상한을 제공하는 데 도움이 될 수 있습니다.

GC 일시 중지에 미치는 영향

이 PEP는 GC 일시 중지를 직접 다루지 않지만, 암시적 컬렉션 중 어느 시점에 GIL이 해제되면(예를 들어 순수 Python 종료 처리기를 실행함으로써) 애플리케이션 코드가 그 사이에 실행되어 일부 애플리케이션에서 visibleGC 일시 중지 시간이 줄어들 가능성이 실제로 있습니다.

이 PEP가 승인되면 향후 작업에서 컬렉션 중에 추측적으로 GIL을 해제하여 이러한 가능성을 더 잘 실현하려 할 수 있지만, 그것이 얼마나 실행 가능한지는 분명하지 않습니다.

미해결 문제

  • gc.set_mode는 여러 동시 호출로부터 보호되어야 할 것입니다. 또한 GC 실행 inside에서(즉, 종료 처리기에서) 호출되면 예외를 발생시켜야 합니다.
  • 종료 시에는 어떻게 됩니까? _PyGC_Fini()가 호출될 때까지 GC 스레드가 실행됩니까?

구현

작성자의 Github 포크 [3]에 있는 threaded_gc 브랜치 [2]에서 초안 구현을 사용할 수 있습니다.

참고 문헌