El modelo de memoria de CPython descansa sobre dos mecanismos que operan en capas distintas: reference counting como primer nivel, y un collector de ciclos generacional como red de seguridad. Entender ambos —y la fisura exacta que los une— es lo que separa a quien depura un leak de memoria en producción de quien simplemente reinicia el proceso y reza.
Cada objeto Python vive en el heap como una estructura C con un campo ob_refcnt. Cada vez que algo apunta a ese objeto —una variable, un elemento en una lista, un argumento en una llamada— CPython incrementa ese contador. Cuando la referencia desaparece, lo decrementa. Si ob_refcnt llega a cero, el objeto se destruye en ese mismo instante, sin pausa, sin coordinación con ningún hilo de GC. Eso es lo que hace que __del__ sea determinista en CPython (a diferencia de otras implementaciones como PyPy o Jython).
El problema estructural es bien conocido pero merece precisión: si a tiene una referencia a b y b tiene una referencia a a, ninguno de los dos llegará jamás a ob_refcnt == 0 aunque no exista ninguna referencia externa al ciclo. Esa es la referencia circular y es la razón por la que reference counting puro, como mecanismo único, crea leaks inevitables.
El módulo gc tapa ese agujero con un collector de ciclos generacional de tres generaciones. La premisa —la hipótesis generacional— es empírica: la mayoría de los objetos mueren jóvenes. Los objetos recién creados entran en la generación 0; si sobreviven una colección, ascienden a la generación 1; si sobreviven otra, a la generación 2. El collector examina cada generación con frecuencia decreciente (por defecto 700/10/10 de threshold), buscando grafos de objetos mutuamente referenciados que el reference counting no puede limpiar. Cuando los encuentra, los libera.
Lo que gc no hace es reemplazar al reference counting: la inmensa mayoría de los objetos Python nunca son tocados por el collector de ciclos porque mueren solos antes de que haya razón para examinarlos.
import gc
import sys
import ctypes
from typing import Optional
class Node:
"""Nodo de grafo con referencia arbitraria a otro nodo."""
def __init__(self, name: str) -> None:
self.name = name
self.peer: Optional["Node"] = None
def __repr__(self) -> str:
return f"Node({self.name!r})"
def __del__(self) -> None:
# Este método se invoca cuando ob_refcnt llega a 0 (o cuando el
# collector de ciclos libera el objeto). Útil para observar cuándo
# ocurre realmente la destrucción.
print(f" __del__ called for {self.name!r}")
def demo_refcount_basics() -> None:
print("=== reference counting básico ===")
x = Node("alpha")
# sys.getrefcount devuelve ob_refcnt + 1 porque el propio argumento
# incrementa el contador mientras dura la llamada.
print(f"refcount tras creación: {sys.getrefcount(x)}") # 2
y = x # segunda referencia
print(f"refcount con alias: {sys.getrefcount(x)}") # 3
del y # decremento; objeto sigue vivo
print(f"refcount tras del y: {sys.getrefcount(x)}") # 2
# Al salir de la función, x sale de scope → ob_refcnt cae a 0 → __del__
def demo_cycle_leak() -> None:
print("\n=== ciclo sin __del__ personalizado: el GC lo recoge ===")
gc.disable() # deshabilitamos el GC para ver el leak limpiamente
a = Node("left")
b = Node("right")
a.peer = b # a → b
b.peer = a # b → a (ciclo cerrado)
# Guardamos la dirección de memoria para verificar después.
addr_a = id(a)
addr_b = id(b)
del a, del b # SyntaxError si se pone así; forma correcta:
# (corrección inline en el código real abajo)
def demo_cycle_leak_correct() -> None:
print("\n=== ciclo: reference counting no puede limpiar esto ===")
gc.disable()
a = Node("left")
b = Node("right")
a.peer = b
b.peer = a
# Contadores tras crear el ciclo: cada objeto tiene 2 referencias
# (la variable local + la referencia cruzada del otro nodo).
print(f"refcount a: {sys.getrefcount(a)}") # 3 (a, b.peer, argumento)
print(f"refcount b: {sys.getrefcount(b)}") # 3
addr_a = id(a)
addr_b = id(b)
del a
del b
# ob_refcnt de ambos bajó a 1 (la referencia cruzada), no a 0.
# __del__ NO se ejecutó. Los objetos siguen vivos en el heap.
# ctypes nos permite inspeccionar si el objeto sigue ahí
# (técnica de diagnóstico; no usar en producción)
ref_a = ctypes.cast(addr_a, ctypes.py_object).value
print(f"objeto 'left' aún existe tras del: {ref_a}") # Node('left')
print("forzando colección de ciclos con gc.collect()...")
collected = gc.collect() # examina las tres generaciones
print(f"objetos recogidos: {collected}")
# Ahora sí se ejecutan los __del__ y la memoria se libera.
gc.enable()
def demo_generations() -> None:
print("\n=== inspección de generaciones ===")
gc.collect() # limpiamos estado previo
threshold = gc.get_threshold()
print(f"thresholds (gen0, gen1, gen2): {threshold}") # (700, 10, 10)
counts_before = gc.get_count()
print(f"objetos en seguimiento antes: {counts_before}")
# Creamos varios objetos rastreables (instancias de clase siempre lo son)
nodes = [Node(f"n{i}") for i in range(5)]
counts_after = gc.get_count()
print(f"objetos en seguimiento después: {counts_after}")
# El GC solo rastrea objetos que *pueden* participar en ciclos.
# int, str, float son inmutables y no contienen referencias → no rastreados.
print(f"¿int rastreado? {gc.is_tracked(42)}") # False
print(f"¿Node rastreado? {gc.is_tracked(nodes[0])}") # True
print(f"¿tuple vacío rastreado? {gc.is_tracked(())}") # False (optimización)
print(f"¿lista rastreada? {gc.is_tracked([])}") # True
if __name__ == "__main__":
demo_refcount_basics()
demo_cycle_leak_correct()
demo_generations()
Lo que está pasando, capa por capa
demo_refcount_basics muestra el comportamiento de ob_refcnt directamente. El +1 que siempre devuelve sys.getrefcount no es un error ni quirk: es el incremento producido por pasar el objeto como argumento a la función. Cuando la llamada termina, ese incremento se revierte. Es la confirmación de que cada uso del objeto, incluyendo pasarlo a una función, cuenta.
demo_cycle_leak_correct expone la fisura. Tras crear el ciclo a ↔ b, cada nodo tiene exactamente dos referencias reales: la variable local y la referencia cruzada del compañero. Al ejecutar del a y del b, eliminamos las variables locales, pero las referencias cruzadas siguen ahí. ob_refcnt nunca llega a cero. Los objetos flotan en el heap sin que nadie los alcance. El truco con ctypes.cast confirma que siguen ocupando memoria en esa dirección.
gc.collect() desencadena el algoritmo de detección de ciclos. El collector recorre el grafo de objetos rastreados, calcula cuáles son inalcanzables y los libera. En ese momento se disparan los __del__. El valor de retorno de gc.collect() es el número de objetos que no podía limpiar el reference counting solo.
demo_generations ilustra una decisión de diseño importante: el GC no rastrea todos los objetos, solo aquellos que pueden contener referencias a otros objetos. int, str, float y bytes son inmutables y no tienen slots de referencia interna, así que gc.is_tracked() devuelve False para ellos. Las tuplas vacías tampoco se rastrean por optimización. Esto mantiene el overhead del collector bajo en workloads donde la mayoría de los objetos son datos escalares.
Errores que debes conocer
Error: llamar a gc.disable() en producción para “mejorar el rendimiento” sin entender que el leak de ciclos se acumulará silenciosamente hasta que el proceso explote.
# ❌ Wrong
gc.disable() # "el GC es lento, lo desactivo"
def process_request(data):
result = build_complex_graph(data) # crea ciclos internamente
return result.summary()
# Los grafos intermedios se acumulan en el heap indefinidamente.
# ✅ Right
# Si realmente necesitas evitar pausas del GC en secciones críticas:
import gc
gc.disable()
try:
result = latency_critical_section()
finally:
gc.enable()
gc.collect() # limpia los ciclos acumulados durante la sección
Deshabilitar el GC solo tiene sentido en ventanas de tiempo acotadas; siempre hay que re-habilitarlo y forzar una colección después.
Error: confiar en que __del__ se ejecuta inmediatamente cuando hay un ciclo, lo que rompe la gestión de recursos como conexiones o ficheros.
# ❌ Wrong
class Connection:
def __del__(self):
self.socket.close() # puede no ejecutarse nunca si hay ciclo
conn = Connection()
conn.callback = lambda: conn # ciclo: conn → lambda → conn
del conn
# __del__ queda en manos del GC, no del reference counting.
# La conexión puede permanecer abierta por tiempo indeterminado.
# ✅ Right
class Connection:
def close(self):
self.socket.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
with Connection() as conn:
conn.callback = lambda: conn
# El cierre es determinista independientemente de ciclos.
__exit__ garantiza el cierre de recursos en el punto exacto de salida del bloque; __del__ es una trampa si el objeto puede participar en un ciclo.
Error: interpretar que gc.collect() libera toda la memoria Python al sistema operativo. El collector libera objetos al allocator interno de CPython (pymalloc), que gestiona arenas de memoria. Esas arenas raramente se devuelven al SO hasta que el proceso termina.
# ❌ Wrong — expectativa incorrecta
import gc, tracemalloc
tracemalloc.start()
data = [bytearray(1024 * 1024) for _ in range(100)] # 100 MB
del data
gc.collect()
# El RSS del proceso visto desde el SO apenas cambia. No es un leak;
# pymalloc retiene las arenas para reutilización futura.
# ✅ Right — para devolver memoria al SO usar allocator alternativo o
# separar el trabajo pesado en un subproceso que sí termina:
from multiprocessing import Process
def heavy_work():
data = [bytearray(1024 * 1024) for _ in range(100)]
process(data)
# Al terminar el proceso, el SO recupera toda la memoria.
p = Process(target=heavy_work)
p.start()
p.join()
Si el RSS es un requisito duro, multiprocessing o mmap con liberación explícita son las herramientas correctas; gc.collect() solo es apropiado para lo que fue diseñado: romper ciclos.
N° 172