====== 11 - Concurrencia: bloqueante, reactiva y Loom ======
Los temas anteriores han convivido con la concurrencia sin explicarla: Tomcat reparte las peticiones entre un pool de hilos y el ''MDC'' (tema 09) aprovecha que cada petición vive en un hilo; el procesamiento asíncrono de eventos (tema 08) salta a otro hilo. Este tema explica qué sostiene esa concurrencia y los tres modelos disponibles hoy: el tradicional con hilos de plataforma, la programación reactiva y los hilos virtuales de **Project Loom**.
===== 1. El problema de la concurrencia =====
Un servidor web recibe **peticiones simultáneas**: mientras atiende una, llegan otras. **Concurrencia** significa que muchas están en marcha a la vez; **paralelismo**, que se ejecutan a la vez, solo posible con varios núcleos.
El tipo de trabajo decide el problema:
^ Trabajo ^ Característica ^ Qué limita el rendimiento ^
| **CPU-bound** | Procesa datos: cálculo, transformación | Los núcleos disponibles |
| **E/S bloqueante** | Espera resultados externos: BD, HTTP, ficheros | Nada: mientras espera, el hilo queda ocioso |
En una API típica, la mayor parte del tiempo de una petición es **espera oculta**. Toda la concurrencia del servidor se juega ahí: no en la CPU, que sobra, sino en no despilfarrar hilos para esperar.
===== 2. Programación tradicional: un hilo por petición =====
==== 2.1 El modelo ====
Cada petición se atiende en un **hilo de plataforma** — un hilo del sistema operativo — de principio a fin. Crear hilos es caro, así que el servidor mantiene un **pool** de tamaño fijo y reutiliza sus hilos; en **Tomcat** el máximo por defecto es 200.
@startuml
allowmixing
actor Cliente
rectangle "Pool de Tomcat\n(máx. 200)" as pool {
component "exec-1" as t1
component "exec-2" as t2
component "exec-3" as t3
}
database "Base de datos" as db
cloud "API externa" as api
Cliente --> t1 : petición A
Cliente --> t2 : petición B
t1 --> db : consulta bloqueante
t2 --> api : llamada bloqueante
note bottom of pool
Mientras el hilo espera la E/S,
no puede atender a nadie más
end note
@enduml
El código es secuencial y directo; la llamada bloqueante devuelve cuando tiene el dato:
@GetMapping("/pedidos/{id}")
ResponseEntity find(@PathVariable Long id) {
// El hilo del pool queda atado durante toda la espera
var pedido = pedidoService.findById(id);
return ResponseEntity.ok(mapper.toResponse(pedido));
}
==== 2.2 El coste de un hilo de plataforma ====
Un hilo de plataforma lo gestiona el sistema operativo y sale caro:
* Reserva memoria para su pila: del orden de un megabyte por hilo.
* El SO decide los cambios de contexto, que tienen coste.
* Miles de hilos vivos son inviables.
El pool es pequeño y el escalado queda atado a su tamaño: con 200 hilos y peticiones que esperan 50 ms de base de datos, el servidor atiende unos 4.000 por segundo; si la espera sube a 1 s — una llamada lenta a un servicio externo —, caen a 200. La CPU no se ha inquietado en ningún caso.
==== 2.3 El estado y el hilo ====
Como el hilo acompaña a la petición de principio a fin, es tentador guardar el estado en él. Dos errores habituales muestran por qué no:
* Un campo **''static''** lo comparten **todos** los hilos: cualquier petición sobrescribe el valor de las demás y puede leer el de otro usuario.
* **''ThreadLocal''** lo aísla dentro del hilo… hasta que el hilo vuelve al pool: sin limpieza, la petición siguiente hereda el dato del usuario anterior.
class CurrentUser {
private static final ThreadLocal USER = new ThreadLocal<>();
static void set(String user) { USER.set(user); }
static String get() { return USER.get(); }
static void clear() { USER.remove(); } // el hilo se reutiliza
}
''SecurityContext'' en Spring Security (tema 07) y el ''MDC'' (tema 09) funcionan exactamente así: contexto ligado al hilo y limpieza garantizada al terminar la petición.
La defensa no es sincronizar el acceso: es no tener estado que compartir. Los servicios del dominio se diseñan **sin estado** entre peticiones; el estado de cada una viaja en sus parámetros y sus DTO.
===== 3. Programación reactiva: no bloquear =====
==== 3.1 El modelo ====
La programación reactiva ataca el problema por el otro extremo: si esperar es lo caro, **nadie espera**. Quien lanza una E/S no queda bloqueado: registra qué hacer con el resultado y el hilo pasa a otra conexión. Un **bucle de eventos** (*event loop*) con muy pocos hilos procesa lo que está listo y recoge los resultados cuando llegan.
Las operaciones se componen en **flujos** asíncronos: transformaciones que se aplican a los datos a medida que estos aparecen. La especificación **Reactive Streams** fija el contrato (''Publisher'' y ''Subscriber'') y añade el **backpressure**: el consumidor pide lo que puede procesar y el productor no lo desborda.
@startuml
allowmixing
actor Cliente
rectangle "Bucle de eventos\n(muy pocos hilos)" as loop {
component "event loop" as el
}
database "BD reactiva\n(R2DBC)" as rdb
cloud "API externa" as api
Cliente --> el : muchas conexiones abiertas
el --> rdb : E/S no bloqueante
el --> api : E/S no bloqueante
note bottom of loop
El hilo no espera: atiende otras conexiones
mientras llegan los datos pendientes
end note
@enduml
==== 3.2 En Java y Spring ====
^ Pieza ^ Papel ^
| **Project Reactor** | Implementación de Reactive Streams: ''Mono'' (0 o 1 elemento) y ''Flux'' (0..n) |
| **Spring WebFlux** | Stack web no bloqueante sobre **Netty**; alternativa al MVC de servlets |
| **R2DBC** | Acceso relacional no bloqueante; JDBC y JPA son bloqueantes |
Flux abiertos() {
return repository.findByEstado(ABIERTO) // emite cuando llegan: el hilo no queda atado
.take(10)
.map(mapper::toResponse);
}
El modelo exige coherencia total: un solo enlace bloqueante — una consulta JDBC, un bucle de cálculo — retiene al hilo del event loop y echa a perder el escalado. Como JDBC y JPA bloquean, la reactiva exige un ecosistema no bloqueante completo (WebFlux + R2DBC), a costa de una legibilidad peor y trazas de error más difíciles.
La reactiva compensa cuando hay **miles de conexiones simultáneas y lentas**: streaming (SSE), tiempo real, flujos largos. En una API CRUD convencional suele ser un coste sin beneficio.
===== 4. Project Loom: hilos virtuales =====
==== 4.1 El modelo ====
**Project Loom** (hilos virtuales definitivos en Java 21) aporta la tercera vía: cambiar el runtime sin cambiar el estilo. Un **hilo virtual** lo gestiona la JVM, no el sistema operativo: cuesta kilobytes y pueden crearse millones. Cuando su código bloquea en E/S, la JVM lo **desmonta** de su **hilo portador** — un hilo de plataforma que solo ejecuta lo que está listo — y este pasa a otro hilo virtual; cuando el dato llega, el hilo virtual se monta de nuevo en cualquier portador libre.
@startuml
allowmixing
rectangle "Hilos virtuales\n(miles, ligeros)" as vt {
component "vt-1" as v1
component "vt-2" as v2
component "vt-3" as v3
}
rectangle "Portadores\n(hilos de plataforma, pocos)" as carriers {
component "portador 1" as c1
component "portador 2" as c2
}
database "Base de datos" as db
v1 --> c1 : montado
v3 --> c1 : montado
v2 --> db : E/S en curso\n(desmontado, sin portador)
@enduml
El código resulta idéntico al tradicional: llamadas bloqueantes ordinarias, pilas legibles y las herramientas de siempre.
==== 4.2 En Java y Spring ====
static void main() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var id = 1; id <= 10_000; id++) {
executor.submit(() -> {
var pedido = client.findById(id); // E/S bloqueante, como siempre
return IO.println(pedido + " en " + Thread.currentThread());
});
}
} // close() espera a que terminen las tareas
}
En Spring Boot, la propiedad convierte los hilos de Tomcat y el executor de tareas (los ''@Async'' del tema 08) en hilos virtuales. La aplicación no cambia: mismo MVC, mismas anotaciones, mismo ecosistema bloqueante — ahora cada espera libera a su portador.
spring:
threads:
virtual:
enabled: true
==== 4.3 Los límites ====
* **Pinning**: hay casos en que el hilo virtual no se desmonta y retiene al portador mientras espera — las llamadas a código nativo y, hasta JDK 24, los bloques ''synchronized''. Con suficientes hilos atados, los portadores se agotan; solo la medición lo detecta.
* **CPU-bound**: los hilos virtuales no añaden núcleos; el cálculo intensivo escala igual que antes.
* **''ThreadLocal'' a escala**: millones de hilos con contexto propio multiplican la memoria; para el contexto ligado al hilo, JDK 25 incorpora de forma definitiva **ScopedValue**.
* **Pools**: un hilo virtual por tarea; reciclar hilos virtuales deshace la ventaja.
La **concurrencia estructurada**, todavía en vista previa, completa el modelo: coordina tareas relacionadas que arrancan juntas, fallan juntas y terminan juntas.
===== 5. Los tres modelos, cara a cara =====
^ ^ **Tradicional** ^ **Reactiva** ^ **Loom** ^
| Hilos | De plataforma, pool acotado | Muy pocos, bucle de eventos | Virtuales, uno por tarea |
| La espera de E/S | El hilo queda ocupado | Nadie espera | El portador queda libre |
| Escalado con E/S | Limitado por el pool | Alto | Alto |
| Estilo del código | Secuencial, directo | Flujos y callbacks | Secuencial, directo |
| Ecosistema | El estándar: JDBC, JPA, MVC | Exige stack no bloqueante completo | El estándar funciona sin cambios |
| Riesgo | Cola en el pool bajo carga | Complejidad y trazas | Pinning puntual, ''ThreadLocal'' a escala |
| Encaja en | CPU-bound o carga baja | Miles de conexiones simultáneas, streaming | La API típica con E/S |
Reactiva y Loom resuelven el mismo problema desde extremos opuestos: la reactiva cambia el **estilo del código** para no bloquear; Loom cambia el **runtime** para que bloquear deje de importar.
===== 6. Qué significa para la arquitectura del curso =====
La concurrencia pertenece a la plataforma de ejecución, no al negocio: el dominio se escribe bloqueante y secuencial — el estilo más legible y testeable — y la infraestructura elige cómo ejecutarlo:
* **Por defecto**, el stack de servlets con sus hilos de plataforma: la carga de una API de curso no lo desborda.
* **Hilos virtuales** cuando la carga crece: activar la propiedad, medir y ajustar; el código no cambia.
* **Reactiva** solo con una necesidad concreta que la justifique: streaming o un gran número de conexiones simultáneas.
Con hilos virtuales hay un hilo nuevo por tarea, así que la herencia del ''ThreadLocal'' sin limpiar se diluye; pero su coste de memoria crece con el número de hilos. Y en el procesamiento asíncrono de eventos (tema 08) sigue vigente el límite del tema 09: el contexto ligado al hilo no viaja solo.
===== 7. Pruebas =====
^ Qué probar ^ Cómo ^
| Lógica de negocio | Pruebas unitarias como siempre: código secuencial, sin hilos y sin levantar Spring |
| Componente con estado interno | Test de concurrencia: N tareas con hilos virtuales y afirmar la invariante final |
| Comportamiento bajo carga | Fuera de la suite: throughput e hilos del proceso con **JMeter** y **VisualVM** |
@Test
void concurrentIncrementsAreNotLost() {
var counter = new AtomicCounter();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var i = 0; i < 1_000; i++) {
executor.submit(counter::increment);
}
} // close() espera las 1.000 tareas: sin sleeps ni esperas arbitrarias
assertEquals(1_000, counter.value());
}
El contador usa ''AtomicInteger'' y la prueba valida la decisión bajo 1.000 tareas concurrentes. El truco está en el ''close()'': determinista, sin depender del ''timing'' — las aserciones sobre nombres de hilo o tiempos concretos convierten la prueba en una ruleta.
Un test concurrente detecta el fallo evidente, pero no demuestra su ausencia: una carrera puede no manifestarse en mil ejecuciones y aparecer en producción. La mejor defensa es de diseño: componentes sin estado y, para el estado compartido necesario, primitivas seguras.