Memory Leaks en Python: Cómo Aparecen y Cómo Cazarlos

Python gestiona memoria automáticamente, pero eso no significa que sea imposible filtrarla. Un memory leak en Python no ocurre porque olvidaste llamar a free() — ocurre porque mantuviste viva una referencia que el garbage collector interpreta como intención. El GC solo puede recolectar objetos que nadie referencia; si algo los referencia, aunque sea accidentalmente, se quedan en memoria hasta que el proceso muere.

El mecanismo central de CPython es reference counting: cada objeto lleva un contador de cuántas referencias lo apuntan. Cuando ese contador llega a cero, el objeto se libera inmediatamente. El GC cíclico (módulo gc) existe como respaldo para romper referencias circulares que el conteo solo no puede resolver. Esto tiene una implicación directa: si creas un ciclo entre dos objetos que tienen __del__, CPython históricamente no podía determinar el orden seguro de destrucción y los dejaba en gc.garbage — un leak garantizado. Python 3.4+ resolvió esto parcialmente con PEP 442, pero los __del__ en ciclos siguen siendo tierra pantanosa.

Los patrones que más frecuentemente producen leaks en código real son cuatro: cachés manuales implementadas como dicts globales que crecen sin política de evicción, sistemas de eventos donde registras callbacks y nunca los deregistras (el observador retiene al observado, o viceversa), closures que capturan objetos grandes más tiempo del necesario, y estructuras de datos propias con ciclos donde __del__ complica la recolección.

Cuando algo crece sin límite, necesitas dos herramientas: tracemalloc para saber dónde se allocaron los bytes, y objgraph para entender qué mantiene vivos los objetos. Sin ambas perspectivas — bytes y grafo de referencias — vas a ciegas.

La solución estructural para cachés y registros de eventos es weakref: una referencia que no incrementa el reference count. Si el único camino hacia un objeto pasa por un weakref, el GC puede recolectarlo libremente.

import tracemalloc
import weakref
import gc
import objgraph
from collections import defaultdict
from typing import Callable, Any


# ── Simulación de un sistema de eventos con leak clásico ─────────────────

class EventBus:
    """
    Registro de listeners usando referencias fuertes: el bus retiene
    a cada listener indefinidamente aunque el caller ya no lo necesite.
    """
    def __init__(self) -> None:
        self._listeners: dict[str, list[Callable]] = defaultdict(list)

    def subscribe_strong(self, event: str, cb: Callable) -> None:
        self._listeners[event].append(cb)  # referencia fuerte → leak potencial

    def subscribe_weak(self, event: str, cb: Callable) -> None:
        # WeakMethod para métodos ligados; weakref.ref para funciones libres
        try:
            ref = weakref.WeakMethod(cb)
        except TypeError:
            ref = weakref.ref(cb)

        def wrapper(*args: Any, **kwargs: Any) -> None:
            fn = ref()
            if fn is not None:
                fn(*args, **kwargs)
            else:
                # El listener fue recolectado: se limpia solo en la próxima emisión
                self._listeners[event].remove(wrapper)

        self._listeners[event].append(wrapper)

    def emit(self, event: str, *args: Any) -> None:
        for cb in list(self._listeners[event]):
            cb(*args)


# ── Caché manual con leak vs. WeakValueDictionary ────────────────────────

class HeavyResource:
    def __init__(self, name: str) -> None:
        self.name = name
        self.payload = bytearray(1024 * 512)  # 512 KB por objeto

    def __repr__(self) -> str:
        return f"HeavyResource({self.name!r})"


# Caché con referencias fuertes: los objetos nunca se liberan aunque
# el caller haya descartado su propia referencia.
_strong_cache: dict[str, HeavyResource] = {}

# Caché con referencias débiles: el objeto se libera cuando nadie más
# lo referencia; la entrada desaparece automáticamente del dict.
_weak_cache: weakref.WeakValueDictionary[str, HeavyResource] = weakref.WeakValueDictionary()


def get_resource(key: str, use_weak: bool = False) -> HeavyResource:
    cache = _weak_cache if use_weak else _strong_cache
    if key not in cache:
        cache[key] = HeavyResource(key)
    return cache[key]


# ── Diagnóstico con tracemalloc + objgraph ────────────────────────────────

def demonstrate_leak() -> None:
    tracemalloc.start()
    snapshot_before = tracemalloc.take_snapshot()

    # Creamos objetos con la caché fuerte y descartamos las referencias locales
    resources = [get_resource(f"item-{i}") for i in range(20)]
    del resources  # las referencias locales desaparecen...
    gc.collect()   # ...pero _strong_cache sigue reteniéndolos

    snapshot_after = tracemalloc.take_snapshot()

    # Top 5 sitios de allocación por diferencia
    stats = snapshot_after.compare_to(snapshot_before, "lineno")
    print("── tracemalloc: top diferencias ──────────────────────────────")
    for stat in stats[:5]:
        print(stat)

    # objgraph muestra cuántas instancias de HeavyResource están vivas
    # y qué las retiene
    print(f"\nInstancias vivas de HeavyResource: {objgraph.count('HeavyResource')}")
    # backref_chain revela la cadena de retención hasta la raíz
    sample = next(iter(_strong_cache.values()))
    print("\n── objgraph: cadena de retención ─────────────────────────────")
    objgraph.show_backrefs(
        [sample],
        max_depth=4,
        # filename="backrefs.png"  # descomentar para exportar grafo visual
    )


