El multithreading
Introducción
La programación multithread es un dominio apasionante, pero que puede convertirse rápidamente en algo muy complejo de poner a punto. Varias ejecuciones paralelas en el centro de su aplicación deberán compartir información, esperarse, intercambiar, etc. El éxito de una arquitectura de este tipo se basa, en primer lugar, en un análisis sólido. Este capítulo no pretende exponer todas las posibilidades de programación multithread y sus implementaciones en Java, sino presentar lo fundamental con la filosofía POO.
Entender el multithreading
Un proceso puede realizar operaciones largas, que van a «bloquear» la aplicación durante sus ejecuciones. Para evitar esto, el desarrollador puede crear una especie de ruta de ejecución paralela que se va a encargar de esta operación y, de esta manera, separarse del ejecutor principal. En este caso, el sistema operativo Windows de Microsoft comparte muy rápidamente el tiempo de máquina entre los diferentes flujos de ejecución (normalmente 20 ms por franja de tiempo), dando la sensación de una ejecución simultánea. Se habla de sistema operativo con derecho preferente. El contenido de una cola de ejecución puede encadenar todas las operaciones que desee sin preocuparse del tiempo que esto implica a nivel global.
El sistema operativo «lo interrumpirá» periódicamente para dar tiempo a la cola de ejecución siguiente y así sucesivamente, hasta volver a ella para que retome su operación allá donde fue interrumpida.
Como ejemplo, retomamos el sensor de entrada/salida equipado con una interfaz de programación muy resumida. El constructor nos brinda un juego de funciones que permite, entre otras cosas, leer el estado binario de las entradas digitales. Desea desarrollar una aplicación domótica que gestione varias operaciones en paralelo, como la iluminación, la calefacción e incluso la alarma....
Multithreading y Java
La parte de encapsulación de un proceso se realiza a través de la clase java.lang.Process. Como la creación de un proceso está estrechamente relacionada con el sistema operativo en el que funciona la máquina virtual, hay que pasar por una clase muy especializada que se llama Runtime. Cada aplicación Java tiene de manera nativa una instancia única sobre un objeto de tipo Runtime. Gracias a ella, usted podrá iniciar un nuevo proceso.
El siguiente extracto de código permite ejecutar el programa Windows calc.exe desde un programa Java.
package demoprocess;
import java.io.IOException;
public class DemoProcess {
public static void main(String[] args) {
// Recuperación de una referencia sobre el "runtime"
Runtime runtime = Runtime.getRuntime();
try {
// Utilización de su método exec
runtime.exec("calc.exe");
} catch (IOException ex) {
// Si la ejecución se realiza incorrectamente, ...
Implementación de los threads en Java
Hay dos maneras principales de programar los threads en Java: extender la clase Thread o implementar la interfaz Runnable.
1. Extender la clase Thread
Extendiendo la clase Thread y situando el código que se debe ejecutar en el método run, tomado de su cuenta, su clase se convierte directamente en «threadable». Después de su instanciación, una sencilla llamada al método start permite arrancar el thread y ejecutar «en paralelo» el código contenido en su método run. El thread se detiene cuando el código del método run se ha ejecutado completamente o se produce una excepción no administrada. Práctico, ¿verdad?
A continuación se muestra un ejemplo de código que utiliza este principio:
package demothread;
// MiClaseThread extiende la clase java.lang.Thread
public class MiClaseThread extends java.lang.Thread {
// ...
// Aquí nos imaginamos diferentes métodos y descriptores
// de acceso
// Ubicamos en el método run la operación "long"
// que se va a ejecutar en un thread instanciado
// y arrancado desde el código que llama
// (el main en este ejemplo)
@Override
public void run(){
// Traza de inicio de operación.
// Se utiliza el método de tipo static
// Thread.currentThread()
// para visualizar el nombre asignado a este thread
System.out.println("Inicio de una operación de 10 segundos "
+ "en el thread "
+ Thread.currentThread().getName());
// Aquí se simula un trabajo de 10 segundas (10 x 1000 ms)
for(int i=0; i<10; i++) ...
Sincronización entre threads
1. Necesidad de la sincronización
La programación de varias rutas de ejecución no plantea ningún problema particular hasta que comparten la misma información o los mismos recursos. En efecto, dando por hecho que el sistema operativo puede interrumpir las operaciones en cualquier momento, se corre el riesgo de tener objetos que estén modificando un thread preferente, que se encuentre en estados inestables para el thread siguiente. Para protegerse de estos funcionamientos incorrectos, hay que «sincronizar» los threads, es decir, proteger las zonas delicadas de las operaciones.
Esto no se va a reproducir en el sistema de gestión, que continuará activando los threads unos después de otros; sencillamente cuando un thread A necesite acceder a un dato común protegido que un thread B no haya terminado de actualizar, entonces el thread A deberá «esperar a la siguiente vuelta». Y si el trabajo del thread B no ha terminado en una vuelta, entonces tendrá que esperar a la siguiente y así sucesivamente.
El mismo principio se aplica si se trata de una operación común que el thread B deberá haber terminado antes de que el thread A pueda realizarlo a su vez. Este es el escenario que ofrece el siguiente extracto de código. En efecto, la operación permite visualizar una cuenta desde cero hasta nueve, realizada por diez threads. El objetivo es obtener la siguiente visualización:
0123456789
0123456789
0123456789
0123456789
0123456789
0123456789
0123456789
0123456789
0123456789
0123456789
A continuación se muestra una primera versión de código «sin protección»:
package demothreadsinsincro;
public class TratamientoSinProteccion{
public void EjecutarTratamiento() {
for(int i=0; i<10; i++){
new Thread(new Runnable()
{
...
Comunicación interthreads
1. El método join
Como se ha visto anteriormente, el método join permite a un thread principal «hibernar» mientras espera el final de la ejecución de un thread secundario.
Ejemplo de código
using System;
using System.Threading;
namespace SincroInterThreads
{
class Program
{
static void Main(string[] args)
{
Prueba t = new Prueba();
t.TratamientoPrincipal();
}
}
class Prueba
{
public void TratamientoPrincipal()
{
Console.WriteLine("Inicio TratamientoPrincipal");
ThreadStart ts
= new ThreadStart(TratamientoSecundario);
Thread t = new Thread(ts);
t.IsBackground = false;
t.Priority = ThreadPriority.Highest;
t.Name = "Es mi thread:)";
t.Start();
t.Join();
Console.WriteLine("Fin TratamientoPrincipal");
}
private void TratamientoSecundario()
{
Console.WriteLine("Inicio TratamientoSecundario");
Thread.Sleep(1000 * 10);
Console.WriteLine("Fin TratamientoSecundario");
}
}
}
Salida por la consola asociada:
Inicio TratamientoPrincipal
Inicio TratamientoSecundario
Fin TratamientoSecundario
Fin TratamientoPrincipal
Pulse una tecla para continuar.
Salida por la consola asociada sin la línea t.Join();:
Inicio TratamientoPrincipal
Fin TratamientoPrincipal
Inicio TratamientoSecundario
Fin TratamientoSecundario
Pulse una tecla para continuar.
Un thread hibernado...
Ejercicio
1. Enunciado
Partiendo del ejemplo anterior (con Thread.sleep(500); en el bucle de consumo), debe introducir la noción de gestión de flujo entre productor y consumidor. El thread de producción debe «dormir» cuando se alcance un número máximo de franjas en espera (diez por ejemplo). El thread de consumo leerá estas franjas y después, cuando la cola esté vacía, el thread de producción deberá retomar su trabajo.
Tipo de comportamiento deseado:
debug:
Inicio Tratamiento global
Inicio GeneradorDeFranjas
Pulse una tecla para parar.
Inicio ConsumidorDeFranjas
Consumidor espera
Franja recibida: 46
Franja consumida: 46
Franja recibida: 67
Franja recibida: 23
Franja recibida: 92
Franja recibida: 20
Franja recibida: 23
Franja consumida: 67
Franja recibida: 54
Franja recibida: 21
Franja recibida: 60
Franja recibida: 66
Franja consumida: 23
Franja recibida: 2
Franja recibida: 58
Franja recibida: 69
Fila saturada
Franja consumida: 92
Franja consumida: 20
Franja consumida: 23
Franja consumida: 54
Franja consumida: 21
Franja consumida: 60
Franja consumida: 66
Franja consumida: 2
Franja consumida: 58
Franja consumida: 69
Saturación de la cola eliminada
Franja recibida: 97
Franja recibida: 76
Franja recibida: 41
Franja recibida: 89
Franja consumida: 97
Franja recibida: 12
Abandono solicitado
InterruptedException en GeneradorDeFranjas
Final de GeneradorDeFranjas
InterruptedException en ConsumidorDeFranjas
Fin ConsumidorDeFranjas
Final Tratamiento global
BUILD SUCCESSFUL (total time: 9 seconds)
A continuación se muestra alguna información para ayudarle:
En el código inicial, el desarrollador ha generado una doble función para el productor: producir y sincronizar el consumidor para que duerma cuando no haya nada que leer. Una solución posible para la nueva problemática...