🧪 Problem-Driven Systems Lab

⚖️ Caso 05 — Comparativa multi-stack: Presión de memoria y fugas de recursos (PHP · Python · Node.js · Java · .NET · Go · Rust)#

TL;DR — legacy retiene sin limite; optimized se estabiliza en cap=1000. Seis stacks tienen GC y la fuga es memoria referenciada de mas; Rust no tiene GC y la fuga es memoria retenida de mas. Mismo grafico de heap, distinto mecanismo.

🐘 PHP · 🐍 Python · 🟢 Node.js · ☕ Java 21 · 🔵 .NET 8 · 🐹 Go 1.23 · 🦀 Rust 1.83

Estructura: 🎯 el problema → una seccion por stack → ⚖️ tabla de decision → 📊 primitiva por stack → 🏁 veredicto y ranking

🎯 El problema que ambos resuelven#

Un proceso de lotes que recibe documentos con payloads variables. La variante legacy acumula buffers sin limpiar, haciendo crecer la presión hasta degradar el servicio. La variante optimizada libera recursos tras cada item, manteniendo un footprint constante.


🐘 PHP: memory_limit, str_repeat, unset, gc_collect_cycles#

Runtime: PHP-FPM en su modelo clásico "nace para morir" — el proceso muere al final de cada request y la memoria se libera automáticamente. El problema aparece en workers de larga vida o en requests con payloads muy grandes que exceden memory_limit.

El fallo legacy en PHP:

php
$buffers = [];
for ($i = 0; $i < $documents; $i++) {
    $payload = str_repeat('x', $payloadKb * 1024);
    $encoded = base64_encode($payload);
    $buffers[] = $encoded;  // Referencia activa: GC no puede reclamar
}
// $buffers sigue vivo — todos los strings están en heap

str_repeat() genera el payload en heap. base64_encode() crea una copia codificada. Ambos quedan referenciados en $buffers. PHP no puede liberar lo que tiene referencias activas. El heap crece O(N) con documents.

La corrección en PHP:

php
for ($i = 0; $i < $documents; $i++) {
    $payload = str_repeat('x', $payloadKb * 1024);
    $hash = hash('sha256', $payload);     // Solo 64 bytes, no payload_kb KB
    $buffers[] = $hash;
    if (count($buffers) > 24) {
        array_shift($buffers);            // Evicción FIFO: O(1) en espacio
    }
    unset($payload);                      // Elimina la referencia explícitamente
}
gc_collect_cycles();                      // Ciclo GC forzado para referencias circulares

unset($payload) elimina la única referencia al string grande. Sin referencias activas, el GC de PHP lo reclama en el siguiente ciclo. array_shift() mantiene el array acotado a 24 entradas.

Medición en PHP: memory_get_usage() y memory_get_peak_usage() reportan uso real del heap PHP. El caso persiste estos valores en estado JSON para mostrar la evolución entre requests.


🐍 Python: tracemalloc, sys.getsizeof, gc.collect, referencias reales#

Runtime: ThreadingHTTPServer. El proceso vive indefinidamente. A diferencia de PHP-FPM, una referencia activa en un módulo persiste entre requests. Esto hace posible simular fugas reales, no solo dentro de una request.

El fallo legacy en Python — fuga real, no simulada:

python
# Módulo-nivel: persiste entre requests
_legacy_buffer_pool: list = []
LEGACY_HARD_CAP = 2000  # Cap de seguridad para evitar OOM en demo

