Los memory leaks en Python son traicioneros precisamente porque “no deberían existir”: el GC lo gestiona todo, ¿no? La realidad es que el GC solo libera objetos cuando su recuento de referencias cae a cero (o cuando el colector cíclico los alcanza). Si algo mantiene una referencia viva a un objeto —un caché global, un closure capturado, un callback registrado en un framework— ese objeto nunca se libera, y el proceso crece indefinidamente.
El problema no es el GC. El problema es tu código retiene referencias sin darte cuenta.
tracemalloc es el instrumentador de memoria de la stdlib. Cuando llamas a tracemalloc.start(), el intérprete empieza a interceptar cada malloc interno y le adjunta el traceback de Python que lo originó. Después puedes tomar snapshots del heap en distintos momentos y compararlos: lo que apareció entre snapshot A y snapshot B es lo que se alocó en ese intervalo sin liberarse. Esto te da nombres de archivo, número de línea y bytes consumidos, no solo conteos de objetos genéricos.
memory_profiler (paquete externo) opera diferente: decora funciones con @profile y mide el RSS del proceso línea por línea usando /proc. Es más grueso pero útil para ver dónde salta el consumo en funciones largas.
Cuándo usas cada uno: tracemalloc cuando tienes un leak confirmado y necesitas rastrear su origen con precisión quirúrgica; memory_profiler cuando todavía estás explorando qué función es la culpable. El error clásico es ir directo a @profile sin haber reducido el espacio de búsqueda — acabas leyendo miles de líneas de output sin contexto.
Si tracemalloc te muestra un objeto que crece pero no entiendes quién lo retiene, gc.get_referrers(obj) te devuelve la lista de todos los objetos que apuntan a él. Úsalo con cuidado: en un proceso grande puede devolverte cientos de referenciadores, y el propio get_referrers crea referencias temporales que contaminan el análisis si no tienes cuidado.
import tracemalloc
import gc
import linecache
from typing import Any
# ── Simulación de un leak real ────────────────────────────────────────────────
# Un "registro de eventos" que nunca se vacía porque los callbacks
# mantienen referencias a closures que capturan objetos grandes.
_event_registry: list[Any] = [] # leak: nadie limpia esta lista
def register_handler(payload: dict) -> None:
"""Registra un handler que captura `payload` en un closure."""
def handler():
return payload # closure retiene `payload` vivo
_event_registry.append(handler)
def simulate_workload(n: int) -> None:
for i in range(n):
# Cada iteración crea un dict de ~1 KB y lo "registra"
data = {"id": i, "buffer": "x" * 1024}
register_handler(data)
# ── Profiling con tracemalloc ─────────────────────────────────────────────────
def display_top(snapshot, key_type: str = "lineno", limit: int = 10) -> None:
"""Imprime las líneas que más memoria alocaron en un snapshot."""
stats = snapshot.statistics(key_type)
print(f"\n{'═'*60}")
print(f"Top {limit} alocaciones por {key_type}:")
print(f"{'═'*60}")
for stat in stats[:limit]:
frame = stat.traceback[0]
filename = frame.filename
lineno = frame.lineno
line = linecache.getline(filename, lineno).strip()
print(f" {filename}:{lineno}: {stat.size / 1024:.1f} KiB")
print(f" └─ {line}")
total = sum(s.size for s in stats)
print(f"\nTotal capturado: {total / 1024 / 1024:.2f} MiB\n")
def find_referrers_of_large_objects(threshold_bytes: int = 50_000) -> None:
"""
Busca objetos en el heap que superen `threshold_bytes`
e imprime quién los referencia.
Solo útil cuando ya sabes que hay un leak; es costoso.
"""
gc.collect() # fuerza ciclo antes de inspeccionar
for obj in gc.get_objects():
try:
import sys
size = sys.getsizeof(obj)
except TypeError:
continue
if size > threshold_bytes:
referrers = gc.get_referrers(obj)
# filtramos el propio frame de esta función para no contaminar
referrers = [
r for r in referrers
if not isinstance(r, dict) or "__name__" not in r
]
if referrers:
print(f"Objeto {type(obj).__name__!r} ({size} bytes)")
print(f" Referenciado por {len(referrers)} objetos:")
for ref in referrers[:3]: # solo los primeros 3
print(f" · {type(ref).__name__}: {repr(ref)[:80]}")
def main() -> None:
# Empieza a trazar con stack depth de 5 frames para contexto útil
tracemalloc.start(5)
snapshot_before = tracemalloc.take_snapshot()
simulate_workload(500)
snapshot_after = tracemalloc.take_snapshot()
# ── Comparación de snapshots ──────────────────────────────────────────────
print("\n📊 DIFERENCIA entre snapshots (lo que creció):")
top_stats = snapshot_after.compare_to(snapshot_before, "lineno")
for stat in top_stats[:5]:
print(f" {stat}")
# ── Top alocadores en el snapshot final ──────────────────────────────────
display_top(snapshot_after, key_type="lineno", limit=5)
# ── Inspección de referrers para el objeto sospechoso ────────────────────
print("🔍 Buscando quién retiene objetos grandes:")
find_referrers_of_large_objects(threshold_bytes=10_000)
tracemalloc.stop()
if __name__ == "__main__":
main()
Qué está pasando en cada decisión
tracemalloc.start(5) activa el trazado con un stack depth de 5. El default es 1 (solo el frame inmediato), lo que te da la línea donde ocurre el malloc pero no el contexto de quién llamó a esa línea. Con depth 5 puedes ver la cadena de llamadas y distinguir si el mismo tipo de objeto se está creando desde dos rutas distintas.
take_snapshot() congela el estado del heap en ese instante. La clave es que dos snapshots comparados te aíslan exactamente qué fue alocado en el intervalo que te importa: antes de tu operación sospechosa y después. compare_to("lineno") devuelve StatisticDiff ordenados por crecimiento neto — lo que más creció primero.
display_top usa linecache.getline para mostrar el código fuente de cada frame. Esto transforma números de línea anónimos en código legible directamente en el output. Sin esto estás leyendo archivo.py:247 y tienes que ir a abrir el archivo manualmente.
El leak en simulate_workload es intencional y representative: _event_registry guarda closures, y cada closure captura data por referencia. El GC no puede liberar data porque handler.__closure__ lo referencia, y _event_registry referencia a handler. Es el patrón exacto que aparece en frameworks de eventos, sistemas de plugins y cachés implementados a mano.
find_referrers_of_large_objects demuestra el workflow de último recurso: cuando tracemalloc te dice dónde se creó el objeto pero no entiendes por qué sigue vivo. gc.get_referrers opera sobre el heap vivo —después de gc.collect()— y te lista todos los contenedores que apuntan al objeto. El filtro del frame local es importante: el propio dict de variables locales de tu función de análisis aparece como referenciador si no lo excluyes, lo que genera falsos positivos confusos.
Errores que debes conocer
Error: Comparar snapshots tomados en threads diferentes sin sincronización. tracemalloc registra alocaciones por thread, pero take_snapshot() captura el estado global. Si otro thread está alocando activamente mientras tomas el snapshot, el diff incluirá noise que no corresponde al código que estás investigando.
# ❌ Wrong import threading threading.Thread(target=simulate_workload, args=(1000,)).start() snapshot = tracemalloc.take_snapshot() # captura estado parcial con race # ✅ Right t = threading.Thread(target=simulate_workload, args=(1000,)) t.start() t.join() # espera a que termine antes de capturar snapshot = tracemalloc.take_snapshot()
join() garantiza que el thread terminó su trabajo antes de congelar el heap, dando un snapshot determinista.
Error: Usar sys.getsizeof para medir el tamaño real de objetos complejos. getsizeof devuelve solo el tamaño del objeto inmediato, sin contar los objetos a los que apunta.
# ❌ Wrong
import sys
data = {"buffer": "x" * 1_000_000}
print(sys.getsizeof(data)) # devuelve ~200 bytes, ignora el string
# ✅ Right
# tracemalloc ya acumula el tamaño transitivo; o usa pympler.asizeof
from pympler import asizeof
print(asizeof.asizeof(data)) # devuelve el tamaño real del grafo de objetos
tracemalloc resuelve esto implícitamente porque rastrea cada alocación individual, pero si estás haciendo comparaciones manuales, asizeof de pympler es la herramienta correcta.
Error: Llamar gc.get_referrers(obj) dentro de un comprehension o función generadora sin capturar el frame. El objeto se convierte en elemento del iterator interno de Python, que aparece como referenciador legítimo y enturbia los resultados.
# ❌ Wrong
# El objeto aparece referenciado por el generator frame interno
suspects = [gc.get_referrers(o) for o in gc.get_objects() if condition(o)]
# ✅ Right
# Materializa la lista primero, luego inspecciona fuera del comprehension
candidates = [o for o in gc.get_objects() if condition(o)]
for obj in candidates:
refs = gc.get_referrers(obj)
process(refs)
Al separar la iteración de la inspección, el frame del comprehension ya no está activo cuando llamas a get_referrers, eliminando ese referenciador espurio.
N° 165