====== 11 - Hilos en Tomcat ====== Un servidor web atiende muchas peticiones a la vez. En **Tomcat**, cada petición la procesa un **hilo**; entender cómo se reparten y reutilizan es necesario para decidir qué estado puede guardarse y dónde. ===== 1. Cada petición se atiende en un hilo ===== **Tomcat** mantiene un **pool de hilos** con hilos **worker** disponibles. Cuando llega una petición **HTTP**, se asigna a un hilo libre; al terminar la respuesta, el hilo vuelve al pool y puede atender otra petición. ^ Peticiones ^ En qué hilos se ejecutan ^ | Simultáneas | Cada una se ejecuta en un hilo distinto | | Sucesivas | Pueden ejecutarse en el mismo hilo reutilizado | @startuml actor "Cliente A" as A actor "Cliente B" as B actor "Cliente C" as C participant "Tomcat" as T participant "Hilo 1" as H1 participant "Hilo 2" as H2 A -> T : Petición A T -> H1 : la atiende el hilo 1 B -> T : Petición B (simultánea) T -> H2 : la atiende el hilo 2 H1 --> A : respuesta H2 --> B : respuesta C -> T : Petición C (posterior) T -> H1 : reutiliza el hilo 1 H1 --> C : respuesta @enduml ==== 1.1 Por qué un pool ==== Crear un **hilo** por cada petición no es viable, porque crear un hilo es **costoso**: * Cada hilo de plataforma reserva memoria para su **pila** (en torno a 1 MB por defecto) y consume recursos del sistema operativo. * Crear y destruir un hilo en cada petición añade un coste temporal a todas ellas. * Sin límite, una avalancha de peticiones crearía miles de hilos a la vez: el servidor se agotaría por la sobrecarga antes que por falta de trabajo. Por eso el servidor **crea los hilos una vez y los reutiliza**: ese conjunto de hilos preparados es el **pool**. Al terminar la petición, el hilo no muere: vuelve al pool y espera la siguiente. En los logs de **Tomcat** se reconocen por su nombre (''http-nio-8080-exec-1'', ''http-nio-8080-exec-2''...): un mismo nombre atiende peticiones distintas porque el hilo se reutiliza. ==== 1.2 Tamaño del pool: mínimo y máximo ==== El pool se describe con dos parámetros: cuántos hilos hay preparados desde el principio (**mínimo**) y hasta dónde puede crecer bajo demanda (**máximo**). En **Spring Boot**, que arranca **Tomcat** embebido, se configura en ''application.properties'': server.tomcat.threads.min-spare=10 # mínimo: hilos preparados desde el arranque server.tomcat.threads.max=200 # máximo: límite superior del pool Con los valores por defecto: * El pool **arranca con 10 hilos** esperando peticiones: es el mínimo que se mantiene vivo aunque no llegue ninguna. * Si llegan más peticiones simultáneas que hilos libres, se **crean hilos nuevos hasta el máximo** (200). * Los hilos que quedan por encima del mínimo se **retiran** cuando llevan un rato sin trabajo: el pool vuelve a encogerse. @startuml start :Llega una petición; if (¿hay hilo libre?) then (sí) :Se asigna un hilo del pool; else (no) if (¿hay menos hilos que el máximo?) then (sí) :Se crea un hilo nuevo; else (no) :La petición espera en cola; endif endif :El hilo procesa la petición; :El hilo vuelve al pool; stop @enduml ==== 1.3 Cuando el pool se satura ==== Si los 200 hilos están ocupados, las peticiones nuevas **esperan en cola** hasta que se libera un hilo; si la cola también se llena, la petición se **rechaza**. Cada petición retiene su hilo **todo el tiempo que dura el procesamiento**. Si el código pasa ese rato esperando a la **base de datos** o a una **API** externa, el hilo queda bloqueado mientras tanto: no atiende ninguna otra petición. Con el pool copado de peticiones lentas, peticiones rápidas que llegan después se quedan en cola aunque su trabajo sea corto. Por eso la duración de las peticiones condiciona el tamaño que necesita el pool. ===== 2. Estado compartido entre hilos ===== Un campo ''static'' pertenece a la clase: todos los hilos ven y modifican el mismo valor. public class SharedValue { private static volatile String value; public static void setValue(String newValue) { value = newValue; } public static String getValue() { return value; } } Si dos peticiones fijan valores distintos a la vez, la segunda puede sobrescribir el valor antes de que la primera lo lea: una petición trabaja con el valor de otra. Es una **condición de carrera**: el resultado depende del orden temporal en que se ejecutan los hilos. ''volatile'' no crea un valor distinto para cada hilo: el campo ''static'' sigue siendo único y compartido. La diferencia es qué garantía hay al leer escrituras de otros hilos: Hilo A: escribe "A" Hilo B: escribe "B" Hilo A: lee el campo * **Con ''volatile'':** si la escritura de B ocurre antes de la lectura de A, A ve "B". La escritura es visible, pero el campo sigue siendo compartido. * **Sin ''volatile'':** Java no garantiza que A vea la escritura reciente de B. Puede observar un valor antiguo; no se puede asumir que verá "A" ni que cada hilo conserve un valor propio. En ambos casos hay acceso compartido. ''volatile'' garantiza **visibilidad**, pero no evita que una escritura sobrescriba otra ni hace indivisible una secuencia de operaciones. Por tanto, no evita la **condición de carrera**. ^ Campo ''static'' ^ Variable local ^ | Un único valor visible para todos los hilos | Cada hilo tiene la suya en su propia pila | | Requiere cuidado ante accesos concurrentes | No se comparte entre peticiones | ===== 3. ThreadLocal: un valor por hilo, no por petición ===== ''ThreadLocal'' guarda un valor distinto para cada hilo: cada hilo solo ve el suyo. public class ThreadData { private static final ThreadLocal DATA_BY_THREAD = new ThreadLocal<>(); public static void setData(String data) { DATA_BY_THREAD.set(data); } public static String getData() { return DATA_BY_THREAD.get(); } public static void clearData() { DATA_BY_THREAD.remove(); } } Pero el valor es por hilo, no por petición. Como Tomcat reutiliza los hilos, el valor fijado durante una petición sigue ahí cuando el mismo hilo atiende la siguiente: la nueva petición encontraría un valor que no le corresponde. Es una **fuga** de datos entre peticiones. La solución es eliminar el valor al terminar la petición con ''remove()'', en un bloque ''finally'' para que se ejecute siempre: try { // procesar la petición usando ThreadData.getData() } finally { ThreadData.clearData(); } Todo valor guardado en un ''ThreadLocal'' durante una petición debe eliminarse al terminarla. Si el hilo se reutiliza, el valor se filtra a la siguiente petición. El modelo de **hilo por petición** de este tema es el que se usa en 1.º DAW. Para cargas con muchísima concurrencia existen dos alternativas, que **no se ven en este curso**: * La **programación reactiva** (**WebFlux** en **Spring**): en lugar de un hilo por petición, un **event loop** con pocos hilos atiende todas las conexiones mediante **I/O no bloqueante**; cuando hay que esperar a la BBDD o a una API externa, el hilo no se bloquea: deja la petición aparcada y sigue con otra. Permite mucha concurrencia con muy pocos hilos, a cambio de un código y una depuración más complejos (una sola llamada bloqueante rompe el modelo). * **Project Loom** (desde **Java 21**): los **hilos virtuales** son tan baratos de crear que se puede tener uno por petición sin preocuparse del pool: el hilo virtual se apoya en un hilo de plataforma solo mientras ejecuta código y lo libera al bloquearse en I/O. Se programa igual que siempre (código secuencial, sin callbacks) y, en **Spring Boot**, se activa con ''spring.threads.virtual.enabled=true''.