Sincronización

prev.gif (997 bytes)chapter.gif (1105 bytes)home.gif (1232 bytes)next.gif (1211 bytes)

Subprocesos (threads)

La palabra reservada synchronized

El modelo Productor/Consumidor

Cargando imágenes


La palabra reservada synchronized

La palabra reservada synchronized se usa para indicar que ciertas partes del código, (habitualmente, una función miembro) están sincronizadas, es decir, que solamente un subproceso puede acceder a dicho método a la vez.

Cada método sincronizado posee una especie de llave que puede cerrar o abrir la puerta de acceso. Cuando un subproceso intenta acceder al método sincronizado mirará a ver si la llave está echada, en cuyo caso no podrá accederlo. Si método no tiene puesta la llave entonces el subproceso puede acceder a dicho código sincronizado.

Las siguientes porciones de código son ejemplos de uso del modificador synchronized

synchronized public void funcion1(){
   //...
}
public void funcion2(){
    Rectangle rect;
    synchronized(rect){
        rect.width+=2;
    }
    rect.height-=3;
}

Un ejemplo que veremos más adelante es la sincronización de una porción de código usando el objeto this, en el interior de una función miembro denominada mover

public void mover(){
   synchronized (this) {
 	indice++;
 	if (indice>= numeros.length) {
 		indice=0;
 	}
  }
//...
}

En la primera porción de código, hemos asegurado un método de modo que un sólo subproceso a la vez puede acceder a la funcion1. En la segunda y tercera porción de código, tenemos un bloque de código asegurado. La anchura width del rectángulo rect no puede ser modificada por varios subprocesos a la vez. La altura height del rectángulo no está dentro del bloque sincronizado y puede ser modificada por varios subprocesos a la vez. El objeto rect se usa en este caso como llave de dicho bloque de código, en el tercer ejemplo este papel lo representa this.

Se debe evitar la sincronización de bloques de código y sustituirlas siempre que sea posible por la sincronización de métodos, lo que está más de acuerdo con el espíritu de la programación orientada a objetos. Se debe tener en cuenta que la sincronización disminuye el rendimiento de una aplicación, por tanto, debe emplearse solamente donde sea estrictamente necesario.

 

El modelo Productor/Consumidor

disco.gif (1035 bytes)thread2: Productor.java, Consumidor.java, Buffer.java, ThreadApp2.java

En la página previa, los subprocesos eran independientes, cada subproceso contiene los recursos que le son necesarios para su ejecución, corren a su propio paso sin interesarse por el estado o las tareas de los otros subprocesos que se ejecutan a la vez. Sin embargo, hay muchas situaciones interesantes en las que los subprocesos comparten datos y han de considerar el estado y las actividades de los otros subprocesos. Una de estas situaciones se denomina modelo Productor/Consumidor: el productor genera un flujo de datos que son recogidos por el consumidor. Cuando dos subprocesos comparten un recurso común han de estar sincronizados de algún modo.

Un problema se origina cuando el productor va más rápido que el consumidor y genera un segundo dato antes de que el consumidor tenga la oportunidad de recoger el primero. El consumidor se saltará un dato. Del mismo modo, si el consumidor es más rápido que el productor, puede que no tenga datos que recoger, o que recoja varias veces el mismo dato.

Cuando estamos diseñando subprocesos que compiten por recursos limitados hemos de tener cuidado en no caer en dos situaciones extremas, denominadas starvation (morir de hambre, el subproceso no progresa por falta de recursos) y deadlock (por ejemplo, cuando dos personas están en conflicto y uno está esperando a que el otro tome la iniciativa para resolverlo y viceversa)

El productor

La clase que describe el productor denominada Productor deriva de la clase Thread y redefine la función run. Tiene como miembro dato un objeto buffer de la clase Buffer que describiremos más adelante.

La función miembro run ejecuta un bucle for, cuando se completa el bucle se alcanza el final de run y el subproceso entra en el estado Death (muerto), y detiene su ejecución.

