====== 05 - Hilos: de la CPU a Java ====== El servidor del tema anterior atiende una petición a la vez, y la solución son los **hilos**. Este tema los recorre de abajo arriba: qué es un hilo para el **sistema operativo** y la **CPU**, cómo se usan en Java y el servidor HTTP concurrente que sale de combinar hilos y sockets. ===== 1. Proceso e hilo ===== Un **proceso** es un programa en ejecución. El **sistema operativo** le asigna memoria propia: el código, la memoria dinámica (**heap**) y sus recursos (ficheros y sockets abiertos). Un **hilo** es una secuencia de ejecución dentro de ese proceso. Todos los hilos del proceso trabajan sobre la misma memoria, pero cada uno ejecuta por su lado: ^ Compartido por todos los hilos ^ Propio de cada hilo ^ | Código y memoria dinámica (**heap**) | **Pila**: variables locales y llamadas en curso | | Ficheros y sockets abiertos | Registros y contador de programa | | Valores ''static'' de las clases | Estado: listo, ejecutando, bloqueado | @startuml rectangle "Proceso (memoria asignada por el SO)" { rectangle "Código" as C rectangle "Heap: objetos y estado compartido" as H rectangle "Pila del hilo 1" as S1 rectangle "Pila del hilo 2" as S2 } C -[hidden]right- H H -[hidden]right- S1 S1 -[hidden]right- S2 @enduml Que compartan la memoria es lo que hace barato comunicar hilos entre sí, y también lo que crea el peligro: dos hilos pueden tocar el mismo objeto a la vez. ===== 2. De los núcleos a los hilos ===== La **CPU** ejecuta instrucciones con sus **núcleos**, pero un servidor tiene muchos más hilos que núcleos. Se distinguen varios niveles: ^ Nivel ^ Qué es ^ | **Núcleo** | Unidad física de ejecución de la CPU | | **Hilo de hardware** | Cola de instrucciones que un núcleo puede atender. Con **SMT** (p. ej. **Hyper-Threading** de **Intel**) un núcleo atiende dos: es la CPU de "8 núcleos y 16 hilos" | | **Hilo del sistema operativo** | La unidad que el SO crea y planifica: cientos en un servidor normal | | **Hilo de plataforma de Java** | Un hilo del SO creado por la **JVM** | Se puede ver la distinción en el propio sistema: ''top -1'' (o pulsar ''1'' dentro de ''top'') muestra una línea por cada CPU lógica —con **SMT**, cada hilo de hardware—: $ top -1 %Cpu0 : 2.0 us, 0.0 sy, 97.9 id, ... %Cpu1 : 0.0 us, 0.3 sy, 99.7 id, ... ... %Cpu15 : 0.0 us, 0.0 sy, 100.0 id, ... Esas 16 líneas son los 16 hilos de hardware de una CPU de 8 núcleos; los hilos del SO de la tabla son los que el **planificador** reparte entre ellas. En lo que queda de curso, **hilo** a secas significa hilo del sistema operativo. ===== 3. El planificador ===== El **planificador** reparte los núcleos entre los hilos que hay **listos** para ejecutar: * A cada hilo le toca una **rodaja de tiempo** en un núcleo. Al agotarla, el SO guarda su contexto (registros, contador de programa) y da el núcleo a otro hilo: es el **cambio de contexto**. * El cambio de contexto no es gratis (guardar y restaurar estado, cachés que se enfrían), pero es mucho más barato que dejar el núcleo sin trabajo. @startuml state "Listo" as L state "Ejecutando" as E state "Bloqueado" as B [*] --> L L --> E : el planificador le asigna un núcleo E --> L : agota su rodaja de tiempo E --> B : llamada bloqueante (I/O, BBDD) B --> L : llega el dato que esperaba E --> [*] : termina @enduml Con 200 hilos en 8 núcleos, casi todo es **concurrencia** —hilos que progresan intercalándose en el mismo núcleo— y poco **paralelismo** —hilos ejecutándose a la vez, uno por núcleo—: Núcleo 1: [ hilo A ][ hilo B ][ hilo A ][ hilo C ] ... El intercalado basta para las **condiciones de carrera** (sección 7): el planificador puede trocear un hilo en cualquier punto, sin necesidad de varios núcleos. ===== 4. Bloqueo e I/O ===== Cuando un hilo pide algo que no está listo —leer de un **socket**, esperar la respuesta de la **base de datos**— pasa a **bloqueado**: el SO lo aparca y el núcleo pasa a otro hilo. Cuando llega el dato, el hilo vuelve a estar listo. @startuml concise "Hilo de la petición" as H concise "Núcleo" as N @0 H is "procesa" N is "hilo de la petición" @1 H is "espera a la BBDD (bloqueado)" N is "otro hilo" @2 H is "procesa" N is "hilo de la petición" @enduml Un hilo bloqueado **no consume CPU**, pero sigue ocupando su **pila** en memoria y, en un servidor, su sitio en el pool. Por eso unas pocas peticiones lentas copan el pool de un servidor aunque apenas gasten procesador: sus hilos están aparcados, no libres. ===== 5. El coste de un hilo ===== * Cada hilo de plataforma es una estructura en el **kernel** y una **pila** reservada (en torno a **1 MB** por defecto): miles de hilos son miles de megas solo de pilas. * Crear y destruir hilos en cada petición añade ese coste a todas. * El **cambio de contexto** se paga en cada intercalado. Por eso los servidores crean un conjunto de hilos una vez y los **reutilizan**: es el **pool** que se usa en la sección 9 y que **Tomcat** trae de serie. ===== 6. Hilos en Java ===== Java crea hilos de plataforma (hilos del SO) con ''Thread''. ''start()'' lanza el hilo; ''join()'' espera su finalización: // Con una lambda (implementa Runnable) Thread worker = new Thread(() -> System.out.println("working")); worker.start(); // Esperar a que termine worker.join(); Crear un hilo por tarea hereda el coste de la sección 5. Un **ExecutorService** mantiene un **pool** de hilos reutilizables: ExecutorService pool = Executors.newFixedThreadPool(10); pool.execute(() -> System.out.println("tarea 1")); pool.execute(() -> System.out.println("tarea 2")); pool.shutdown(); Las tareas se encolan y los hilos del pool las ejecutan; al terminar una tarea, el hilo queda disponible para la siguiente. Los hilos **se reutilizan** entre tareas distintas. ===== 7. Estado compartido: condición de carrera ===== 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 tareas fijan valores distintos a la vez, la segunda puede sobrescribir el valor antes de que la primera lo lea. Es una **condición de carrera**: el resultado depende del orden temporal en que se ejecutan los hilos. ''volatile'' no crea un valor distinto por hilo: el campo sigue siendo único y compartido. Garantiza **visibilidad** (los hilos ven la última escritura), pero las operaciones siguen siendo sueltas y pueden solaparse. ^ 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 tareas | ===== 8. ThreadLocal: un valor por hilo ===== ''ThreadLocal'' guarda un valor distinto para cada hilo: 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 tarea ni por usuario**. Como el pool reutiliza los hilos, el valor fijado en una tarea sigue ahí cuando el mismo hilo ejecuta la siguiente: es una **fuga** de datos. La solución es eliminar el valor al terminar, con ''remove()'' en un bloque ''finally'': try { // procesar la tarea usando ThreadData.getData() } finally { ThreadData.clearData(); } Todo valor guardado en un ''ThreadLocal'' debe eliminarse al terminar la tarea. Si el hilo se reutiliza, el valor se filtra a la siguiente. ===== 9. El servidor HTTP concurrente ===== El servidor del tema 04 atiende una petición a la vez. La solución es combinar ambas piezas: un **pool de hilos** atendiendo las conexiones del servidor de sockets. public class ConcurrentHttpServer { private static final ExecutorService POOL = Executors.newFixedThreadPool(10); public static void main(String[] args) throws IOException { try (ServerSocket serverSocket = new ServerSocket(8080)) { while (true) { Socket socket = serverSocket.accept(); POOL.execute(() -> handle(socket)); } } } private static void handle(Socket socket) { try (socket) { // leer petición y responder como en el tema 04 } catch (IOException exception) { // registrar el error } } } * ''accept()'' devuelve el ''Socket'' y el bucle principal queda libre para aceptar la siguiente conexión. * El pool ejecuta cada petición en un hilo worker. * Los hilos del pool **se reutilizan** entre peticiones. @startuml participant "Servidor" as S participant "Hilo 1" as H1 participant "Hilo 2" as H2 actor "Cliente A" as A actor "Cliente B" as B actor "Cliente C" as C A -> S : conexión A S -> H1 : handle(socketA) B -> S : conexión B (simultánea) S -> H2 : handle(socketB) H1 --> A : respuesta H2 --> B : respuesta C -> S : conexión C (posterior) note right of S : reutiliza un hilo del pool S -> H1 : handle(socketC) H1 --> C : respuesta @enduml Las reglas de los hilos siguen valiendo en el servidor: * Estado ''static'' compartido → condición de carrera entre peticiones. * ''ThreadLocal'' sin limpiar → fuga de datos a la siguiente petición del mismo hilo. * Variables locales → seguras: cada hilo tiene su propia pila. ===== 10. HttpServer del JDK ===== Java incluye un servidor HTTP con pool de hilos integrado, útil como alternativa intermedia entre el servidor propio y Tomcat: public class JdkHttpServer { public static void main(String[] args) throws IOException { HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); server.createContext("/books", exchange -> { String body = "{\"message\":\"books endpoint\"}"; exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(200, body.getBytes().length); try (OutputStream output = exchange.getResponseBody()) { output.write(body.getBytes()); } }); server.setExecutor(Executors.newFixedThreadPool(10)); server.start(); } } ''HttpServer'' resuelve el parseo del protocolo; el ''Executor'' gestiona el pool. Lo que sigue sin resolver: enrutado por rutas, archivos estáticos, ciclo de vida de componentes... Eso es exactamente lo que aporta un **contenedor de servlets**. ===== 11. Hacia Tomcat ===== ^ Capa ^ Problema que resuelve ^ | Sockets | Comunicación TCP | | Pool de hilos | Concurrencia entre peticiones | | HttpServer / contenedor | Protocolo HTTP, enrutado, recursos | | **Tomcat** | Todo lo anterior + ciclo de vida de **servlets** y filtros | En los hilos del pool de **Tomcat** se reconocen las piezas de este tema. Con ''top -H'' el sistema muestra cada hilo como una línea, con su estado en la columna ''S'' (''R'' ejecutando, ''S'' durmiendo o bloqueado); los del pool de Tomcat se llaman ''http-nio-8080-exec-N'': $ top -H -p 1234 PID USER PR NI VIRT RES S COMMAND 1251 cesar 20 0 3560224 42100 R http-nio-8080-exec-1 1252 cesar 20 0 3560224 42100 S http-nio-8080-exec-2 1230 cesar 20 0 3560224 42100 S main Una petición que espera a la **base de datos** tiene su hilo en estado ''S'' hasta que llega la respuesta. Un hilo de plataforma de Java se apoya **uno a uno** en un hilo del SO: la **JVM** no planifica, el planificador del SO decide cuándo se ejecuta. Desde **Java 21** existen además **hilos virtuales** (miles de hilos de la JVM sobre pocos hilos del SO); no se usan en este curso. Este servidor es el modelo mental con el que entender **Tomcat** (tema 06): un contenedor que acepta conexiones, las reparte entre hilos de un pool y delega cada petición en un servlet a través de la cadena de filtros.