Saltar al contenido

Concurrencia en Java

Fuente: Wikipedia | Autor: Jacobofandebillgates | Licencia: CC BY-SA 3.0

Introducción 

Vamos a introducirnos en el mundillo de la programación concurrente a través de una pequeña guía (o manual, como gusteis llamarle) eminentemente práctica, utilizando uno de los lenguajes de programación más completos que existen hoy en día; Java. Esta entrada en mi blog no es definitiva, por lo que hiré añadiendo nuevos contenidos. Para probar los ejemplos recomiendo en vez de javac y un editor de texto plano un IDE, ya que sus mayores funcionalidades pueden ser óptimas para cacharrear y experimentar. Dos IDEs altamente recomendables son Netbeans y Eclipse. Para los ejemplos con crear proyectos Java SE será suficiente.

Concurrencia: Concepto clave

Para empezar, tenemos que comprender el concepto de concurrencia, que no es más que la ejecución solapada en el tiempo de varias tareas de forma que se realizan de forma paralela y a la vez (real o aparentemente, depende también del hardware, concretamente del procesador o procesadores). Un claro ejemplo es un procesador de textos: Tanto el de LibreOffice como Microsoft Word te permiten escribir y mientras lo haces, pueden estar revisando la ortografía, con un asistente de ayuda en pantalla o imprimiendo otro documento. Existe un único programa ejecutándose (el procesador de textos) con su correspondiente proceso, pero cada uno de estos componentes tiene un hilo de ejecución distinto que se ejecuta de forma asíncrona y concurrente.

Procesos e hilos (¡Qué lío!)

Ahora es dónde la mayoría de la gente se lía y surge la pregunta ¿cuál es la diferencia entre proceso e hilo? Intentaremos aclararla.
  • Un proceso no es más que la acción de ejecutar un programa sobre un procesador, es decir; es una unidad de ejecución que necesita unos recursos determinados como tiempo de procesador (le será asignado por el S.O.) y una cantidad de memoria reservada sólo para él. Por norma general, un programa posee un único proceso salvo que el programador codifique lo contrario: Un ejemplo puede ser un programa en C/C++ que haga uso de fork() en el que dos procesos colaboran entre sí para resolver un determinado problema utilizando mecanismos de sincronización (semáforos y monitores) y comunicación (segmentos de memoria compartida).
  • Un hilo (conocido también como proceso ligero, hebra o thread) es una unidad de ejecución paralela al proceso principal pero que carece de zona de memoria propia reservada (usa la del proceso principal) y tiene como finalidad, en general, la de realizar una tarea adicional al flujo del programa principal (como imprimir, generar una vista preliminar, un pdf…) o la de aprovechar el hardware disponible para realizar la misma tarea en menor tiempo utilizando para ello a la vez varias unidades de ejecución (Procesadores Intel con cores hyperthreading). Los hilos hoy en día se crean a través de un hilo padre, el cual se corresponde unívocamente con el proceso principal. En Java sería el hilo principal o main thread,  por lo que cuando escribimos un programa no concurrente en Java (un hola mundo por ejemplo), tenemos un único proceso que se corresponde con su hilo principal.
Resumiendo: Un programa puede ejecutarse mediante uno o más procesos (uno es lo habitual), los cuales poseen sus hilos principales asociados, pudiendo ejecutar otros hilos de forma concurrente.

Los hilos en Java

En Java existen dos formas de lanzar hilos, y al ser un lenguaje de P.O.O. se realiza mediante clases. Estas formas son:
  1. Una clase que implemente la interfaz Runnable (fuerza a que el programador escriba el método run() con las acciones a ejecutar del hilo).
  2. Una clase que herede de la superclase Thread, por lo que heredará el método run(), teniendo que sobreescribirlo.
Usaremos la primera por ser más correcta, ya que así podremos utilizar herencia como mecanismo de reutilización de código, siendo la superclase otra distinta a Thread. Bien es cierto que para ejemplos o programas sencillos la segunda funciona perfectamente.
Una vez tengamos nuestra clase, podemos detener (pausar) la ejecución de un hilo mediante sleep(tiempo_en_ms). Al invocar este método de la clase Thread se puede lanzar una excepción de tipo InterruptedException la cual es necesario capturar. En caso de lanzarse esta excepción, se abortadirectamente la ejecución del hilo. El tiempo durante el cual un hilo está durmiendo y después continúa ejecutándose es aproximado, ya que puede tardar algo más en función del planificador de tareas.

Ejemplo en Java de concurrencia aleatoria con dos hilos haciendo uso de sleep()

A continuación os muestro un ejemplo sencillo, de un programa que lanza dos hilos (puede ser modificado fácilmente para que lance más), mostrando por pantalla cuándo inician su ejecución, cuando la finalizan y cuando la reanudan tras deternerse haciendo uso de sleep() un tiempo aleatorio. De este modo, al ejecutar varias veces el programa comprobaréis que no siempre terminan igual ni duran lo mismo. Esto es muy importante ya que de antemano nunca sabremos qué hilo terminará primero ni cuando, ya que el S.O. gestiona los recursos (concretamente los procesadores) a todos los programas, no sólo al nuestro, que estén en ejecución.
package pruebaconcurrencia;

