Es un flujo de control secuencial dentro de un programa. Los que hemos trabajado con UNIX estamos acostumbrados a la noción de proceso y a su creación o eliminación (kill). Un thread es, al igual que un proceso, un flujo de control que puede gozar de cierta autonomía (puede tener sus propias estructuras de datos), pero a diferencia de un proceso, diversos threads de una aplicación pueden compartir los mismos datos. A partir de ahora usaremos la expresión hilo como traducción de thread.
Un hilo no puede correr por sí mismo, se ejecuta dentro de un programa. Se pueden programar múltiples hilos
de ejecución para que corran simultáneamente en el mismo programa. La utilidad de la programación multihilos
resulta evidente. Por ejemplo, un navegador Web puede descargar un archivo de un sitio, y acceder a otro
sitio al mismo tiempo. Si el navegador puede realizar simultáneamente dos tareas, no tendrá que esperar hasta
que el archivo haya terminado de descargarse para poder navegar a otro sitio.
Son más frecuentes de lo que parece
El interprete Java (la máquina virtual de Java, JVM o Java Virtual Machine) inicia un conjunto de hilos sin intervención del programador (llamados hilos de utilidad). Ejemplos de hilos producidos por la JVM:
Puede elegir entre dos técnicas para implementar hilos:
Nosotros empezaremos hablando de la primera técnica.
Un ejemplo sencillo sin hilos
Empezaremos por un ejemplo muy simple en el que hay una clase que produce datos y otra que los visualiza. La productora de datos se llama "tarea_datos" y la que los muestra se llama "tarea_vista". "tarea_vista" recibe una referencia a "tarea_datos" para visualizar el resultado. Este ejemplo todavía no contiene hilos, su comprensión nos ayudará a entender como funcionan los hilos en los siguientes ejemplos. Para acercarnos a lo que será la explicación sobre hilos, cada clase realiza su labor en la función run(). "tarea_datos" lee un número de un archivo y la "tarea_vista" lo muestra.
import java.io.*;
/****************************************
* Primera versión, sin hilos.
* En un sólo hilo todo el procesamiento es secuencial y por tanto
* realiza la operación correctamente, visualizando el dato
****************************************/
public class control01 {
public control01() {
tarea_datos01 t1 = new tarea_datos01();
tarea_vista01 t2 = new tarea_vista01( t1 );
t1.run(); // Lee dato
t2.run(); // Lo visualiza
}
public static void main(String[] args) { control01 control1 = new control01(); }
}
/******************* Clase que obtiene el dato ******************/
class tarea_datos01 {
private double resultado = -1;
public void run() { leer_datos(); }
void leer_datos() {
try {
BufferedReader in = new BufferedReader( new FileReader("xxx") );
resultado = Double.parseDouble( in.readLine() );
in.close();
}
catch (Exception e) { System.out.println( e.getMessage() ); }
}
double obt_resultado() { return resultado; }
}
/****************** Clase visualizadora *******************/
class tarea_vista01 {
private tarea_datos01 td;
tarea_vista01( tarea_datos01 td ) { this.td = td; }
public void run() { System.out.println( "Resultado: " + td.obt_resultado() ); }
}
Lo que hace la función main() es sencillo de entender:
Evidentemente, tarea_vista es una clase excesivamente ligera o pequeña (se podría decir que "raquítica"). Se ha definido en nuestro ejemplo por razones pedagógicas.
Puesto que el orden es secuencial y hay un único hilo (el hilo de ejecución de la función main()), el resultado será el esperado: imprimir por pantalla el número leido del archivo.
¿Qué ocurre si no se cumple el orden secuencial y lógico de "I) obtener el dato y II) visualizarlo", sino que
primero se visualiza y después se obtiene el resultado? Entonces, el resultado que veríamos por pantalla
sería incorrecto, es decir, el valor por defecto: -1. Esto es lo que nos ocurrirá en la siguiente versión.
Subclases de Thread
Vamos a convertir el ejemplo anterior a la programación multihilos, de tal forma que cada tarea sea un hilo. ¿Por qué usar hilos? Piensa, por ejemplo, que puede interesar simultanear la lectura de un voluminoso fichero con otras tareas. Al fin y al cabo, estamos acostumbrados a esta beneficiosa simultaneidad mientras navegamos por la web, donde a un tiempo puedes estar descargando un fichero y abriendo una página.
Algunas reglas:
En este caso estamos viendo un pequeño ejemplo que simula una situación típica para usar hilos: necesitamos un flujo de control que produce los datos (por lectura y/o cálculo) y otro que los consume (visualización, etc.), pero no hay seguridad de que el consumo este sincronizado con la produccción. Al aplicar un esquema de programación multihilo vamos a enfrentarnos a alguno de los retos de la programación concurrente.
Veamos el ejemplo. Lo único que se ha hecho es heredar de la clase Thread y aplicar la regla 2, es decir, llamamos a start() y esta función, predefinida por Java, llama automáticamente a run():
import java.io.*;
/****************************************
* Primera versión con hilos, que muestra el resultado incorrecto (-1).
* Ya que el hilo de visualización realiza su tarea antes de que
* el hilo de datos termine de leer los datos. Para simular una 'larga'
* lectura usamos sleep( milisegundos )
****************************************/
public class control02 {
public control02() {
tarea_datos02 t1 = new tarea_datos02();
tarea_vista02 t2 = new tarea_vista02( t1 );
t1.start(); // Lee dato
t2.start(); // Lo visualiza
}
public static void main(String[] args) { control02 control1 = new control02(); }
}
/******************* Clase que obtiene dato ******************/
class tarea_datos02 extends Thread {
private double resultado = -1;
public void run() { leer_datos(); }
void leer_datos() {
try {
BufferedReader in = new BufferedReader( new FileReader("xxx") );
sleep( 1000 );
resultado = Double.parseDouble( in.readLine() );
in.close();
}
catch (Exception e) { System.out.println( e.getMessage() );}
}
double obt_resultado() { return resultado; }
}
/****************** Clase visualizadora *******************/
class tarea_vista02 extends Thread {
private tarea_datos02 td;
tarea_vista02( tarea_datos02 td ) { this.td = td; }
public void run() { System.out.println( "Resultado: " + td.obt_resultado() ); }
}
Con t1.start() se inicia el hilo que realiza la lectura. Paralelamente se arranca t2 con t2.start(), que llama a t2.run() para visualizar el número. Lo que el programa devuelve es:
     Resultado: -1
