MiniMark GC에서 파이널라이저(finalizer) 실행 순서 정하기

RPython 인터페이스

PyPy와 같은 RPython 프로그램에서는 RPython 수준과 애플리케이션 수준의 __del__()을 모두 제어할 수 있는 세밀한 방법이 필요합니다. 이를 가능하게 하기 위해 RPython 인터페이스는 이제 다음과 같습니다(2016년 5월부터):

  • RPython 객체는 __del__()을 가질 수 있습니다. 이 메서드는 CPython에서와 마찬가지로 객체에 대한 마지막 참조가 사라지는 즉시 GC가 호출합니다. 하지만 장기적인 목표는 모든 __del__() 메서드가 충분히 단순한 코드만 포함하도록 하는 것입니다. 이 조건을 만족하는 메서드를 “소멸자(destructor)”라고 부릅니다. 예를 들어, 소멸자는 객체를 되살리는 연산을 사용할 수 없습니다. 이 메서드가 소멸자임을 보장하려면 @rgc.must_be_light_finalizer 데코레이터를 사용하십시오.
  • 소멸자 테스트를 통과하지 못하는 RPython 수준의 __del__()는 하위 호환성을 위해 지원되지만, 더 이상 사용이 권장되지 않습니다(deprecated). 이 문서의 나머지 부분에서는 모든 __del__()가 소멸자라고 가정합니다.
  • 더 고급스러운 용도 — 특히 __del__ 메서드를 가진 애플리케이션 수준 객체 — 에는 RPython 수준의 __del__() 메서드를 사용하지 않습니다. 대신 rgc.FinalizerController.register_finalizer()를 사용합니다. 이를 통해 객체에 파이널라이저(finalizer) 메서드를 붙일 수 있으며, 단순한 RPython __del__()보다 순서를 더 세밀하게 제어할 수 있습니다.

파이널라이저(finalizer)와 구별하기 위해 __del__()를 일관되게 소멸자(destructor)라고 부르려고 합니다. 파이널라이저는 더 먼저, 위상 순서(topological order)대로 실행됩니다. 우리가 충분히 영리하다면 이 시점에도 객체에 여전히 도달할 수 있을지도 모르므로 주의해야 합니다. 반면 소멸자는 맨 마지막에 실행됩니다. 이때는 더 이상 객체로 할 수 있는 일이 없으며, GC가 즉시 객체를 해제합니다.

소멸자

소멸자(destructor)는 GC가 메모리를 해제하려고 할 때 직접 호출하는 RPython __del__() 메서드입니다. 추가로 할당된 raw 메모리 블록 하나만 해제하면 되는 객체를 위한 것입니다.

__del__()에 넣을 수 있는 코드의 종류에는 제약이 있으며, 이 제약은 __del__()이 호출하는 다른 모든 함수에도 적용됩니다. 이 제약은 실제로 검사됩니다. 특히 GC 객체를 담고 있는 필드에는 접근할 수 없습니다. 현재로서는 외부 C 함수도 호출할 수 없습니다.

소멸자는 GC가 객체의 메모리를 해제하는 바로 그 시점에 호출됩니다. 객체가 존재하는 한(어떤 파이널라이저 큐 안에 있든 다른 어디에 있든), 그 객체의 소멸자는 호출되지 않습니다.

Register_finalizer

전체 파이널라이저(full finalizer)용 인터페이스는 PyPy를 염두에 두고 만들었지만, 다른 곳에서도 두루 쓸 수 있을 것입니다.

요점은 rgc.FinalizerQueue 클래스를 상속하는 서브클래스를 만드는 것입니다:

  • 클래스 수준 속성 base_class를 지정해야 합니다. 이 속성은 파이널라이저가 있는 모든 인스턴스의 기반 클래스입니다. (서로 관련 없는 여러 클래스에 파이널라이저가 필요하다면, 서로 관련 없는 FinalizerQueue 서브클래스도 여러 개 필요합니다.)
  • finalizer_trigger() 메서드를 오버라이드합니다. 아래를 참고하십시오.

그런 다음 이 서브클래스의 전역(또는 객체 공간별) 인스턴스를 하나 만들고, 이를 fin이라고 부릅니다. 런타임에는 파이널라이저가 필요한 모든 인스턴스 obj에 대해 fin.register_finalizer(obj)를 호출합니다. 각 obj는 fin.base_class의 인스턴스여야 하지만, 그러한 인스턴스 모두에 파이널라이저를 등록해야 하는 것은 아닙니다. 일반적으로 우리는 가능한 한 적은 수의 객체에만 파이널라이저를 등록하려고 합니다(예: 애플리케이션 수준 __del__() 메서드를 가진 객체인 경우에만).

