질문의 역사
이 주제는 Python이 순수 참조 카운팅에서 Python 2.0에 도입된 혼합 가비지 컬렉션 모델로 발전해온 과정에서 생겨났습니다. 핵심 문제는 개발자들이 외부 리소스(예: 파일 핸들 또는 네트워크 소켓)를 관리하기 위해 파이널라이저 메소드(__del__)를 사용할 때 발생했습니다. 파이널라이저가 있는 객체들이 순환 참조를 형성할 경우, Python은 안전한 파괴 순서를 결정할 수 없어서 크래시나 리소스 누수를 야기할 수 있습니다. 이러한 한계로 인해 순환 가비지 수집기 모듈(gc)이 구현되었고, "수집할 수 없는" 가비지에 대한 특수 처리가 이루어졌습니다.
문제
객체 그룹이 참조 순환을 형성하고 그 중 적어도 하나가 사용자 정의 __del__ 메소드를 정의하면, Python은 결정론적 파괴 딜레마에 직면합니다. 인터프리터는 어떤 객체를 먼저 최종화해야 할지 결정할 수 없기 때문에, 순환은 상호 의존성을 내포하고 있으며 하나를 파괴하면 나머지는 유효하지 않은 상태가 될 수 있습니다. 따라서 Python은 이러한 객체들을 gc.garbage 리스트로 이동시키고 메모리를 해제하지 않습니다. 이 행동은 현대 버전에서도 파이널라이저가 안전한 수집을 방해할 때 지속되어, 장시간 실행되는 애플리케이션에서 점진적인 메모리 누수를 초래합니다.
해결책
확실한 해결책은 __del__ 메소드를 완전히 피하고 컨텍스트 매니저(with 문)나 weakref 콜백을 리소스 정리에 사용하는 것입니다. 파이널라이저를 피할 수 없는 경우, 객체가 도달 불가능해지기 전에 인스턴스 변수를 None으로 설정하여 명시적으로 참조 순환을 끊어야 합니다. Python 3.4부터는 가비지 수집기가 많은 경우 최종화를 신중하게 정렬하여 파이널라이저가 포함된 순환을 수집할 수 있지만, 명시적 리소스 관리는 여전히 가장 신뢰할 수 있는 패턴입니다.
import gc class Resource: def __init__(self, name): self.name = name self.peer = None def __del__(self): print(f"{self.name} 정리 중") # 파이널라이저로 순환 생성 a = Resource("A") b = Resource("B") a.peer = b b.peer = a # 외부 참조 제거 del a, b gc.collect() print(f"수집할 수 없는 객체: {gc.garbage}") # 복잡한 시나리오에서 객체를 포함할 수 있습니다.
우리는 Node 객체가 그래프의 계산 단계를 나타내는 고처리량 데이터 처리 파이프라인을 유지했습니다. 각 노드는 이웃에 대한 참조를 보유하고 있었으며 GPU 메모리 핸들을 해제하기 위해 __del__ 메소드를 포함하고 있었습니다. 집약적인 작업 부하 중에 우리는 프로파일링에서 명백한 메모리 누수가 없었음에도 불구하고 메모리 사용이 일관되게 증가하는 것을 관찰했습니다. 조사를 통해 복잡한 그래프 토폴로지가 노드 간에 참조 순환을 생성하며, __del__ 메소드의 존재가 순환 GC가 이러한 객체를 수집하는 것을 방해해 gc.garbage에서 축적되게 하는 원인을 밝혀냈습니다.
솔루션 1: 컨텍스트 관리자로 리팩토링
우리는 __del__을 명시적 acquire() 및 release() 메소드로 대체하는 것을 고려했습니다. 이 접근법은 가비지 수집에 대한 파이널라이저 장벽을 완전히 제거하고 결정론적 리소스 정리를 제공할 것입니다. 그러나 이는 수천 줄의 그래프 구성 코드를 수정해야 했고, 개발자들이 node 사용을 with 블록으로 감싸는 것을 잊으면 리소스 누수의 위험이 있었습니다. 특히 레거시 콜백 기반 구성 요소에서 그랬습니다.
솔루션 2: 그래프 엣지를 위한 약한 참조 구현
우리는 모든 이웃 참조를 weakref.ref 객체로 변경하여 노드가 외부 참조가 남아 있지 않을 때 즉시 수집될 수 있도록 탐색했습니다. 우아하지만, 이는 그래프 탐색 알고리즘이 항상 죽은 약한 참조를 확인하고 반복 중에 일시적인 "유령" 노드를 처리해야 하므로 상당한 복잡성을 도입했습니다. 이 접근법은 우리의 사용 사례에 대한 성능을 상당히 저하시켰고 그래프 탐색 로직의 광범위한 리팩토링을 요구했습니다.
솔루션 3: 정리 프로토콜을 통한 명시적 순환 끊기
우리는 destroy() 메소드를 구현하여 노드를 그래프에서 제거하기 전에 self.neighbors = [] 및 self.gpu_handle = None을 명시적으로 설정했습니다. 이는 기존 API 표면을 유지하면서도 순환을 결정론적으로 끊었습니다. 우리는 이 솔루션을 선택했는데, 이는 노드 제거 로직에 대한 변경을 국지화하고 전체 코드베이스에 걸쳐 우려 사항을 확산시키지 않았으며 기존 그래프 알고리즘과의 하위 호환성을 유지했기 때문입니다.
결과
명시적 정리 프로토콜을 구현하고 CI 테스트 중 gc.garbage가 비어 있는지 검증하는 어서션을 추가한 후, 메모리 사용량은 일정한 기준선에서 안정화되었습니다. 서비스는 이전의 점진적인 메모리 축적 없이 몇 주 동안 실행되었습니다. 우리는 앞으로의 개발자들이 파이널라이저와 순환 참조 간의 상호작용을 이해할 수 있도록 패턴에 대한 문서를 작성했습니다.
왜 gc.garbage 는 여전히 객체를 포함하고 있나요? Python 3.4+에서도 파이널라이저가 포함된 순환 내에서?
Python 3.4는 안전한 순서로 파이널라이저를 호출하고 이후에 참조를 정리하여 순환 GC를 처리하는 데 상당한 개선을 이루었지만, 특정 조건에서 객체가 여전히 gc.garbage에 나타날 수 있습니다. 만약 __del__ 메소드가 객체를 전역 변수에 저장하여 부활시키면, GC는 순환을 안전하게 수집할 수 없으므로 이를 gc.garbage로 이동시켜 무한 루프를 방지합니다. 또한, 순환 GC 프로토콜을 제대로 지원하지 않는 사용자 정의 tp_dealloc 슬롯이 있는 C 확장 객체는 충돌을 피하기 위해 수집할 수 없는 것으로 간주될 수 있습니다.
weakref.ref가 콜백과 함께 사용될 때, 순환 가비지 컬렉터는 어떻게 상호작용하나요? 참조 대상이 수집할 수 없는 순환의 일부일 때?
후보자들은 종종 객체가 도달 불가능해지면 약한 참조 콜백이 즉시 실행된다고 잘못 가정합니다. 실제로 콜백은 객체가 실제로 파괴되고 메모리가 해제될 때 작동합니다. 객체가 GC가 끊을 수 없는 파이널라이저가 포함된 참조 순환에 참여하면, 객체는 gc.garbage에 할당된 상태로 남아 있으며 약한 참조 콜백은 결코 실행되지 않습니다. 이 구별은 객체 파괴 알림을 위한 약한 참조 콜백을 기반으로 하는 리소스 정리 시스템을 설계하는 데 중요한 요소입니다.
__del__ 메소드의 "부활" 문제란 무엇이며, 어떻게 순환 참조의 가비지 수집을 방해하나요?
부활은 파이널라이저 메소드가 죽어가는 인스턴스를 전역 변수에 할당하거나 지속적인 컨테이너에 삽입하여, GC가 파괴를 위해 마크한 후에 효과적으로 다시 살아나게 하는 경우를 의미합니다. 순환 참조 상황에서, 만약 하나의 객체의 __del__가 순환 내의 다른 객체를 부활시킨다면, 전체 순환이 다시 도달 가능하게 됩니다. Python의 가비지 컬렉터는 이 이상 현상을 탐지하여 전체 순환을 gc.garbage로 이동시키고 파괴와 부활의 잠재적으로 무한 루프를 해결하려고 하지 않으며, 프로세스 종료 시까지 메모리를 회수하지 않습니다.