import java.util.Random;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 *
 * @author fmgarcia
 */
public class Hilo implements Runnable {
    private int nombre;
    private int duración;

    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) {
        // Lanzamos dos hilos de forma concurrente que duren un tiempo aleatorio:
        Random aleatorio = new Random(1337);

        for (int i=0; i<2; i++) {
            // Un hilo tendrá un tiempo de ejecución comprendido entre los 0 y 10 segundos.
            new Thread(new Hilo(i, aleatorio.nextInt(10000))).start();
        }
    }

    public Hilo(int nombre, int duración) {
        this.nombre = nombre;
        this.duración = duración;
    }

    /**
     * Método que contiene las acciones que hará el hilo cuando se ejecute.
     */
    @Override
    public void run() {
        System.out.println("Soy el hilo "+this.nombre+" y he iniciado mi ejecución.");
        System.out.println("Soy el hilo "+this.nombre+" y voy a parar mi ejecución "+this.duración+" ms.");
        try {
            Thread.sleep(this.duración);
        } catch (InterruptedException ex) { // Sleep puede lanzar una excepción que aborte la ejecución del hilo.
            Logger.getLogger(Hilo.class.getName()).log(Level.SEVERE, null, ex);
        }
        System.out.println("Soy el hilo "+this.nombre+" y continúo mi ejecución.");
        System.out.println("Soy el hilo "+this.nombre+" y he finalizado mi ejecución.");
    }
}

Ejemplo en Java de concurrencia con excepción de tipo InterruptedException

Como dije anteriormente, al dormir un hilo con sleep(), éste puede lanzar una interrupción de tipo InterruptedException que provoca que se aborte la ejecución del mismo. Tenemos entonces que rodear toda sentencia de este tipo con un bloque try-catch y actuar en consecuencia.
En la parte del catch tendríamos todas las instrucciones que haríamos en caso de ser necesarias antes de desechar el hilo como, por ejemplo, un mensaje de error. La última instrucción de este bloque siempre será un return ya que de otra forma el hilo puede verse forzado a continuar su ejecución sin hacer el sleep() y pueden surgir comportamientos inexperados en el programa.
Es importante saber que cuando surge este tipo de interrupción, realmente lo que pasa es que se activa una bandera (flag) de la case Thread denominada interrupted, pasando a valer true en vez de false. Se puede capturar la excepción después del sleep() con un if comprobando el valor de este flag, pero yo recomiendo la opción del try-catch.
Este ejemplo es similar al anterior, con la salvedad de que se genera intencionadamente una interrupción que aborte la ejecución del segundo hilo, capturando la excepción y haciendo un tratamiento del error. Podeis observar como el flag interrupted se activa y la ejecución del primer hilo no se ve alterada para nada. Podeis también quitar el return del bloque catch para observar qué comportamientos adquiere el programa.
package pruebaconcurrencia;

import java.util.Random;
/**
 *
 * @author fmgarcia
 */
public class Hilo implements Runnable {
    private int nombre;
    private int duración;
   
    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) {        // Lanzamos dos hilos de forma concurrente que duren un tiempo aleatorio:
        Random aleatorio = new Random(1337);
        for (int i=0; i<2; i++) {
            // Un hilo tendrá un tiempo de ejecución comprendido entre los 0 y 10 segundos.
            new Thread(new Hilo(i, aleatorio.nextInt(10000))).start();
        }
    }
    public Hilo(int nombre, int duración) {        this.nombre = nombre;
        this.duración = duración;
    }

    /**
     * Método que contiene las acciones que hará el hilo cuando se ejecute.
     */
    @Override
    public void run() {
        System.out.println("Soy el hilo "+this.nombre+" y he iniciado mi ejecución. Mi flag de interrupción vale "+Thread.interrupted());
        System.out.println("Soy el hilo "+this.nombre+" y voy a parar mi ejecución "+this.duración+" ms.");
        try {            // Para simular la finalización inesperada del hilo 1 lo hacemos lanzando una nueva excepción:
            if (this.nombre==1) throw new InterruptedException();
            Thread.sleep(this.duración);
        } catch (InterruptedException ex) { // Sleep puede lanzar una excepción que aborte la ejecución del hilo.
            // AQUÍ VA EL CÓDIGO ESPECÍFICO DEL TRATAMIENTO INESPERADO DEL HILO:
            System.out.println("INTERRUPCIÓN INESPERADA DEL HILO "+this.nombre);
            // El flag interrupted de la clase Thread se activa:
            System.out.println("FLAG DE INTERRUPCIÓN VALE "+Thread.interrupted());
            return;
        }
        System.out.println("Soy el hilo "+this.nombre+" y continúo mi ejecución.");
        System.out.println("Soy el hilo "+this.nombre+" y he finalizado mi ejecución.");
    }
}

El método join()