En el bucle for, se genera una letra al azar y se pone en el objeto buffer, llamando a la función poner de la clase Buffer. A continuación, se imprime y se hace un pausa por un número determinado de milisegundos llamando a la función sleep y pasándole el tiempo de pausa, durante este tiempo el subprocerso esta en el estado Not Runnable.

El bucle for no se suele utilizar sino un bucle while de la forma que se explicó en la página anterior

public class Productor extends Thread {
  private Buffer buffer;
  private final String letras="abcdefghijklmnopqrstuvxyz";
  public Productor(Buffer buffer) {
    this.buffer=buffer;
  }
    public void run() {
        for(int i=0; i<10; i++){
            char c=letras.charAt((int)(Math.random()*letras.length()));
            buffer.poner(c);
            System.out.println(i+" Productor: " +c);
            try {
                sleep(400);
            } catch (InterruptedException e) { }
        }
    }
}

El consumidor

La clase que describe al consumidor denominado Consumidor, deriva también de la clase Thread y redefine el método run. La definición de run es similar a la de la clase Productor, salvo que en vez de poner un carácter en el buffer, recoge el carácter guardado en el buffer intermedio llamando a la función recoger.

El tiempo de pausa (argumento de la función sleep) puede ser distinto en la clase Productor que en la clase Consumidor. Por ejemplo, para hacer que el productor sea más rápido que el consumidor, se pone un tiempo menor en la primera que en la segunda.

public class Consumidor extends Thread {
  private Buffer buffer;
  public Consumidor(Buffer buffer) {
    this.buffer=buffer;
  }
  public void run(){
    char valor;
    for(int i=0; i<10; i++){
        valor=buffer.recoger();
        System.out.println(i+ " Consumidor: "+valor);
        try{
            sleep(100);
        }catch (InterruptedException e) { }
    }
  }
}

 

El buffer (primera aproximación)

El objeto compartido entre el productor y el consumidor está descrito por la clase denominada Buffer. Vamos a llegar a la definición de dicha clase en dos pasos sucesivos.

La clase Buffer tiene dos miembros dato: el primero contenido guarda un carácter (es el buffer), el segundo, disponible indica si el buffer está lleno o está vacío, según que ésta variable valga true o false.

La definición de las funciones poner y recoger es muy simple:

public class Buffer {
  private char contenido;
  private boolean disponible=false;
  public Buffer() {
  }
  public char recoger(){
    if(disponible){
        disponible=false;
        return contenido;
    }
    return ('\t');
  }
  public void poner(char c){
    contenido=c;
    disponible=true;
  }        
}

La aplicación

Definimos una clase que describe una aplicación. En la función main, creemos tres objetos un objeto b de la clase Buffer, un objeto p de la clase Productor y otro objeto c de la clase Consumidor. Al constructor de las clases Productor y Consumidor le pasamos el objeto b compartido de la clase Buffer.

Ponemos en marcha los subprocesos descritos por las clases Productor y Consumidor, mediante la llamada a la función start de modo que el estado de los subprocesos pasa de New Thread a Runnable. Los subprocesos una vez puestos en marcha mueren de muerte natural, pasando al estado Death después de ejecutar un bucle for de 10 iteracciones en la función run.

public class ThreadApp2 {
  public static void main(String[] args) {
    Buffer b=new Buffer();
    Productor p=new Productor(b);
    Consumidor c=new Consumidor(b);
    p.start();
    c.start();
  }
}

Cuando corremos la aplicación observamos la siguiente salida

sync1.gif (6581 bytes) sync2.gif (6536 bytes) sync3.gif (6588 bytes)

La imagen de la izquierda corresponde a la situación en la que el consumidor va más rápido (100 como argumento de sleep) que el productor (400 como argumento de sleep). En la primera iteracción (líneas marcadas con 0), el consumidor va al buffer y no encuentra ninguna letra (inicialmente esta vacío), el productor pone en el buffer la letra f. En la segunda iteracción (líneas marcadas con 1) el consumidor consume la letra f, y el buffer se queda vacío, el consumidor vuelve a acceder de nuevo al buffer y no encuentra nada, etc.

