🧪 Problem-Driven Systems Lab

⚖️ Caso 11 — Comparativa multi-stack: Reportes pesados que bloquean la operación (PHP · Python · Node.js · Java · .NET · Go · Rust)#

TL;DR — el reporte sin acotar le come CPU a /order-write; con limitador, la operacion conserva su latencia. Es el caso que no se traduce literal: Go y Rust no tienen pool de threads que agotar.

🐘 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 reporting analítico que compite con escrituras operacionales por el mismo recurso de bloqueo. La variante legacy comparte el lock entre reporting y escrituras, causando contention. La variante isolated usa locks separados por dominio, protegiendo las escrituras del impacto analítico.


🐘 PHP: flock() — bloqueo a nivel de sistema operativo#

Runtime: PHP-FPM. Cada request es un proceso separado. No hay estado compartido en memoria entre procesos. El único mecanismo de sincronización cross-process es el sistema de archivos — por eso PHP usa flock().

El fallo legacy en PHP:

php
// El reporte y las escrituras comparten el MISMO archivo de lock
$lockFile = '/tmp/pdsl-case11-php/shared.lock';

// Ruta de reporte (proceso FPM #1):
$fp = fopen($lockFile, 'w');
flock($fp, LOCK_EX);                 // Adquiere lock exclusivo — bloquea
usleep($reportDurationUs);           // Simula procesamiento largo (segundos)
flock($fp, LOCK_UN);                 // Libera

// Ruta de escritura (proceso FPM #2, concurrente):
$fp2 = fopen($lockFile, 'w');
if (!flock($fp2, LOCK_EX | LOCK_NB)) {  // Non-blocking: falla si ocupado
    http_response_code(503);
    echo json_encode(['error' => 'lock_contention', 'blocked_ms' => $elapsed]);
    exit;
}

flock(LOCK_EX) es un bloqueo exclusivo a nivel de kernel del SO. Cuando el proceso de reporte lo retiene, flock(LOCK_EX | LOCK_NB) en el proceso de escritura retorna false inmediatamente. El LOCK_NB (non-blocking) es la clave: sin él, la escritura esperaría indefinidamente.

La corrección en PHP — locks separados:

php
// Reporte aislado: usa su propio archivo de lock
$reportLockFile = '/tmp/pdsl-case11-php/reporting.lock';
$opLockFile     = '/tmp/pdsl-case11-php/operational.lock';

// El reporte solo toca el reporting lock — nunca el operational
$fp = fopen($reportLockFile, 'w');
flock($fp, LOCK_EX);
// ... procesamiento analítico ...
flock($fp, LOCK_UN);

// Las escrituras solo tocan el operational lock — nunca el reporting
$fp2 = fopen($opLockFile, 'w');
if (!flock($fp2, LOCK_EX | LOCK_NB)) { /* contention en op lock */ }

Dos archivos de lock distintos. Los dominios son físicamente separados a nivel de kernel. El reporting nunca interfiere con las escrituras.

Por qué PHP necesita flock() y no threading: PHP-FPM ejecuta procesos OS separados. No hay threading.Lock inter-proceso en PHP. flock() es la única primitiva de sincronización cross-process portable de PHP stdlib.


🐍 Python: threading.Lock — bloqueo en proceso#

Runtime: ThreadingHTTPServer. Todos los handlers de request corren como hilos del mismo proceso Python. threading.Lock es la primitiva de sincronización correcta para hilos en el mismo proceso.

El fallo legacy en Python:

python
# Lock compartido entre reporting y escrituras
_shared_lock = threading.Lock()

# Ruta de reporte (hilo 1):
def handle_report_legacy(rows, period_days):
    _shared_lock.acquire(blocking=True)   # Adquiere — bloquea hasta conseguirlo
    try:
        time.sleep(report_duration)        # Simula procesamiento largo
    finally:
        _shared_lock.release()

# Ruta de escritura (hilo 2, concurrente):
def handle_order_write(mode="legacy"):
    acquired = _shared_lock.acquire(blocking=False)  # Non-blocking
    if not acquired:
        return {"error": "lock_contention", "blocked": True}, 503
    try:
        # ... procesa la escritura ...
    finally:
        _shared_lock.release()

