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 cració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 es en forma similar a como un
objeto tiene acceso a otros objetos. En Java un hilo es un objeto con
capacidad de correr en forma concurrente el método run(). En
cierta manera es como
tener dos "program counters" para un mismo código. Una
diferencia con los procesos es que carece de sentido y no es posible en
este enfoque 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 thread 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--
}
}