⚖️ 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:
// 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:
// 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:
# 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:
# 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 bloqueanLos 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:
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:
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:
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:
// /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 queueLa correccion en 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 intactoSenal 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#:
// /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 esperandoLa correccion en C#:
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 exclusivoAlternativa: 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:
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.ExclusiveSchedulerreemplaza elExecutors.newFixedThreadPool(2)Java — aislamiento del pool principal.ThreadPool.GetAvailableWorkerThreadses la primitiva mas cercana amonitorEventLoopDelay()Node yThreadPoolExecutor.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
ThreadPooldel CLR es global por proceso (no se instancian pools por dominio), asi que aislar requiereTaskSchedulercustom oThreaddedicado.
🐹 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:
| Go | Rust (std) | |
|---|---|---|
| Limitador | chan struct{} con capacidad N | Mutex<usize> + Condvar |
| Espera | el <- bloquea hasta que haya slot | SLOT_FREED.wait(used) duerme al thread |
| Ceder CPU | runtime.Gosched() | thread::yield_now() |
| Unidad de concurrencia | goroutine, ~2 KB inicial | thread del SO, ~8 MB de stack virtual |
| Multiplexado | el runtime reparte N goroutines sobre GOMAXPROCS | 1:1 — cada thread es del kernel |
| Escala practica | cientos de miles de goroutines | miles 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.
| Aspecto | PHP | Python | Node.js | Java | .NET | Go | Rust | Razon |
|---|---|---|---|---|---|---|---|---|
| Modelo de aislamiento | procesos FPM | ThreadPoolExecutor aparte | worker_threads | pool de threads dedicado | scheduler dedicado | semaforo de concurrencia | Mutex + Condvar | Java y .NET son el modelo canonico del problema; Go y Rust no tienen pool que separar. |
| Modo de falla real | pool FPM agotado | GIL + pool | event loop bloqueado | pool agotado | pool agotado | scheduler saturado | threads del SO agotados | El sintoma es el mismo; la causa raiz y el instrumento cambian. |
| Coste por unidad de concurrencia | proceso (~MB) | thread del SO | thread del SO | thread del SO | thread del SO | goroutine ~2 KB | thread del SO (~8 MB virtual) | Go escala a cientos de miles; Rust con std, a miles. |
| Ceder CPU | n/a | time.sleep(0) | setImmediate | Thread.yield() | Thread.Yield() | runtime.Gosched() | thread::yield_now() | Todos tienen la primitiva; el trabajo pesado la usa periodicamente. |
| ¿Espera activa o pasiva? | n/a | pasiva | n/a | pasiva | pasiva | pasiva (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.
| Stack | Primitiva central en este caso |
|---|---|
| PHP | proceso por request; el pool FPM es el limite |
| Python | ThreadPoolExecutor separado (GIL de por medio) |
| Node.js | worker_threads para no bloquear el loop |
| Java 21 | pool de threads dedicado (ExecutorService de 2) |
| .NET 8 | ConcurrentExclusiveSchedulerPair / thread dedicado |
| Go 1.23 | semaforo de concurrencia — no hay pool que agotar; runtime.Gosched() |
| Rust 1.83 | Mutex+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.
| Stack | Por que | |
|---|---|---|
| 🥇 | Java 21 / .NET 8 | Son 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.23 | No 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.83 | Mutex + 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. |
| 4º | Node.js 22 | worker_threads saca el CPU del loop; es la unica forma de no bloquear el proceso entero. |
| 5º | Python 3.12 | ThreadPoolExecutor separado, con el GIL limitando el paralelismo real del trabajo CPU. |
| 6º | PHP 8.3 | Procesos 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.