threading.Lock().acquire(blocking=False) es el equivalente directo de flock(LOCK_EX | LOCK_NB). Si el lock está retenido por el reporte, la escritura retorna False inmediatamente y devuelve HTTP 503.

La corrección en Python — locks separados por dominio:

python
# Dos locks completamente independientes
_report_lock = threading.Lock()       # Solo para reporting
_operational_lock = threading.Lock()  # Solo para escrituras

def handle_report_isolated(rows, period_days):
    _report_lock.acquire(blocking=True)    # Solo toca el reporting lock
    try:
        time.sleep(report_duration)
    finally:
        _report_lock.release()

def handle_order_write(mode="isolated"):
    acquired = _operational_lock.acquire(blocking=False)  # Solo toca el op lock
    if not acquired:
        return {"error": "lock_contention"}, 503
    # El reporting NUNCA retiene _operational_lock → writes nunca bloquean

Los dos locks son objetos distintos en memoria. Son completamente independientes — no pueden interferir entre sí. Ningún código que toque _report_lock puede bloquear a código que toque _operational_lock.


🟢 Node.js: monitorEventLoopDelay() + setImmediate — el lock es el event loop#

Runtime: Node.js 22 single-thread. No hay locks porque no hay concurrencia paralela en el codigo JS — todo corre en un solo thread del event loop. La "contencion" es de otro tipo: una operacion sincronica costosa bloquea el loop entero y todas las requests concurrentes lo pagan.

El fallo legacy en Node — bloqueo sincronico del event loop:

javascript
const blockEventLoop = (ms) => {
  const end = Date.now() + ms;
  while (Date.now() < end) { /* spin */ }   // CPU sincronico, no cede el loop
};

const runReportFlow = async (mode, scenario, rows) => {
  if (mode === 'legacy') {
    const blockMs = Math.min(900, 200 + Math.floor(rows / 1000));
    blockEventLoop(blockMs);   // bloquea TODAS las requests concurrentes
  }
};

Mientras el while corre, ninguna otra request puede ser atendida — el writer concurrente espera. Es el equivalente Node de un flock(LOCK_EX) que no cede.

La medicion del impacto con primitiva Node:

javascript
const { monitorEventLoopDelay } = require('perf_hooks');
const ELOOP = monitorEventLoopDelay({ resolution: 10 });
ELOOP.enable();

// En las metricas:
event_loop: {
  p50_ms: Number((ELOOP.percentile(50) / 1e6).toFixed(2)),
  p99_ms: Number((ELOOP.percentile(99) / 1e6).toFixed(2)),
  max_ms: Number((ELOOP.max / 1e6).toFixed(2)),
}

monitorEventLoopDelay() devuelve un histograma de Node nativo del lag del event loop. Tras correr report-legacy, event_loop_lag_ms_p99 sube notoriamente y el efecto sobre /order-write es directamente visible. PHP y Python no tienen esa metrica nativa porque su modelo de concurrencia es diferente.

La correccion isolated en Node:

javascript
if (mode === 'isolated') {
  await new Promise((r) => setImmediate(r));   // cede el loop al final del tick actual
  reporting.queue_depth = Math.min(120, ...);  // simula encolar a worker pool/replica
}

setImmediate es la forma idiomatica Node de decir "deja que otras operaciones pendientes corran antes". Para isolation real, el siguiente paso seria worker_threads — un thread paralelo de verdad para CPU-heavy. Lo dejamos como evolucion del caso.


☕ Java 21: ThreadPoolExecutor saturation observable + ExecutorService dedicado para reporting#

Runtime: El HttpServer JDK usa un Executor que entregamos — un ThreadPoolExecutor acotado (4 threads) hace que la saturacion sea realista y observable. Java no tiene event loop; el equivalente es saturacion del pool.

El fallo legacy en Java:

