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