메이저 컬렉션이 끝나면 GC는 파이널라이저가 등록되어 있으면서 도달할 수 없는 모든 객체 obj를 찾아, 그 객체들이 의존하는 모든 객체와 함께 다시 도달 가능한 것으로 표시합니다. 그런 다음 위상 순서를 하나 정하고(순환이 있으면 임의로 끊습니다), 객체들과 각 객체에 등록된 파이널라이저 함수들을 그 순서대로 미리 빌드된 fin 인스턴스 전용 큐에 넣습니다. 마지막으로 메이저 컬렉션이 끝나면 fin.finalizer_trigger()를 호출합니다.

이 finalizer_trigger() 메서드는 어떤 작업을 직접 수행할 수도 있고, 나중에(예: 두 바이트코드 사이에) 수행되도록 미룰 수도 있습니다. 작업을 직접 수행하는 경우, 이 메서드는 (직접적으로든 간접적으로든) GIL이 해제되게 만들 수 없다는 점에 유의하십시오.

대기열에 들어간 항목을 찾으려면 fin.next_dead()를 반복해서 호출하십시오. 이 함수는 대기열의 다음 항목을 반환하며, 대기열이 비어 있으면 None을 반환합니다.

이론상으로는 같은 클래스의 객체들에 대해 서로 다른 FinalizerQueue 인스턴스 여러 개를 함께 사용해도 어느 정도는 동작할 것이며, (역시 이론상으로는) 같은 obj를 같은 큐에 여러 번 등록하거나 여러 큐에 등록할 수도 있습니다. 다만 이는 테스트되지 않았습니다. 현재로서는 변환되지 않은 에뮬레이션에서 같은 객체를 여러 번 등록하는 것을 지원하지 않습니다.

rpython -O0에서 사용하는 Boehm 가비지 컬렉터는 register_finalizer()를 완전히 무시한다는 점에 유의하십시오.

파이널라이저의 실행 순서

컬렉션이 끝나면 MiniMark GC는 파이널라이저가 있으면서 도달할 수 없게 된 객체 중 일부에 대해 파이널라이저를 호출해야 합니다. 기본적으로 객체 a에서 객체 b로 이어지는 참조 체인이 있으면 b의 파이널라이저를 즉시 호출해서는 안 되며, b를 살려 둔 채 다음 컬렉션이 끝난 뒤 파이널라이저 호출을 다시 시도해야 합니다.

(프로그램이 파이널라이저가 있는 객체들의 체인을 메이저 컬렉션의 진행 속도(매우 느립니다)보다 빠르게 생성하기 시작하면, 드물지만 성가신 문제가 발생한다는 점에 유의하십시오. 2013년 8월에 저희는 대신 메이저 컬렉션에서 도달 불가능한 것으로 확인된 모든 객체의 파이널라이저를 전부 호출하는 방식을 시도했습니다. 그 브랜치인 gc-del은 끝내 병합되지 않았습니다. 실제 환경에서 사용되는 프로그램들에 어떤 실질적인 영향이 있을지는 아직 불분명합니다.)

순환이 있으면 이 기본 아이디어는 통하지 않습니다. 객체를 영원히 살려 두거나 파이널라이저를 하나도 호출하지 않는 것은 좋은 방법이 아닙니다. 저희가 생각해 낸 모델은 이런 경우 순환 안에 있는 객체 중 하나의 파이널라이저만 호출하면 된다는 것입니다 – 물론 순환 바깥에 파이널라이저를 가지면서 그 순환을 참조하는 다른 객체가 없을 때에만 그렇습니다.

더 정확히 말하면, 객체 사이의 참조 그래프가 주어졌을 때:

for each strongly connected component C of the graph:
    if C has at least one object with a finalizer:
        if there is no object outside C which has a finalizer and
        indirectly references the objects in C:
            mark one of the objects of C that has a finalizer
            copy C and all objects it references to the new space

