Concurrencia en Java Concurrencia y Distribución Programación Avanzada Posgrado en Ciencia e Ingenierı́a de la Computación, UNAM 1. El mecanismo de threads Una aplicación Java o applet puede contener secciones de código que se ejecutan simultáneamente. Una sección de código que se ejecuta independientemente se conoce con el nombre de thread ó proceso ligero. Un thread comparte el espacio de direcciones con la aplicación principal o applet, y por tanto, puede accesar a los datos que son visibles a la aplicación principal o applet, ası́ como a cualquier otro thread que se ejecute concurrentemente. De tal modo, debe tenerse mucho cuidado al accesar los datos compartidos, ya que un thread puede estar modificando un dato compartido mientras otro lo está leyendo. Los detalles exactos de cómo se implementa un thread dependen de la máquina donde la aplicación o applet se ejecuta. En una máquina multiprocesador puede haber una verdadera ejecución simultánea de threads, mientras que en una máquina uniprocesador la ejecución simultánea se simula mediante conmutar rápidamente entre threads individuales para dar la ilusión de una ejecución simultánea. Por ejemplo, considérese una aplicación en Java que prueba si un número natural es primo, y puede realizar esta actividad mientras que otros threads realizan otras actividades. El método estático isPrime() en la clase Useful prueba si un número long es primo o no. Sin embargo, este proceso toma bastante tiempo si el número es grande. De tal modo, el código de la clase Useful se muestra a continuación: class Useful { public static boolean isPrime(long n){ if (n <= 0 || n%2 == 0) return false; long rootN = (long) (Math.sqrt((double) n)) + 1; 1 for (long i == 3; i <= rootN; i+=2){ if (n%i == 0) return false; } return true; } } Para crear un “objeto activo”, se requiere que tal objeto implemente la interfaz Runnable. Esta interfaz define un solo método llamado run() que se sobrecarga con el método que eventualmente ejecuta un thread por separado. La interfaz Runnable se define como sigue: interface Runnable{ public abstract void run(); } La clase Prime implementa la interfaz Runnable: class Prime implements Runnable{ private long theNumber; private boolean theResult; ... } El constructor de la clase registra cualquier dato de entrada que requiera el thread. En este caso en particular, el número que se comprueba si es primo: public Prime(final long n){ theNumber = n; } El método run() implementa el cómputo para determinar si el número es primo. El cuerpo de este método llama al método estático isPrime() para realizar tal cómputo. public void run(){ theResult = Useful.isPrime(theNumber); } El método result() se usa para retornar el resultado del cómputo que se ha almacenado en la variable theResult de tipo boolean. 2 public boolean result(){ return theResult; } Nótese que el método result presenta la respuesta correcta sólo si el método run() ha terminado. 1.1. Poniendo todo junto Una instancia de la clase Prime se crea inicialmente con la responsabilidad de implementar las acciones que realice el thread. Prime prime = new Prime(99); Nótese que esta clase implementa la interfaz Runnable. El objeto prime se “envuelve” por una instancia de la clase Thread para crear un objecto activo thread. Thread thread = new Thread(prime); El objeto activo thread se inicia mediante enviarle el mensaje start(). thread.start(); Esto prepara un thread nuevo ejecutándose separadamente, que realiza el método run() en la clase Prime. Este proceso se muestra en la Figura 1, que muestra el comienzo de la ejecución del thread recién creado. Para obtener el resultado correcto del objeto prime, el cómputo debe haber finalizado. Una llamada al método join() de la clase Thread causa que temporalmente se espere hasta que el thread ha terminado de ejecutarse. Por ejemplo, el siguiente código causa una espera hasta que el thread independiente que se está ejecutando haya terminado. thread.join(); La Figura 2 muestra al programa principal esperando por el objeto activo thread después de llamar al método join(). El programa principal se suspende hasta que la unión se lleva a cabo. Naturalmente, si el objeto activo ha terminado de ejecutarse, el programa principal continúa inmediatamente. 3 Threads 2 thread 1 programa principal thread.start() Tiempo Figura 1: Dos threads ejecutándose. Threads 2 thread 1 programa principal thread.join() thread.start() Tiempo Figura 2: El programa principal espera hasta que el thread termina. 4 1.2. El programa completo El programa completo para crear separadamente un thread para verificar si el número 99 es primo se muestra a continuación. Mientras que el cómputo se lleva a cabo, el programa principal puede simultáneamente realizar otro tipo de acciones. class Main { public statuc void main(String args[]) { try { long number = 99; Prime prime = new Prime(number); Thread thread = new Thread(prime); thread.start(); // Otras acciones thread.join(); System.out.println("El numero " + number + (prime.result() ? " es " : " no es " ) + "primo"); } catch (InterruptedException exc) { } } } Nótese la necesidad de proveer un manejador de exceptiones InterruptedException. En este caso, tal manejador es ignorado. 2. La clase java.lang.Thread Los principales métodos de la clase Thread son: 5 Método Thread() Thread(name) Thread(fo) Thread(fo,name) activeCount() destroy() getName() isAlive() join() join(delay) run() sleep(delay) start() yield() Responsabilidad Crea un nuevo thread. Crea un nuevo thread con el nombre name. Crea un nuevo thread que ejecuta el método run de la clase fo (ésta implementa la interfaz Runnable). Crea un nuevo thread con el nombre name que ejecuta el método run de la clase fo (ésta implementa la interfaz Runnable). Retorna el número de threads activos en el grupo de threads actual. Destruye el thread. No hay recolección de basura. Retorna el nombre del thread. Retorna true si el thread está activo. Espera a que el thread termine. Espera al menos un tiempo delay en milisegundos para que el thread termine. Ejecuta el objecto función del thread que implementa la interfaz Runnable. Causa que el thread “duerma” por delay milisegundos. Causa el inicio de actividad del thread, llamando al método run() de la clase. Después de detener temporalmente al thread, permite a otros threads continuar. Los métodos de la clase java.lang.Object que interactúan con los threads son: 6 Método notifyAll() notify() wait(delay) wait() 3. Responsabilidad Despierta a todos los threads que esperan a este objeto. Un thread entra en estado de espera cuando invoca alguno de los métodos wait. Despierta a un solo thread que espera a este objeto. Espera hasta que las siguientes dos condiciones ocurran: (a) Un método notify() ó notifyAll() se llama desde otro thread de este objeto; (b) El tiempo delay en milisegundos ha transcurrido. Espera hasta que uno de los métodos notify() ó notifyAll() se llama desde otro thread de este objeto. Heredando de la clase Thread Una forma alternativa de implementar un thread en Java es heredar directamente de la clase Thread. Por ejemplo, el programa anterior podrı́a haberse escrito como sigue: class Prime extends Thread{ private long theNumber; private boolean theAnswer; public Prime(final long number){ theNumber = number; } public void run(){ theAnswer = Useful.isPrime(theNumber); } public boolean result(){ return theAnswer; } } Entonces, después de que la instancia de la clase Prime se crea, su método run() se invoca mediante el método start(). Un nuevo programa completo se muestra a continuación que utiliza la nueva clase Prime para implementar un thread por separado. class Main { 7 public statuc void main(String args[]) { try { long number = 99; Prime thread = new Prime(number); thread.start(); // Otras acciones thread.join(); System.out.println("El numero " + number + (prime.result() ? " es " : " no es " ) + "primo"); } catch (InterruptedException exc) { } } } 4. Exclusión mutua y secciones crı́ticas En muchos casos de ejecución real, las secciones de código no deben ejecutarse concurrentemente. El ejemplo clásico es añadir o remover datos en un buffer compartido. Por ejemplo, para realizar una copia entre dos dispositivos separados, se puede utilizar un buffer compartido para emparejar las diferencias en tiempo de respuesta. Esto se ilustra en el diagrama de la Figura 3. read Disco Reader thread Writer thread put write Disco get Datos compartidos Figura 3: Se copia mediante un buffer para emparejar las diferencias en velocidades de lectura y escritura. El problema es prevenir que ambos threads de lectura y escritura accese simultáneamente al buffer, causando la consecuente corrupción de ı́ndices y datos. Para ello, Java permite la creación de un monitor. En esencia, un mo8 nitor es un objeto con métodos especiales sincronizados. Sólo uno de los métodos sincronizados puede ejecutarse en un momento dado de tiempo. Si otro thread intenta acceder a un método sincronizado mientras otro método sincronizado está siendo ejecutado, la solicitud se encola hasta que el método sincronizado que actualmente se ejecuta termine. En Java, un monitor se implementa como una clase cuyos métodos se declaran como synchronized. Cuando un mensaje se envı́a a un método que ha sido declarado como synchronized, el método se ejecuta sólamente si no hay un “candado” sobre el objeto. Si el objeto tiene candado, el proceso que envió el mensaje se detiene temporalmente hasta que el objeto deja de tener candado. Un objeto tiene candado cuando un método synchronized se invoca, y deja de tenerlo cuando el método termina. Un método puede causar que un objeto deje de tener candado mediante ejecutar el método wait(). Sin embargo, esto causa que el objeto se suspenda hasta que otro thread envie el mensaje notify() ó notifyAll() al objeto. Un thread reactivado que se detuvo al ejecutar el método wait() pondrá de nuevo el candao al objeto. Obviamente, si hay dos o más threads esperando con el método wait(), sólo uno de ellos puede ser reactivado. 5. La implementación Desarrolle un programa concurrente Copy que implemente la copia eficiente de disco a disco, emparejando las diferencias de velocidad, mediante dos threads y un monitor, de un archivo de texto “origen” a otro archivo de texto “destino”. El monitor se utliza para proveer un acceso serializado al buffer de datos compartidos. Los datos pueden añadirse al buffer y removerse del buffer, pero estas operaciones no pueden realizarse simultáneamente. Esto se muestra en la Figura 4. Las responsabilidades de los componentes individuales se muestran a continuación: Clase Reader Writer Buffer Instancia thread (reader) thread (writer) monitor (buffer) Responsabilidades Leer datos del archivo de entrada y escribirlos en el buffer. Debe bloquearse si el buffer se llena. Tomar datos del buffer y escribirlos en el archivo de salida. Debe bloquearse si el buffer está vacı́o. Serializar el almacenamiento y recuperación de los datos a y desde el buffer. 9 read Disco Reader thread Writer thread put write Disco get Datos compartidos Figura 4: Copy se implementa utilizando dos threads y un objeto buffer protegido. 5.1. La clase Buffer La clase Buffer debe contener dos métodos sincronizados put() y get() que permitan la entrada y salida de datos al buffer respectivamente. El buffer se implementa como una cola. El primer elemento de entrada al buffer será el primer elemento que se saque del buffer. La implementación de la cola debe utilizar un arreglo para simular las propiedades de una cola. Para hacer este proceso lo más general posible, la cola debe implementarse como una colección de elementos de tipo Object, de modo que cualquier objeto pueda colocarse en la cola. La Figura 5 muestra una cola con cuatro objetos a, b, c y d que han sido encolados en la cola. Los objetos se incorporan al final de la cola, y se remueven de la cabeza. theHead theTail a b c d Figura 5: Una cola implementada con un arreglo. La cola debe implementarse como un arreglo con apuntadores int para representar la cabeza (theHead) y la cola (theTail). Se debe incluir también 10 un contador theNoOfObjects que lleva la cuenta de los elementos en la cola. Este contador debe utilizarse para eliminar la ambigüedad entre la cola “llena” y la cola “vacı́a”. 5.2. La clase Reader La clase Reader debe ser una subclase de Thread. El constructor debe registrar el archivo desde donde se leen los datos, ası́ como el objeto buffer que almacena temporalmente los datos. Su método run() debe ser capaz de abrir el archivo de entrada, convertir cada lı́nea del archivo en una cadena (string) y añadirla al buffer. También la clase debe considerar y manejar cualquier excepción con sus respectivos bloques catch. Cuando ocurre un error o se alcanza el final del archivo, un objeto null debe añadirse al buffer, lo que signica que ya no hay más objetos a ser añadidos al buffer. 5.3. La clase Writer La clase Writer debe ser también una subclase de Thread. La instancia de la clase Writer debe ser capaza de recabar instancias de tipo string del buffer, y escribirlas en el archivo de salida. Ası́, su constructor debe registrar el archivo a donde se escriben los datos, ası́ como el objeto buffer. La implementación del método run() debe abrir el archivo de salida y escribir los datos recabados del buffer ahı́. Un buffer vacı́o se representa mediante retornar el valor null para el objeto recabado. Todo error de entrada/salida debe ser capturado, reportando su naturaleza al usuario del programa. 5.4. La clase Copy La clase principal Copy crea todos los objetos necesarios con los parámetros de archivos de entrada y salida. Una vez iniciado, los threads deben ejecutarse independientemente, añadiendo y sacando datos del buffer compartido hasta que se terminen los datos. Una vez finalizada la operación, el programa termina. Una vez compilado, el programa debe realizar una operación de copia. Por ejemplo, supóngase que se copian los contenidos de un archivo from.dat a un archivo to.dat. Esto debe lograrse mediante la instrucción en lı́nea: java Copy from.dat to.dat 11 Referencias [1] Ken Arnold and James Gosling. The Java Programming Language. Addison-Wesley, 1996. [2] Barry Boone. Java Essentials for C and C++ Programmers. AddisonWesley, 1996. [3] Brinch Hansen, P. The Programming Language Concurrent Pascal. In IEEE Transactions on Software Engineering, 1(2), 1975, pp. 199-207. [4] Gary Cornell and Cay S. Horstsmann. Core Java. Prentice-Hall, 1996. [5] David Flanagan. Java in a Nutshell. O’Reilly, 1996. [6] Hoare, C.A.R. Monitors : An Operating System Structuring Concept. In Communications of the ACM 17, 1974, pp.549-557. 12