Python의 순환 가비지 컬렉터(GC)는 파이널라이저가 있는 순환 객체 그래프의 파괴 과정에서 엄격한 순서 제약을 시행합니다. GC가 도달할 수 없는 순환을 감지하면, 먼저 __del__ 메소드를 가진 객체와 그렇지 않은 객체를 구분합니다. 파이널라이저가 있는 객체의 경우, GC는 __del__ 메소드를 호출하기 전에 모든 약한 참조를 명시적으로 지웁니다(콜백을 None 인수로 호출함). 이 순서는 콜백이나 파이널라이저가 해당 객체에 대한 새로운 강한 참조를 생성하여 죽어가는 객체가 다시 도달할 수 있게 되는 부활이라는 위험한 상황을 방지합니다. 파이널라이저 실행 전에 약한 참조를 무효화함으로써, Python은 객체가 파괴 과정 내내 도달할 수 없는 상태로 남아 있도록 보장하여, 결정론적인 가비지 컬렉션을 보장합니다.
Python으로 구축된 고주파 거래 플랫폼에서 시장 데이터 패킷을 관리하기 위해 커스텀 객체 풀을 구현했습니다. 각 패킷 객체는 패킷이 가비지 컬렉션될 때 지연 메트릭을 기록하기 위해 약한 참조 콜백을 등록했습니다. 또한 패킷은 __del__ 메소드를 통해 관리되는 열린 네트워크 소켓 리소스를 보유하여 연결이 자동으로 닫히도록 했습니다. 스트레스 테스트 중, 애플리케이션은 패킷 객체가 논리적으로 도달할 수 없음에도 불구하고 메모리에 무한히 남아 있는 심각한 메모리 누수를 나타냈습니다.
해결책 1: 개입 없이 자동 가비지 컬렉션에 의존하기.
초기 아키텍처는 CPython의 GC가 패킷과 내부 콜백 레지스트리 간의 순환 참조를 자동으로 처리할 것이라고 가정했습니다. 그러나 이 접근 방식은 __del__ 메소드와 순환 객체에서의 weakref 콜백 간의 상호 작용으로 인해 부활이 발생했기 때문에 실패했습니다. 약한 참조 콜백이 수집 중에 작동하여 패킷 객체를 가비지 컬렉터가 순환을 완전히 끊기 전에 전역 메트릭 사전에 다시 등록하고 있었습니다. 이로 인해 메모리를 소모하지만 부분적으로 파괴된 좀비 객체가 생성되어 불일치한 소켓 상태와 파일 설명자 고갈을 초래했습니다.
해결책 2: 명시적 release() 메소드 및 수동 정리 구현하기.
우리는 __del__를 완전히 제거하고 개발자에게 참조 해제하기 전에 packet.release()를 명시적으로 호출하도록 요구하는 방안을 고려했습니다. 이는 GC 상호 작용 문제를 없애지만, 상당한 API 취약성을 초래했습니다. 개발자들은 예외 처리 경로에서 패킷을 해제하는 것을 자주 잊었고, 결과적으로 발생한 리소스 누수는 원래의 메모리 문제보다 디버깅하기가 더 어려웠습니다. 또한 명시적 접근 방식은 비즈니스 논리와 메모리 관리 문제로 코드 가독성을 낮추는 광범위한 try-finally 블록이 필요했습니다.
해결책 3: weakref.finalize 및 컨텍스트 관리자를 사용하여 리팩토링하기.
선택한 해결책은 __del__ 메소드를 weakref.finalize 등록 및 컨텍스트 관리자(with 문)로 교체했습니다. 우리는 패킷 객체에서 모든 __del__ 메소드를 제거하여 GC가 이들을 파이널라이저가 없는 표준 순환 쓰레기처럼 처리할 수 있도록 했습니다. 정리 알림을 위해, 우리는 콜백 함수에 객체를 전달하지 않는 weakref.finalize로 전환하여 부활을 방지하였습니다. 네트워크 소켓은 예외와 상관없이 종료를 보장하는 명시적 컨텍스트 관리자를 통해 관리되었습니다.
이 접근 방식은 Python의 가비지 컬렉션 아키텍처와 일치했기 때문에 성공적이었습니다. 순환 객체에서 파이널라이저를 제거함으로써, 우리는 GC가 약한 참조를 안전하게 지우고 부활 위험 없이 순환을 수집할 수 있도록 허용했습니다. 메모리 사용량이 안정화되었고, 지연 메트릭은 객체 생명 주기와 간섭하지 않고 계속해서 올바르게 기록되었습니다.
import weakref import gc class DataPacket: def __init__(self, packet_id): self.packet_id = packet_id self.peer = None # 생산에서 순환을 생성합니다. # GC 순서 문제를 피하기 위해 __del__ 제거 def log_cleanup(ref, pid): # 안전: 패킷 ID를 수신하므로 객체가 아님 print(f"패킷 {pid} 정리됨") # 사용 예 packet = DataPacket(123) packet.peer = packet # 자기 순환 # 부활 위험 없이 안전한 마무리 weakref.finalize(packet, log_cleanup, packet.packet_id) packet = None gc.collect() # 부활 없이 안전하게 수집
왜 gc.collect()를 호출한다고 해서 모든 객체에 대해 약한 참조 콜백이 즉시 호출되는 것이 보장되지 않습니까?
후보자들은 종종 gc.collect()가 모든 weakref 콜백을 동기적으로 호출한다고 가정합니다. 그러나 weakref 콜백은 특정 수집 주기 동안 도달할 수 없게 된 객체에 대해서만 호출됩니다. 만약 객체가 여전히 루트로부터 도달할 수 있다면, 그 콜백은 비활성 상태로 남게 됩니다. 또한, CPython은 순환 가비지를 단계별로 처리합니다: __del__ 메소드를 가진 객체는 별도로 처리되며, 그들의 약한 참조는 파이널라이저가 실행되기 전에 지워집니다. 이러한 객체의 콜백은 지연되거나 처리되는 세대와 관련하여 특정 순서로 진행될 수 있습니다. weakref 콜백이 명시적 호출인 gc.collect()가 아니라 객체 파괴 이벤트에 결부되어 있다는 것을 이해하는 것은 정리 동작을 예측하는 데 필수적입니다.
Python의 순환 가비지 수집에서 "부활" 위험이란 무엇입니까?
부활은 객체의 __del__ 메소드나 weakref 콜백이 파괴 중인 객체에 대한 새로운 강한 참조를 생성하여 객체가 다시 수집 중에 도달할 수 있게 되는 것을 의미합니다. 이는 GC가 이미 객체의 내부 상태를 정리하기 시작했기 때문에 혼란스러운 상태에 놓일 여지가 있어 위험합니다. Python은 파이널라이저를 호출하기 전에 약한 참조를 지움으로써 부활을 방지합니다. GC가 순환 쓰레기를 감지하면, __del__이 있는 객체를 식별하고 이를 임시 목록으로 이동시키며, 모든 weakref 항목을 지운 후(콜백을 None으로 호출), 그제야 파이널라이저를 실행합니다. 이는 사용자 코드가 실행될 때 객체가 약한 참조를 통해 명확히 도달할 수 없는 상태임을 보장합니다.
weakref.finalize는 가비지 수집 안전성 측면에서 일반 weakref.ref 콜백과 어떻게 다른가요?
weakref.finalize는 부활 문제를 피하도록 특별히 설계되었습니다. dying 객체를 콜백의 인수로 전달하는 weakref.ref와는 달리, finalize는 객체를 받지만 등록된 콜백 함수에 객체를 전달하지 않습니다. 대신, 콜백을 인수로 해당 객체를 포함하지 않도록 미리 등록된 인수로 호출합니다. 이 설계는 콜백이 객체를 부활시킬 수 없도록 보장합니다. 후보자들은 종종 finalize 객체가 콜백이 호출될 때까지 Python의 내부 레지스트리에 의해 살아남아 있으므로, 원래 생성 스코프가 종료되더라도 정리가 일어날 수 있음을 간과합니다.