def demonstrate_fix() -> None:
    tracemalloc.start()
    snapshot_before = tracemalloc.take_snapshot()

    resources = [get_resource(f"item-{i}", use_weak=True) for i in range(20)]
    del resources
    gc.collect()

    snapshot_after = tracemalloc.take_snapshot()
    stats = snapshot_after.compare_to(snapshot_before, "lineno")
    print("── tracemalloc con WeakValueDictionary ───────────────────────")
    for stat in stats[:5]:
        print(stat)

    print(f"\nInstancias vivas de HeavyResource: {objgraph.count('HeavyResource')}")
    print(f"Entradas en _weak_cache: {len(_weak_cache)}")  # debe ser 0


if __name__ == "__main__":
    print("=== ESCENARIO CON LEAK ===")
    demonstrate_leak()
    _strong_cache.clear()

    print("\n=== ESCENARIO SIN LEAK ===")
    demonstrate_fix()

Qué está pasando en cada decisión

subscribe_weak con WeakMethod: los métodos ligados (instance.method) son objetos temporales — un weakref.ref directo moriría inmediatamente porque nadie más retiene la referencia al método. WeakMethod resuelve esto almacenando por separado la referencia débil al objeto (self) y al método de la clase, reconstruyendo el método ligado solo cuando lo necesitas. Si en cambio registras una función libre, WeakMethod lanza TypeError y caemos al weakref.ref normal.

El wrapper interno que queda en la lista sí es una referencia fuerte, pero es un objeto liviano que solo apunta indirectamente al listener real. Cuando el listener muere, ref() devuelve None y el wrapper se elimina en la siguiente emisión — limpieza lazy sin necesidad de un thread de mantenimiento.

WeakValueDictionary: las claves son strings normales, pero los valores son referencias débiles. Cuando el único path hacia un HeavyResource es esta caché — es decir, el caller descartó su propia referencia — el reference count llega a cero y el objeto se libera. La entrada del dict desaparece automáticamente: no tienes que gestionar la evicción manualmente. El trade-off es que no puedes depender de que el objeto siga ahí; si necesitas que persista, alguien tiene que mantener una referencia fuerte.

tracemalloc.compare_to("lineno"): compara dos snapshots línea a línea y te dice exactamente qué línea de código allocó cuántos bytes adicionales. La clave es tomar el snapshot antes de crear los objetos sospechosos, no al inicio del proceso — así la diferencia es limpia.

objgraph.show_backrefs: recorre el grafo de referencias hacia atrás desde el objeto objetivo. En la salida (o en el PNG si lo exportas) vas a ver algo como dict → _strong_cache → module → .... Esa cadena es la prueba de quién está reteniendo al objeto. Sin esto, tracemalloc te dice cuántos bytes tienes pero no por qué no se liberaron.

gc.collect() explícito antes del snapshot: CPython tiene tres generaciones de GC cíclico que se ejecutan en umbrales. Si no fuerzas la colección, podrías estar midiendo objetos que el GC aún no procesó, contaminando la comparación.

Errores que debes conocer

Error: registrar métodos ligados con weakref.ref directamente, esperando que la referencia débil sobreviva.

# ❌ Wrong
class Handler:
    def on_event(self, data):
        print(data)

h = Handler()
ref = weakref.ref(h.on_event)  # h.on_event crea un objeto temporal
print(ref())  # None — ya fue recolectado

# ✅ Right
ref = weakref.WeakMethod(h.on_event)
print(ref())  # <bound method Handler.on_event of <Handler object>>

h.on_event crea un nuevo objeto de método ligado cada vez que lo evalúas; weakref.ref no crea ninguna referencia fuerte, así que ese objeto temporal se recolecta al instante. WeakMethod retiene por separado la referencia débil a h y al método de la clase.


Error: confiar en __del__ para liberar recursos en objetos que participan en ciclos de referencia.

# ❌ Wrong
class Node:
    def __init__(self):
        self.sibling = None

    def __del__(self):
        print("limpiando")  # puede no ejecutarse nunca si hay ciclo

a, b = Node(), Node()
a.sibling = b
b.sibling = a  # ciclo: a → b → a
del a, b
# En Python < 3.4, terminan en gc.garbage y __del__ no se llama

# ✅ Right — usa contextmanager o cierra recursos explícitamente
import contextlib

class Node:
    def __init__(self):
        self.sibling = None

    @contextlib.contextmanager
    def managed(self):
        try:
            yield self
        finally:
            self.sibling = None  # rompe el ciclo antes de salir del scope

Romper el ciclo explícitamente antes de que el objeto quede sin referencias hace que el reference counting ordinario pueda liberarlo, sin depender del GC cíclico ni de __del__.


Error: usar objgraph.show_backrefs sobre una variable local justo después de asignarla, y confundirse con las referencias del frame activo.

# ❌ Wrong
obj = MyObject()
objgraph.show_backrefs([obj])  # el frame actual aparece como retenedor → ruido

# ✅ Right
def find_real_retainers(target):
    # Llamar desde una función distinta elimina el frame del caller del grafo
    objgraph.show_backrefs([target], max_depth=5)

obj = MyObject()
find_real_retainers(obj)

El grafo de referencias incluye el frame de Python activo como retenedor del objeto. Si mides desde el mismo contexto donde creaste el objeto, ese frame aparece como raíz y oscurece los retenedores reales. Delegar la llamada a una función separada evita que el frame investigado forme parte del análisis.


Un detalle operacional que vale la pena tener en producción: tracemalloc tiene overhead no trivial (~30% de memoria adicional en algunos workloads), así que lo ideal es activarlo condicionalmente con una variable de entorno o un endpoint de diagnóstico, no de forma permanente en todos los procesos.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio