Lea la primera parte de nuestra serie de blogs dedicada a la concurrencia en Java. En el siguiente artículo vamos a echar un vistazo más de cerca a las diferencias entre hilos y procesos, pools de hilos, ejecutores y ¡mucho más!
En general, el enfoque de programación convencional es secuencial. Todo en un programa ocurre paso a paso.
Pero, de hecho, el paralelo es cómo funciona el mundo entero: es la capacidad de ejecutar más de una tarea simultáneamente.
Hilo conductor vs. proceso
Para debatir temas avanzados como concurrencia en Java o multihilo, tenemos que establecer algunas definiciones comunes para estar seguros de que estamos en la misma página.
Empecemos por lo básico. En el mundo no secuencial, tenemos dos tipos de representantes de concurrencia: procesos y
hilos. Un proceso es una instancia del programa en ejecución. Normalmente, está aislado de otros procesos.
El sistema operativo se encarga de asignar recursos a cada proceso. Además, actúa como un conductor que
los programa y los controla.
Un hilo es una especie de proceso pero a un nivel inferior, por lo que también se conoce como hilo ligero. Múltiples hilos pueden ejecutarse en un
proceso. Aquí el programa actúa como planificador y controlador de hilos. De esta forma los programas individuales parecen hacer
múltiples tareas al mismo tiempo.
La diferencia fundamental entre hilos y procesos es el nivel de aislamiento. El proceso tiene su propio conjunto de
recursos, mientras que el hilo comparte datos con otros hilos. Puede parecer un enfoque propenso a errores y, de hecho, lo es. En
Ahora, no vamos a centrarnos en eso, ya que está fuera del alcance de este artículo.
Procesos, hilos... Vale... Pero, ¿qué es exactamente la concurrencia? Concurrencia significa que se pueden ejecutar varias tareas al mismo tiempo.
tiempo. No significa que esas tareas tengan que ejecutarse simultáneamente: eso es el paralelismo. Concurrenc en Javay tampoco
requieren disponer de varias CPU o incluso de varios núcleos. Se puede conseguir en un entorno de un solo núcleo aprovechando
cambio de contexto.
Un término relacionado con la concurrencia es multithreading. Se trata de una característica de los programas que les permite ejecutar varias tareas a la vez. No todos los programas utilizan este enfoque, pero los que lo hacen pueden denominarse multihilo.
Ya casi estamos listos, sólo falta una definición. Asincronía significa que un programa realiza operaciones no bloqueantes.
Inicia una tarea y luego sigue con otras cosas mientras espera la respuesta. Cuando recibe la respuesta, puede reaccionar ante ella.
Todo ese jazz
Por defecto, cada Aplicación Java se ejecuta en un proceso. En ese proceso, hay un hilo relacionado con el main()
método de
una aplicación. Sin embargo, como se ha mencionado, es posible aprovechar los mecanismos de múltiples hilos dentro de una
programa.
Ejecutable
Hilo
es un Java en la que ocurre la magia. Esta es la representación del objeto del hilo antes mencionado. Para
crear su propio hilo, puede ampliar el Hilo
clase. Sin embargo, no es un enfoque recomendable. Hilos
como mecanismo que ejecuta la tarea. Las tareas son piezas de código que queremos ejecutar en modo concurrente. Podemos definirlos utilizando la función Ejecutable
interfaz.
Pero basta de teoría, pongamos nuestro código donde está nuestra boca.
Problema
Supongamos que tenemos un par de matrices de números. Para cada matriz, queremos saber la suma de los números de una matriz. Supongamos
Imaginemos que hay muchas matrices de este tipo y que cada una de ellas es relativamente grande. En tales condiciones, queremos hacer uso de la concurrencia y sumar cada matriz como una tarea independiente.
int[] a1 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int[] a2 = {10, 10, 10, 10, 10, 10, 10, 10};
int[] a3 = {3, 4, 3, 4, 3, 4, 2, 1, 3, 7};
Tarea ejecutable1 = () -> {
int suma = Arrays.stream(a1).suma();
System.out.println("1. La suma es: " + suma);
};
Tarea ejecutable2 = () -> {
int suma = Arrays.stream(a2).suma();
System.out.println("2. La suma es: " + suma);
};
Runnable tarea3 = () -> {
int suma = Arrays.stream(a3).suma();
System.out.println("3. La suma es: " + suma);
};
nuevo Thread(tarea1).start();
nuevo subproceso(tarea2).start();
new Thread(task3).start();
Como puede verse en el código anterior Ejecutable
es una interfaz funcional. Contiene un único método abstracto ejecutar()
sin argumentos. La dirección Ejecutable
debe ser implementada por cualquier clase cuyas instancias estén destinadas a ser
ejecutado por un hilo.
Una vez definida una tarea, puede crear un subproceso para ejecutarla. Esto puede hacerse mediante nuevo Hilo()
constructor que
toma Ejecutable
como argumento.
El último paso es iniciar()
un hilo recién creado. En la API también existen ejecutar()
métodos en Ejecutable
y en
Hilo
. Sin embargo, esa no es una forma de aprovechar la concurrencia en Java. Una llamada directa a cada uno de estos métodos resulta en
ejecutando la tarea en el mismo hilo que el main()
se ejecuta el método.
Thread pools y Ejecutores
Cuando hay muchas tareas, crear un hilo separado para cada una no es una buena idea. Crear un Hilo
es un
operación pesada y es mucho mejor reutilizar los hilos existentes que crear otros nuevos.
Cuando un programa crea muchos hilos de corta duración es mejor utilizar un pool de hilos. El pool de hilos contiene un número de
hilos listos para ejecutarse pero actualmente no activos. Dando un Ejecutable
al pool hace que uno de los hilos llame a la función
ejecutar()
método de dado Ejecutable
. Tras completar una tarea, el hilo sigue existiendo y se encuentra en estado inactivo.
De acuerdo, ya lo has entendido: prefiere los thread pools a la creación manual. Pero, ¿cómo se pueden utilizar los thread pools? El Ejecutores
tiene una serie de métodos de fábrica estáticos para construir pools de hilos. Por ejemplo newCachedThredPool()
crea
un pool en el que se crean nuevos hilos según sea necesario y los hilos inactivos se mantienen durante 60 segundos. Por el contrario,
newFixedThreadPool()
contiene un conjunto fijo de hilos, en el que los hilos ociosos se mantienen indefinidamente.
Veamos cómo podría funcionar en nuestro ejemplo. Ahora no necesitamos crear hilos manualmente. En su lugar, tenemos que crear
Servicio de ejecutor
que proporciona un pool de hilos. A continuación, podemos asignarle tareas. El último paso es cerrar el hilo
para evitar pérdidas de memoria. El resto del código anterior permanece igual.
ExecutorService executor = Executors.newCachedThreadPool();
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
ejecutor.shutdown();
Llamable
Ejecutable
parece una forma ingeniosa de crear tareas concurrentes, pero tiene un defecto importante. No puede devolver ningún
valor. Es más, no podemos determinar si una tarea está terminada o no. Tampoco sabemos si se ha completado
normal o excepcional. La solución a esos males es Llamable
.
Llamable
es similar a Ejecutable
en cierto modo también envuelve tareas asíncronas. La principal diferencia es que es capaz de
devuelven un valor. El valor devuelto puede ser de cualquier tipo (no primitivo), ya que la función Llamable
es un tipo parametrizado.
Llamable
es una interfaz funcional que tiene llamar()
que puede lanzar un Excepción
.
Ahora vamos a ver cómo podemos aprovechar Llamable
en nuestro problema de matrices.
int[] a1 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int[] a2 = {10, 10, 10, 10, 10, 10, 10, 10};
int[] a3 = {3, 4, 3, 4, 3, 4, 2, 1, 3, 7};
Callable task1 = () -> Arrays.stream(a1).sum();
Callable task2 = () -> Arrays.stream(a2).sum();
Callable task3 = () -> Arrays.stream(a3).sum();
ExecutorService executor = Executors.newCachedThreadPool();
Futuro future1 = executor.submit(task1);
Futuro future2 = executor.submit(task2);
Future future3 = executor.submit(task3);
System.out.println("1. La suma es: " + future1.get());
System.out.println("2. La suma es: " + future2.get());
System.out.println("3. La suma es: " + future3.get());
ejecutor.shutdown();
Bien, podemos ver cómo Llamable
y se envía a Servicio de ejecutor
. Pero, ¿qué diablos es Futuro
?
Futuro
actúa como puente entre hilos. La suma de cada matriz se produce en un hilo separado y necesitamos una forma de
devolver esos resultados a main()
.
Para recuperar el resultado de Futuro
necesitamos llamar a get()
método. Aquí puede ocurrir una de dos cosas. En primer lugar, el
resultado del cálculo realizado por Llamable
está disponible. Entonces lo obtenemos inmediatamente. En segundo lugar, el resultado no es
listo todavía. En ese caso get()
se bloqueará hasta que el resultado esté disponible.
Futuro computable
El problema con Futuro
es que funciona en el "paradigma push". Cuando se utiliza Futuro
tienes que ser como un jefe que
pregunta constantemente: '¿Está hecha la tarea? ¿Está lista?" hasta que ofrezca un resultado. Actuar bajo una presión constante es
caro. Mucho mejor sería pedir Futuro
qué hacer cuando esté preparado para su tarea. Desgraciadamente,
Futuro
no puede hacerlo sino Futuro computable
puede.
Futuro computable
funciona en el "paradigma pull". Podemos decirle qué hacer con el resultado cuando haya completado sus tareas. En
es un ejemplo de enfoque asíncrono.
Futuro computable
funciona perfectamente con Ejecutable
pero no con Llamable
. En su lugar, es posible suministrar una tarea a
Futuro computable
en forma de Proveedor
.
Veamos cómo se relaciona lo anterior con nuestro problema.
int[] a1 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int[] a2 = {10, 10, 10, 10, 10, 10, 10, 10};
int[] a3 = {3, 4, 3, 4, 3, 4, 2, 1, 3, 7};
CompletableFuture.supplyAsync(() -> Arrays.stream(a1).sum())
.thenAccept(System.out::println);
CompletableFuture.supplyAsync(() -> Arrays.stream(a2).sum())
.thenAccept(System.out::println);
CompletableFuture.supplyAsync(() -> Arrays.stream(a3).sum())
.thenAccept(System.out::println);
Lo primero que llama la atención es lo corta que es esta solución. Además, también tiene un aspecto limpio y ordenado.
Tarea a CompletableFuture
puede ser proporcionada por supplyAsync()
que toma Proveedor
o por runAsync()
que
toma Ejecutable
. Una llamada de retorno - un fragmento de código que debe ejecutarse al finalizar la tarea - se define mediante thenAccept()
método.
Conclusiones
Java proporciona una gran cantidad de enfoques diferentes para la concurrencia. En este artículo, apenas hemos tocado el tema.
No obstante, hemos cubierto los aspectos básicos de Hilo
, Ejecutable
, Llamable
y CallableFuture
que plantea una buena cuestión
para seguir investigando el tema.