¿Y si un proceso ligero tiene que finalizar después de que lo haga otro? Pues para ello existe el método join() de la clase Thread. Al invocarlo sobre una subclase de la mencionada antes o sobre una que implemente la interfaz Runnable, el hilo actual detendrá su ejecución hasta que finalice la del otro hilo. Durante su invocación, Java puede lanzar una excepción e interrupir la ejecución del hilo al igual que sucede con sleep() por lo que habrá que capturarla y procesarla con un bloque try-catch.
Es importante saber también que join() es una función miembro sobrecargada, por lo que también se le puede indicar un tiempo aproximado (en milisegundos o nanosegundos), el cual dará de margen al proceso a finalizar su ejecución antes de dicho tiempo. Al igual que sucede con sleep el tiempo es aproximado y siempre tiene un margen de error circunstancial.

Ejemplo en Java de un hilo que no espera a que termine la ejecución de otro

En este caso tenemos un hilo padre (clase Hilo) que crea un hilo hijo (clase HiloHijo), aunque la clase HiloHijo hereda de padre por reutilización mediante herencia de la interfaz Runnable, aunque podría ser otra clase independiente con dicha interfaz. A diferencia de lo que cabría esperar, el hilo main puede finalizar antes que todos los hilos lanzados en nuestro programa, ya que puede ejecutar sus últimas instrucciones antes. Del mimo modo, otro hilo padre no tiene por qué esperar a que su hijo termine de ejecutarse. Si pruebas este programa varias veces, verás que aunque el hilo hijo «duerme» 500ms mientras que el padre no, el orden de finalización de los procesos varía y a veces el padre termina antes que el hijo o viceversa, depende de tu arquitectura hardware y del planificador de procesos de tu S.O. por lo que el resultado de antemano es impredecible:
// Clase Hilo.java

package pruebaconcurrencia;
/**
 *
 * @author fmgarcia
 */
public class Hilo implements Runnable {
    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) {
        // Lanzamos dos hilos de forma concurrente:
        Thread hilo = new Thread(new Hilo()); // El hilo está instanciado pero todavía no está listo para ejecutarse.
        hilo.start(); // Ahora sí que el primer hilo se ejecuta
    }
    public Hilo() { }
    /**
     * Método que contiene las acciones que hará el hilo cuando se ejecute.
     */
    @Override
    public void run() {
        System.out.println("Soy el hilo padre y he iniciado mi ejecución para crear un hilo hijo.");
        Thread hiloQueFinalizaAntes = new Thread(new HiloHijo()); // Creamos el hijo.
        hiloQueFinalizaAntes.start(); // El proceso hijo empieza a estar listo.
        System.out.println("Soy el hilo padre y he finalizado mi ejecución."); // El padre no espera por el hijo.
    }
}

// Clase HiloHijo.java
package pruebaconcurrencia;
/**
 *
 * @author fmgarcia
 */
public class HiloHijo extends Hilo {
    public HiloHijo() { }

    @Override
    public void run() {
        System.out.println("Soy el hilo hijo y he iniciado mi ejecución.");
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            System.out.println(e);
            return;
        }
        System.out.println("Soy el hilo hijo y he finalizado mi ejecución.");
    }
}

Ejemplo en Java de un hilo que detiene su ejecución hasta que termine la ejecución de otro

Por norma general, si observais el código anterior dormir un proceso ligero un tiempo determinado no garantiza que otro ejecutado concurrentemente finalice antes que él. En cambio, utilizando el método join() en el proceso padre invocándolo sobre el hijo, sí que detiene la ejecución del padre hasta que finalice la ejecución del hijo.
El método run() de la clase Hilo quedaría tal como sigue y podrás ejecutar el programa tantas veces como quieras, pero ya te digo que el padre va a ser bueno y esperar que su hijo termine primero.
Ahora sólo te faltaría utilizar join() para forzar a que el hilo principal no finalice antes que ningún otro (eso te lo propongo como ejercicio y no te quejes, ¡es tan sólo una única línea de código!).
// Método run reescrito de la clase Hilo haciendo uso de join
public void run() {
        System.out.println("Soy el hilo padre y he iniciado mi ejecución para crear un hilo hijo.");
        Thread hiloQueFinalizaAntes = new Thread(new HiloHijo()); // Creamos el hijo.
        hiloQueFinalizaAntes.start(); // El proceso hijo empieza a estar listo.
        try {
            hiloQueFinalizaAntes.join(); // Ahora el padre espera a que el hijo finalice antes su ejecución.
        } catch (InterruptedException e) { // Join puede lanzar una interrupción al igual que sleep.
            System.out.println(e);
            return;
        }
        System.out.println("Soy el hilo padre y he finalizado mi ejecución."); // El padre no espera por el hijo.
}

Conclusiones. 

La concurrencia en cualquier lenguaje de programación es un tema peliagudo y propenso a ser el centro de problemas y quebraderos de cabeza de una aplicación. La mejor forma es coger experiencia y practicar, practicar mucho hasta dominarlo. Para quientes deseen profundizar en el tema les recomiendo el manual ORACLE LESSON: JAVA CONCURRENCY.
 

Publicado enApuntesProgramación de alto nivelSistemas Operativos