def run_batch_legacy(documents, payload_kb):
    for i in range(documents):
        raw = secrets.token_bytes(max(1, payload_kb * 1024 // 8))
        b64 = base64.b64encode(raw).decode("ascii")
        _legacy_buffer_pool.append(b64)   # Referencia viva en módulo
        if len(_legacy_buffer_pool) > LEGACY_HARD_CAP:
            _legacy_buffer_pool[:] = _legacy_buffer_pool[-LEGACY_HARD_CAP:]

_legacy_buffer_pool vive en el módulo — no muere con la request. Cada llamada acumula más strings base64. El GC de Python no puede reclamarlos porque la lista los referencia activamente.

Medición con sys.getsizeof — tamaño real del objeto:

python
def deep_sizeof(obj) -> int:
    if isinstance(obj, list):
        return sys.getsizeof(obj) + sum(sys.getsizeof(x) for x in obj)
    if isinstance(obj, dict):
        return sys.getsizeof(obj) + sum(
            sys.getsizeof(k) + sys.getsizeof(v) for k, v in obj.items()
        )
    return sys.getsizeof(obj)

retained_kb = deep_sizeof(_legacy_buffer_pool) / 1024  # Bytes reales

sys.getsizeof() de stdlib reporta el tamaño real del objeto en bytes. No es una estimación manual — es lo que Python realmente tiene en heap.

La corrección en Python — evicción real, del explícito, gc.collect:

python
_optimized_cache: dict = {}  # Máximo 24 entradas

def run_batch_optimized(documents, payload_kb):
    for i in range(documents):
        raw = secrets.token_bytes(max(1, payload_kb * 1024 // 8))
        digest = hashlib.sha256(raw).hexdigest()[:16]
        del raw                            # Elimina la única referencia al buffer grande
        _optimized_cache[f"{i}-{digest}"] = digest
        if len(_optimized_cache) > 24:
            oldest = next(iter(_optimized_cache))
            del _optimized_cache[oldest]   # Evicción FIFO

    gc.collect()                           # Ciclo GC explícito post-batch
    retained_kb = deep_sizeof(_optimized_cache) / 1024

del raw elimina la referencia al buffer grande inmediatamente. gc.collect() libera cualquier ciclo de referencia que el contador de referencias no detectó. El cache está acotado a 24 × 16 bytes = 384 bytes máximo.

tracemalloc para snapshot antes/después:

python
if not tracemalloc.is_tracing():
    tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()
# ... batch ...
snapshot_after = tracemalloc.take_snapshot()
stats = snapshot_after.compare_to(snapshot_before, "lineno")
delta_kb = sum(s.size_diff for s in stats) / 1024

tracemalloc de stdlib mide la memoria asignada por el propio intérprete Python. El delta entre snapshots muestra exactamente cuánto asignó el batch — incluyendo overhead interno de listas y strings.


🟢 Node.js: V8 heap medido con process.memoryUsage(), fuga en array de modulo#

Runtime: Node.js 22 single-thread con event loop. El proceso vive indefinidamente, igual que Python — y como Python, las referencias a nivel de modulo persisten entre requests. Esa es la fuga "autentica" del laboratorio.

Medicion del heap V8:

javascript
const heapSnapshotKb = () => {
  const m = process.memoryUsage();
  return {
    heap_used_kb: Number((m.heapUsed / 1024).toFixed(2)),
    heap_total_kb: Number((m.heapTotal / 1024).toFixed(2)),
    rss_kb: Number((m.rss / 1024).toFixed(2)),
    external_kb: Number((m.external / 1024).toFixed(2)),
  };
};

La api process.memoryUsage() expone cuatro metricas distintas que no existen en PHP ni Python con esa separacion:

  • heapUsed: bytes vivos en el heap V8 (objetos JS).
  • heapTotal: bytes reservados por V8 para el heap.
  • rss: memoria del proceso entero (incluye stack, native code, etc.).
  • external: Buffers/ArrayBuffer fuera del heap V8 (I/O nativa).

Permite distinguir "fuga de objetos JS" (heapUsed sube) de "fuga de I/O nativa" (external/rss suben sin que heapUsed lo haga). Esa separacion no la tiene memory_get_usage() de PHP ni tracemalloc de Python.

La fuga real:

javascript
const legacyRetained = [];   // Modulo. V8 no puede reclamar nunca mientras la raiz exista.
const LEGACY_HARD_CAP = 2000;

const runBatch = async (mode, ...) => {
  if (mode === 'legacy') {
    for (let i = 0; i < documents; i += 1) {
      const raw = crypto.randomBytes((payloadKb * 1024) / 8);
      legacyRetained.push(raw.toString('base64'));
    }
    if (legacyRetained.length > LEGACY_HARD_CAP) {
      legacyRetained.splice(0, legacyRetained.length - LEGACY_HARD_CAP);
    }
  }
};

La referencia vive en el cierre del modulo. V8 no puede reclamar mientras esa raiz exista. El array crece request a request hasta el cap.

La sanitizacion:

javascript
const optimizedCache = new Map();
const OPTIMIZED_CACHE_MAX = 24;

if (mode === 'optimized') {
  for (let i = 0; i < documents; i += 1) {
    const raw = crypto.randomBytes((payloadKb * 1024) / 8);
    const digest = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
    optimizedCache.set(digest, true);
    // raw sale de scope; V8 GC lo reclama en el siguiente minor GC
  }
  if (optimizedCache.size > OPTIMIZED_CACHE_MAX) {
    const drop = [...optimizedCache.keys()].slice(0, optimizedCache.size - OPTIMIZED_CACHE_MAX);
    for (const k of drop) optimizedCache.delete(k);
  }
  if (typeof globalThis.gc === 'function') globalThis.gc();
}

raw vive en scope local de la iteracion. Despues de calcular el digest, el scope termina y V8 marca el buffer como reclamable. Solo el digest (16 chars) queda en el Map con eviction. Si Node corre con --expose-gc, globalThis.gc() fuerza un ciclo; sin el flag, V8 reclama solo (la presion baja casi igual).


☕ Java 21: heap del JVM + LinkedHashMap LRU built-in + Runtime metrics#

Runtime: JVM con GC generacional (G1 por defecto en JDK 21). El "leak" en Java NO es que el SO pierda memoria — es que el GC no puede recolectar porque las referencias siguen alcanzables desde una raiz (static field). El heap puede crecer hasta -Xmx y a partir de ahi OutOfMemoryError.

Motor de memoria: Runtime.getRuntime() expone tres numeros: totalMemory() (heap actualmente asignado), freeMemory() (libre dentro del heap actual), maxMemory() (limite via -Xmx). Sin agente, sin JFR, sin perfilador — basta con stdlib.

El fallo legacy en Java:

java
private static final List<byte[]> legacyAccumulator =
    Collections.synchronizedList(new ArrayList<>());

byte[] payload = new byte[sizeKb * 1024];
legacyAccumulator.add(payload);   // referencia retenida → GC no puede liberar

La static List es raiz GC. Cada add retiene el byte[]. Tras N requests, totalMemory() sube monoticamente hasta tocar maxMemory()OutOfMemoryError: Java heap space. El GC corre, pero no encuentra nada que recolectar.

La correccion en Java:

java
private static final Map<Integer, byte[]> optimizedCache =
    Collections.synchronizedMap(new LinkedHashMap<>(OPTIMIZED_CAP, 0.75f, true) {
        @Override protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> e) {
            return size() > OPTIMIZED_CAP;   // eviccion automatica → GC libera el oldest
        }
    });

LinkedHashMap.removeEldestEntry es LRU built-in del JDK — una linea agrega politica de eviccion. Cuando rebasa el cap, el Map quita la entrada mas antigua; la referencia muere; el GC en la proxima pasada libera memoria.

Senales propias del runtime: heap_used_mb, heap_total_mb, heap_max_mb. Diferencia con Node process.memoryUsage(): Node expone heapUsed/heapTotal/rss/external separados (V8 + Node buffers). Java agrega todo en el heap del JVM — no hay "buffers off-heap" excepto si usas ByteBuffer.allocateDirect() (fuera de scope de este caso).


🔵 .NET 8: LRU manual con Dictionary + LinkedList, Process.WorkingSet64 como senal#

Runtime: .NET 8 sobre HttpListener. CLR con GC generacional (Gen0/Gen1/Gen2 + LOH). Una fuga es referencia alcanzable desde una raiz static — el GC no la puede recolectar.

El fallo legacy en C#:

csharp
private static readonly List<byte[]> legacyAccumulator = new();
private static readonly object syncLeak = new();

var payload = new byte[sizeKb * 1024];
lock (syncLeak) { legacyAccumulator.Add(payload); }   // nunca se libera

Cada request agrega un nuevo byte[] al static List<>. Las referencias siguen alcanzables → GC nunca libera → WorkingSet64 crece monotonicamente. Si los payloads pasan de 85 KB van al LOH (Large Object Heap) y la presion se nota mas (Gen2 se dispara).

La correccion en C#:

csharp
private const int OPTIMIZED_CAP = 1000;
private static readonly Dictionary<int, LinkedListNode<(int K, byte[] V)>> index = new();
private static readonly LinkedList<(int K, byte[] V)> order = new();

lock (sync) {
    if (index.TryGetValue(k, out var node)) { order.Remove(node); order.AddFirst(node); }
    else {
        var n = order.AddFirst((k, payload));
        index[k] = n;
        if (index.Count > OPTIMIZED_CAP) {
            var last = order.Last!;
            order.RemoveLast();
            index.Remove(last.Value.K);   // eviccion → GC libera el oldest
        }
    }
}

.NET no tiene LinkedHashMap.removeEldestEntry como Java, asi que la LRU se compone manual con Dictionary<K, LinkedListNode<...>> + LinkedList<...>: lookup O(1) por dictionary, reordenamiento O(1) por linked list. Misma garantia que la version Java en una decena de lineas mas.

Senales propias del runtime:

  • Process.GetCurrentProcess().WorkingSet64 — RSS visto por el OS.
  • GC.GetTotalMemory(forceFullCollection: false) — memoria gestionada actual del CLR.
  • GC.CollectionCount(2) — cuantos GC Gen2 se han disparado (senal de presion seria).
  • GC.Collect() disponible en /reset-lab para forzar comparacion antes/despues.

Notas idiomaticas vs los otros stacks:

  • A diferencia de Java, .NET no trae LRU built-in en el BCL (hay que componerla a mano o usar MemoryCache con su politica de size limit).
  • A diferencia de PHP, el proceso vive indefinido — la fuga persiste como en Python/Node/Java.
  • using var + IDisposable es la disciplina .NET para liberar recursos no gestionados (sockets, handles). El leak del caso es deliberado para referencia, no para uso real.


🐹 Go 1.23: GC igual que Java, pero con runtime.ReadMemStats sin agente#

La fuga: un []byte global que crece por request. Igual que en Java, .NET, Node y Python, no es memoria sin liberar — es memoria referenciada de mas, que el GC no puede tocar porque el slice sigue apuntando a ella.

La solucion: LRU con container/list + map[int64]*list.Element. Go no trae un LinkedHashMap con removeEldestEntry como Java: hay que construirla. Mas codigo, cero magia oculta.

El instrumento: runtime.ReadMemStats da HeapAlloc, HeapSys y NumGC sin agente externo ni JMX, y runtime/debug.FreeOSMemory() es el equivalente honesto del System.gc() de Java.

El extra que ningun otro stack reporta: runtime.NumGoroutine(). Este caso no dispara esa fuga, pero en Go la mas habitual es una goroutine bloqueada para siempre en un canal sin lector — y esa metrica es la que la delata.


🦀 Rust 1.83: sin GC, liberacion deterministica y contada#

Este es el caso donde Rust dice algo que ningun otro stack del lab puede decir, y tambien donde mas facil seria contar una mentira comoda. Las dos cosas, explicitas:

Lo que Rust garantiza. No hay GC. La memoria se libera cuando el valor sale de scope, via Drop. Y aca eso es observable:

rust
impl Drop for Tracked {
    fn drop(&mut self) {
        LIVE_BYTES.fetch_sub(self.bytes.len() as i64, Ordering::Relaxed);
        DROPPED_TOTAL.fetch_add(1, Ordering::Relaxed);
    }
}

/state expone live_bytes y dropped_total. Verificado: 5 cargas de 512 KB dan live_mb 2, dropped_total 0; despues del reset, live_mb 0, dropped_total 5. La liberacion ocurre en el reset, no "en algun momento". Ningun otro stack del laboratorio puede mostrar esa cifra.

Lo que Rust NO garantiza — y es el punto del caso. El borrow checker no impide esta fuga. Meter cosas en un Vec global y no sacarlas nunca es codigo perfectamente seguro y perfectamente legal: compila sin un solo warning. Rust previene use-after-free, doble free y data races; no previene "guardar de mas".

La leccion cruzada: en PHP, Python, Node, Java, .NET y Go la fuga es memoria referenciada de mas que el GC no puede tocar. En Rust es memoria retenida de mas que el programador nunca solto. Distinto mecanismo, identico bug de diseño, identico grafico de heap subiendo hasta el OOM. Quien crea que elegir Rust lo protege de este caso, no leyo el caso.

⚖️ Diferencias de decision, no de correccion#

Los siete stacks implementan el mismo algoritmo. Esta tabla contrasta como lo expresa cada uno.

AspectoPHPPythonNode.jsJava.NETGoRustRazon
¿Hay GC?sisisisisisinoRust es el unico con liberacion deterministica.
Naturaleza de la fugareferencia retenidareferencia retenidareferencia retenidareferencia retenidareferencia retenidareferencia retenidavalor nunca soltadoDistinto mecanismo, identico bug de diseño.
¿El compilador la impide?nonononononotampoco — es codigo seguro y legalEl borrow checker previene use-after-free, no previene guardar de mas.
Instrumentomemory_get_usagetracemallocprocess.memoryUsage()Runtime + JMXProcess.WorkingSet64runtime.ReadMemStats sin agenteDrop que cuenta liberacionesSolo Rust puede reportar dropped_total: contabilidad del destructor.
LRUmanualOrderedDictMap + shiftLinkedHashMap built-inDictionary+LinkedListcontainer/list + mapVecDeque + HashMapSolo Java la trae lista; el resto se construye.

La diferencia mas importante: en PHP la fuga "se limpia" al morir el proceso. En Python y Node la fuga persiste en el modulo — comportamiento autentico de servicios long-running reales (workers, daemons, servidores web). Lo distintivo de Node: process.memoryUsage().external permite detectar fugas de Buffers/I/O nativa que tracemalloc no ve, y process.memoryUsage().rss mide el costo total para el OS independiente del runtime.


📊 Primitiva central por stack#

Los siete stacks resuelven el mismo problema. Lo que cambia es la primitiva y donde duele.

StackPrimitiva central en este caso
PHPproceso por request: la fuga muere con el proceso
Pythongc + sys.getsizeof
Node.jsprocess.memoryUsage(); heap V8
Java 21LinkedHashMap.removeEldestEntry LRU + Runtime metrics
.NET 8Dictionary+LinkedList LRU + Process.WorkingSet64
Go 1.23container/list LRU + runtime.ReadMemStats + NumGoroutine()
Rust 1.83sin GC: impl Drop que cuenta sus liberaciones (dropped_total observable)

🏁 Veredicto: que stack resuelve mejor este problema#

⚠️ Ranking de fit, no de calidad de lenguaje. Mide que tan directamente las primitivas nativas del runtime expresan la solucion de este caso concreto. El orden cambia — a veces se invierte — de un caso a otro: leer varios rankings juntos dice mas que cualquiera por separado.

StackPor que
🥇Rust 1.83Liberacion deterministica y observable: dropped_total es contabilidad real del destructor. Ningun otro stack puede mostrar esa cifra.
🥈Go 1.23runtime.ReadMemStats sin agente externo, y NumGoroutine() cubre ademas la fuga de concurrencia tipica del runtime.
🥉Java 21LinkedHashMap.removeEldestEntry es la unica LRU built-in del set; el resto se construye a mano.
.NET 8Process.WorkingSet64 + LRU manual con Dictionary+LinkedList.
Node.js 22process.memoryUsage() distingue heap V8 de RSS, que es mas de lo que ofrecen los dos ultimos.
Python 3.12 / PHP 8.3tracemalloc sirve; en PHP el proceso muere y se lleva la fuga puesta, lo que oculta el problema en vez de resolverlo.

Lectura honesta: Ojo con la lectura facil: Rust gana en instrumentacion, no en inmunidad. El borrow checker no impide esta fuga — meter cosas en un Vec global compila sin un warning.

Ver esta carpeta en GitHub ↗