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)라고 부르려고 합니다. 파이널라이저(finalizer)는 더 일찍, 그리고 위상학적 순서(topological order)로 실행됩니다. 이 시점에는 충분히 영리하게 처리한다면 객체가 여전히 도달 가능할 수 있다는 점에 주의해야 합니다. 반면 소멸자(destructor)는 마지막에 실행됩니다. 이 시점에는 객체에 대해 더 이상 아무것도 할 수 없으며, GC가 즉시 이를 해제합니다.
소멸자(Destructor)¶
소멸자(destructor)는 GC가 메모리를 해제하려고 할 때 직접 호출하는 RPython __del__() 메서드입니다. 추가로 raw 메모리 블록 하나만 해제하면 되는 객체를 위한 것입니다.
__del__()안에 넣을 수 있는 코드의 종류에는 제약이 있으며, 이는 그것이 호출하는 다른 모든 함수에도 적용됩니다. 이 제약은 검사됩니다. 특히 GC 객체를 담고 있는 필드에는 접근할 수 없습니다. 현재는 외부 C 함수도 호출할 수 없습니다.
소멸자는 GC가 객체의 메모리를 해제하는 시점에 정확히 호출됩니다. 객체가 (파이널라이저 큐든 어디든) 존재하는 한, 그 소멸자는 호출되지 않습니다.
Register_finalizer¶
완전한 종료자(finalizer)를 위한 인터페이스는 PyPy를 염두에 두고 만들어졌지만, 일반적으로도 유용할 것입니다.
이 아이디어는 rgc.FinalizerQueue 클래스를 서브클래싱하는 것입니다:
- 클래스 수준 속성
base_class를 지정해야 하며, 이는 파이널라이저가 있는 모든 인스턴스의 기반 클래스입니다. (서로 무관한 여러 클래스에 파이널라이저가 필요하다면, 서로 무관한 여러FinalizerQueue서브클래스가 필요합니다.) finalizer_trigger()메서드를 오버라이드합니다. 아래를 참조하십시오.
그런 다음 이 서브클래스의 전역(또는 공간별) 인스턴스를 하나 생성합니다. 이를 fin이라고 부릅니다. 런타임에는 파이널라이저가 필요한 모든 인스턴스 obj에 대해 fin.register_finalizer(obj)를 호출합니다. 각 obj는 fin.base_class의 인스턴스여야 하지만, 그런 인스턴스 모두가 파이널라이저를 등록해야 하는 것은 아닙니다. 일반적으로 우리는 가능한 한 적은 수의 객체에만 파이널라이저를 등록하려고 합니다(예: 앱 레벨 __del__()메서드가 있는 객체인 경우에만).
메이저 컬렉션 후, GC는 파이널라이저가 등록되어 있으면서 도달 불가능한 모든 객체 obj를 찾아, 그 객체들이 의존하는 모든 객체와 함께 다시 도달 가능하도록 표시합니다. 그런 다음 위상 정렬(순환이 있으면 무작위로 끊습니다)을 선택하고, 그 순서대로 객체와 등록된 파이널라이저 함수를 미리 만들어진(prebuilt) fin 인스턴스 전용 큐에 넣습니다. 마지막으로 메이저 컬렉션이 끝나면 fin.finalizer_trigger()를 호출합니다.
이 finalizer_trigger() 메서드는 직접 작업을 수행하거나, 나중에(예: 두 바이트코드 사이에서) 수행하도록 지연시킬 수 있습니다. 직접 작업을 수행하는 경우, (직접적으로든 간접적으로든) GIL이 해제되도록 할 수 없다는 점에 유의하십시오.
대기 중인 항목을 찾으려면 fin.next_dead()를 반복적으로 호출합니다. 이 함수는 큐에 있는 다음 항목을 반환하며, 큐가 비어 있으면 None을 반환합니다.
이론상으로는, 같은 클래스의 객체에 대해 여러 개의 서로 다른 FinalizerQueue 인스턴스를 누적시키면 어느 정도 작동할 것이며, (역시 이론상으로는) 같은 obj가 같은 큐에 여러 번, 혹은 여러 개의 큐에 등록될 수 있습니다. 하지만 이는 테스트되지 않았습니다. 현재로서는 번역(translation)되지 않은 에뮬레이션이 같은 객체를 여러 번 등록하는 것을 지원하지 않습니다.
rpython -O0에서 사용되는 Boehm 가비지 컬렉터는 register_finalizer()를 완전히 무시한다는 점에 유의하시기 바랍니다.
파이널라이저(finalizer)의 순서¶
컬렉션이 끝난 후, MiniMark GC는 파이널라이저를 가지고 있으며 도달 불가능해진 객체 중 일부에 대해서만 파이널라이저를 호출해야 합니다. 기본적으로, 객체 a에서 객체 b로 이어지는 참조 체인이 있다면 b의 파이널라이저를 즉시 호출해서는 안 되며, 대신 b를 살아있는 상태로 유지한 뒤 다음 컬렉션 이후에 다시 그 파이널라이저 호출을 시도해야 합니다.
(프로그램이 파이널라이저(finalizer)가 있는 객체 체인을 메이저 컬렉션이 진행되는 속도(매우 느림)보다 더 빠르게 생성하는 즉시, 이는 드물지만 성가신 문제를 일으킨다는 점에 유의하시기 바랍니다. 2013년 8월에는 그 대신 메이저 컬렉션에서 도달 불가능한 것으로 발견된 모든 객체의 모든 파이널라이저를 호출하도록 시도했습니다. 그 브랜치인 gc-del은 결코 병합되지 않았습니다. 실제 환경에서 사용되는 프로그램들에 어떤 실질적인 결과를 가져올지는 여전히 불분명합니다.)
기본 아이디어는 순환(cycle)이 있을 때 실패합니다. 객체를 영원히 살아있게 하거나 파이널라이저(finalizer)를 전혀 호출하지 않는 것은 좋은 생각이 아닙니다. 우리가 생각해낸 모델은 이 경우 순환 안의 객체 중 하나의 파이널라이저(finalizer)만 호출하면 된다는 것입니다 – 단, 물론 순환 밖에 파이널라이저를 가지고 있으면서 그 순환에 대한 참조를 가진 다른 객체가 없는 경우에만 그렇습니다.
더 정확히 말하면, 객체 간 참조 그래프가 주어졌을 때:
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에 있습니다. 불변조건은 x에서 y로의 참조가 있다면 state[y] >= state[x]라는 것입니다.
상태 2는 파이널라이저(finalizer)에서 도달 가능하지만, 파이널라이저와 같은 강한 연결 요소(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과 x2가 동일한 강한 연결 요소(strongly connected component)에 속하지 않고, x1에서 x2로의 참조 경로가 존재한다고 가정하겠습니다. 그러면:
- x1이 x2보다 먼저 열거되면, x2는 closure(x1)에 속하며, 따라서 x1을 처리할 때 x2의 상태는 최소한 >= 2가 됩니다. 나중에 x2를 처리할 때는 그냥 건너뛰기 때문에(“continue” 줄) 표시되지 않습니다.
- x2가 x1보다 먼저 열거되면, 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, 2, 3으로 변할 때, 각 객체의 자식을 최대 3번까지 따라갑니다. 객체의 상태를 변경하지 않는 방문에서는 자식을 재귀적으로 따라가지 않습니다.
실제로는 MiniMark GC에서 헤더 내 두 비트(bit)의 조합으로 4가지 상태를 인코딩할 수 있습니다:
상태 GCFLAG_VISITED GCFLAG_FINALIZATION_ORDERING 0 아닙니다 아니오 1 아니오 예 2 예 예 3 예 아니요
따라서 위에서 상태 1에서 상태 2로 전이하는 루프는 실제로는 그저 재귀적 방문일 뿐입니다. 또한 다음 컬렉션 전에 정리하기 위해, 마지막에(상태 2에서 상태 3으로) FINALIZATION_ORDERING 비트도 지워야 합니다.