UT2. Programación multihilo
La programación multihilo es una técnica que permite la ejecución concurrente de múltiples hilos dentro de un solo proceso. Un hilo es la unidad más pequeña de procesamiento que puede ser programada por un sistema operativo. La programación multihilo es especialmente útil para mejorar el rendimiento de aplicaciones que realizan múltiples tareas simultáneamente, como servidores web, aplicaciones de procesamiento de datos y juegos.
Contexto de ejecución de los hilos
Cada hilo tiene su propio contexto de ejecución, que incluye:
- Pila de llamadas (stack): Cada hilo tiene su propia pila de llamadas,
- Registros de CPU: Cada hilo mantiene sus propios registros de CPU,
- Contador de programa (program counter): Cada hilo tiene su propio contador de programa que indica la próxima instrucción a ejecutar.
- Variables locales: Cada hilo tiene sus propias variables locales que no son compartidas con otros hilos.
- Identificador de hilo (thread ID): Cada hilo tiene un identificador único que lo distingue de otros hilos dentro del mismo proceso.
- Estado del hilo: Cada hilo puede estar en diferentes estados, como ejecutando, listo, bloqueado o terminado.
- Prioridad del hilo: Cada hilo puede tener una prioridad que afecta su planificación por parte del sistema operativo.
- Recursos asignados: Cada hilo puede tener recursos asignados, como memoria y archivos abiertos, que son gestionados por el sistema operativo.
- Contexto de seguridad: Cada hilo puede tener un contexto de seguridad que define sus permisos y accesos a recursos del sistema.
Recursos compartidos por los hilos
Aunque cada hilo tiene su propio contexto de ejecución, los hilos dentro del mismo proceso comparten ciertos recursos, como:
- Memoria del proceso: Todos los hilos de un proceso comparten el mismo espacio de direcciones de memoria, lo que permite que los hilos accedan y modifiquen variables globales y estáticas.
- Archivos abiertos: Los hilos comparten los mismos descriptores de archivos abiertos, lo que permite que múltiples hilos lean y escriban en los mismos archivos.
- Recursos del sistema: Los hilos comparten recursos del sistema, como conexiones de red y dispositivos de entrada/salida.
- Variables estáticas: Las variables estáticas son compartidas entre todos los hilos del mismo proceso, lo que permite que los hilos accedan a datos comunes.
Cuando creamos un objeto de una clase, puede ocurrir que varios hilos de ejecución accedan a ese objeto. Es importante recordar que todos los campos del objeto son compartidos entre todos los hilos.
Supongamos una clase como esta:
public class Empleado(){
int numHorasTrabajadas=0;
public void int incrementarHoras(){
numHorasTrabajadas++;
}
}
Si varios hilos ejecutan sin querer el método incrementar en teoría debería ocurrir que el número se incrementase tantas veces como hilos. Sin embargo, es muy probable que eso no ocurra. ¿Por qué?
El problema principal es que la instrucción: numHorasTrabajadas++;
Parece una sola acción (atómica) para nosotros los humanos, pero para el procesador (CPU), esa línea en realidad se descompone en tres pasos distintos:
- Leer: El hilo consulta la memoria para ver cuánto vale numHorasTrabajadas.
- Modificar: El hilo suma 1 a ese valor en su propia memoria temporal.
- Escribir: El hilo guarda el nuevo valor en la memoria compartida del objeto.
El problema ocurre cuando dos hilos intentan hacer esto al mismo tiempo exacto y se "cruzan" en medio de esos tres pasos.
Imaginemos que numHorasTrabajadas vale 10 y dos hilos (Hilo A y Hilo B) entran a ejecutar incrementarHoras():
- Hilo A (Lee): Lee el valor 10.
- (Justo aquí, el sistema operativo pausa al Hilo A y le da turno al Hilo B).
- Hilo B (Lee): Lee el valor... ¡todavía 10! (porque A aún no ha guardado su cambio).
- Hilo B (Modifica): Suma 1 (10 + 1 = 11).
- Hilo B (Escribe): Guarda el 11 en la variable compartida.
- (Ahora vuelve el Hilo A).
- Hilo A (Modifica): Tenía guardado que leyó un 10. Suma 1 (10 + 1 = 11).
- Hilo A (Escribe): Guarda el 11 en la variable compartida.
La variable vale 11, aunque se ejecutó el incremento dos veces. Debería valer 12. Se ha "perdido" una operación. (Condición de carrera).
Para evitar esto, en Java se utilizan mecanismos de sincronización (como la palabra clave synchronized o clases atómicas) que obligan a los hilos a esperar su turno, asegurando que nadie lea la variable mientras otro la está modificando.
Estados de un hilo. Cambios de estado.
Entender el ciclo de vida de un hilo es fundamental para saber por qué tu programa a veces se detiene, va lento o no hace lo que esperas. Los hilos no solo "funcionan" o "no funcionan"; pasan por varios estados definidos.
En Java (y en la mayoría de los sistemas operativos), un hilo puede estar en uno de estos estados:
- Nuevo (NEW): El objeto hilo ha sido creado (
new Thread()), pero aún no ha empezado a "vivir". - Ejecutable (RUNNABLE): El hilo está listo para ejecutarse y espera su turno para usar la CPU.
- Bloqueado / Esperando (BLOCKED, WAITING, TIMED_WAITING): El hilo no puede continuar porque está esperando a que ocurra algo externo (como obtener un recurso o recibir una señal).
- Terminado (TERMINATED): El hilo ha completado su tarea o ha sido detenido abruptamente.
Cambios de estado
Los hilos cambian de estado en función de las acciones que realizan y las condiciones del sistema:
- De Nuevo a Ejecutable: Cuando llamas a
start(), el hilo pasa de Nuevo a Ejecutable. - De Ejecutable a Bloqueado/Esperando: Si el hilo intenta acceder a un recurso que está siendo usado por otro hilo, o si llama a métodos como
sleep(),wait(), ojoin(), pasa a un estado de espera. - De Bloqueado/Esperando a Ejecutable: Cuando el recurso está disponible o la condición de espera se cumple, el hilo vuelve a estar listo para ejecutarse.
- De Ejecutable a Terminado: Cuando el hilo completa su tarea (el método
run()termina) o si es interrumpido, pasa al estado Terminado. - De Ejecutable a Ejecutable: El sistema operativo puede pausar un hilo en ejecución para darle tiempo de CPU a otro hilo, pero ambos permanecen en el estado Ejecutable.
Es vital entender que tú como programador no controlas cuándo el hilo pasa de "Listo" a "Ejecutando". Eso lo decide el Sistema Operativo. Tú solo puedes decirle al hilo: "Ponte a dormir" (sleep) o "Ponte en la cola para trabajar" (start). No puedes decirle: "Ejecuta AHORA MISMO". Solo puedes sugerir prioridades.
Librerías y clases
Java proporciona varias librerías y clases para facilitar la programación multihilo, entre las más importantes se encuentran:
- java.lang.Thread: La clase principal para crear y gestionar hilos.
- java.lang.Runnable: Una interfaz que define el método
run(), que contiene el código que se ejecutará en el hilo.
La clase Thread proporciona varios métodos útiles, como:
start(): Inicia la ejecución del hilo.run(): Contiene el código que se ejecutará en el hilo.sleep(long millis): Pausa la ejecución del hilo durante un tiempo especificado.join(): Espera a que un hilo termine su ejecución antes de continuar.synchronized: Una palabra clave que se utiliza para controlar el acceso a bloques de código o métodos, asegurando que solo un hilo pueda ejecutarlos a la vez.
Por otro lado, la interfaz Runnable permite definir el código que se ejecutará en un hilo sin necesidad de extender la clase Thread, lo que facilita la reutilización del código y la implementación de múltiples interfaces.
La documentación de Java, recomienda el uso de la interfaz Runnable para definir tareas que se ejecutarán en hilos, ya que permite una mayor flexibilidad y separación de responsabilidades.
Además, Java ofrece la biblioteca java.util.concurrent, que incluye clases y utilidades avanzadas para la programación multihilo y simplifica mucho el manejo de hilos.
Tenemos el siguiente ejemplo básico de creación y ejecución de un hilo utilizando la interfaz Runnable:
class EjecutorTareaCompleja implements Runnable{
private String nombre;
int numEjecucion;
public EjecutorTareaCompleja(String nombre){
this.nombre=nombre;
}
@Override
public void run() {
String cad;
while (numEjecucion<100){
for (double i=0; i<4999.99; i=i+0.04)
{
Math.sqrt(i);
}
cad="Soy el hilo "+this.nombre;
cad+=" y mi valor de i es "+numEjecucion;
System.out.println(cad);
numEjecucion++;
}
}
}
public class LanzaHilos {
public static void main(String[] args) {
int NUM_HILOS=500;
EjecutorTareaCompleja op;
for (int i=0; i<NUM_HILOS; i++)
{
op=new EjecutorTareaCompleja ("Operacion "+i);
Thread hilo=new Thread(op);
hilo.start();
}
}
}
La clase EjecutorTareaCompleja define qué va a hacer el hilo. Implementa Runnable, lo que obliga a definir el método run(), que es donde va el código que se ejecutará en el hilo.
Dentro del método run(), hay un bucle que calcula la raíz cuadrada de muchos números con el único propósito de obligar al procesador (CPU) a trabajar intensamente. Esto simula una "Tarea Compleja" (como procesar una imagen o calcular una ruta GPS) para que el hilo no termine instantáneamente.
La variable numEjecucion es de instancia (no es static). Esto significa que cada hilo tiene su propio contador personal que empieza en 0. No se pelean por él.
- El Jefe:
La clase
LanzaHiloses la que crea los hilos. En su métodomain(), define que se van a crear 500 hilos (NUM_HILOS=500).
En resumen:
- Se crean 500 objetos distintos de tipo EjecutorTareaCompleja.
- Se crean 500 hilos (Threads) distintos.
- Se da la orden de arranque (start()) a los 500 casi a la vez.
- El sistema operativo tiene que pausar constantemente a unos hilos para dejar pasar a otros (Context Switching).
- Indeterminismo: Si ejecutas el programa dos veces, el orden de los mensajes en la consola será completamente diferente. Nunca habrá dos ejecuciones iguales.
Gestión de hilos
Java proporciona varias herramientas para gestionar hilos de manera eficiente. Por ejemplo, la clase Thread permite establecer un nombre para cada hilo, lo que facilita su identificación durante la depuración y el monitoreo mediante setName(). Además, se pueden asignar prioridades a los hilos utilizando el método setPriority(int priority), lo que influye en el orden en que el sistema operativo programa la ejecución de los hilos. Donde 1 es la prioridad más baja y 10 la más alta. (Tenemos las palabras clave MIN_PRIORITY=1, NORM_PRIORITY=5 y MAX_PRIORITY=10).
Cuando lanzamos una operación también podemos usar el método Thread.sleep(numero) y poner nuestro hilo "a dormir".
El método join() permite que un hilo espere a que otro hilo termine su ejecución antes de continuar. Esto es útil cuando un hilo depende del resultado de otro.
En el siguiente programa se emplean estos métodos para gestionar la ejecución de hilos:
class Calculador implements Runnable{
@Override
public void run() {
int num=0;
while(num<5){
System.out.println("Calculando...");
try {
long tiempo=(long) (1000*Math.random()*10);
if (tiempo>8000){
Thread hilo=Thread.currentThread();
System.out.println(
"Terminando hilo:"+ hilo.getName()
);
hilo.join();
}
Thread.sleep(tiempo);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Calculado y reiniciando.");
num++;
}
Thread hilo=Thread.currentThread();
String miNombre=hilo.getName();
System.out.println("Hilo terminado:"+miNombre);
}
}
public class LanzadorHilos {
public static void main(String[] args) {
Calculador vHilos[]=new Calculador[5];
for (int i=0; i<5 ; i++){
vHilos[i]=new Calculador();
Thread hilo=new Thread(vHilos[i]);
hilo.setName("Hilo "+i);
if (i==0){
hilo.setPriority(Thread.MAX_PRIORITY);
}
hilo.start();
}
}
}
Este código contiene dos conceptos clave de la programación concurrente: Prioridades de Hilos y un Error Fatal (Deadlock) provocado intencionadamente.
Vamos a analizarlo por partes:
Prioridad de Hilos
En la clase LanzadorHilos, ves este bloque:
if (i==0){
hilo.setPriority(Thread.MAX_PRIORITY);
}
Le dice al Planificador de Hilos (Scheduler) del Sistema Operativo que el "Hilo 0" es VIP. Thread.MAX_PRIORITY suele tener un valor de 10 (la normal es 5). En teoría, el "Hilo 0" debería recibir más tiempo de CPU que los demás. En Java, la prioridad es solo una sugerencia. No garantiza que el Hilo 0 termine antes, especialmente porque en el código hay pausas aleatorias (sleep) que anulan la ventaja de ser rápido (si el Hilo 0 es rápido pero duerme 9 segundos, el Hilo 1 que es lento pero duerme 1 segundo terminará antes).
El "Suicidio" del Hilo (join)
Aquí está el verdadero problema (y la curiosidad) de este código. Fíjate en el método run() de Calculador:
if (tiempo > 8000){ // Si el tiempo aleatorio es mayor a 8 segundos
Thread hilo = Thread.currentThread(); // Obtiene referencia a SÍ MISMO
System.out.println("Terminando hilo:" + hilo.getName());
hilo.join(); // <--- ¡AQUÍ ESTÁ EL PROBLEMA!
}
El método .join() significa: "Espera aquí detenido hasta que el hilo 'hilo' se muera/termine".
El hilo (digamos, Hilo 3) obtiene una referencia a sí mismo (currentThread). El hilo se dice a sí mismo: "Voy a pausarme y no haré nada hasta que yo mismo termine".
Resultado: Deadlock (Bloqueo Mutuo).
- El hilo no puede terminar porque está esperando.
- El hilo está esperando porque no ha terminado.
Es como si te dijeran: "No te muevas hasta que hayas salido de la habitación". Jamás saldrás, porque no te puedes mover.
Si entra en ese if, el hilo se quedará "colgado" (congelado) para siempre en esa línea y nunca imprimirá "Calculado y reiniciando" ni "Hilo terminado".
¿Con qué probabilidad ocurre esto?
long tiempo=(long) (1000*Math.random()*10); // Genera entre 0 y 10.000 ms
Hay aproximadamente un 20% de probabilidades en cada vuelta del bucle de que tiempo sea mayor que 8000.
Si tienes 5 hilos y cada uno da 5 vueltas, es casi seguro que varios hilos se queden bloqueados ("colgados") y nunca terminen el programa.
El lugar natural y correcto para usar .join() es en el hilo jefe (el main) o en aquel hilo que necesita los resultados de los demás para poder continuar. Ocurría lo mismo en con el método waitfor() de los procesos.
Un ejemplo correcto de uso de .join() sería este:
class Calculador implements Runnable {
private int id;
public Calculador(int id) {
this.id = id;
}
@Override
public void run() {
System.out.println("Hilo " + id + " iniciando cálculos...");
try {
// Simulamos trabajo con un tiempo aleatorio
int tiempo = (int) (Math.random() * 3000) + 1000;
Thread.sleep(tiempo);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("--> Hilo " + id + " ha terminado.");
}
}
public class PruebaJoin {
public static void main(String[] args) {
int NUM_HILOS = 5;
// 1. Array para guardar las referencias a los hilos
// (Si no los guardamos, no podemos hacer join sobre ellos después)
Thread[] misHilos = new Thread[NUM_HILOS];
System.out.println("----- INICIANDO HILOS -----");
// 2. Bucle de ARRANQUE
for (int i = 0; i < NUM_HILOS; i++) {
misHilos[i] = new Thread(new Calculador(i));
misHilos[i].start();
}
System.out.println("----- EL MAIN ESPERA AHORA -----");
// 3. Bucle de ESPERA (JOIN)
// El main se detendrá en cada línea 'join' hasta que ese hilo termine.
for (int i = 0; i < NUM_HILOS; i++) {
try {
// "Main, espérate aquí hasta que el hilo 'i' termine"
misHilos[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 4. Esta línea SOLO se ejecuta cuando TODOS han terminado
System.out.println("----- ¡TODOS HAN TERMINADO! -----");
System.out.println("El programa principal ya puede cerrarse.");
}
}
Analicemos lo que ocurre en el bloque del join:
- El bucle llega a i=0 y ejecuta misHilos[0].join().
- El hilo main se duerme (se bloquea). No hace nada más.
- Mientras tanto, los hilos 0, 1, 2, 3 y 4 siguen trabajando en paralelo.
- Cuando el Hilo 0 termina su trabajo, el main se despierta.
-
El bucle pasa a i=1 y ejecuta misHilos[1].join().
-
Caso A: Si el Hilo 1 ya había terminado mientras esperábamos al 0, el join retorna inmediatamente (no espera nada).
- Caso B: Si el Hilo 1 sigue trabajando, el main vuelve a dormirse.
Actividades
A2.1 Area de un triángulo
Crear un programa que lance 10 hilos de ejecución donde a cada hilo se le pasará la base y la altura de un triángulo, y cada hilo ejecutará el cálculo del área de dicho triángulo informando de qué base y qué altura recibió y cual es el área resultado.