java
// /report-legacy corre SINCRONO en el thread del HttpServer (mainPool)
for (int i = 0; i < rows; i++) checksum += (i * 13L) % 7;
// → mainPool.getActiveCount sube; /order-write queda en queue

La correccion en Java:

java
ExecutorService reportingPool = Executors.newFixedThreadPool(2);

CompletableFuture<Long> fut = CompletableFuture.supplyAsync(() -> {
    for (int i = 0; i < rows; i++) checksum += (i * 13L) % 7;
    return checksum;
}, reportingPool);    // pool separado, mainPool intacto

Senal propia del runtime: mainPool.getActiveCount() y mainPool.getQueue().size() se exponen en /activity. Es el equivalente Java de monitorEventLoopDelay() de Node — observabilidad nativa de saturacion sin agente externo.


🔵 .NET 8: ConcurrentExclusiveSchedulerPair + ThreadPool.GetAvailableWorkerThreads#

Runtime: .NET 8 sobre HttpListener. CLR ThreadPool global compartido por todos los handlers. Reporting CPU-bound sin aislamiento → satura el pool → /order-write espera.

El fallo legacy en C#:

csharp
// /report-legacy corre SINCRONO en el worker thread del HttpListener (mainPool)
long checksum = 0;
for (int i = 0; i < rows; i++) checksum += (i * 13L) % 7;
// → ThreadPool.GetAvailableWorkerThreads cae; /order-write queda esperando

La correccion en C#:

csharp
private static readonly ConcurrentExclusiveSchedulerPair reportingPair = new();

var checksum = await Task.Factory.StartNew(() => {
    long c = 0;
    for (int i = 0; i < rows; i++) c += (i * 13L) % 7;
    return c;
}, CancellationToken.None, TaskCreationOptions.LongRunning, reportingPair.ExclusiveScheduler);
// ThreadPool global intacto; reporting corre en el scheduler exclusivo

Alternativa: Thread dedicado con Thread.IsBackground = true para trabajo realmente long-running. Otra opcion: Channel<T> para encolar trabajo y consumirlo desde un worker dedicado.

Senal propia del runtime:

csharp
ThreadPool.GetMaxThreads(out int maxWorker, out _);
ThreadPool.GetAvailableWorkerThreads(out int availWorker, out _);
int busy = maxWorker - availWorker;

Se exponen en /activity. Es el equivalente .NET de monitorEventLoopDelay() Node y de ThreadPoolExecutor.getActiveCount() Java — observabilidad nativa de saturacion sin agente externo.

Notas idiomaticas vs los otros stacks:

  • ConcurrentExclusiveSchedulerPair.ExclusiveScheduler reemplaza el Executors.newFixedThreadPool(2) Java — aislamiento del pool principal.
  • ThreadPool.GetAvailableWorkerThreads es la primitiva mas cercana a monitorEventLoopDelay() Node y ThreadPoolExecutor.getActiveCount() Java. Sin agentes APM.
  • A diferencia de Node, el CLR usa thread pool real (no event loop) — la saturacion se observa como "menos workers disponibles", no como "lag del loop".
  • A diferencia de Java, el ThreadPool del CLR es global por proceso (no se instancian pools por dominio), asi que aislar requiere TaskScheduler custom o Thread dedicado.


🐹 Go 1.23 y Rust 1.83: el caso que NO se traduce literal#

Java y .NET aislan con pools de threads separados: un ThreadPoolExecutor de 4 para trafico y otro de 2 para reporting. Ese modelo no existe en Go ni en Rust — ninguno tiene un ExecutorService en su biblioteca estandar que copiar.

Traducir literalmente el pool habria producido codigo que compila y no enseña nada, porque estaria resolviendo un problema que esos runtimes no tienen. Los dos usan limitadores de concurrencia, con primitivas distintas:

GoRust (std)
Limitadorchan struct{} con capacidad NMutex<usize> + Condvar
Esperael <- bloquea hasta que haya slotSLOT_FREED.wait(used) duerme al thread
Ceder CPUruntime.Gosched()thread::yield_now()
Unidad de concurrenciagoroutine, ~2 KB inicialthread del SO, ~8 MB de stack virtual
Multiplexadoel runtime reparte N goroutines sobre GOMAXPROCS1:1 — cada thread es del kernel
Escala practicacientos de miles de goroutinesmiles de threads, no cientos de miles

