Hilos (Threads) en Java
En la primera parte de este curso vimos procesos en sistemas operativos
que surgen de Unix. En esos ejemplos cada proceso se caracteriza por tener
un único control de flujo; es decir, la ejecución del programa seguía una
secuencia única. Cuando creamos un nuevo proceso con fork, creamos una nueva
secuencia que se ejecuta concurrentemente con el proceso padre, pero no
comparten las zonas de datos y la comunicación entre ellos debe realizarse
con mecanismos ofrecidos por el sistema operativo. Por ello aparecen las
pipes y los otros mecanismos de comunicación entre procesos.
Luego vimos la creación de hilos, hebras o threads (en inglés) como otra
forma de crear concurrencia de actividades; sin embargo, la gran diferencia
es que los hilos comparten el código y el acceso a algunos datos de forma
similar a como un objeto en Java tiene acceso a otros objetos. En Java un
hilo es un objeto con capacidad de correr en forma concurrente el método
run(). De cierta manera es como tener dos "program counters" para un mismo
código. Una diferencia con los procesos es que con hebras (en C o Java)
carece de sentido hacer mutar un proceso con algo similar a exec().
Definición de Hebras o Thread
Una thread es un único flujo de control dentro
de un programa. Algunas veces es llamado contexto de ejecución porque cada
thread debe tener sus propios recursos, como el program counter y el stack
de ejecución, como el contexto de ejecución. Sin embargo, toda thread en
un programa aún comparte muchos recursos, tales como espacio de
memoria y archivos abiertos. Threads también son llamadas procesos
livianos (lightweight process).
NOTA: Es mucho más simple (rápido) crear y
destruir una thread que un proceso.
Thread Paralelas y Concurrentes
Cuando dos threads corren en paralelo,
ambas están siendo corridas al mismo
tiempo en diferentes CPUs. Por otro lado cuando dos threads concurrentes están en progreso, o
intentando obtener tiempo de ejecución de la CPU al mismo tiempo, no
necesariamente están corriendo en forma simultánea en dos CPUs
diferentes.
Ejemplo: Programa de thread única
En el programa WithoutThread.java
, el método run() en la clase NoThreadPseudoIO es creado para simular una
operación de I/O de 10 segundos. Se aprecia aquí que la tarea principal se
ve demorada por la lentitud de la operación de I/O
Ejemplo de programa Multithreded
(multihilo)
Este ejemplo simula una operación de I/O
lenta. Para no retrasar el resto de la ejecución esta operación se efectúa
en una hebra. El programa multithreaded WithThread.java
declara la clase para simular IO como una subclase de la clase Thread.
Después que la thread es creada, el programa multihilos (multithreaded)
usa el método start() de la clase Thread para hacer partir la operación de
I/O. El método start() a su vez llama a run() de la subclase sin que el
llamado sea explícito en el código java.
El método showElapsedTime() imprime el tiempo
en segundos desde que el programa partió, junto con el mensaje del
usuario. El método currentTimeMillis() de la clase System del paquete
java.lang retorna un entero que corresponde a la diferencia de tiempo en
milisegundos desde la hora 00:00:00 GMT on January 1, 1970. y la
actual.
Creación y ejecución de Threads
Hay dos formas de hacer correr una
tarea
concurrentemente con otra: crear una nueva clase como subclase de
la clase Thread o declarar una clase que implemente la interfaz Runnable
y luego usarla en el constructor de la clase Thread.
Uso de Subclase
Cuando se crea una subclase de Thread, la subclase debería redefinir el
método run() para "sobre montar" el método run() de la clase Thread.
La tarea concurrente es desarrollada en
este método run().
public class WithThread {
/* ...*/
public static void main (String args[]) {
ThreadedPseudoIO pseudo = new ThreadedPseudoIO();
pseudo.start();
// Tarea principal continúa concurrentemente
}
/* Otros métodos */
}
class ThreadedPseudoIO extends Thread {
/*.....*/
public void run () {
// Tarea segundaria concurrente
}
}
Ejecución del método run()
Una instancia de la subclase es creada con new, luego llamamos al método
start() de la thread para hacer que la máquina virtual Java ejecute el
método run(). Ojo para iniciar la concurrencia invocamos a start(), así
invocamos a run() en forma indirecta. Si invocamos a run() directamente,
se comportará como el llamado a cualquier método llamado dentro de un
mismo hilo (sin crear uno independiente).
Ejemplo de hebra como clase hija de Thread: PrimeraHebra.java
Implementación de la Interfaz Runnable
La interfaz Runnable requiere que sólo un método sea implementado, el
método run(). Primero creamos una instancia de esta clase con new, luego
creamos una instancia de Thread con otra sentencia new y usamos el objeto
recién creado en el constructor. Finalmente, llamamos el método start() de
la instancia de Thread para iniciar la tarea definida en el método run().
public class RunnableThread {
/* ... */
public static void main (String args[]) {
RunnablePseudoIO pseudo = new RunnablePseudoIO();
Thread thread = new Thread (pseudo);
thread.start();
// Tarea principal continúa concurrentemente
}
/* Otros métodos */
}
class RunnablePseudoIO implements Runnable {
/* ..... */
public void run() {
// Tarea segundaria concurrente
}
}
Ejemplo simple: PrimeraHebraRunnable.java
RunnableThread.java
Una instancia de una clase que defina el
método run() - ya sea como subclase de Thread o implementando la interfaz
Runnable - debe ser pasada como argumento en la creación de una instancia
de Thread. Cuando el método start() de esta instancia es llamado, Java run
time sabe qué método run() ejecutar.
Control de la Ejecución de una Thread
Varios métodos de la clase java.lang.Thread
controlan la ejecución de una thread.
Métodos de uso común:
void start(): usado para iniciar el cuerpo de
la thread definido por el método run(). La hebra que lo invoca continúa
concurrentemente.
void sleep(): pone a dormir por un tiempo mínimo especificado a la
hebra que lo invoca.
void join(): usado por otra hebra para esperar por el término de la thread
sobre la cual el método es invocado, por ejemplo por término de método
run().
void yield(): Mueve a la thread desde el estado de corriendo al final de
la cola de procesos en espera por la CPU.
MethodTest.java:
programa de prueba métodos: start, sleep, join, wait y notify (los dos
últimos más adelante).
Java 2 dejó obsoleto (deprecated) varios de
estos métodos definidos en versiones previas (Java 1.0 y Java 1.1.) para
prevenir inconsistencia de datos y deadlock. Se recomienda evitar el
uso de estos métodos. Ellos son:
void stop() el cual detiene la ejecución de la thread no importando
consideración alguna.
void suspend() el cual para temporalmente la ejecución de una
thread.
void resume() reactiva una thread suspendida.
Otros ejemplos:
Múltiples hilos cada uno con contador
down.
Múltiples hilos controlando bloques
de
colores que cambian.
Ciclo de Vida de una Hebra (Thread)
Cada hilo, después de su creación y antes de
su destrucción, estará en uno de cuatro estados: recién creada,
"corrible", bloqueada, o muerta.
Recién
creada (New thread): entra aquí inmediatamente después de su
creación. Es decir luego del llamado a new. En este estado los datos
locales son ubicados e iniciados. Luego de la invocación a start(), el
hilo pasa al estado "corrible".
Corrible (Runnable): Aquí
el contexto de ejecución existe y el hilo puede ocupar la CPU en
cualquier momento. Este estado puede subdividirse en dos: Corriendo y
encolado. La transición entre estos dos estados es manejado por el
itinerador de la máquina virtual.
Nota: Un hilo que invoca al método yield() voluntariamente se mueve a sí
misma al estado encolado desde el estado corriendo.
Bloqueada (not Runnable): Se
ingresa cuando: el hilo invoca el método wait() de algún objeto, el hilo
invoca sleep(), el hilo espera por alguna operación de I/O, o el hilo
invoca join() de otro hilo para espera por su término. El hilo vuelve al
estado Corrible cuando ocurre el evento por que espera.
Muerta (Dead): Se llega a
este estado cuando el hilo termina su ejecución (concluye el método run)
o es detenida por otro hilo llamando al su método stop(). Esta última
acción no es recomendada.
El siguiente código puede ser usado para evitar el llamado a stop() y
así evitar estados de inconsistencia. Invocando a safeStop() se consigue
terminar el hilo la próxima vez que la variable done es chequeada.
boolean done = false;
public void run() {
while(!done) {
....
}
// aquí podemos hacer todo lo requerido para terminar en un estado
consiste
}
public safeStop() {
done = true;
}
El método isAlive() invocado sobre un hilo, permite saber si aún puede
correr.
Sincronización
de hilos
Todos los hilos de un programa comparten el
espacio de memoria, haciendo posible que dos hilos accedan la misma
variable o corran el mismo método de un objeto al "mismo tiempo". Se crea
así la necesidad de disponer de un mecanismo para bloquear el acceso
de un hilo a un dato si éste está siendo usado por otro hilo.
Por ejemplo, si en un sistema un método
permite hacer un depósito y luego dos hilos lo invocan, es posible que al
final sólo un depósito quede registrado al ejecutar las instrucciones en
forma traslapadas.
Deposit.java
Modelo de
Monitores
Java utiliza la idea de monitores para sincronizar el acceso a datos. Un
monitor es un lugar bajo guardia donde todos los recursos tienen el mismo
candado. Sólo una llave abre todos los candados dentro de un monitor, y un
hilo debe obtener la llave para entrar al monitor y acceder a esos
recursos protegidos.
Cada objeto en Java
posee un candado y una llave para manejo de zonas críticas.
También
existe un candado y llave por
cada clase, éste se usa para los métodos estáticos.
Si varios hilos desean entrar al monitor simultáneamente, sólo uno obtiene
la llave. Los otros son dejados fuera (bloqueados) hasta que el hilo con
la llave termine de usar el recurso exclusivo, esto devuelve la llave a la
Máquina Virtual Java.
Puede ocurrir deadlock si dos hilos están esperando por el recurso cuya
llave la tiene el otro.
En un momento un hilo puede tener las llaves de varios monitores.
En Java los recursos protegidos por un monitor son fragmento de programa
en forma de métodos o bloques de sentencias encerradas con paréntesis {}.
Palabra reservada
synchronized:
es usada para indicar que el método o bloque de sentencias es sincronizada
por un monitor (el del objeto que tiene el método). Cuando queremos
sincronizar un bloque, un objeto encerrado por ( ) sigue a la palabra
synchronized, así la máquina virtual sabe qué candado chequear.
Por ejemplo, el método deposit() puede ser sincronizado para correr sólo
un hilo a la vez.
La salida del programa Deposit2 es casi igual, excepto que el primer
mensaje del segundo hilo es traslapado con el de la primera, porque el
primer println() no está dentro del bloque synchronized.
Deposit2.java
Llave del monitor
Existe un llave única por cada objeto. Ésta es ocupada en métodos no
estático sincronizados (synchronized) y en un bloque synchroinized que
usan el mismo objeto como argumento (el objeto es usado como argumento de
esta sentencia). Como los métodos estáticos también pueden ser
sincronizados, cada objeto y cada clase puede definir zonas de código de
acceo exclusivo si hay cualquier método sincronizado o bloque de
sentencias asociado con ellos. Más aún, la llave de un monitor de una
clase es diferente de las llaves de los monitores de las instancias (esto
porque un método estático puede ser llamado antes de existir ninguna
instancia).
Multihilos más avanzada
Para lograr buena sincronización entre las tareas en ocasiones debemos
hacer uso de otros mecanismos de sincronización. Par ello veremos el
ejemplo de un productor de números enteros y un consumidor de ellos.
Productor consumidor
NoWaitPandC.java
Este programa no garantiza que todos los enteros generados serán leído y
que cada entero será leído sólo una vez. La sentencia Synchronized
garantiza que sólo una operación sea hecha a la vez, pero no garantiza que
ambos hilos lo hagan el forma alternada.
Para lograr alternancia, usamos los llamados a wait y notify.
Método
wait() hará que el hilo
que invoca se bloquee hasta que ocurra un timeout u otro hilo llame el
método notify() o notifyAll() sobre el mismo objeto (lo primero que
ocurra).
Cuando un hilo llama a wait(), la llave que éste tiene es liberada, así
otro proceso que esperaba por ingresar al monitor puede hacerlo.
notify()
sólo despierta o desbloquea un hilo, si lo hay esperando. notifyAll()
despierta a todos los que estén esperando. Luego que un hilo despierta y
como parte del wait() tratará de reingresar al monitor pidiendo la llave
nuevamente, podría tener que esperar a que otro hilo la libere.
Los llamados wait(), notify() y notifyAll(), sólo pueden ser llamados
dentro de un método o bloque sincronizado.
PandC.java
Si usted se pregunta cómo puede implementar semáforos en Java, considere
la clase:
public class Semaphore {
private int value;
public Semaphore (int initial){
value = initial;
}
synchronized public void up() {
value++;
notify();
}
synchronized public void down()
throws InterruptedException {
while (value == 0) wait();
value--;
}
}