for each marked object:
    detach the finalizer (so that it's not called more than once)
    call the finalizer

알고리즘

deal_with_objects_with_finalizers()가 실행되는 동안 각 객체 x는 가능한 4가지 상태 중 하나에 있을 수 있습니다:

state[x] == 0:  unreachable
state[x] == 1:  (temporary state, see below)
state[x] == 2:  reachable from any finalizer
state[x] == 3:  alive

처음에 객체는 직전에 수행된 일반 스윕(sweep)에서 복사되었는지 여부에 따라 상태 0 또는 상태 3에 있습니다. 불변 조건(invariant)은 x에서 y로의 참조가 있으면 state[y] >= state[x]가 성립한다는 것입니다.

상태 2는 파이널라이저에서 도달할 수 있지만 그 파이널라이저와 같은 강한 연결 요소(strongly connected component)에 속할 수도 있는 객체에 사용됩니다. 이러한 객체가 같은 강한 연결 요소에 속하지 않는 것이 확실한 파이널라이저에서 도달할 수 있다는 것이 증명되면, 그 객체의 상태는 3으로 바뀝니다. 상태가 3인 객체의 파이널라이저는 호출되어서는 안 됩니다.

closure(x)를 x 자신을 포함하여 x에서 도달할 수 있는 객체들의 목록이라고 합시다. 마킹된 객체들의 목록을 얻기 위한 (고수준) 의사 코드:

marked = []
for x in objects_with_finalizers:
    if state[x] != 0:
        continue
    marked.append(x)
    for y in closure(x):
        if state[y] == 0:
            state[y] = 2
        elif state[y] == 2:
            state[y] = 3
for x in marked:
    assert state[x] >= 2
    if state[x] != 2:
        marked.remove(x)

이 방식은 objects_with_finalizers를 열거하는 순서와 관계없이 올바르게 동작합니다. 먼저 [x1, .., xn]이 모두 도달할 수 없는 같은 강한 연결 요소(strongly connected component)에 속한다고 가정합니다. 즉, 파이널라이저(finalizer)가 있는 객체 중 외부에서 이 강한 연결 요소를 참조하는 객체는 없습니다. 그러면 다음과 같습니다.

  • x1이 처리될 때, 이전에 무엇을 했든 그와 무관하게 state[x1] == .. == state[xn] == 0입니다. 따라서 x1이 마킹되고, state[x1] = .. = state[xn] = 2로 설정합니다.
  • x2, … xn을 처리할 때에는 상태가 != 0이므로 아무것도 하지 않습니다.
  • 마지막 루프에서는 x1만 마크되어 있고 state[x1] == 2이므로 x1은 마크된 상태로 남습니다.

이제 x1과 x2가 같은 강한 연결 요소(strongly connected component)에 속하지 않으며, x1에서 x2로 가는 참조 경로가 있다고 가정해 봅시다. 그러면 다음과 같습니다.

  • x1이 x2보다 먼저 열거되면 x2는 closure(x1)에 속하므로, x1을 처리할 때 x2의 상태는 적어도 >= 2가 됩니다. 나중에 x2를 처리할 때는 x2를 그냥 건너뛰므로(“continue” 줄) x2는 표시되지 않습니다.
  • x2가 x1보다 먼저 열거되면, x2를 처리할 때 x2를 표시하고 그 상태를 >= 2로 설정합니다(x2가 closure(x2)에 속하기 전입니다). 그다음 x1을 처리할 때 state[x2] == 3으로 설정합니다. 따라서 마지막 루프에서 x2는 “marked” 목록에서 제거됩니다.

이것은 알고리즘이 우리가 의도한 대로 동작하고 있음을 증명한다고 생각합니다.

다음 단계는 알고리즘에서 closure() 사용을 제거하되, 새 알고리즘이 적절한 성능을 갖도록 하는 것입니다. 여기서 적절한 성능이란 알고리즘이 상태를 조작하는 객체의 수에 선형인 성능을 뜻합니다.:

marked = []
for x in objects_with_finalizers:
    if state[x] != 0:
        continue
    marked.append(x)
    recursing on the objects y starting from x:
        if state[y] == 0:
            state[y] = 1
            follow y's children recursively
        elif state[y] == 2:
            state[y] = 3
            follow y's children recursively
        else:
            don't need to recurse inside y
    recursing on the objects y starting from x:
        if state[y] == 1:
            state[y] = 2
            follow y's children recursively
        else:
            don't need to recurse inside y
for x in marked:
    assert state[x] >= 2
    if state[x] != 2:
        marked.remove(x)

이 알고리즘에서는 객체의 상태가 0에서 1로, 1에서 2로, 2에서 3으로 바뀔 때 각 객체의 자식을 최대 3번 따라갑니다. 객체의 상태를 바꾸지 않는 방문에서는 그 객체의 자식을 재귀적으로 따라가지 않습니다.

실제로 MiniMark GC에서는 헤더에 있는 두 비트를 조합하여 이 4가지 상태를 인코딩할 수 있습니다:

상태 GCFLAG_VISITED GCFLAG_FINALIZATION_ORDERING
0 아닙니다 아니요
1 아니요 예
2 예 예
3 예 아니요

따라서 위에서 본, 상태 1에서 상태 2로 전이시키는 루프는 사실 재귀적 방문에 지나지 않습니다. 또한 다음 컬렉션 전에 정리해 두기 위해, 마지막에(상태 2에서 상태 3으로) FINALIZATION_ORDERING 비트도 지워야 합니다.