La imagen del centro corresponde a la situación en la que el productor va más rápido (100 como argumento de sleep) que el consumidor (400 como argumento de sleep). En la primera iteracción (líneas marcadas con 0) el consumidor accede al buffer y lo encuentra vacío, el productor pone en el buffer el leta u. En sucesivas iteracciones el productor pone en el buffer las letras b, a continuación la sustituye por r y luego, por d. En la segunda iteracción (línea marcada por 1) del consumidor consume esta última letra d, etc.

En la tercera imagen, el productor y el consumidor tienen las misma rapidez (100 como argumento de sus funciones sleep). El productor y el consumidor están más coordinados, ya que la letra que pone el productor en el buffer es consumida por el consumidor. Esta situación es ideal y limitada a unas pocas iteracciones, ya que en general, los dos subprocesos ejecutan tareas distintas durante miles de iteracciones, por lo que no tienen la misma velocidad aún caundo el argumento de la función sleep sea el mismo.

 

El buffer (segunda aproximación)

Para resolver este problema los subprocesos han de estar sincronizados de dos modos. En primer lugar, se ha de poner el modificador synchronized delante de los métodos poner y recoger. Los dos subprocesos no pueden de este modo acceder simultáneamente al objeto buffer compartido.

Como ya hemos mencionado en el primer apartado, una sección crítica es un bloque de código o un método que es identificado por la palabra reservada synchronized. De este modo, el consumidor no puede acceder al objeto buffer cuando el productor lo está cambiando (llama a poner). El productor no podrá cambiarlo cuando el consumidor está obteniendo (llama a recoger) el valor que guarda dicho objeto.

En segundo lugar, se ha de mantener una cordinación entre el productor y el consumidor, de modo que cuando el productor ponga una letra en el buffer avise al consumidor de que el buffer está disponible para recoger dicha letra y viceversa, es decir, cuando el consumidor recoja la letra avise al productor de que el buffer está vacío. A su vez, el consumidor esperará hasta que el buffer esté lleno con una letra y el productor esperará hasta que el buffer esté nuevamente vacío para poner otra letra.

La clase Thread nos proporcionan los métodos wait, notify y notifyAll, para hacer que los subprocesos esperen hasta que se cumpla una determinada condición, y cuando se cumpla se notifica a otros subprocesos que la condición ha cambiado.

El código de las funciones miembro recoger y poner será ahora el siguiente:

Espera (wait) mientras (while) disponible sea false (buffer vacío). En caso contrario (disponible sea true o el buffer esté lleno), devuelve la letra contenida en el buffer (return contenido), poner de nuevo disponible en false (se ha vaciado el buffer al recoger la letra), y avisa (notify o notifyAll) a los otros subprocesos de este hecho.

Espera (wait) mientras (while) disponible sea true (buffer lleno). En caso contrario (disponible sea false o el buffer esté vacío), guarda en el buffer (miembro dato contenido) la letra que se le pasa en el parámetro de la función poner. Cambia el valor de disponible a true, y avisa (notify o notifyAll) a los otros subprocesos de este hecho.

El método wait de la clase Thread hace que el subproceso espere en un estado Not Runnable hasta que sea avisado (notify) de que continúe. El método notify informa al subproceso en espera que continúe su ejecución. notifyAll es similar a notify excepto que se aplica a todos los subprocesos en estado de espera. Estos métodos solamente se pueden llamar desde funciones sincronizadas (con modificador synchronized). Las llamadas a la función wait como sleep deben de estar dentro de un bloque try...catch.

El código de la clase Buffer será ahora el siguiente

public class Buffer {
  private char contenido;
  private boolean disponible=false;
  public Buffer() {
  }

  public synchronized char recoger(){
    while(!disponible){
        try{
            wait();
        }catch(InterruptedException ex){}
    }
    disponible=false;
    notify();
    return contenido;
  }

