Hilos
En resumen: Un hilo es una línea de ejecución separada dentro del programa; varios hilos trabajan al mismo tiempo y comparten la memoria, por eso un contador compartido se protege con synchronized.
Problema
Tres informes, uno tras otro
Thread.sleep(ms) detiene el programa durante esos milisegundos. La pausa se puede interrumpir desde fuera, y entonces lanza la excepción comprobada InterruptedException, por eso download la captura.static void download(String file, int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { return; } System.out.println("Descargué " + file);}
public static void main(String[] args) { download("marzo.pdf", 600); download("abril.pdf", 200); download("mayo.pdf", 400); System.out.println("Listo");}Descargué marzo.pdfDescargué abril.pdfDescargué mayo.pdfListoLa idea
Proceso e hilo
Cuando ejecutas el programa, el sistema operativo le da un proceso: su propia memoria, sus propios archivos abiertos. Dentro del proceso trabajan uno o más hilos (threads). Un hilo es una secuencia de instrucciones que se ejecutan una tras otra. Hasta ahora, cada uno de tus programas tenía un solo hilo: el que ejecuta main.
Los hilos de un mismo proceso comparten su memoria: ven los mismos objetos. Eso los hace prácticos (se pasan datos con facilidad) y peligrosos (pueden estorbarse entre sí, como verás en un momento).
- En paralelo significa de verdad al mismo tiempo, en distintos núcleos del procesador.
- Concurrente significa en un solo núcleo, turnándose tan rápido que parece al mismo tiempo.
Para quien programa, la diferencia rara vez importa: en los dos casos no sabes qué hilo continuará y cuándo.
Más precisamente, para curiosos
- El navegador Chrome lanza un proceso aparte para cada pestaña. Por eso un sitio que se cuelga no detiene a los demás, y también por eso el navegador usa mucha memoria.
- Todo programa que hace varias cosas a la vez usa hilos: un juego reproduce el sonido en un hilo y ejecuta la lógica en otro; un servidor web atiende a cada cliente en su propio hilo.
- El precio: cada hilo tiene su propia pila, y la memoria para ella se reserva por separado. Miles de hilos cuestan mucho. Por eso los programas reales usan un grupo de hilos (la última idea de la lección).
Míralo
El primer hilo
La clase Thread es un hilo. A su constructor le pasas qué hacer: un objeto de la interfaz Runnable, que tiene un solo método, run(). Lo más corto es con una lambda:
Thread worker = new Thread(() -> download("abril.pdf", 200));worker.start();System.out.println("main no espera");worker.join();System.out.println("El informe está descargado");main no esperaDescargué abril.pdfEl informe está descargadostart()lanza el hilo nuevo y vuelve enseguida.mainsigue sin esperar, por eso «main no espera» se imprime primero.join()hace quemainespere a que el hilo termine.
download es el método de la primera tarjeta. Es bueno que capture InterruptedException por sí mismo: run() de Runnable no tiene throws, así que el código del hilo no puede dejar salir la excepción comprobada. join() también la lanza, por eso a partir de aquí main es public static void main(String[] args) throws InterruptedException.
Más precisamente, para curiosos
class ReportThread extends Thread { @Override public void run() { System.out.println("1. una subclase de Thread"); }}
class ReportJob implements Runnable { @Override public void run() { System.out.println("2. una clase que implementa Runnable"); }}
public static void main(String[] args) throws InterruptedException { Thread a = new ReportThread(); Thread b = new Thread(new ReportJob()); Thread c = new Thread(new Runnable() { @Override public void run() { System.out.println("3. una clase anónima"); } }); Thread d = new Thread(() -> System.out.println("4. una lambda")); for (Thread t : new Thread[] { a, b, c, d }) { t.start(); t.join(); }}1. una subclase de Thread2. una clase que implementa Runnable3. una clase anónima4. una lambdaThread es la forma más antigua. Es más flexible separar la tarea (Runnable) del hilo: así la misma tarea se puede dar a un grupo de hilos, y tu clase queda libre para heredar de otra clase.Míralo
Tres a la vez
long start = System.currentTimeMillis();Thread march = new Thread(() -> download("marzo.pdf", 600));Thread april = new Thread(() -> download("abril.pdf", 200));Thread may = new Thread(() -> download("mayo.pdf", 400));march.start();april.start();may.start();march.join();april.join();may.join();long ms = System.currentTimeMillis() - start;System.out.println("Listo en menos de un segundo: " + (ms < 1000));Descargué abril.pdfDescargué mayo.pdfDescargué marzo.pdfListo en menos de un segundo: trueLos hilos arrancan en el orden marzo, abril, mayo, pero terminan en el orden de las pausas: abril (0,2 s), mayo (0,4 s), marzo (0,6 s). La espera completa dura lo que la más larga, 0,6 segundos en lugar de 1,2.
El sistema operativo decide en qué orden llegan los hilos aprintln. Aquí las pausas son lo bastante distintas, así que el orden es siempre el mismo. Si fueran iguales, podría cambiar en cada ejecución.Pruébalo
start o run
¿Qué imprimirá el programa? (el segundo argumento de Thread es el nombre del hilo)
Thread t = new Thread(() -> System.out.println("Se ejecuta en: " + Thread.currentThread().getName()), "descarga");t.run();t.start();t.join();t.run() es una llamada normal a un método: la lambda se ejecuta enseguida, en el hilo de main. No hay ningún hilo nuevo. Solo t.start() lanza el hilo «descarga», que ejecuta la misma lambda. Escribir run() en lugar de start() es un error frecuente: el programa funciona, solo que nada va en paralelo.La idea
La vida de un hilo
getState() te dice en cuál está:Thread t = new Thread(() -> download("mayo.pdf", 500));System.out.println(t.getState());t.start();Thread.sleep(100);System.out.println(t.getState());t.join();System.out.println(t.getState());NEWTIMED_WAITINGDescargué mayo.pdfTERMINATED| Estado | Cuándo |
|---|---|
NEW |
creado, todavía no lanzado con start() |
RUNNABLE |
trabajando o listo para trabajar |
BLOCKED, WAITING, TIMED_WAITING |
esperando: un cerrojo, otro hilo (join) o un tiempo fijo (sleep) |
TERMINATED |
run() ha terminado |
Un hilo se lanza una sola vez. Un segundo start() sobre el mismo hilo lanza IllegalThreadStateException.
Más precisamente, para curiosos
Más métodos de Thread:
interrupt()le pide al hilo que se detenga. Si está durmiendo o esperando,sleep/joinlanzaInterruptedException. Por eso la capturamos endownload.getName(),threadId()(antesgetId()): el nombre y el número del hilo.Thread.currentThread()te da el hilo en el que se está ejecutando el código.Thread.yield()sugiere que el hilo puede ceder el procesador a otro. Rara vez hace falta.
Problema
Las visitas perdidas
increment() 100 000 veces:class Counter { private int count = 0;
public void increment() { count++; }
public int getCount() { return count; }}
public static void main(String[] args) throws InterruptedException { Counter visits = new Counter(); Runnable work = () -> { for (int i = 0; i < 100_000; i++) { visits.increment(); } }; Thread first = new Thread(work); Thread second = new Thread(work); first.start(); second.start(); first.join(); second.join(); System.out.println("Visitas: " + visits.getCount());}Esperas 200 000. Cinco ejecuciones:
Visitas: 150329Visitas: 138149Visitas: 168862Visitas: 165672Visitas: 157704Cada vez menos, y cada vez distinto. La razón es que count++ no es un solo paso, sino tres:
int value = count; // 1. leervalue = value + 1; // 2. sumar 1count = value; // 3. escribirclass Counter { private int count = 5;
public void increment() { count++; // leer, sumar 1, escribir }
public synchronized void safeIncrement() { count++; }
public synchronized int getCount() { return count; }}Paso a paso, en palabras
- count vale 5. Los hilos A y B llaman a increment() al mismo tiempo.
- A lee count: 5.
- B lee count antes de que A haya escrito: también 5.
- A suma 1 en su propia copia y escribe 6.
- B también escribe 6. Dos llamadas, y count vale 6, no 7: una de las sumas se ha perdido.
- Otra vez desde el principio, con synchronized: A toma el cerrojo, lee 5 y escribe 6.
- B llega al método y espera hasta que A suelte el cerrojo.
- B toma el cerrojo, lee 6 y escribe 7: no se pierde nada.
Cuando los dos hilos leen el mismo valor antes de que alguno haya escrito, una de las sumas se pierde. Esto se llama condición de carrera (race condition). Que ocurra o no depende de cuándo el sistema operativo cambia de hilo, por eso el resultado es distinto en cada ejecución.
La idea
synchronized: de uno en uno
synchronized delante del método hace justo eso:class Counter { private int count = 0;
public synchronized void increment() { count++; }
public synchronized int getCount() { return count; }}Visitas: 200000Cada objeto tiene un cerrojo (lock). El hilo lo toma antes de entrar en un método sincronizado y lo suelta al salir. El otro hilo que quiere el mismo objeto espera. Ahora el resultado es 200 000 en cada ejecución. getCount() también es sincronizado: así el hilo que lee ve el último valor escrito.
Si necesitas cerrar solo una parte del método, existe el bloque sincronizado con un objeto como cerrojo. Para un contador hay además una clase lista: AtomicInteger (de java.util.concurrent.atomic), cuyo incrementAndGet() hace los tres pasos de una vez, sin cerrojo:
class Counter { private int count = 0; private final Object lock = new Object();
public void increment() { synchronized (lock) { count++; } }
public int getCount() { synchronized (lock) { return count; } }}class Counter { private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }
public int getCount() { return count.get(); }}Visitas: 200000Más precisamente, para curiosos
wait()ynotifyAll(): un hilo dentro de un método sincronizado puede soltar el cerrojo y dormirse hasta que otro hilo lo despierte. Así la cocina espera un pedido, y la caja espera a que la cocina recoja el anterior (el problema del «productor y consumidor»):
class OrderBox { private String order;
public synchronized void put(String newOrder) throws InterruptedException { while (order != null) { wait(); } order = newOrder; notifyAll(); }
public synchronized String take() throws InterruptedException { while (order == null) { wait(); } String result = order; order = null; notifyAll(); return result; }}
public static void main(String[] args) throws InterruptedException { OrderBox box = new OrderBox(); Thread kitchen = new Thread(() -> { try { for (int i = 0; i < 3; i++) { System.out.println("La cocina recibió " + box.take()); } } catch (InterruptedException e) { return; } }); kitchen.start(); box.put("pizza"); box.put("ensalada"); box.put("sopa"); kitchen.join();}La cocina recibió pizzaLa cocina recibió ensaladaLa cocina recibió sopawait() está dentro de un while, no de un if: el hilo puede despertarse sin motivo, así que vuelve a comprobar la condición.
- Interbloqueo (deadlock): dos hilos se esperan mutuamente para siempre. La oficina tiene la impresora y espera el escáner, y la recepción tiene el escáner y espera la impresora (
pauseessleepcon la excepción capturada):
Object printer = new Object();Object scanner = new Object();
Thread office = new Thread(() -> { synchronized (printer) { pause(100); synchronized (scanner) { System.out.println("La oficina está copiando"); } }});Thread reception = new Thread(() -> { synchronized (scanner) { pause(100); synchronized (printer) { System.out.println("La recepción está copiando"); } }});El programa se cuelga y nunca imprime nada. La forma de evitarlo es que todos los hilos tomen los cerrojos en el mismo orden.
- Prioridades:
setPriorityde 1 a 10 sugiere lo importante que es el hilo. El sistema operativo puede ignorarlo, así que no dependas de las prioridades para que el programa sea correcto:
Thread report = new Thread(() -> download("anual.pdf", 100));report.setPriority(Thread.MIN_PRIORITY); // 1; NORM_PRIORITY es 5, MAX_PRIORITY es 10System.out.println(report.getName() + ", priority " + report.getPriority());Pruébalo
Dos cajeros automáticos
Elige la línea con el error.
withdraw no es sincronizado. Los dos hilos comprueban balance >= amount al mismo tiempo, los dos ven 100 y los dos retiran. La comprobación y el retiro tienen que ser un todo: public synchronized void withdraw(int amount) (y synchronized para getBalance). Entonces el segundo cajero espera al primero, ve 20 € y rechaza el retiro. El saldo se queda en 20 €.La idea
Grupo de hilos y Future
ExecutorService (de java.util.concurrent):static int sizeOf(String file, int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { return -1; } return file.length() * 100;}String[] files = { "marzo.pdf", "abril.pdf", "mayo.pdf", "junio.pdf" };ExecutorService pool = Executors.newFixedThreadPool(2);List<Future<Integer>> sizes = new ArrayList<>();for (String file : files) { sizes.add(pool.submit(() -> sizeOf(file, 300)));}for (int i = 0; i < files.length; i++) { System.out.println(files[i] + ": " + sizes.get(i).get() + " KB");}pool.shutdown();marzo.pdf: 900 KBabril.pdf: 900 KBmayo.pdf: 800 KBjunio.pdf: 900 KBExecutors.newFixedThreadPool(2)crea un grupo de 2 hilos. Las cuatro tareas se turnan en ellos.submitrecibe una tarea que devuelve un resultado y enseguida te da unFuture: una promesa de que el resultado llegará.get()espera a que la tarea termine y devuelve el resultado.shutdown()le dice al grupo que no acepte tareas nuevas y que detenga los hilos cuando terminen. Sin él, el programa no termina: los hilos del grupo esperan más trabajo.
Aquí main tiene throws Exception, porque get() también lanza ExecutionException si la tarea lanzó una excepción.
Más precisamente, para curiosos
execute. Entonces esperas a todas con awaitTermination después de shutdown:ExecutorService pool = Executors.newFixedThreadPool(2);for (int i = 1; i <= 5; i++) { String file = "informe-" + i + ".pdf"; pool.execute(() -> download(file, 100));}pool.shutdown();pool.awaitTermination(1, TimeUnit.MINUTES);System.out.println("Todos terminaron.");Descargué informe-2.pdfDescargué informe-1.pdfDescargué informe-3.pdfDescargué informe-4.pdfDescargué informe-5.pdfTodos terminaron.while (!executor.isTerminated()) { }. Eso también espera, pero hace girar el procesador en vano. awaitTermination duerme hasta que el grupo termina.Pruébalo
¿Qué resultado sale primero?
«archivo.zip» tarda 0,6 s en descargarse, y «nota.txt», 0,1 s. ¿Qué imprimirá el programa?
ExecutorService pool = Executors.newFixedThreadPool(2);Future<Integer> big = pool.submit(() -> sizeOf("archivo.zip", 600));Future<Integer> small = pool.submit(() -> sizeOf("nota.txt", 100));System.out.println("Grande: " + big.get());System.out.println("Pequeño: " + small.get());pool.shutdown();El archivo pequeño de verdad está listo primero. Pero big.get() es la primera línea, y espera al grande. Cuando este llega, small.get() devuelve enseguida, porque el resultado ya está ahí. El orden de la salida es el orden de las llamadas a get(), así que es el mismo en cada ejecución.
Ejercicio
Programas con hilos
Resuélvelos en tu editor. main puede tener throws InterruptedException (o throws Exception para Future.get()).
1. Suma en paralelo. El array sales tiene 1 000 000 de ventas, sales[i] = i % 100. Divídelo en 4 partes iguales y suma cada una en su propio hilo. Cada hilo escribe en su propio lugar de el array long[] partial, así que no necesitas sincronización. Espéralos con join y suma las partes:
Partes: [12375000, 12375000, 12375000, 12375000]Total: 495000002. Descarga con un grupo. Descarga seis informes (marzo.pdf, abril.pdf, mayo.pdf, junio.pdf, julio.pdf, agosto.pdf) con un grupo de 3 hilos. Usa sizeOf(file, 300) de la tarjeta del grupo. Imprime los tamaños en el orden de los archivos, el total, y si todo tardó menos de 1,5 segundos (uno tras otro serían 1,8 s):
marzo.pdf: 900 KBabril.pdf: 900 KBmayo.pdf: 800 KBjunio.pdf: 900 KBjulio.pdf: 900 KBagosto.pdf: 1000 KBTotal: 5400 KBMenos de 1,5 segundos: true3. Un contador sin pérdidas. Cuatro hilos, cada uno con 50 000 visitas, aumentan un contador compartido. Escribe el contador de forma que el resultado sea siempre 200 000. Pruébalo primero sin sincronización y mira cuánto se pierde, luego corrígelo una vez con synchronized y otra con AtomicInteger.
Visitas: 200000Más tareas
- Una prueba para el contador. Escribe una prueba de JUnit que lance 8 hilos con 10 000 aumentos cada uno y compruebe 80 000 con
assertEquals. Ejecútala varias veces contra el contador sin sincronizar. Un test que falla solo a veces es la señal típica de una condición de carrera. - El interbloqueo. Ejecuta el ejemplo de la impresora y el escáner de «Más precisamente» y mira cómo se cuelga. Corrígelo de forma que los dos hilos tomen primero la impresora.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas · Clases anidadas · Lambdas · Tests con JUnit
- Continúa en: Programación en red · Quicksort y el mal pivote · La pila de llamadas por dentro · De la recursión al bucle
- Ejercicios sobre el tema: Programas en el mundo real