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.

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.

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.

Pool de Tomcat(máx. 200)exec-1exec-2exec-3ClienteBase de datosAPI externaMientras el hilo espera la E/S,no puede atender a nadie máspetición Apetición Bconsulta bloqueantellamada bloqueante

El código es secuencial y directo; la llamada bloqueante devuelve cuando tiene el dato:

@GetMapping("/pedidos/{id}")
ResponseEntity<PedidoResponse> 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));
}

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.

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

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.

Bucle de eventos(muy pocos hilos)event loopClienteBD reactiva(R2DBC)API externaEl hilo no espera: atiende otras conexionesmientras llegan los datos pendientesmuchas conexiones abiertasE/S no bloqueanteE/S no bloqueante

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

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.

Hilos virtuales(miles, ligeros)Portadores(hilos de plataforma, pocos)vt-1vt-2vt-3portador 1portador 2Base de datosmontadomontadoE/S en curso(desmontado, sin portador)

El código resulta idéntico al tradicional: llamadas bloqueantes ordinarias, pilas legibles y las herramientas de siempre.

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

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

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.
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.
  • clase/daw/dws/1eval/concurrencia.txt
  • Última modificación: 2026/10/21 11:45
  • por cesguiro