  public synchronized void poner(char valor){
     while(disponible){
        try{
            wait();
        }catch(InterruptedException ex){}
    }
    contenido=valor;
    disponible=true;
    notify();
  }
}

La salida del programa será ahora la adecuada, tal como se muestra en la figura inferior. El productor y el consumidor están ya coordinados.

sync4.gif (6714 bytes)

 

Cargando imágenes

El retraso en la carga de las imágenes y otros recursos desde un servidor remoto es una situación que experimentamos continuamente. El lenguaje Java dispone de una librería de clases que trata con eficacia este problema.

El modelo productor-consumidor tiene en cuenta la progresiva carga de las imágenes y supone que el productor genera los pixels de la imagen y el consumidor la muestra. Java utiliza también el concepto de filtro que permite cambiar el aspecto de la imagen a medida que pasa desde el productor al consumidor.

El modelo productor/consumidor tiene muchas ventajas entre las que cabe destacar la modularidad y la interacción asíncrona entre productor y consumidor. Esto último significa, que una vez que se ha conectado el productor con el consumidor, el productor notifica al consumidor solamente cuando hay información adicional disponible, mientras tanto, el applet o la aplicación pueden seguir haciendo otro trabajo.

Los productores representan la fuente de los datos situados al otro lado de la red. Los consumidores representan los applets o aplicaciones situados a este lado de la red. Su trabajo conjunto permite enviar los recursos desde un lado hacia el otro.

El interface ImageProducer tiene los siguientes métodos:

La clase que describe un objeto productor ha de implementar el interface ImageProducer. Como vemos los métodos requieren que se les pase un objeto cuya clase implemente el interface ImageConsumer. Ya que un productor no tiene sentido sin su consumidor asociado. Un productor puede tener múltiples consumidores que se añaden mediante addConsumer y se eleminan mediante removeConsumer.

La clase que describe el objeto consumidor (normalmente es uno) ha de implementar el interface ImageConsumer, que es más complejo.

Veamos el proceso de carga de una imagen proveniente de la Red.

  1. El productor ImageProducer comienza a leer la imagen. Lo primero que lee es la anchura y la altura de la imagen. Notifica al consumidor ImageConsumer de la dimensión de la imagen llamando al método setDimension.
  1. A continuación, el productor lee el mapa del color de la imagen. El productor determina el tipo de modelo de color que usa la imagen, y se lo comunica al consumidor llamando al método setColorModel.
  1. El productor llama al método setHints del consumidor para comunicarle como pretende enviarle los pixels de la imagen. Los posibles valores son del parámetro hintflags de esta función son
Nombre Significado
RANDOMPIXELORDER=1 No hace mención de la forma en la que se envían los pixels
TOPDOWNLEFTRIGHT=2 Para dibujar la imagen de arriba hacia abajo, desde la izquierda a la derecha
COMPLETESCANLINES=4 Se envían filas completas de pixels
SINGLEPASS=8 El envío de los pixels de la imagen se hace en una sola llamada a setPixels
SINGLEFRAME=16 Una sola imagen
  1. El productor empieza a "producir" los pixels, llamado a la función setPixels del consumidor para enviarle la imagen. Esto se puede hacer a lo largo de muchas llamadas especialmente si se envia línea por línea de la imagen COMPLETESCANLINES. O puede hacerse en una única llamada si se hace en un solo paso SINGLEPASS.
  1. Finalmente, el productor llama al método imageComplete del consumidor para comunicarle que la imagen ha sido ya enviada. Si hay algún fallo durante este proceso, por ejemplo, se corta la comunicación con el servidor remoto, el parámetro status toma el valor IMAGEERROR. Cuando todos los pixels de la imagen se ha enviado con éxito el parámetro status toma el valor STATICIMAGEDONE

 

Nota: para un estudio de los filtros consultar uno de los libros

David M. Geary. Graphic Java 2. Volume I. AWT.  Java Series. Sun Microsystems Press (1998)

David M. Geary. Graphic JAVA 1.1. Mastering the AWT, second edition.  Sun Microsystems (1997).