
참조 카운팅은 어떻게 메모리를 해제하고 어디서 실패하는가
- 1참조 카운팅은 어떻게 메모리를 해제하고 어디서 실패하는가읽는 중
- 2세대별 가비지 컬렉터가 순환 참조를 찾아내는 다섯 단계
- 3GIL은 무엇을 지키고 무엇을 지키지 않는가
- 4Lock부터 Barrier까지, GIL이 있어도 동기화가 필요한 이유
CPython의 메모리 관리는 참조 카운팅에서 시작합니다. 모든 객체는 자신을 가리키는 참조가 몇 개인지 세고 있고, 이 값이 0이 되는 순간 그 자리에서 메모리가 해제됩니다. 별도의 수집기가 돌기를 기다리지 않으므로 해제 시점이 코드에 그대로 드러납니다.
대신 이 방식만으로 회수하지 못하는 묶음이 있습니다. 서로를 가리키는 객체들은 바깥에서 오는 참조를 전부 끊어도 카운트가 0에 닿지 못합니다. CPython이 참조 카운팅 위에 순환 전용 수집기를 하나 더 얹어 둔 이유가 여기에 있습니다.
카운트는 언제 오르고 언제 내려가는가
객체가 만들어지면 카운트는 1에서 시작합니다. 다른 이름에 대입하거나 컨테이너에 넣거나 함수 인자로 넘기면 1씩 오르고, 그 이름이 사라지거나 다른 객체로 덮이거나 스코프를 벗어나면 1씩 내려갑니다. 0에 닿으면 곧바로 해제 루틴이 불리고, 그 객체가 들고 있던 참조들도 함께 1씩 줄어듭니다. 그 바람에 0이 된 객체가 있으면 연쇄적으로 같이 해제됩니다.
sys.getrefcount()로 지금 카운트를 확인할 수 있습니다.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a))
# 2
b = a
print(sys.getrefcount(a))
# 3
b = None
print(sys.getrefcount(a))
# 2반환값에는 항상 1이 얹혀 있습니다
sys.getrefcount(a)를 부르는 순간 인자로 전달된 참조가 하나 더 잡힙니다. 그래서 앞 그림의 카운트에 1을 더한 값이 그대로 찍힙니다. 처음에 2가 나온 것은 실제 참조가 하나뿐이라는 뜻입니다. 공식 문서도 이 값이 예상보다 하나 크다고 명시하고 있으므로, 절대값보다 증감을 읽는 편이 안전합니다.
작은 정수와 None의 카운트를 읽지 않는 이유
같은 것을 정수나 None에 해 보면 값이 전혀 다릅니다.
>>> import sys
>>> sys.getrefcount(1)
4294967295
>>> sys.getrefcount(None)
4294967295두 가지가 겹쳐 있습니다. 먼저 파이썬은 -5부터 256까지의 정수를 미리 만들어 두고 재사용합니다. c = 1은 새 객체를 만드는 일이 아니라 이미 있는 객체를 가리키는 일이라, 인터프리터 곳곳에서 쓰이는 만큼 카운트가 큽니다.
여기에 Python 3.12의 PEP 683이 얹혀 값이 아예 고정됐습니다. None, True, False, 작은 정수, 인터프리터가 미리 만들어 두는 짧은 문자열처럼 프로세스가 끝날 때까지 살아 있는 객체는 immortal로 표시되고, 참조 카운트를 갱신하는 코드가 이들을 건너뜁니다. 대신 포화값 하나를 계속 들고 있습니다. 64비트 CPython 3.13에서 이 값은 UINT_MAX, 즉 4294967295입니다.
문서도 이 점을 못 박고 있습니다. immortal 객체의 반환값은 실제 참조 수와 무관하며, 0이나 1이 아닌 이상 값을 신뢰하지 말라는 것입니다. 참조 카운팅이 실제로 어떻게 움직이는지 보려면 리스트처럼 평범한 객체를 써야 합니다.
카운트가 0에 닿지 못하는 경우
순환 참조는 둘 이상의 객체가 서로를 가리키는 상태입니다. 각자가 상대를 붙잡고 있으니, 바깥에서 오는 참조를 모두 끊어도 카운트가 1에서 멈춥니다.
class Node:
def __init__(self, value):
self.value = value
self.parent = None
self.child = None
node_a = Node("A")
node_b = Node("B")
node_a.child = node_b
node_b.parent = node_a
del node_a, node_b마지막 del이 핵심입니다. del 전에는 두 객체가 전역 이름에도 묶여 있어서, 순환이 없더라도 어차피 해제되지 않습니다. 순환이 문제로 드러나는 시점은 바깥 참조를 모두 끊은 뒤입니다.
카운트가 1에서 멈췄으니 해제 루틴은 불리지 않습니다. 순환 수집기를 꺼 두고 돌려 보면 분명하게 드러납니다.
import gc
class Node:
def __init__(self, name):
self.name = name
self.parent = None
self.child = None
def __del__(self):
print(f"{self.name} 해제")
gc.disable()
a, b = Node("A"), Node("B")
a.child, b.parent = b, a
del a, b
print("del 실행 완료")del 실행 완료
A 해제
B 해제del 직후에는 아무것도 찍히지 않고, 인터프리터가 종료되며 남은 객체를 정리할 때에야 두 줄이 나옵니다. 프로그램이 도는 내내 이 묶음이 메모리를 붙잡고 있었다는 뜻입니다. gc.disable()을 빼면 순환 수집기가 같은 묶음을 찾아내 회수합니다.
__del__이 붙어 있어도 수집 대상이 된다는 점은 짚어 둘 만합니다. 파이썬 3.4 이전에는 소멸자를 어느 쪽부터 불러야 할지 정할 수 없다는 이유로 이런 묶음을 gc.garbage에 쌓아 두고 손대지 않았는데, PEP 442가 소멸 단계와 메모리 해제 단계를 분리하면서 이 제약이 사라졌습니다.
약한 참조로 순환을 끊기
weakref로 만든 참조는 카운트를 올리지 않습니다. 부모와 자식이 서로를 알아야 하는 구조라면 한쪽 방향만 약한 참조로 바꿔도 고리가 끊어집니다.
import gc
import weakref
class Node:
def __init__(self, name):
self.name = name
self.child = None
self.parent = None
def __del__(self):
print(f"{self.name} 해제")
gc.disable()
p, c = Node("parent"), Node("child")
p.child, c.parent = c, weakref.ref(p)
del p, c
print("del 실행 완료")parent 해제
child 해제
del 실행 완료수집기를 꺼 둔 채로도 del 시점에 곧바로 해제됩니다. 대신 쓰는 쪽의 코드가 달라집니다. 약한 참조는 대상이 사라지면 None을 돌려주므로, c.parent()처럼 호출해서 살아 있는지 매번 확인해야 합니다.
순환을 아예 만들지 않는 선택지도 있습니다. 부모 객체 대신 인덱스나 키를 들고 다니거나, 다 쓴 참조를 None으로 명시적으로 끊으면 같은 효과를 냅니다.
마치며
참조 카운팅의 값어치는 해제 시점이 코드에 드러난다는 데 있습니다. 마지막 이름이 사라지는 그 줄에서 메모리가 돌아오므로, 큰 배열이나 파일 핸들을 언제 놓는지 추적하기가 쉽습니다. 대가는 모든 대입에서 카운트를 올리고 내리는 비용, 그리고 순환 하나를 못 지운다는 구멍입니다.
구멍을 어떻게 막을지가 실무의 선택지입니다. 순환 수집기에 맡기면 코드는 깨끗해지지만 회수 시점을 예측할 수 없고, 추적 대상 객체가 많아질수록 수집 비용이 눈에 띕니다. 약한 참조나 구조 변경으로 고리 자체를 없애면 카운트만으로 즉시 회수되는 대신, 참조가 살아 있는지 확인하는 코드가 붙습니다. 캐시나 옵저버, 부모 포인터처럼 순환이 자연스럽게 생기는 자리에서는 후자를 먼저 검토할 만합니다.
함께 보면 좋은 글
- 가비지 컬렉션 : 참조 카운팅이 놓친 순환을 수집기가 찾아내는 절차
- Global Interpreter Lock : 참조 카운트 갱신을 지켜 주는 잠금
- 스레드 동기화 : GIL이 있어도 lock이 필요한 이유