This is an old revision of the document!
Table of Contents
Concurrencia
Programación multihilo
Multiproceso
El multiproceso consiste en la ejecución de varios procesos diferentes de forma simultánea para la realización de una o varias tareas relacionadas o no entre sí. En este caso, cada uno de estos procesos es una aplicación independiente. El caso más conocido es aquel en el que nos referimos al Sistema Operativo (Windows, Linux, MacOS, . . .) y decimos que es multitarea puesto que es capaz de ejecutar varias tareas o procesos (o programas) al mismo tiempo.
Multihilo
Hablamos de multihilo cuando se ejecutan varias tareas relacionadas o no entre sí dentro de una misma aplicación. En este caso no son procesos diferentes sino que dichas tareas se ejecutan dentro del mismo proceso del Sistema Operativo. A cada una de estas tareas se le conoce como hilo o thread (en algunos contextos también como procesos ligeros).
En ambos casos estaríamos hablando de lo que se conoce como Programación Concurrente. Hay que tener en cuenta que en ninguno de los dos casos la ejecución es realmente simultánea, ya que el Sistema Operativo es quién hace que parezca así, pero los ejecuta siguiendo lo que se conoce como algoritmos de planificación.
Algoritmos de planificación
En entornos multitarea, un algoritmo de planificación indica la forma en que el tiempo de procesamiento debe repartirse entre todas las tareas que deben ejecutarse en un momento determinado. Existen diferentes algoritmos de planificación, cada uno con sus ventajas e inconvenientes, pero todos intentan cumplir con los siguientes puntos:
- Debe ser imparcial y eficiente
- Debe minimizar el tiempo de respuesta al usuario, sobre todo en aquellos procesos o tareas más interactivas
- Debe ejecutar el mayor número de procesos
- Debe mantener un equilibrio en el uso de los recursos del sistema
Programación concurrente, paralela y distribuida
Programación concurrente
Es la programación de aplicaciones capaces de realizar varias tareas de forma simultánea utilizando hilos o threads. En este caso todas las tareas compiten por el uso del procesador (lo más habitual es disponer sólo de uno) y en un instante determinado sólo una de ellas se encuentra en ejecución. Además, habrá que tener en cuenta que diferentes hilos pueden compartir información entre sí y eso complica mucho su programación y coordinación.
Programación paralela
Es la programación de aplicaciones que ejecutan tareas de forma paralela, de forma que no compiten por el procesador puesto que cada una de ellas se ejecuta en uno diferente. Normalmente buscan resultados comunes dividiendo el problema en varias tareas que se ejecutan al mismo tiempo.
Programación distribuida
Es la programación de aplicaciones en las que las tareas a ejecutar se reparten entre varios equipos diferentes (conectados en red, a los que llamaremos nodos). Juntos, estos equipos, forman lo que se conoce como un Sistema Distribuido, que busca formar redes de equipos que trabajen con un fin común
\begin{figure}[h!]
\centering \includegraphics[scale=0.8]{partes/varios/concurrencia.jpg} \caption{Programación concurrente/paralela}
\end{figure}
\begin{figure}[h!]
\centering \includegraphics[scale=1]{partes/varios/distribuida.jpg} \caption{Programación distribuida}
\end{figure}
¿Qué son los hilos?
Un hilo o thread es cada una de las tareas que puede realizar de forma simultánea una aplicación. Por defecto, toda aplicación dispone de un único hilo de ejecución, al que se conoce como hilo principal. Si dicha aplicación no despliega ningún otro hilo, sólo será capaz de ejecutar una tarea al mismo tiempo en ese hilo principal.
Así, para cada tarea adicional que se quiera ejecutar en esa aplicación, se deberá lanzar un nuevo hilo o thread. Para ello, todos los lenguajes de programación, como Java, disponen de una API para crear y trabajar con ellos.
En cualquier caso, es muy importante conocer los estados en los que se pueden encontrar un hilo. Estos estados se suelen representar mediante un gráfico como el que sigue:
Estados de un hilo
\begin{figure}[h!]
\centering \includegraphics[scale=1.4]{partes/varios/estados_hilo.png} \caption{Estados de un hilo}
\end{figure}
Programación multihilo en Java
Creación de un hilo
Para la creación de hilos en Java disponemos de varias vías, combinando el uso de la clase Thread y el interface Runnable según nos interese:
- Podemos utiliza la clase Thread heredando de ella. Es quizás la forma más cómoda porque una clase que hereda de Thread se convierte automáticamente en un hilo. Tiene una pega: esa clase ya no podrá heredera de ninguna otra, por lo que si la arquitectura de nuestra aplicación lo requiere ya no podríamos.
- Si tenemos la limitación que acabamos de comentar para el primer caso, podemos implementar el interface Runnable de forma que la clase que nosotros estamos implementado podrá además heredar sin ninguna limitación. Sólo cambia un poco la forma de trabajar directamente con la clase hilo.
- Por otra parte también podemos crear un hilo utilizando una clase anónima. No es un método que se recomiende pero en algunos casos, cuando la clase que hace de hilo no va a tener una estructura concreta es bastante cómodo hacerlo de esta manera.
Crear un hilo heredando de la clase Thread
public class Tarea extends Thread { @Override public void run() { for (int i = 0; i < 10; i++) { System.out.println("Soy un hilo y esto es lo que hago"); } } } . . . public class Programa { public static void main(String args[]) { Tarea tarea = new Tarea(); tarea.start(); System.out.println("Yo soy el hilo principal y sigo haciendo mi trabajo"); System.out.println("Fin del hilo principal"); } }
Crear un hilo implementando la interfaz Runnable
public class OtraClase { . . . . . . } . . . public class Tarea extends OtraClase implements Runnable { @Override public void run() { for (int i = 0; i < 10; i++) { System.out.println("Soy un hilo y esto es lo que hago"); } } } . . . public class Programa { public static void main(String args[]) { Tarea tarea = new Tarea(); Thread hilo = new Thread(tarea); hilo.start(); System.out.println("Yo soy el hilo principal y sigo haciendo mi trabajo"); System.out.println("Fin del hilo principal"); } }
Crear un hilo implementando una clase anónima/expresión lambda
public class Programa { public static void main(String args[]) { Thread hilo = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 10; i++) { System.out.println("Soy un hilo y esto es lo que hago"); } }); hilo.start(); System.out.println("Yo soy el hilo principal y sigo haciendo mi trabajo"); System.out.println("Fin del hilo principal"); } }
En cualquier caso tenemos que tener siempre en cuenta las siguientes consideraciones:
- Siempre se debe sobreescribir (Override) el método run() e implementar allí lo que tiene que hacer el hilo
- Podemos hacer que el hilo haga un número finito de cosas o bien que esté siempre en segundo plano (tendremos que asegurar que el método run() se ejecuta de forma continuada)(¿cómo se hace eso?)
- Los problemas vienen cuando existen varios hilos. Hay que tener en cuenta que pueden compartir datos y código y encontrarse en diferentes estados de ejecución
- La ejecución de nuestra aplicación será thread-safe si se puede garantizar una correcta manipulación de los datos que comparten los hilos de la aplicación sin resultados inesperados (más adelante veremos cómo)
- Además, en el caso de aplicaciones multihilo, también nos puede interesar sincronizar y comunicar unos hilos con otros
También resulta interesante saber cómo detener un hilo. En este caso, la API de Java desaconsejó el método stop() que en un principio ideó para detener la ejecución. Así, hoy en día, se nos anima a que seamos nosotros quienes implementemos formas limpias de detener nuestros hilos.
Sincronización de hilos
El API de Java proporciona una serie de métodos en la clase Thread para la sincronización de los hilos en una aplicación:
- join() Se espera la terminación del hilo que invoca a este método antes de continuar
- Thread.sleep(int) El hilo que ejecuta esta llamada permanece dormido durante el tiempo especificado como parámetro (en ms)
- isAlive() Comprueba si el hilo permanece activo todavía (no ha terminado su ejecución) - yield() Sugiere al scheduler que sea otro hilo el que se ejecute (no se asegura)
public static void main(String args[]) { Hilo hilo1 = new Thread(new Tarea()); Hilo hilo2 = new Thread(new Tarea()); hilo1.start(); hilo2.start(); . . . . . . hilo1.join(); hilo2.join(); System.out.println("Fin de la ejecución de los dos hilos"); }
El hilo principal espera a que ambos hilos se hayan ejecutado para continuar (o para lo que sea)
public static void main(String args[]) { Hilo hilo1 = new Thread(new Tarea()); Hilo hilo2 = new Thread(new Tarea()); hilo1.start(); hilo1.join(); hilo2.start(); hilo2.join(); System.out.println("Fin de la ejecución de los dos hilos"); }
En este caso los hilos se ejecutan uno después de otro
public class Tarea implements Runnable { @Override public void run() { for (int i = 0; i < 10; i++) { System.out.println("Soy un hilo y esto es lo que hago"); try { Thread.sleep(500); } catch (InterruptedException ie) { ie.printStackTrace(); } } } }
En este caso el hilo duerme (detiene su ejecución) durante el tiempo especificado (en ms). Durante ese momento podrán ejecutarse otros hilos
public class TareaPrincipal implements Runnable { @Override public void run() { for (int i = 0; i < 10; i++) { System.out.println("Soy la TarePrincipal"); try { Thread.sleep(500); } catch (InterruptedException ie) { ie.printStackTrace(); } } } } . . . public class TareaAlive implements Runnable { private Thread otroHilo; public TareaAlive(Thread otroHilo) { this.otroHilo = otroHilo; } @Override public void run() { while (otroHilo.isAlive()) { System.out.println("Yo hago cosas mientras el otro hilo siga en ejecución"); try { Thread.sleep(500); } catch (InterruptedException ie) { ie.printStackTrace(); } } System.out.println("El otro hilo ha terminado. Yo también"); } } . . . public class Programa { public static void main(String args[]) { TarePrincipal tareaPrincipal = new TareaPrincipal(); Thread hiloPrincipal = new Thread(tareaPrincipal); TareaAlive tareaAlive = new TareaAlive(hiloPrincipal); Thread hiloAlive = new Thread(tareaAlive); hiloPrincipal.start(); hiloAlive.start(); System.out.println("Se han terminado los dos hilos?"); } }
isAlive() está indicando que el hilo está vivo (ha iniciado su ejecución y aún no ha muerto, puede estar en cualquier estado intermedio, incluso durmiendo)
Programación concurrente
synchronize
- En un entorno multi-hilo, se da una condición de carrera cuando más de un hilo intenta “al mismo tiempo” actualizar un recurso compartido (que puede ser una variable)
- Java proporciona un mecanismo para evitar estos problemas (palabra reserva synchronized)
- Evita que más de un hilo puedan acceder a un zona de código o ejecutar un método determinado
public class RecursoCompartido { private int valor; public synchronized void calcular() { setValor(getValor() + 1); } }
Sin la palabra reservada synchronized, si más de un hilo ejecuta el método calcular, los resultados obtenidos pueden ser bastante inesperados (hacer prueba con y sin ella)
Executors y pools de hilos
En el apartado anterior vimos como crear y lanzar hilos individualmente utilizando la clase Thread que Java proporcina en su API. El código funciona perfectamente pero realmente se vuelve complicado de desarrollar si lo que tenemos que gestionar es una gran cantidad de hilos de forma simultánea. Para esos casos, Java proporciona un framework, Executors, que permite una gestión mucho más sencillo de cualquier tarea que se ejecuta en segundo plano.
Utilizando Executors, como programador, te olvidas en parte de la gestión de los hilos. Simplemente hay que centrarse en programar el código a realizar y asignar dicho código (una clase Runnable ) a un Executor , que se encargará de crear el hilo, lanzarlo y gestionar su ejecución.
Veamos un ejemplo de cómo lanzar una serie de tareas en segundo plano utilizando la clase \verb ExecutorService que viene con el framework Executors de Java.
Supongamos una clase muy sencilla que implementa \verb Runnable para definir un código que debe ejecutarse en segundo plano:
public class Tarea implements Runnable { @Override public void run() { System.out.println("Soy una clase que implementa Runnable"); System.out.println("Ya me termino"); } }
Para lanzar la tarea, o varias instancia de la misma, podemos crear un pool de hilos (en este caso dos) y pasarles las tareas al objeto Executor para que sea él quién los lance. En este caso, puesto que sólo se dispone de dos hilos en el pool, sólo se podrán ejecutar dos hilos en cada momento. Si en algún momento se ocuparán los dos hilos, las demás tareas tendrían que esperar hasta que alguno de los dos quedará libre para ejecutarse.
Finalmente se ejecuta el método shutdown(), que hará finalizar el Executor una vez hayan terminado todas las tareas que se encuentran ejecutándose con él.
Hay que tener en cuenta que existe también un método shutdownNow que hace que el Executor finalice devolviendo todas las tareas que estaban esperando y no llegaron a ejecutarse. Se esperará que las tareas activas terminen o bien sean interrumpidas.
// Crea un executor con dos hilos ExecutorService executor = Executors.newFixedThreadPool(2); // Encola tareas a ajecutar executor.execute(new Tarea()); executor.execute(new Tarea()); executor.execute(new Tarea()); executor.execute(new Tarea()); // Finaliza el executor. Terminará cuando todas las tareas que tiene // se acaben de forma natural executor.shutdown();
También es posible trabajar con un ExecutorService que simplemente cuente con un hilo
// Crea un executor con dos hilos ExecutorService executor = Executors.newSingleThreadExecutor(); executor.execute(new Tarea()); . . .
En el siguiente caso, instanciamos un pool de hilos planificados, de forma que podremos lanzar las tareas para que comiencen tras un tiempo de retraso asignado en su lanzamiento a través del método schedule():
// Crea un executor planificado con dos hilos ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); // Encola tareas a ajecutar añadiendo retraso // en el lanzamiento de cada una de ellas executor.schedule(new Tarea(), 1, TimeUnit.SECONDS); executor.schedule(new Tarea(), 2, TimeUnit.SECONDS); executor.schedule(new Tarea(), 3, TimeUnit.SECONDS); executor.schedule(new Tarea(), 4, TimeUnit.SECONDS); // Finaliza el executor. Terminará cuando todas las tareas que tiene // se acaben de forma natural executor.shutdown();
En cualquier caso, podemos encontrarnos con dos tipos de pools de hilos:
- Fixed: Se pueden crear a través de los métodos \verb Executors.newFixedThreadPool(…) y crean un pool de hilos fijo (de número determinado) que se reutilizan a medida que van quedando libres. En ningún caso se crearán más hilos de los fijados al crear el pool. Si todos los hilos están ocupados y se encola una nueva tarea, ésta tendrá que esperar hasta que un hilo quede libre para ejecutarla.
- Cached: Se pueden crear a través de los métodos \verb Executors.newCachedThreadPool(…) y crear un pool de hilos no fijo (sin número determinado) que se pueden reutilizar si quedan libres. En este caso, si todos los hilos están ocupados y se encola una nueva tarea se creará un nuevo hilo para poder ejecutarla. Los hilos que no hayan sido reutilizados en un tiempo determinado (6 segundos) serán finalizados y eliminados del pool.
Callable y Future
Hasta el momento hemos trabajado con objetos Runnable como objetos que se pueden asignar a un hilo para ejecutar su código en segundo plano. Los objetos \verb Runnable no son capaces de devolver ningún valor, simplemente ejecutan el código (el método run() no devuelve nada). Tampoco pueden lanzar excepciones puesto que el método run() de Runnable no está prearado para ello. Java proporciona también una interface llamada Callable que permite crear objetos que pueden ser asignados a hilos para ejecutarse en segundo plano, y que además permite que éstas tareas devuelvan un resultado al finalizar y que lancen una excepción.
public class TareaCallable implements Callable<Boolean> { @Override public Boolean call() throws Exception { // Realiza algo return true; } }
Ligado a este nuevo tipo de tarea Callable aparece un nuevo tipo llamado \verb Future que es el tipo de dato en el que estas nuevas tareas devolverán su valor. Puesto que ahora tenemos una tarea en segundo plano que debe devolver un valor al finalizar, necesitamos, de alguna manera, esperar y recoger ese valor cuando corresponda. Así, un objeto \verb Future es capaz de almacenar el valor de devolución de un método que ha sido lanzado pero todavía no ha terminado.
Veamos ahora un ejemplo lanzando una tarea Callable y cómo se recoge su resultado:
// Crea un executor con dos hilos ExecutorService executor = Executors.newFixedThreadPool(2); // Los Callable se ejecutan con el método submit() Future<Boolean> futuro = executor.submit(new TareaCallable()); // Podemos seguir ejecutando código en el hilo principal System.out.println("El programa sigue funcionando . . ."); // Recogemos el resultado de la tarea Callable // Hay que tener en cuenta que la llamada al método get() // bloquea la ejecución hasta que termine la tarea Boolean resultado = futuro.get(); System.out.println("El resultado de la tarea es: " + resultado); // Finaliza el executor. Terminará cuando todas las tareas que tiene // se acaben de forma natural executor.shutdown();
Hay que tener en cuenta que la llamada al método get() bloquea la ejecución del hilo principal hasta que la tarea termine y devuelva el resultado. La clave aqui está en que unas líneas antes ya tenemos el objeto \verb Future y podemos seguir ejecutando instrucciones hasta que realmente necesitemos utilizar el valor, que será cuando invoquemos al método get(). Además, siempre podremos cancelar el objeto Future e incluso comprobar si ha terminado para decidir hacer algo mientras la tarea no lo haya hecho. De esa manera, una vez lanzada la tarea y obtenido el futuro, podemos ver si procede o no acabar obteniendo el valor que devuelva. En el siguiente caso, vamos a suponer que a partir de un cierto tiempo de ejecución se cancela la tarea y el programa sigue. Modificamos la tarea TareaCallable para forzar a que dure un tiempo determinado y que asi el ejemplo tenga cierta duración y podamos comprobar como espera (o no) en función del tiempo de timeout que le hayamos asignado, o bien utilizando otro criterio cualquiera.
public class TareaCallable implements Callable<Boolean> { @Override public Boolean call() throws Exception { // Realiza algo return true; } }
Y el programa principal quedaría asi:
// Crea un executor con dos hilos ExecutorService executor = Executors.newSingleThreadExecutor(); long tiempoPasado = 0; long tiempoInicio = System.currentTimeMillis(); // Los Callable se ejecutan con el método submit() Future<Boolean> futuro = executor.submit(new TareaCallable()); // Podemos seguir ejecutando código en el hilo principal System.out.println("El programa sigue funcionando . . ."); // Ejecutamos mientras la tarea no haya terminado while (!futuro.isDone()) { try { System.out.println("Esperando a que termine la tarea o haciendo algo mientras"); Thread.sleep(300); // Vamos sumando el tiempo de espera tiempoPasado = System.currentTimeMillis() - tiempoInicio; // 4000 hace de timeout. Pasado ese tiempo cancelamos la tarea // del segundo plano if (tiempoPasado > 4000) { futuro.cancel(true); } } catch (InterruptedException ie) { ie.printStackTrace(); } } // Llegados a este punto podemos saber si la tarea fue cancelada if (futuro.isCancelled()) { System.out.println("La tarea fue cancelada"); } // o bien si terminó correctamente else { // Recogemos el resultado de la tarea Callable try { Boolean resultado = futuro.get(); System.out.println("El resultado de la tarea es: " + resultado); } catch (ExecutionException|InterruptedException e) { e.printStackTrace(); } } // Finaliza el executor. Terminará cuando todas las tareas que tiene // se acaben de forma natural executor.shutdown();
En cualquier caso, el método get() puede ser invocado para establecer un tiempo de timeout, a partir del cual el método devolvería una excepción TimeoutException. Es un caso muy útil cuando, desde nuestra aplicación, invocamos a algún servicio remoto del que no podemos garantizar su disponibilidad en todo momento.
. . . // Espera 10 segundos antes de lanzar la excepción TimeoutException Boolean resultado = futuro.get(10, TimeUnit.SECONDS); . . .
Además, es posible lanzar varias tareas al mismo tiempo con el método executeAll de la clase ExecutorService:
. . . List<Callable<Boolean>> listaTareas = . . . List<Future<Boolean>> futuros = excecutor.invokeAll(listaTareas); . . . for (Future<Boolean> futuro : futuros) { System.out.println("Resultado :" + futuro.get()); } . . .
CountDownLatch
Sirve como contador para bloquear una llamada hasta que éste llega a cero
Se puede utilizar para que el código del hilo principal no continúe hasta que algo se haya ejecutado por todos los hilos que son necesarios
public class Tarea implements Runnable { private CountDownLatch countDownLatch; public Tarea(CountDownLatch countDownLatch) { this.countDownLatch = countDownLatch; } @Override public void run() { hacerAlgo(); countDownLatch.countDown(); } } . . . . . . CountDownLatch countDownLatch = new CountDownLatch(4); for . . . executorService.execute(new Tarea(countDownLatch)); . . . countDownLatch.await(); // Espera hasta que el contador sea 0 System.out.println("Ya se han ejecutado las 5 tareas");
CyclicBarrier
Similar a CountDownLatch pero reutilizable
Se utiliza para que los hilos se esperen los unos a los otros antes de realizar alguna tarea concreta
public class Tarea implements Runnable { private CyclicBarrier cyclicBarrier; public Tarea(CyclicBarrier cyclicBarrier) { this.cyclicBarrier = cyclicBarrier; } @Override public void run() { hacerAlgo(); cyclicBarrier.await(); hacerOtraCosa(); } } . . . // Acepta como segundo argumento un Runnable que será ejecutado por el último hilo que pase la barrera CyclicBarrier cyclicBarrier = new CyclicBarrier(2); if (!cyclicBarrier.isBroken()) { executorService.execute(new Tarea(cyclicBarrier)); . . . }
Colecciones sincronizadas
Son lo que se conocen como colecciones thread-safe, puesto que están listas para usarse en entornos de concurrencia con varios hilos ejecutándose.
En la clase Collection hay una serie de métodos estáticos que devuelven instancia de colecciones sincronizadas. Por ejemplo:
- synchronizedList()
- synchronizedMap()
- synchronizedSet()
Además, existe alguna colección ya sincronizada como Vector (similar a ArrayList) y HashTable (similar a HashMap)
Objetos atómicos
Los objetos atómicos son estructuras Java que sirven para representar valores y realizar operaciones de forma atómica para algunos tipos de datos.
AtomicInt, AtomicLong y AtomicBoolean son algunos de los objetos atómicos que tenemos disponibles dentro de la API de Java
Por poner algún ejemplo, AtomicInt garantiza la ejecución atómica de algunas operaciones como estas:
- get()
- compareAndSet(valorComparacion, nuevoValor)
- addAndGet(valor)
- getAndIncrement()
- incrementAndGet()
- decrementAndGet()
- getAndDecrement()
Variables volatile
Es una palabra reservada que sirve para indicarle a Java que la variable debe ser escriba en memoria principal de forma inmediata cuando vea modificado su valor, para evitar que otros hilos que la utilicen puedan tener un valor diferente no actualizado
- private volatile int valor;
Hay que tener en cuenta que no siempre será suficiente y sólo servirá para situaciones donde un hilo esté leyendo alguna variable que otro hilo modifique. No nos ayudará a evitar las condiciones de carrera que hemos visto anteriormente. En esos casos necesitaremos, como ya vimos, crear zonas sincronizadas para evitar problemas.
Streams paralelos
Locks
Patrón Observer
© 2021-2024 Santiago Faci