Resulta evidente que se ha visualizado la variable antes de haber leido su
valor del archivo. Necesitamos sincronizar los hilos. Necesitamos que el hilo de visualización no
se adelante al hilo de cálculo.
Los métodos sleep() e interrupt()
Esta es una primera aproximación a la sincronización de hilos, usando sleep(), que nos sirve para decirle a un hilo que se duerma durante un periodo de tiempo (medido en milisegundos). En nuestro ejemplo modificamos tarea_datos() para que devuelva el resultado más tarde (y correctamente), es decir, retrasamos un hilo para "dar tiempo" a otro para que termine su tarea:
double obt_resultado() {
try {
sleep(1050); // Duermo un poco
return resultado; // Devuelvo el dato
}
catch (InterruptedException e) {
System.out.println( e.getMessage() );
return -1;
}
}
sleep() exige el manejo de InterruptedException.
Pero no es una solución muy elegante: ¿el sueño debe durar 1 segundo?, ¿o tal vez un minuto?, ...
Por cierto, nuestra llamada invoca al método de un objeto (this). Pero podemos usar
sleep() como método de clase (ya que es static) en la forma:
     Thread.sleep( 1050 );
Lo que estamos haciendo es dormir al hilo actual, dando así la posibilidad de más ciclos de procesamiento al resto de hilos.
La excepción InterruptedException se dispara cuando otro hilo llama a interrupt() del hilo que se quiere interrumpir. No suele usarse interrupt(), ya que los hilos suelen interrumpirse cuando termina, llega al fin de run(). Con interrupt() despertamos a un hilo que se encuentra dormido o bloqueado, por ejemplo por una larga operación de entrada/salida, por wait() o por sleep(). Hay una excepción a la regla sobre InterruptedException: si se llama a interrupt() cuando el hilo no está durmiendo o esperando, no se genera InterruptedException. Puede saber si un hilo esta interrumpido por medio de la función "boolean isInterrupted()":
if ( !isInterrupted() ) ...Los métodos stop() y suspend() han sido desaconsejados por SUN.
Uno de los problemas centrales de la programación multihilo es manejar situaciones en las que más de un hilo tiene acceso a la misma estructura de datos. Por ejemplo, si un hilo estuviera intentando actualizar los elementos de una lista, mientras otro está simultáneamente intentando clasificarla, su programa puede bloquearse o producir resultados incorrectos. Para evitar este problema, debe utilizar la sincronización de hilos.
La forma más sencilla de evitar que dos objetos accedan a un método de un tercero al mismo tiempo es que el primer hilo lo bloquee. Cuando un hilo mantiene un bloqueo, otro hilo que también lo necesita tiene que esperar hasta que el primer hilo libera su bloqueo. ¿Cómo se hace? declarando métodos sincronizados (synchronized), por ejemplo:
synchronized void leer_datos() { ... }
Una buena metáfora es pensar la situación de bloqueo como una tradicional cabina de teléfonos que tiene un cerrojo en su interior, cuando un hilo accede al método cierra la puerta y bloquea la cabina, de tal forma que otro hilo que quiera acceder a la cabina se debe mantener en espera hasta que el primero sale de la cabina.
Para mantener un método libre de problemas con los hilos (thread-safe), utilice la palabra clave synchronized. El hilo anula el bloqueo cuando sale del último método sincronizado. En nuestro caso hemos hecho que dos métodos sean sincronizados:
synchronized void leer_datos() {
try {
BufferedReader in = new BufferedReader( new FileReader("xxx") );
sleep( 1000 );
resultado = Double.parseDouble( in.readLine() );
in.close();
}
catch (IOException e) { System.out.println( e.getMessage() ); }
}
synchronized double obt_resultado() { return resultado; } // Hemos quitado sleep() de aqui
Funciona. Visualizamos el número que teniamos almacenado en el archivo. ¿Por qué? El objeto 'vista' no puede acceder a tarea_datos04.obt_resultado hasta que el objeto de 'tarea_datos04' no ha sido desbloqueado.
Hay un malentendido habitual: creer que lo que se bloquea es el método. Es un error natural, puesto que ponemos la palabra synchronized en un método tendemos a pensar que el bloqueo se produce sobre el método. Lo diremos de forma breve: ES EL OBJETO EL QUE ESTA BLOQUEADO, NO EL METODO. Para la JVM cada objeto tiene un contador de bloqueo, que registra el número de métodos sincronizados que permanecen en ejecución. Cuando el contador alcanza el valor de cero, se libera el bloqueo.
Ya hemos vista como podemos poner secuencialmente los hilos por medio de synchronized. Los métodos wait() y notify() extienden o potencian esta capacidad. Estos métodos los hereda toda clase de Java, ya que están definidos en la clase Object. Sólo pueden usarse en métodos sincronizados.
Con wait() un hilo se queda en espera y libera el cerrojo sobre el objeto que hubiese bloqueado (recuerde aquí la metáfora de una cabina de teléfono con cerrojo). Importante para evitar esperas "eternas" (y aplicaciones "colgadas") es tener en cuenta que cuando un hilo entra en espera no tiene posibilidad de despertarse sólo, sino que depende de otro hilo que lo despierte mediante notity(). Cuando no estes seguro de cual es el hilo que debes despertar lo más seguro es usar notifyAll(). Las aplicaciones obedecen a una serie de reglas:
Regla 1 (esta regla ya la consideramos al hablar de synchronized): si varios hilos modifican un objeto, declare los métodos modificadores, así como los de sólo lectura, como sincronizados. En nuestro ejemplo anterior:
synchronized void leer_datos() {
BufferedReader in = new BufferedReader( new FileReader("xxx") );
....
}
synchronized double obt_resultado() { return resultado; }
Regla 2: cuando un hilo depende del estado de un objeto, debe esperar dentro del objeto (no fuera), en un método sincronizado en el que llama a wait(). Un esquema:
synchronized tipo obtener_dato() {
notifyAll(); // Despierto a los demás
while ( !condicion ) // Mientras no se cumplan prerrequisitos para conseguir dato
wait(); // Espero
return dato; // Hago mi trabajo
}
Regla 3: cuando un hilo cambia el estado de un objeto llama a notifyAll(), ya que de esta forma da oportunidad a los demás para despertar y comprobar si el cambio les afecta. Un esquema:
synchronized tipo modificar_dato() {
while ( !condicion ) // Mientras no se cumplan prerrequisitos para hacer mi trabajo
wait(); // Espero
... modifico datos ... // Hago mi trabajo
notifyAll(); // Despierto a los demás
}
Regla 4: el método run() suele tener la forma siguiente (ver Horstmann y Cornell ,2003, p. 62-63):
public void run() {
try {
while ( !interrupted() ) {
... hago mi trabajo ...
sleep( x );
}
}
catch (InterruptedException e) { }
}
El método interrupted() es estático y comprueba si el hilo actual ha sido interrumpido. Además su llamada reiniza el estado de "interrumpido" del hilo. A diferencia de isInterrupted() que hace la comprobación pero no modifica el estado "interrumpido" del hilo.
Vea un buen ejemplo de wait() y notify() en Niemeyer y Knudsen (2000, pag. 257).
Un ejemplo de animación con múltiples hilos
Vamos a ver un ejemplo de animación en el que el uso de hilos resulta imprescindible. Tenemos
un applet que contiene un panel donde se mueven las bolas que vayamos creando, otro panel contiene
el botón "iniciar", que conlleva la creación de un hilo, con su bola movil correspondiente. Para que
las bolas se muevan simultaneamente resulta necesario que el movimiento de cada bola se haga en un
hilo.
Este ejemplo, se ha adaptado de Horstmann y Cornell (2003, p. 13-17). En nuestro caso no usamos Swing, sino AWT (se usa la clase Applet, en vez de JFrame). Además, a diferencia del original, las bolas no salen de la misma posición y su velocidad varía.
El applet no tiene una complejidad especial:
/***********************************************************************
Al presionar el botón Iniciar, se crea y arranca un hilo
Cada hilo mueve una bola
************************************************************************/
public class animacion_bolas extends Applet {
int origen_x = 0;
private panel_bolas pbola = new panel_bolas();
private Panel pbotones = new Panel();
Button b_iniciar = new Button( "Iniciar"); // Boton para iniciar una animación
/******* Inicializar el applet ********/
public void init() {
try {
jbInit();
}
catch(Exception e) { e.printStackTrace(); }
}
/**************Inicialización de componentes ***********/
private void jbInit() throws Exception {
this.setLayout( new BorderLayout() );
pbola.setBackground(Color.cyan);
b_iniciar.addActionListener(new animacion_bolas_b_iniciar_actionAdapter(this));
pbotones.add( b_iniciar );
add(pbola, BorderLayout.CENTER);
add(pbotones, BorderLayout.SOUTH);
}
/**** Crea la bola: (1) se la pasa al panel y (2) se la pasa al hilo, después start */
public void aniadir_bola() {
origen_x += 10;
if ( origen_x > 150 )
origen_x = 0;
bola b = new bola( pbola, origen_x, 0 );
pbola.add(b); // Añadir bola a panel
hilo_de_bola thread = new hilo_de_bola( b, origen_x/5 );
thread.start();
}
/**** Gestiona el evento de pulsar botón: añade bola ****/
void b_iniciar_actionPerformed(ActionEvent e) {
aniadir_bola();
}
}
Lo único relevante es ver que al pulsar el botón se llama a la función "aniadir_bola()". En esta función se crea la bola (pasándole al constructor el panel de bolas y las coordenadas de su origen. A continuación se crea el hilo (se le pasa la bola y el periodo de refresco medido en milisegundos). Por último, se inicia el hilo con thread.start();
El panel de bolas lleva el registro (Vector) de las bolas creadas y pinta las bolas en respuesta al evento paint:
class panel_bolas extends Panel {
private Vector lista_bolas = new Vector();
public void add( bola b ) { lista_bolas.add(b); }
public void paint(Graphics g) {
Graphics2D g2 = (Graphics2D)g;
for (int i = 0; i < lista_bolas.size(); i++) {
bola b = (bola)lista_bolas.get(i);
b.draw(g2);
}
}
}
Lo interesante es ver lo que ocurre cuando se ejecuta con start() el hilo: mueve cada periodo de "milisegundos" la bola. Para determinar la velocidad usamos sleep(). Con sleep() no sólo se consigue que duerma el hilo, sino que además tenemos un hilo bien diseñado (podriamos decir "educado"), ya que al dormir permite que otros hilos hagan su trabajo.
class hilo_de_bola extends Thread {
private bola b;
private int milisegundos;
public hilo_de_bola( bola abola, int milisegundos ) {
b = abola;
this.milisegundos = milisegundos;
}
public void run() {
try {
for (int i = 1; i < = 1000; i++) {
b.move(); // Ordeno al objeto que se mueva
sleep( milisegundos ); // Duerme al hilo
}
}
catch (InterruptedException e) { System.out.println( e.getMessage() ); }
}
}
La clase bola es responsable de mover la posición de la bola y dibujarla:
class bola {
private panel_bolas pbolas;
private static final int XSIZE = 15;
private static final int YSIZE = 15;
private int x = 0;
private int y = 0;
private int dx = 2; // Salto en la X
private int dy = 2; // Salto en la Y
/***** Construye la bola, asociada a un panel y unas coordenadas de origen ******/
public bola(panel_bolas c, int origen_x, int origen_y ) {
pbolas = c;
x = origen_x;
y = origen_y;
}
/** Dibuja la bola en su posición actual ******/
public void draw(Graphics2D g2) {
g2.fill(new Ellipse2D.Double(x, y, XSIZE, YSIZE));
}
/***** Cambio la posición de la bola y ordeno repintar *****/
public void move() {
/*** Incremento x,y ***/
x += dx;
y += dy;
/**** Si llega al limite inferior o superior de x, invierte el movimiento */
if (x < 0) {
x = 0;
dx = -dx;
}
if (x + XSIZE >= pbolas.getWidth()) {
x = pbolas.getWidth() - XSIZE;
dx = -dx;
}
/**** Si llega al limite inferior o superior de y, invierte el movimiento */
if (y < 0) {
y = 0;
dy = -dy;
}
if (y + YSIZE >= pbolas.getHeight()) {
y = pbolas.getHeight() - YSIZE;
dy = -dy;
}
pbolas.repaint(); // Repintar el panel
}
}
/**** Gestión de evento de botón ******/
class animacion_bolas_b_iniciar_actionAdapter implements java.awt.event.ActionListener {
animacion_bolas adaptee;
animacion_bolas_b_iniciar_actionAdapter(animacion_bolas adaptee) {
this.adaptee = adaptee;
}
public void actionPerformed(ActionEvent e) {
adaptee.b_iniciar_actionPerformed(e);
}
}
En ocasiones puede interesar que una clase que hereda de otra (por ejemplo de Applet) al mismo tiempo contenga el método run() propio de un Thread. Pero en Java, a diferencia de C++, no hay herencia múltiple; en esta situación una solución típica de Java es combinar la herencia (extends) con la recepción (implements) de un interfaz. En nuestro ejemplo ocurre que la clase hereda (extends) de Applet y reciba un interfaz que le permita contener el método deseado, run(). Para ello usamos "implements Runnable". En nuestro siguiente ejemplo tenemos un applet que funciona como un reloj: cada segundo se muestra por pantalla la hora:
public class reloj extends Applet implements Runnable {
private Thread hilo;
int periodo_refresco = 1000;
/************************************************************************
* run del hilo: se duerme durante un segundo
***********************************************************************/
public void run() {
while ( hilo != null ) {
try {
Thread.sleep( periodo_refresco );
}
catch (InterruptedException e ) { return; }
repaint();
}
}
public void paint( java.awt.Graphics g ) {
g.drawString( new java.util.Date().toString( ), 10, 25 );
}
/***********************************************************************
* No confundirse: este método es el start del applet.
* Desde aquí llamamos a start del hilo.
***********************************************************************/
public void start( ) {
if ( hilo == null ) {
hilo = new Thread(this); // Coloco applet en hilo
hilo.start();
}
}
/***********************************************************************
* Parada del applet: si la referencia al hilo no es nula: paro el hilo.
***********************************************************************/
public void stop( ) {
if ( hilo != null ) {
hilo.interrupt();
hilo = null;
}
}
}
Aspectos de la solución: