1. Información general
En este artículo, aprenderemos sobre Future . Una interfaz que existe desde Java 1.5 y que puede ser bastante útil cuando se trabaja con llamadas asincrónicas y procesamiento concurrente.
2. Creando futuros
En pocas palabras, la clase Future representa un resultado futuro de un cálculo asincrónico, un resultado que eventualmente aparecerá en Future después de que se complete el procesamiento.
Veamos cómo escribir métodos que creen y devuelvan una instancia de Future .
Los métodos de ejecución prolongada son buenos candidatos para el procesamiento asincrónico y la interfaz Future . Esto nos permite ejecutar algún otro proceso mientras esperamos que se complete la tarea encapsulada en Future .
Algunos ejemplos de operaciones que aprovecharían la naturaleza asincrónica de Future son:
- Procesos computacionales intensivos (cálculos matemáticos y científicos)
- manipular grandes estructuras de datos (big data)
- llamadas a métodos remotos (descarga de archivos, eliminación de HTML, servicios web).
2.1. Implementación de futuros con FutureTask
Para nuestro ejemplo, vamos a crear una clase muy simple que calcula el cuadrado de un entero . Esto definitivamente no encaja en la categoría de métodos de "larga ejecución", pero vamos a poner una llamada Thread.sleep () para que dure 1 segundo en completarse:
public class SquareCalculator { private ExecutorService executor = Executors.newSingleThreadExecutor(); public Future calculate(Integer input) { return executor.submit(() -> { Thread.sleep(1000); return input * input; }); } }
El bit de código que realmente realiza el cálculo está contenido en el método call () , proporcionado como una expresión lambda. Como puede ver, no tiene nada de especial, excepto por la llamada sleep () mencionada anteriormente.
Se vuelve más interesante cuando dirigimos nuestra atención al uso de Callable y ExecutorService .
Invocable es una interfaz que representa una tarea que devuelve un resultado y tiene un solo método call () . Aquí, hemos creado una instancia de él usando una expresión lambda.
Crear una instancia de Callable no nos lleva a ningún lado, aún tenemos que pasar esta instancia a un ejecutor que se encargará de iniciar esa tarea en un nuevo hilo y nos devolverá el valioso objeto Future . Ahí es donde entra ExecutorService .
Hay algunas formas en que podemos obtener una instancia de ExecutorService , la mayoría de ellas son proporcionadas por los métodos de fábrica estáticos de la clase de utilidad Executors . En este ejemplo, usamos el newSingleThreadExecutor () básico , que nos da un ExecutorService capaz de manejar un solo hilo a la vez.
Una vez que tenemos un objeto ExecutorService , solo necesitamos llamar a submit () pasando nuestro Callable como argumento. submit () se encargará de iniciar la tarea y devolverá un objeto FutureTask , que es una implementación de la interfaz Future .
3. Consumir futuros
Hasta este punto, hemos aprendido a crear una instancia de Future .
En esta sección, aprenderemos cómo trabajar con esta instancia explorando todos los métodos que forman parte de la API de Future .
3.1. Usando isDone () y get () para obtener resultados
Ahora necesitamos llamar a calculate () y usar el Future devuelto para obtener el Integer resultante . Dos métodos de Future API nos ayudarán con esta tarea.
Future.isDone () nos dice si el ejecutor ha terminado de procesar la tarea. Si la tarea se completa, devolverá verdadero ; de lo contrario, devolverá falso .
El método que devuelve el resultado real del cálculo es Future.get () . Observe que este método bloquea la ejecución hasta que se completa la tarea, pero en nuestro ejemplo, esto no será un problema ya que primero verificaremos si la tarea se completa llamando a isDone () .
Al usar estos dos métodos, podemos ejecutar otro código mientras esperamos a que finalice la tarea principal:
Future future = new SquareCalculator().calculate(10); while(!future.isDone()) { System.out.println("Calculating..."); Thread.sleep(300); } Integer result = future.get();
En este ejemplo, escribimos un mensaje simple en la salida para que el usuario sepa que el programa está realizando el cálculo.
El método get () bloqueará la ejecución hasta que se complete la tarea. Pero no tenemos que preocuparnos por eso ya que nuestro ejemplo solo llega al punto donde se llama a get () después de asegurarnos de que la tarea está terminada. Entonces, en este escenario, future.get () siempre regresará inmediatamente.
Vale la pena mencionar que get () tiene una versión sobrecargada que toma un tiempo de espera y un TimeUnit como argumentos:
Integer result = future.get(500, TimeUnit.MILLISECONDS);
La diferencia entre get (long, TimeUnit) y get () , es que el primero lanzará una TimeoutException si la tarea no regresa antes del período de tiempo de espera especificado.
3.2. Cancelación de un futuro W ITH Cancelar ()
Supongamos que hemos activado una tarea pero, por alguna razón, ya no nos importa el resultado. Podemos usar Future.cancel (boolean) para decirle al ejecutor que detenga la operación e interrumpa su hilo subyacente:
Future future = new SquareCalculator().calculate(4); boolean canceled = future.cancel(true);
Nuestra instancia de Future del código anterior nunca completaría su operación. De hecho, si intentamos llamar a get () desde esa instancia, después de la llamada a cancel () , el resultado sería una CancellationException . Future.isCancelled () nos dirá si un Future ya fue cancelado. Esto puede resultar muy útil para evitar obtener una CancellationException .
Es posible que una llamada a cancel () falle. En ese caso, su valor devuelto será falso . Tenga en cuenta que cancel () toma un valor booleano como argumento; esto controla si el hilo que ejecuta esta tarea debe interrumpirse o no.
4. Más multihilo con rosca piscinas
Nuestro ExecutorService actual es de un solo hilo ya que se obtuvo con Executors.newSingleThreadExecutor. Para resaltar este "hilo único", activemos dos cálculos simultáneamente:
SquareCalculator squareCalculator = new SquareCalculator(); Future future1 = squareCalculator.calculate(10); Future future2 = squareCalculator.calculate(100); while (!(future1.isDone() && future2.isDone())) { System.out.println( String.format( "future1 is %s and future2 is %s", future1.isDone() ? "done" : "not done", future2.isDone() ? "done" : "not done" ) ); Thread.sleep(300); } Integer result1 = future1.get(); Integer result2 = future2.get(); System.out.println(result1 + " and " + result2); squareCalculator.shutdown();
Ahora analicemos la salida de este código:
calculating square for: 10 future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done calculating square for: 100 future1 is done and future2 is not done future1 is done and future2 is not done future1 is done and future2 is not done 100 and 10000
It is clear that the process is not parallel. Notice how the second task only starts once the first task is completed, making the whole process take around 2 seconds to finish.
To make our program really multi-threaded we should use a different flavor of ExecutorService. Let's see how the behavior of our example changes if we use a thread pool, provided by the factory method Executors.newFixedThreadPool():
public class SquareCalculator { private ExecutorService executor = Executors.newFixedThreadPool(2); //... }
With a simple change in our SquareCalculator class now we have an executor which is able to use 2 simultaneous threads.
If we run the exact same client code again, we'll get the following output:
calculating square for: 10 calculating square for: 100 future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done 100 and 10000
This is looking much better now. Notice how the 2 tasks start and finish running simultaneously, and the whole process takes around 1 second to complete.
There are other factory methods that can be used to create thread pools, like Executors.newCachedThreadPool() that reuses previously used Threads when they are available, and Executors.newScheduledThreadPool() which schedules commands to run after a given delay.
For more information about ExecutorService, read our article dedicated to the subject.
5. Overview of ForkJoinTask
ForkJoinTask is an abstract class which implements Future and is capable of running a large number of tasks hosted by a small number of actual threads in ForkJoinPool.
In this section, we are going to quickly cover the main characteristics of ForkJoinPool. For a comprehensive guide about the topic, check our Guide to the Fork/Join Framework in Java.
Then the main characteristic of a ForkJoinTask is that it usually will spawn new subtasks as part of the work required to complete its main task. It generates new tasks by calling fork() and it gathers all results with join(), thus the name of the class.
There are two abstract classes that implement ForkJoinTask: RecursiveTask which returns a value upon completion, and RecursiveAction which doesn't return anything. As the names imply, those classes are to be used for recursive tasks, like for example file-system navigation or complex mathematical computation.
Let's expand our previous example to create a class that, given an Integer, will calculate the sum squares for all its factorial elements. So, for instance, if we pass the number 4 to our calculator, we should get the result from the sum of 4² + 3² + 2² + 1² which is 30.
First of all, we need to create a concrete implementation of RecursiveTask and implement its compute() method. This is where we'll write our business logic:
public class FactorialSquareCalculator extends RecursiveTask { private Integer n; public FactorialSquareCalculator(Integer n) { this.n = n; } @Override protected Integer compute() { if (n <= 1) { return n; } FactorialSquareCalculator calculator = new FactorialSquareCalculator(n - 1); calculator.fork(); return n * n + calculator.join(); } }
Notice how we achieve recursiveness by creating a new instance of FactorialSquareCalculator within compute(). By calling fork(), a non-blocking method, we ask ForkJoinPool to initiate the execution of this subtask.
The join() method will return the result from that calculation, to which we add the square of the number we are currently visiting.
Now we just need to create a ForkJoinPool to handle the execution and thread management:
ForkJoinPool forkJoinPool = new ForkJoinPool(); FactorialSquareCalculator calculator = new FactorialSquareCalculator(10); forkJoinPool.execute(calculator);
6. Conclusion
In this article, we had a comprehensive view of the Future interface, visiting all its methods. We've also learned how to leverage the power of thread pools to trigger multiple parallel operations. The main methods from the ForkJoinTask class, fork() and join() were briefly covered as well.
We have many other great articles on parallel and asynchronous operations in Java. Here are three of them that are closely related to the Future interface (some of them are already mentioned in the article):
- Guide to CompletableFuture – an implementation of Future with many extra features introduced in Java 8
- Guide to the Fork / Join Framework en Java - más sobre ForkJoinTask que cubrimos en la sección 5
- Guía para Java ExecutorService - dedicada a la interfaz ExecutorService
Consulte el código fuente utilizado en este artículo en nuestro repositorio de GitHub.