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