Tema 1: Programación Concurrente
¿Qué es un programa concurrente?
Un programa que hace más de una cosa a la vez. Un hilo (thread) es una secuencia de llamadas que se ejecuta independientemente de otras, posiblemente al mismo tiempo, compartiendo recursos del sistema (ficheros, objetos). Los datos (objetos) son compartidos entre hilos pero cada hilo mantiene su propia pila.
Todo proceso tiene mínimo un hilo: el hilo principal (main).
Diagrama de transición de estados de los hilos en Java
Prioridades
Las prioridades de cada uno de los hilos se pueden fijar especificando en un rango MIN_PRIORITY a MAX_PRIORITY [0..10]. El valor por defecto es 5. Esta prioridad no garantiza la ejecución, es decir, si pones una prioridad alta frente a una mínima, no garantiza que se ejecute antes el de la prioridad máxima. Utilizamos el método setPriority() para modificar la prioridad del hilo en cuestión.
Cada hilo hereda la prioridad del hilo creador.
Interrupciones
Una interrupción indica al hilo que este debe dejar de actuar. Se envía con Thread.interrupt(). Esto puede lanzar una excepción InterruptedException.
Creación de hilos
Para poder crear un hilo, podemos realizarlo de dos maneras distintas. Un hilo debe contar con un método run(), que será lo que lance al utilizar el método start().
Extender de la clase Thread
class MiHilo extends Thread {
public void run() {
System.out.println("Soy un hilo");
}
}
new MiHilo().start();
Extender de la clase Thread, nos permitirá heredar el método run() y redefinirlo. Sin embargo, esto nos limita si queremos heredar de alguna clase más, es por eso que se prefiere la siguiente opción:
Implementar la interfaz Runnable
Dado que en Java se permite la herencia múltiple de interfaces, no provocará ningún problema si lo que queremos es heredar métodos de otras clases.
class Hilo implements Runnable {
int id;
int contador = 1000;
public Hilo(int id) { this.id = id; }
public void run() {
for (; contador > 0; contador--) {
System.out.println("Soy el hilo: " + id);
Thread.yield(); // ceder paso
}
}
}
// En main:
Thread t = new Thread(new Hilo(i));
t.start();
Thread.join()
Para poder suspender el hilo actual hasta que termine un hilo en concreto, utilizaremos el método join.
t.join() suspende el hilo actual hasta que t termine:
Thread t = new Thread(new Hilo(1));
t.start();
t.join(); // espera a que t termine
System.out.println("Programa terminado...");
Dormir hilos
Podemos dormir un hilo durante un tiempo especificado con el método Thread.sleep(milisegundos)
Thread.sleep(1000);
Siempre dentro de try-catch(InterruptedException ex)
Ejecutores
Desde Java 5, podemos delegar la gestión de los hilos a ejecutores. Esto nos permite reutilizar hilos y optimizar el rendimiento de los procesos.
Los ejecutores cuentan con una jerarquía de 3 niveles:
- Executor →
execute(Runnable): lanza un hilo con Runnable. - ExecutorService → añade gestión del ciclo de vida (
shutdown, submit). - ScheduleExecutorService → permite planificar con retardo.
Tipos de depósitos (Executors factory):
newFixedThreadPool(n)→ depósito fijo de n hilos.newCachedThreadPool()→ depósito dinámico. Aconsejable para muchas tareas de corta vida.newSingleThreadExecutor()→ ejecuta como máximo 1 tarea a la vez.newScheduledThreadPool(n)→ para ejecutar tareas periodicas conscheduleAtFixedRate.
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
exec.execute(new HiloB(i));
}
exec.shutdown(); // terminar servicio
// ScheduledExecutorService - ejemplo:
ScheduledExecutorService exec2 = Executors.newScheduledThreadPool(1);
exec2.scheduleAtFixedRate(t, 0, 5, TimeUnit.SECONDS); // cada 5 seg
Concurrencia
Un programa concurrente hace más de una cosa a la vez. La JVM y el SO subyacente hacen posible la simultaneidad: paralelismo físico o tiempo compartido.
Aplicaciones de la concurrencia:
- Servicios Web: soportar múltiples conexiones concurrentes, mejorar latencia.
- Cálculo numérico: maximizar rendimiento con paralelismo real de CPU.
- Procesamiento E/S: dispositivos independientes de CPU.
- Simulación: objetos con comportamientos autónomos.
- Aplicaciones GUI: no bloquear la interfaz con tareas largas.
- Software basado en componentes: mejorar autonomía.
- Código móvil (applets): aislar efectos de código desconocido.
- Sistemas empotrados: tareas en tiempo real.
Programación Concurrente vs Programación Distribuida
La programación concurrente:
- Se restringe a estructuras que afectan a una sola JVM.
- Hilos compartiendo memoria dentro del mismo proceso.
La programación distribuida:
- Múltiples JVM en distintos sistemas informáticos.
- Comunicación por red (sockets).
Estructuras para la ejecución concurrente
Las estructuras que usamos para la ejecución concurrente cuentan con las siguientes características:
- Autonomía e independencia
- Administración: planificación, creación, gestión, comunicación.
- Capacidad para compartir recursos subyacentes (CPU, memoria, canales E/S)
- Comunicación: canales (sockets) / áreas de memoria compartidas / cerrojos
La jerarquía sigue la siguiente estructura:
- Llamada → Tarea → Hilo → Proceso → Sistema Distribuido
Concurrencia y Programación Orientada a Objeto
Las principales diferencias que tiene la programación concurrente respecto a la programación secuencial orientada a objeto son:
- Ejecuciones no deterministas
- Comprensión de código no es secuencial.
- Las interferencias entre actividades necesitan diseño conservador.
Transformaciones y modelo de objetos
Tenemos dos estrategias de transformación:
- Que los objetos pasivos vivan en un contexto multihilo.
- "Silenciar" objetos activos para expresarlos en estructuras de hilo.
Modelos mixtos: la programación concurrente deja la mayoría de las transformaciones a la JVM y al SO.
La programación concurrente vs paralela: el grado de control del desarrollador sobre las transformaciones los distingue.
Imposiciones de diseño
Los sistemas cuentan con dos perspectivas complementarias:
- Centrado en objetos: conjunto de objetos
- Centrado en la actividad: conjunto de actividades (mensajes, cadenas de llamadas, secuencia de eventos, tareas, sesiones)
Un objeto puede implicarse en varias actividades. Una actividad puede atravesar múltiples objetos.
Seguridad - Nunca sucede nada malo
Nuestro objetivo principal es asegurar que todos los objetos del sistema mantengan estados coherentes. Un estado coherente es aquel en el que todos los campos tienen valores legales y significativos.
Pueden existir distintos conflictos de almacenamiento a bajo nivel:
- Lectura/Escritura: un hilo lee el valor de un campo mientras otro escribe en el mismo → dificil prever el valor leido.
- Escritura/Escritura: dos hilos intentan escribir en el mismo campo → difícil conocer el valor de la siguiente lectura.
Utilizamos la exclusión para garantizar la atomicidad de las acciones públicas. Esto significa que cada acción se ejecuta hasta la terminación sin interferencias.
Mediante el uso de invariantes podemos proteger estos estados coherentes de los objetos del sistema. Algunos ejemplos son:
- CuentaBancaria: saldo = depositos + intereses - reintegros.
- BufferLimitado: contador de elementos siempre entre 0 y capacidad.
- Contador: valor entero no negativo.
Vivacidad - Algo sucede eventualmente
En sistemas vivaces cada una de las actividades progresa hacia la terminación. Podemos encontrarnos con los siguientes tipos de bloqueos transitorios:
- synchronized
- wait()
- E/S
- Competencia CPU
- Fallo
La vivacidad puede verse afectada cuando existen bloqueos permanentes:
- Interbloqueo: dependencias circulares de cerrojos.
- Señales perdidas: un hilo empieza a esperar después de la notificación.
- Cerrojos anidados en un monitor: hilo espera para adquirir cerrojo que necesita otro hilo que debe despertarlo.
- Falta de vivacidad: una acción repetida falla continuamente.
- Inanición: JVM no puede asignar CPU
- Falta de recursos: hilos tienen limites de recursos
- Fallo distribuido: no accesible máquina remota por socket.
Rendimiento - Que se ejecute pronto y rápido
Para poder garantizar el rendimiento podemos aplicar las siguientes medidas:
- Productividad
- Latencia
- Capacidad
- Eficiencia
- Escalabilidad
- Degradación
En los diseños multihilo, se empeora la eficiencia para mejorar la latencia.
La concurrencia introduce una sobrecarga:
- Cerrojos: mayor sobrecarga en métodos synchronized
- Monitores: mayor corte que los cerrojos
- Cambios de contexto entre hilos
- Planificación de hilos
- Sincronización de memoria entre CPUs