Multitarea
Los procesadores y los sistemas operativos modernos permiten la multitarea: la realización simultánea de dos o más actividades, al menos en apariencia. Y esa última aclaración es la que importa, porque una computadora con una sola CPU no puede hacer dos cosas a la vez. Lo que hacen los sistemas operativos es repartir el tiempo entre dos o más actividades, o aprovechar los tiempos muertos de una —mientras espera que el usuario apriete una tecla, por ejemplo— para trabajar en otra. En computadoras con dos o más procesadores la multitarea sí es real, porque cada procesador puede ejecutar un hilo diferente.
Hay que distinguir dos cosas que se confunden todo el tiempo:
- Un proceso es un programa ejecutándose de forma independiente y con su propio espacio de memoria. Un sistema operativo multitarea puede ejecutar más de un proceso a la vez.
- Un thread o hilo es un flujo secuencial simple dentro de un proceso. Un único proceso puede tener varios hilos ejecutándose.
El ejemplo del apunte es el mejor para fijarlo: un navegador web —Chrome, Firefox— es un proceso, mientras que cada una de las pestañas o ventanas que traen páginas HTML está formada por al menos un hilo más.
Un sistema multitarea da la impresión de estar haciendo varias cosas a la vez, y eso es una gran ventaja para quien lo usa. Pero además hay algo más importante: sin threads hay tareas que son prácticamente imposibles de ejecutar, en particular las que tienen tiempos de espera importantes entre etapas. Si un programa tiene que esperar la respuesta de un servidor, con un solo hilo se queda congelado; con varios, puede seguir atendiendo al usuario.
Qué es un thread
Un thread es un flujo simple de ejecución dentro de un programa. Todos los programas que se escribieron hasta acá contenían un solo thread, el que arranca en main. Pero un programa puede iniciar la ejecución de varios concurrentemente, y ahí aparecen dos propiedades que definen todo lo que sigue:
- Los hilos de un mismo programa comparten la memoria y usan el mismo contexto y los mismos recursos asignados al proceso. También tienen variables y atributos propios del hilo, pero la memoria del programa es una sola. De ahí va a salir el problema de la sincronización.
- Un thread no puede existir independientemente de un programa: se ejecuta dentro de un proceso, no afuera.
Lo que tiene que hacer cada hilo se indica en el método run(), que define su actividad principal. Y un hilo puede hacer cualquier cosa que haga un programa común: la única diferencia es que su código va ahí adentro.
Threads daemon y no daemon
Los threads pueden ser daemon o no daemon. Son daemon aquellos hilos que realizan servicios generales en segundo plano: tareas que no forman parte de la esencia del programa y que se ejecutan mientras la aplicación no termine. Un ejemplo típico es el hilo que está comprobando permanentemente si el usuario apretó un botón.
Y hay una consecuencia práctica que hay que conocer: un programa Java termina cuando solo quedan corriendo threads de tipo daemon. Es decir, mientras haya un solo hilo no daemon vivo, la aplicación sigue existiendo. Por defecto, y si no se indica lo contrario, los threads son no daemon.
El primer ejemplo
Un hilo que escribe «NO» doscientas veces, y un programa principal que escribe «SI» otras doscientas:
public class NoThread extends Thread {
public void run() {
for (int i = 1; i <= 200; i++) {
System.out.print("NO ");
}
}
}
public class UnThread {
public static void main(String args[]) {
NoThread t = new NoThread();
t.start();
for (int i = 1; i <= 200; i++) {
System.out.print("SI ");
}
}
}
La salida es una serie intercalada de síes y noes:
SI SI SI SI NO NO NO NO NO NO NO NO NO NO NO NO SI SI SI SI SI SI SI SI SI SI NO NO NO SI SI SI SI SI SI NO NO …
Lo que muestra ese resultado: una vez que el hilo arranca, el tiempo de la CPU se reparte entre todos los procesos e hilos del sistema. Así que se intercalan las instrucciones del main() con las del run(), y también con las de otros procesos —del sistema operativo y de otros programas— que puedan estar ejecutándose. El orden exacto no se puede predecir: puede cambiar en cada ejecución. Esa imprevisibilidad es la característica central de la programación concurrente, y todo lo que sigue en la lección trata de manejarla.
Las dos formas de crear un thread
En Java hay dos formas, y conviene conocer las dos porque la elección no es libre: a veces solo una es posible.
- Heredar de la clase
java.lang.Thready sobrescribir el métodorun(). - Implementar la interfaz
java.lang.Runnable, que declara el métodorun(), y después crear un objetoThreadpasándole ese objeto en el constructor.
Tanto Thread como Runnable están en java.lang, que se importa solo: no hace falta importarlos. Es el mismo paquete de String y System, que se vio en la lección 2.
Forma 1: heredar de Thread
public class EjExtendsThread extends Thread {
// constructor
public EjExtendsThread(String str) {
super(str);
}
// redefinición del método run()
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Este es el thread : " + getName());
}
}
public static void main(String[] args) {
// Creo dos objetos de la clase con diferente string
EjExtendsThread miThread1 = new EjExtendsThread("Hilo de prueba");
EjExtendsThread miThread2 = new EjExtendsThread("Prueba de hilo");
// Ejecuto ambos threads
miThread1.start();
miThread2.start();
}
}
Tres cosas para mirar de ese código. El constructor recibe un String —opcional— para poner nombre al hilo, y con super() llama al constructor de la superclase Thread. Se redefine run(), que es la actividad principal. Y para poner en marcha el hilo hay que crear el objeto y llamar a start(), que es el método heredado de Thread que se encarga de llamar a run().
Forma 2: implementar Runnable
public class EjRunnable implements Runnable {
// un nombre
String nameThread;
// constructor
public EjRunnable(String str) {
nameThread = str;
}
// definición del método run()
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Este es el thread: " + nameThread);
}
}
}
public class PruebaRunnable {
public static void main(String[] args) {
// Se crean dos objetos Runnable
EjRunnable p1 = new EjRunnable("Hilo de prueba");
EjRunnable p2 = new EjRunnable("Prueba de hilo");
// Se crean objetos Thread pasándoles los Runnable como argumento
Thread miThread1 = new Thread(p1);
Thread miThread2 = new Thread(p2);
// Se arrancan los objetos Thread
miThread1.start();
miThread2.start();
}
}
Acá también se define run(), pero además hace falta crear un objeto Thread para lanzar la ejecución, pasándole la referencia al objeto Runnable. Cuando se ejecute start(), el run() de Thread se va a encargar de llamar al run() de la clase nueva.
JFrame, o una antigua applet, que hereda de Applet— no queda más remedio que implementar Runnable, porque Java no tiene herencia múltiple: una clase solo puede tener una superclase, y ya está ocupada. Cuando no hay ese conflicto, heredar de Thread suele ser más simple, porque se escribe menos. Pero la forma 2 tiene una ventaja de diseño que no es menor: la clase sigue siendo una clase común, y el hilo es solo una forma de usarla. Es la misma discusión de la lección 5 sobre favorecer la composición.
El ciclo de vida de un thread
Un thread pasa por cuatro estados a lo largo de su vida, y conocerlos es lo que permite entender por qué un programa concurrente se comporta como se comporta:
| Estado | Qué significa |
|---|---|
| 1. Nuevo (New) | El thread fue creado pero no inicializado: todavía no se ejecutó start(), así que no está en la cola de ejecución de la CPU. Si se intenta llamar a cualquier método de Thread distinto de start(), se produce el error IllegalThreadStateException. |
| 2. Ejecutable (Runnable) | El thread puede estar ejecutándose, siempre que se le haya asignado tiempo de CPU. En la práctica puede no estar ejecutándose en un instante determinado en beneficio de otro, pero sigue en la cola de ejecución. Es la diferencia entre «ejecutable» y «ejecutándose»: estar en condiciones de correr no es estar corriendo. |
| 3. Bloqueado (Blocked / Not Runnable) | Un thread ejecutable pasa a este estado por alguna de estas razones: se invocó su sleep() o su suspend(); hizo una llamada a wait(); está esperando una operación de entrada/salida; o está tratando de entrar a un método synchronized de un objeto que otro hilo tiene bloqueado. |
| 4. Muerto (Dead) | La forma habitual de morir es terminar el método run(). También existe el método stop(), pero está considerado peligroso y no se debe usar. |
Crear un thread no es ejecutarlo
Vale la pena subrayarlo porque es el primer error que se comete: la creación de un thread no implica que empiece a ejecutarse nada. Hace falta start(). Y el método start() es el que llama al run(): si la clase hereda de Thread, el run() que se redefine; si implementa Runnable, el run() de Thread se encarga de llamar al de la clase nueva. Una vez llamado start(), se dice que el thread está «corriendo», pero eso no quiere decir que se esté ejecutando todo el tiempo, porque tiene que compartir la CPU con los demás: por eso se dice que es runnable.
Detener un thread temporalmente
El sistema operativo se ocupa de asignar tiempos de CPU a los threads ejecutables. Y aunque la computadora tenga más de un procesador, la cantidad de threads simultáneos suele superar la cantidad de CPUs, así que el tiempo se reparte para que parezca que todos corren a la vez, aunque en un instante dado solo unos pocos lo hagan.
Renunciar al tiempo de CPU: yield()
Un thread puede, en un momento dado, renunciar voluntariamente a su tiempo de CPU y devolvérselo al sistema para que se lo asigne a otro: eso se hace con yield(). Conviene usarlo en las actividades que tienden a «monopolizar» la CPU. El método viene a decir que para ese thread no es tan importante ejecutarse continuamente. Y si ningún otro thread está pidiendo la CPU con intensidad, el sistema se la vuelve a asignar casi de inmediato al que fue generoso.
Las formas de bloquear un thread
Si lo que se quiere es pasar al estado Not Runnable, hay cuatro maneras:
sleep()— detiene el thread un tiempo preestablecido. Se lo llama normalmente desde elrun(). Recibe el tiempo en milisegundos y opcionalmente un tiempo adicional en nanosegundos.wait(), heredado de la claseObject— espera a que suceda algo necesario para continuar. Vuelve a ejecutable cuando se llamannotify()onotifyAll().- Espera de entrada/salida — el thread está esperando a que termine una operación de E/S.
- Bloqueo por un método sincronizado — el thread intenta llamar a un método
synchronizedde un objeto que otro hilo tiene bloqueado.
El thread vuelve automáticamente de Not Runnable a Runnable cuando cesa la condición, según el caso: cuando pasa el tiempo de sleep(); cuando otro hilo llama a notify()/notifyAll(); cuando termina la E/S; o cuando se libera el bloqueo del objeto.
stop() puede provocar bloqueos del programa (deadlock) porque interrumpe al hilo en cualquier punto, incluso en el medio de una operación que había que terminar. suspend() —que se revierte con resume()— se desaconseja por razones parecidas: deja al hilo detenido sin soltar los recursos que tenía tomados. Los dos están marcados como obsoletos en las versiones modernas del lenguaje. La forma correcta de detener un hilo se ve en la sección de wait y notify.
Finalizar un thread
Un thread finaliza cuando el método run() devuelve el control: o porque terminó lo que tenía que hacer —un for que se ejecuta una cantidad determinada de veces— o porque dejó de cumplirse una condición —un while que sale del bucle—.
Para saber si un thread está «vivo» está el método isAlive(), que devuelve true si el thread fue inicializado y no terminó, y false si todavía es nuevo o si ya finalizó. Es lo que se usa antes de intentar interactuar con un hilo, para no encontrarse con un objeto que ya no hace nada.
Sincronización
La sincronización nace de la necesidad de evitar que dos o más threads accedan a los mismos recursos al mismo tiempo. Los ejemplos del apunte son bien concretos: si un hilo intenta escribir en un archivo y otro intenta borrarlo al mismo tiempo, la situación no es deseable; y si un hilo tiene que esperar a que otro le suministre los datos que necesita —el clásico productor/consumidor—, hace falta coordinar los dos.
Las secciones de código que acceden a un mismo recurso —un objeto, un archivo del disco— desde dos o más hilos se llaman secciones críticas. Para sincronizarlas se usa el modificador synchronized en los métodos del objeto-recurso con los que puedan producirse conflictos. Así, Java asocia un bloqueo (lock) al recurso sincronizado:
public synchronized void metodoSincronizado() {
// accediendo, por ejemplo, a las variables de un objeto
}
Bloquear un recurso significa que sobre ese objeto no pueden actuar simultáneamente dos métodos sincronizados. El segundo queda esperando a que el primero termine y libere el objeto.
Cuidado con lo que no se sincroniza
Acá hay una advertencia que es la causa de la mayoría de los errores en programas concurrentes: solo los métodos sincronizados comprueban si un objeto está bloqueado. Por lo tanto, si se sincronizan algunos métodos de un objeto pero otros no, el programa puede no funcionar correctamente: los métodos no sincronizados pueden acceder libremente a las variables miembro, ignorando el bloqueo.
La consecuencia práctica: todos los métodos que accedan a un recurso compartido tienen que estar declarados synchronized. Y como la sincronización protege la memoria reservada para un objeto, cuando se prevé que ciertas variables pueden tener problemas de sincronización conviene declararlas private, para que solo sean accesibles a través de métodos de la clase que sí están sincronizados.
Los dos niveles de bloqueo
- A nivel de objetos. Se consigue declarando los métodos como
synchronized. Cuando se ejecuta un método sincronizado sobre un objeto concreto, el sistema bloquea ese objeto: si otro thread intenta ejecutar algún método sincronizado de ese mismo objeto, queda esperando. Como el bloqueo es por objeto, sí se pueden ejecutar simultáneamente métodos sobre objetos distintos de la misma clase. - A nivel de clases. Se consigue con
synchronized static, porque los métodos de clase se corresponden con las variables de clase. Un método declarado así bloquea la clase entera: durante su ejecución, ningún método sincronizado va a poder acceder a ningún objeto de esa clase. Es lo que hace falta cuando lo que se comparte es un atributostatic.
El ejemplo del contador
Este ejemplo es el que muestra el problema y la solución en el mismo lugar. Un contador con un atributo y un método que lo incrementa en tres pasos —y esos tres pasos son el problema—:
public class Contador {
private long valor = 0;
public void incrementa() {
long aux;
aux = valor;
aux++;
valor = aux;
}
public long getValor() {
return valor;
}
}
public class Contable extends Thread {
Contador contador;
public Contable(Contador c) {
contador = c;
}
public void run() {
for (int i = 1; i <= 100000; i++) {
contador.incrementa();
}
System.out.println("Contado hasta ahora: " + contador.getValor());
}
}
public class ThreadsSincronizados {
public static void main(String arg[]) {
Contador c = new Contador();
Contable c1 = new Contable(c);
Contable c2 = new Contable(c); // los dos comparten el MISMO contador
c1.start();
c2.start();
}
}
La sección crítica es contador.incrementa(): desde ahí se accede a un objeto compartido por los dos hilos. Y la salida del programa sin sincronizar sorprende:
Contado hasta ahora: 124739
Contado hasta ahora: 158049
Cuando debería haber sido un número entre 100000 y 200000 en la primera línea y 200000 exactos en la segunda. Se perdieron decenas de miles de incrementos, y veamos por qué.
Lo que muestra el esquema: incrementar no es una operación atómica. Son tres pasos —leer, sumar, escribir— y el sistema puede interrumpir al hilo entre uno y otro. Si los dos hilos leen 50 antes de que ninguno escriba, los dos escriben 51 y uno de los dos incrementos se perdió. Y eso pasó un número enorme de veces durante la ejecución.
Con synchronized en incrementa(), el objeto se bloquea: el segundo hilo espera a que el primero termine los tres pasos. La salida pasa a ser la correcta:
Contado hasta ahora: 186837
Contado hasta ahora: 200000
La segunda línea siempre da 200000, que es la cuenta exacta de los dos hilos. La primera sigue variando —es la foto del momento en que el hilo 1 imprime— y eso es normal: no se puede saber cuál de los dos va a terminar antes.
Sincronizar solo una parte del método
No siempre hace falta bloquear el objeto de principio a fin del método. Se puede sincronizar una parte indicando entre paréntesis qué objeto se quiere sincronizar:
public void metodo() {
// código que no necesita exclusión
synchronized (objetoASincronizar) {
// solo esta parte queda protegida
}
}
wait, notify y notifyAll
Acá aparece un detalle que es fácil de pasar por alto y tiene consecuencias: si dentro de un método sincronizado se usa sleep(), el objeto bloqueado permanece bloqueado todo ese tiempo. Los demás hilos no pueden acceder a él, aunque en realidad no haya peligro, porque durante ese tiempo el que mantiene el bloqueo no va a tocar nada.
Para esos casos conviene usar wait() en lugar de sleep(). La diferencia es la clave de todo: wait() libera el bloqueo del objeto. Mientras el hilo espera, el resto puede seguir usando el objeto a través de métodos sincronizados.
Los métodos, que todas las clases heredan de Object:
wait()— detiene el hilo hasta que alguien llame anotify()onotifyAll()del objeto, o hasta que termine el tiempo indicado como argumento. Se puede llamar sin argumentos —espera indefinidamente— o con un tiempo máximo. A diferencia desleep(), que detiene el hilo exactamente el tiempo indicado,wait()establece un tiempo máximo: si en ese plazo llega la notificación, el hilo continúa sin esperar a que se cumpla.notify()— lanza una señal que le indica al sistema que puede activar uno de los hilos que están esperando para acceder al objeto.notifyAll()— lanza la señal a todos los hilos que están esperando la liberación del objeto.
Cuando un objeto bloqueado recibe un notifyAll(), los hilos que estaban esperando pueden volver a intentar acceder a sus métodos sincronizados. Y una regla que produce errores difíciles de leer: wait() y notify() tienen que estar dentro de un método synchronized; si no, se obtiene una excepción IllegalMonitorStateException en tiempo de ejecución. La razón es lógica: solo el que tiene el bloqueo puede esperar sobre él y liberarlo, y solo el que lo tiene puede avisar a los demás.
El uso típico de wait() es esperar a que se cumpla una condición ajena al propio hilo. Cuando la condición se cumple, el que la produjo llama a notifyAll() para avisar a los demás que pueden usar el objeto. Ese es el esquema del productor y el consumidor, y también es la forma correcta de detener un hilo: se lo convierte en un bucle que espera, y se lo termina cambiando la condición y llamando a notifyAll().
Prioridades
Para conseguir un reparto más eficiente de los recursos se establecen prioridades en los threads. En un momento dado puede interesar que un proceso termine sus cálculos lo antes posible, y entonces se le da más tiempo de CPU. Eso no significa que los demás no necesiten CPU: necesitan menos.
Las prioridades son variables de la clase Thread con valores enteros, y tienen tres constantes que las definen:
| Constante | Valor | Qué es |
|---|---|---|
MAX_PRIORITY | 10 | La prioridad máxima. |
NORM_PRIORITY | 5 | La prioridad por defecto. |
MIN_PRIORITY | 1 | La prioridad mínima. |
Cuando se crea un thread nuevo, hereda la prioridad del thread desde el que fue inicializado —o sea, del que hizo el new—. Se cambia con setPriority() y se consulta con getPriority().
El algoritmo de reparto de Java elige, en general, el thread de mayor prioridad, aunque no siempre, justamente para evitar que algunos procesos queden dormidos para siempre. Cuando hay dos o más threads con la misma prioridad más alta, el sistema no establece diferencias entre ellos y los ejecuta alternativamente, según cómo lo haga el sistema operativo. Y ahí está el peligro: un thread podría no ejecutarse nunca porque siempre hay otros de mayor prioridad. Java lo evita asignando CPU también a los de menor prioridad, aunque por períodos mucho más cortos.
El método yield() completa el esquema: permite que un thread renuncie a su tiempo de CPU a favor de otro de la misma prioridad —nunca de uno de prioridad inferior—.
Grupos de threads
Todo hilo de Java debe formar parte de un grupo de hilos (ThreadGroup): puede pertenecer al grupo por defecto o a uno creado expresamente. Los grupos sirven para manejar varios threads como si fueran un solo objeto: por ejemplo, se pueden detener varios con una sola llamada. Y hay una regla que no tiene vuelta atrás: una vez que un thread fue asociado a un grupo, no puede cambiar de grupo.
Cuando arranca un programa, el sistema crea un grupo llamado main, donde vive el método main(). Si al crear un thread no se especifica grupo, pasa a pertenecer al grupo del thread que lo creó. Si el programa no crea ningún grupo adicional, todos van a estar en el grupo main.
Para que un thread pertenezca a un grupo concreto hay que indicarlo al crearlo, con alguno de los constructores que reciben el grupo:
public Thread (ThreadGroup grupo, Runnable destino)
public Thread (ThreadGroup grupo, String nombre)
public Thread (ThreadGroup grupo, Runnable destino, String nombre)
Y un ThreadGroup, a su vez, pertenece a otro ThreadGroup. Si no se especifica cuál, el nuevo grupo pertenece al grupo desde el que fue creado. Sus constructores son:
ThreadGroup (ThreadGroup parent, String nombre);
ThreadGroup (String nombre); // el padre es el del thread actual
Un ejemplo mínimo, con las dos cosas juntas:
ThreadGroup miThreadGroup = new ThreadGroup("Mi Grupo de Threads");
Thread miThread = new Thread(miThreadGroup, "un thread para mi grupo");
Multitarea en Swing
En las aplicaciones Java que usan Swing —la biblioteca de interfaces gráficas, que aparece en la lección 7— manejar bien la concurrencia es particularmente importante. Una aplicación bien hecha usa la concurrencia para que la interfaz nunca se bloquee: la idea es que siempre pueda responder a lo que hace el usuario, sin importar qué esté haciendo la aplicación en ese momento.
Una aplicación Swing tiene tres tipos de hilos:
- Un hilo inicial o principal. Es el que crea la máquina virtual al arrancar la aplicación y llama al
main(). En una aplicación Swing, su tarea principal es crear e inicializar la interfaz gráfica. Cuando termina de hacerlo, elmain()suele terminar, y con él el hilo principal. - Un hilo de despacho de eventos. Es el que dibuja y actualiza los componentes y el que responde a las interacciones del usuario llamando a los manejadores de eventos. De ahí salen dos conclusiones: todos los manejadores de eventos son ejecutados por este hilo, y toda interacción con los componentes de la interfaz debe hacerse únicamente desde este hilo. Acceder a los componentes desde otro hilo puede causar errores de dibujo y, en el peor caso, un interbloqueo.
- Varios hilos trabajadores, también llamados hilos en segundo plano. Son los que hacen el trabajo pesado.
Cómo se cumple la regla 1
El main() de una ventana Swing suele escribirse así:
public class SwingMultitarea extends javax.swing.JFrame {
public static void main(String[] args) {
new SwingMultitarea().setVisible(true);
}
}
Y aunque parezca inofensivo, viola la regla 1: está interactuando con componentes Swing desde el hilo principal. El error es fácil de cometer, y los problemas de sincronización que puede provocar no son evidentes: aparecen cada tanto, en una máquina distinta, y no hay forma de reproducirlos. La forma correcta es esta:
public class SwingMultitarea extends javax.swing.JFrame {
public static void main(String[] args) {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
new SwingMultitarea().setVisible(true);
}
});
}
}
El hilo principal crea un objeto Runnable que crea la interfaz, pero cuyo run() no lo ejecuta el hilo principal: lo va a ejecutar el hilo de despacho de eventos. Para eso se pasa el objeto a invokeLater() o a invokeAndWait(), los dos métodos estáticos de javax.swing.SwingUtilities. Lo que hacen es poner el objeto al final de la cola de despacho de eventos, así que el código se ejecuta cuando al hilo de despacho le toque, después de los eventos que ya estaban pendientes.
| Método | Cómo funciona | Cuándo usarlo |
|---|---|---|
invokeLater() |
Asíncrono: después de encolar el objeto, retorna inmediatamente sin esperar a que se ejecute. El hilo que lo llamó puede seguir trabajando. No lanza ninguna excepción. | Es el caso general. |
invokeAndWait() |
Síncrono: no retorna hasta que el hilo de despacho ejecutó el run(). Mientras tanto, el hilo que lo llamó queda esperando. Puede lanzar InterruptedException si lo interrumpen mientras espera, o InvocationTargetException si el Runnable lanza un error o una excepción de ejecución. |
Para leer el valor de un componente, para actualizar la interfaz, o para asegurar que algo se muestra en pantalla antes de seguir. |
Cómo se cumple la regla 2
Los manejadores de eventos se ejecutan en un solo hilo, el de despacho. Mientras el código de un evento se está ejecutando, ningún otro evento se procesa: el siguiente empieza cuando termina el anterior. Por eso la capacidad de respuesta de la aplicación depende directamente de cuánto tarda cada manejador, y de ahí la regla 2: si un manejador tiene que hacer una tarea larga o que se puede bloquear, esa tarea va en un hilo trabajador aparte. Si el hilo de despacho se queda con una tarea larga, los eventos se acumulan y la aplicación parece bloqueada: no puede responder a nada.
Para eso Java trae la clase SwingWorker, que resuelve el problema sin que uno tenga que manejar los hilos a mano. El ejemplo del apunte busca números primos en segundo plano, mientras la ventana sigue respondiendo y muestra una barra de progreso:
import javax.swing.SwingUtilities;
public class MultiSwing {
public static void main(String[] args) {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
new VentanaPrincipal();
}
});
}
}
La ventana tiene dos botones, una lista donde se muestran los primos encontrados y una barra de progreso. Lo importante está en el actionPerformed: cuando se aprieta Iniciar, crea el trabajador y lo lanza con execute(); cuando se aprieta Parar, lo cancela con cancel(true).
import java.util.ArrayList;
import java.util.concurrent.ExecutionException;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JProgressBar;
import javax.swing.SwingWorker;
public class GeneraPrimos extends SwingWorker<ArrayList<Integer>, Integer> {
private DefaultListModel modeloLista;
private JProgressBar barraProgreso;
private JButton botonInicio, botonParada;
public GeneraPrimos(DefaultListModel modeloLista, JProgressBar barraProgreso,
JButton botonInicio, JButton botonParada) {
this.modeloLista = modeloLista;
this.barraProgreso = barraProgreso;
this.botonInicio = botonInicio;
this.botonParada = botonParada;
}
// Este método lo ejecuta un hilo trabajador
protected ArrayList<Integer> doInBackground() {
Integer valorTemp = new Integer(1);
ArrayList<Integer> lista = new ArrayList<Integer>();
for (int i = 0; i < 100; i++) {
for (int j = 0; j < 100 && !isCancelled(); j++) {
valorTemp = encuentraSiguientePrimo(valorTemp.intValue());
}
publish(new Integer(i)); // avisa el progreso
lista.add(valorTemp);
}
return lista;
}
// Este lo ejecuta el hilo de despacho de eventos
protected void process(java.util.List<Integer> lista) {
if (!isCancelled()) {
Integer parteCompletada = lista.get(lista.size() - 1);
barraProgreso.setValue(parteCompletada.intValue());
}
}
// Este también, cuando el trabajador termina
protected void done() {
if (!isCancelled()) {
try {
ArrayList<Integer> results = get();
for (Integer i : results) {
modeloLista.addElement(i.toString());
}
this.botonInicio.setEnabled(true);
this.botonParada.setEnabled(false);
} catch (ExecutionException ex) {
ex.printStackTrace();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
private Integer encuentraSiguientePrimo(int num) {
do {
if (num % 2 == 0) {
num++;
} else {
num = num + 2;
}
} while (!esPrimo(num));
return new Integer(num);
}
private boolean esPrimo(int num) {
for (int i = 2; i <= num / 2; i++) {
if (num % i == 0) {
return false;
}
}
return true;
}
}
La clase es la mejor síntesis de toda la lección, porque reparte el trabajo entre los hilos según lo que cada uno puede hacer. doInBackground() es lo único que se ejecuta en el hilo trabajador: ahí va el cálculo pesado, y es el único lugar del código que no toca la interfaz. publish() manda el progreso, y process() —que sí corre en el hilo de despacho— actualiza la barra. done() también corre en el hilo de despacho y es donde se carga el resultado en la lista y se vuelven a habilitar los botones. Y isCancelled() permite que el bucle de doInBackground() corte cuando el usuario aprieta Parar: es la forma correcta de terminar un hilo, que se había dejado pendiente al hablar de stop().
Ejercicios
1. Proceso o thread
Indicar si cada caso es un proceso o un thread, y justificar: un editor de texto abierto, la ventana de autoguardado del mismo editor, un servidor web atendiendo pedidos, y el programa de la calculadora del sistema.
2. Predecir la salida
Explicar por qué la salida del primer ejemplo de la lección no se puede predecir, y qué cambiaría si el main() llamara a t.run() en lugar de t.start().
3. Los dos estados
Explicar la diferencia entre «ejecutable» (runnable) y «ejecutándose», y decir en qué estado queda un thread en cada uno de estos casos: recién creado con new, después de llamar a sleep(1000), esperando a que termine la lectura de un archivo, y cuando su run() termina.
4. Encontrar el error
Este programa debería imprimir 20000, pero imprime números menores. Explicar por qué y corregirlo:
public class Acumulador {
private int total = 0;
public void sumar() {
total = total + 1;
}
public int getTotal() {
return total;
}
}
5. wait o sleep
Explicar qué diferencia hay entre sleep() y wait() cuando se usan dentro de un método sincronizado, y cuál conviene en un esquema de productor y consumidor.
6. Swing
Explicar por qué este main() está mal y reescribirlo bien:
public static void main(String[] args) {
JFrame v = new JFrame("Mi ventana");
v.setSize(400, 300);
v.setVisible(true);
}
Respuestas sugeridas
1. El editor abierto es un proceso: es un programa ejecutándose con su propio espacio de memoria. La ventana de autoguardado es un thread de ese proceso: comparte su memoria y es una tarea en segundo plano —probablemente un daemon—. Un servidor web es un proceso, y cada pedido que atiende se resuelve en un thread distinto dentro de ese proceso. La calculadora del sistema es otro proceso independiente.
2. No se puede predecir porque, una vez que el hilo arranca, el tiempo de CPU se reparte entre todos los procesos e hilos del sistema, incluidos los del sistema operativo. El orden en que se intercalan las instrucciones del main() y del run() depende de ese reparto, que cambia en cada ejecución. Si en lugar de t.start() se llamara a t.run(), no habría ningún hilo nuevo: se estaría llamando al método directamente, y el resultado sería los doscientos «NO» seguidos y después los doscientos «SI», siempre en el mismo orden. Es la diferencia entre usar el método y lanzarlo en un hilo.
3. «Ejecutable» significa que está en condiciones de ejecutarse y está en la cola de la CPU; «ejecutándose» significa que en ese instante tiene la CPU. Un thread ejecutable puede no estar ejecutándose, porque la CPU la tiene otro. Los casos: recién creado con new → nuevo; después de sleep(1000) → no ejecutable (dormido); esperando la lectura de un archivo → no ejecutable (bloqueado por entrada/salida); cuando el run() termina → muerto.
4. El método sumar() no está sincronizado, así que dos hilos pueden ejecutarlo al mismo tiempo sobre el mismo objeto y perder incrementos: es exactamente el problema del contador. La corrección es agregar synchronized:
public synchronized void sumar() {
total = total + 1;
}
Y hay una segunda corrección que conviene hacer por el mismo motivo: getTotal() también lee el atributo compartido, así que también debería ser synchronized. Si se sincroniza uno y el otro no, el que no está sincronizado puede leer un valor a medio actualizar.
5. sleep() detiene el hilo manteniendo el bloqueo del objeto: nadie más puede acceder a él durante ese tiempo, aunque el hilo dormido no vaya a tocar nada. wait() detiene el hilo liberando el bloqueo, así que los demás pueden seguir usando el objeto a través de sus métodos sincronizados. Por eso en un productor y consumidor conviene wait(): el consumidor tiene que esperar a que haya datos, y mientras espera los demás tienen que poder trabajar. Usar sleep() ahí bloquearía el recurso durante toda la espera y el productor no podría dejarlo. Además, wait() despierta en cuanto llega el notify(), sin esperar a que se cumpla el tiempo máximo.
6. Está mal porque está creando y mostrando componentes Swing desde el hilo principal, y la regla 1 dice que solo el hilo de despacho de eventos puede tocar la interfaz. Lo correcto:
public static void main(String[] args) {
javax.swing.SwingUtilities.invokeLater(new Runnable() {
public void run() {
JFrame v = new JFrame("Mi ventana");
v.setSize(400, 300);
v.setVisible(true);
}
});
}
El código adentro del run() es el mismo; lo que cambia es quién lo ejecuta. Ese es el punto de toda la regla: el mismo código, en manos del hilo equivocado, produce errores que aparecen cada tanto y no se pueden reproducir.
Resumen
- La multitarea es la ejecución simultánea de varias actividades. Un proceso es un programa con su propio espacio de memoria; un thread es un flujo de ejecución dentro del proceso, y los hilos comparten la memoria.
- Un thread existe solo dentro de un proceso, y lo que hace se define en el método
run(). Los hilos daemon son de segundo plano, y el programa termina cuando solo quedan daemons. - Se crean de dos formas: heredando de
Threado implementandoRunnable. Si la clase ya hereda de otra, solo queda la segunda. Y crear no es ejecutar: hay que llamar astart(). - El ciclo de vida tiene cuatro estados: nuevo, ejecutable, no ejecutable y muerto. Se pasa a no ejecutable con
sleep(),wait(), una espera de E/S o el bloqueo de un objeto.stop()ysuspend()están desaconsejados.isAlive()dice si sigue vivo. - La sincronización protege las secciones críticas. El modificador
synchronizedbloquea el objeto, y hay bloqueo a nivel de objeto y a nivel de clase (synchronized static). Solo los métodos sincronizados respetan el bloqueo, así que todos los que toquen el recurso tienen que estarlo. wait(),notify()ynotifyAll()coordinan los hilos y liberan el bloqueo, a diferencia desleep(). Tienen que estar dentro de un método sincronizado.- La sincronización cuesta tiempo de CPU y puede llevar a un bloqueo indefinido, así que se usa solo donde hace falta.
- Las prioridades van de 1 a 10, con 5 por defecto, y un hilo las hereda del que lo creó.
yield()cede la CPU a un hilo de la misma prioridad. - Los grupos de threads permiten tratarlos como un conjunto, y un hilo no puede cambiar de grupo.
- En Swing hay tres tipos de hilos: el principal, el de despacho de eventos y los trabajadores. Las dos reglas: solo el hilo de despacho toca la interfaz —y para eso se usan
invokeLater()einvokeAndWait()— y las tareas largas van en un hilo trabajador, conSwingWorker. Si no, la ventana parece colgada.
Con esto el programa puede hacer varias cosas a la vez. Lo que falta es que guarde los datos en un lugar del que no se pierdan: eso es la lección 7.
Licencia. Obra derivada de los apuntes de clase del Prof. Matías E. García —Clase Teórica 5—, publicada en profmatiasgarcia.com.ar bajo CC BY-NC-SA 2.5 Argentina; el desarrollo del texto, los diagramas y los ejercicios son de Prof Mauro Bobyk.