En Go el modo de falla no es "agotar el pool" —no hay pool— sino saturar el scheduler: una goroutine CPU-bound monopoliza su procesador logico, y si hay tantas como GOMAXPROCS, las que sirven trafico esperan.

En Rust el modelo thread-per-connection de este stack es honesto para un laboratorio y seria la primera cosa a cambiar en produccion: ahi se usa tokio, que multiplexa tareas igual que Go multiplexa goroutines. Escribirlo con std::thread mantiene el caso sin dependencias y deja el trade-off a la vista en vez de esconderlo detras de un runtime.

El sintoma final es el mismo en los siete stacks —la operacion se degrada— pero la causa raiz y el instrumento cambian. Ese es el punto del 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
Modelo de aislamientoprocesos FPMThreadPoolExecutor aparteworker_threadspool de threads dedicadoscheduler dedicadosemaforo de concurrenciaMutex + CondvarJava y .NET son el modelo canonico del problema; Go y Rust no tienen pool que separar.
Modo de falla realpool FPM agotadoGIL + poolevent loop bloqueadopool agotadopool agotadoscheduler saturadothreads del SO agotadosEl sintoma es el mismo; la causa raiz y el instrumento cambian.
Coste por unidad de concurrenciaproceso (~MB)thread del SOthread del SOthread del SOthread del SOgoroutine ~2 KBthread del SO (~8 MB virtual)Go escala a cientos de miles; Rust con std, a miles.
Ceder CPUn/atime.sleep(0)setImmediateThread.yield()Thread.Yield()runtime.Gosched()thread::yield_now()Todos tienen la primitiva; el trabajo pesado la usa periodicamente.
¿Espera activa o pasiva?n/apasivan/apasivapasivapasiva (canal)pasiva (Condvar)Ninguno quema CPU esperando turno, que es justo lo que el caso mide.

Lo distintivo de Node: el problema no es concurrencia paralela — es que un trabajo CPU-bound bloquea el loop entero y degrada todo el servicio. La medicion via monitorEventLoopDelay() es la primitiva exacta que detecta el bloqueo, sin instrumentacion adicional.


📊 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; el pool FPM es el limite
PythonThreadPoolExecutor separado (GIL de por medio)
Node.jsworker_threads para no bloquear el loop
Java 21pool de threads dedicado (ExecutorService de 2)
.NET 8ConcurrentExclusiveSchedulerPair / thread dedicado
Go 1.23semaforo de concurrencia — no hay pool que agotar; runtime.Gosched()
Rust 1.83Mutex+Condvar — duerme al thread, sin busy-wait; thread del SO 1:1

🏁 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.

StackPor que
🥇Java 21 / .NET 8Son el modelo canonico del problema: pools separados, y getActiveCount() / GetAvailableWorkerThreads exponen la saturacion sin agente externo. El caso nacio pensando en este modelo.
🥈Go 1.23No tiene pool que agotar, asi que resuelve con semaforo de concurrencia. La goroutine de ~2 KB le da un techo de escala que ningun otro stack alcanza.
🥉Rust 1.83Mutex + Condvar con espera pasiva es correcto y explicito, pero std::thread es 1:1 con el SO: el techo esta miles de veces mas abajo que en Go.
Node.js 22worker_threads saca el CPU del loop; es la unica forma de no bloquear el proceso entero.
Python 3.12ThreadPoolExecutor separado, con el GIL limitando el paralelismo real del trabajo CPU.
PHP 8.3Procesos FPM aislados: no hay nada que aislar, y tampoco nada que observar desde dentro.

Lectura honesta: Es el unico caso donde Java y .NET ganan, y por una buena razon: el problema esta enunciado en su vocabulario. Traducir literalmente su ExecutorService a Go o Rust habria producido codigo que compila y no enseña nada.

Ver esta carpeta en GitHub ↗