Guía de la interfaz del futuro en Java

La interfaz Future representa un resultado que finalmente se devolverá. En este artículo, usaremos las interfaces Future y Callable para crear una aplicación Java simultánea.

Introducción

En este artículo, revisaremos la funcionalidad de la interfaz Future como una de las construcciones de concurrencia de Java. También veremos varias formas de crear una tarea asíncrona, porque un ‘Futuro’ es solo una forma de representar el resultado de un cálculo asíncrono.

El paquete java.util.concurrent se agregó a Java 5. Este paquete contiene un conjunto de clases que facilita el desarrollo de aplicaciones concurrentes en Java. En general, la simultaneidad es un tema bastante complejo y puede parecer un poco desalentador.

A Java Future is very similar to a JavaScript Promesa.

Motivación

Una tarea común para el código asíncrono es proporcionar una interfaz de usuario receptiva en una aplicación que ejecuta una operación costosa de cálculo o lectura/escritura de datos.

Tener una pantalla congelada o ninguna indicación de que el proceso está en progreso da como resultado una experiencia de usuario bastante mala. Lo mismo ocurre con las aplicaciones que son completamente lentas:

La minimización del tiempo de inactividad mediante el cambio de tareas puede mejorar drásticamente el rendimiento de una aplicación, aunque depende del tipo de operaciones involucradas.

La obtención de un recurso web puede retrasarse o ser lenta en general. La lectura de un archivo enorme puede ser lenta. Esperar un resultado de microservicios en cascada puede ser lento. En las arquitecturas sincrónicas, la aplicación que espera el resultado espera a que se completen todos estos procesos antes de continuar.

En arquitecturas asíncronas, continúa haciendo cosas que puede sin el resultado devuelto mientras tanto.

Implementación

Antes de comenzar con los ejemplos, veamos las interfaces y clases básicas del paquete java.util.concurrent que vamos a usar.

La interfaz Java Callable es una versión mejorada de Runnable. Representa una tarea que devuelve un resultado y puede generar una excepción. Para implementar Callable, debe implementar el método call() sin argumentos.

Para enviar nuestro Callable para ejecución concurrente, usaremos el ExecutorService. La forma más fácil de crear un ExecutorService es usar uno de los métodos de fábrica de la clase Executors. Después de la creación de la tarea asincrónica, el ejecutor devuelve un objeto “Futuro” de Java.

Si desea leer más sobre El Marco Ejecutor, tenemos un artículo detallado al respecto.

La interfaz del futuro

La interfaz Future es una interfaz que representa un resultado que eventualmente se devolverá en el futuro. Podemos verificar si un ‘Futuro’ recibió el resultado, si está esperando un resultado o si falló antes de que intentemos acceder a él, lo cual cubriremos en las próximas secciones.

Primero echemos un vistazo a la definición de la interfaz:

1
2
3
4
5
6
7
public interface Future<V> {
    V get() throws InterruptedException, ExecutionException;
    V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
    boolean isCancelled();
    boolean isDone();
    boolean cancel(boolean mayInterruptIfRunning)
}

El método get() recupera el resultado. Si el resultado aún no se ha devuelto a una instancia Future, el método get() esperará a que se devuelva el resultado. Es crucial tener en cuenta que get() bloqueará su aplicación si la llama antes de que se haya devuelto el resultado.

También puede especificar un tiempo de espera después del cual el método get() generará una excepción si el resultado aún no se ha devuelto, lo que evitará grandes cuellos de botella.

El método cancel() intenta cancelar la ejecución de la tarea actual. El intento fallará si la tarea ya se completó, se canceló o no se pudo cancelar por otras razones.

Los métodos isDone() y isCancelled() están dedicados a averiguar el estado actual de una tarea Callable asociada. Por lo general, los usará como condicionales para verificar si tiene sentido usar los métodos get() o cancel().

La interfaz invocable

Vamos a crear una tarea que tarde algún tiempo en completarse. Definiremos un DataReader que implemente Callable:

1
2
3
4
5
6
7
8
public class DataReader implements Callable {
    @Override
    public String call() throws Exception {
        System.out.println("Reading data...");
        TimeUnit.SECONDS.sleep(5);
        return "Data reading finished";
    }
}

Para simular una operación costosa, usamos TimeUnit.SECONDS.sleep(). Llama a Thread.sleep(), pero es un poco más limpio durante períodos de tiempo más largos.

De manera similar, tengamos una clase de procesador que procese algunos otros datos al mismo tiempo:

1
2
3
4
5
6
7
8
public class DataProcessor implements Callable {
    @Override
    public String call() throws Exception {
        System.out.println("Processing data...");
        TimeUnit.SECONDS.sleep(5);
        return "Data is processed";
    }
}

Ambos métodos tardan 5 segundos cada uno en ejecutarse. Si tuviéramos que llamar uno tras otro sincrónicamente, la lectura y el procesamiento tomarían ~10s.

Ejecutar tareas futuras

Ahora, para llamar a estos métodos desde otro, crearemos una instancia de un ejecutor y le enviaremos nuestro DataReader y DataProcessor. El ejecutor devuelve un ‘Futuro’, por lo que empaquetaremos el resultado en un objeto envuelto en un ‘Futuro’:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static void main(String[] args) throws InterruptedException, ExecutionException {
    ExecutorService executorService = Executors.newFixedThreadPool(2);

    Future<String> dataReadFuture = executorService.submit(new DataReader());
    Future<String> dataProcessFuture = executorService.submit(new DataProcessor());

    while (!dataReadFuture.isDone() && !dataProcessFuture.isDone()) {
            System.out.println("Reading and processing not yet finished.");
            // Do some other things that don't depend on these two processes
            // Simulating another task
            TimeUnit.SECONDS.sleep(1);
        }
    System.out.println(dataReadFuture.get());
    System.out.println(dataProcessFuture.get());
}

Aquí, hemos creado un ejecutor con dos subprocesos en el grupo ya que tenemos dos tareas. Puede usar newSingularThreadExecutor() para crear uno solo si solo tiene una tarea simultánea para ejecutar.

Si enviamos más de estas dos tareas a este grupo, las tareas adicionales esperarán en la cola hasta que surja un lugar libre.

Ejecutar este fragmento de código producirá:

1
2
3
4
5
6
7
8
9
Reading and processing not yet finished.
Reading data...
Processing data...
Reading and processing not yet finished.
Reading and processing not yet finished.
Reading and processing not yet finished.
Reading and processing not yet finished.
Data reading finished
Data is processed

El tiempo de ejecución total será ~5 s, no ~10 s, ya que ambos se estaban ejecutando al mismo tiempo. Tan pronto como hayamos enviado las clases al ejecutor, se habrá llamado a sus métodos call(). Incluso tener un Thread.sleep() de un segundo cinco veces no afecta mucho el rendimiento ya que se ejecuta en su propio hilo.

Es importante tener en cuenta que el código no se ejecutó más rápido, simplemente no esperó redundantemente por algo que no tenía que hacer y realizó otras tareas mientras tanto.

Lo importante aquí es el uso del método isDone(). Si no tuviéramos la verificación, no habría ninguna garantía de que los resultados estuvieran empaquetados en Futures antes de que accediéramos a ellos. Si no lo fueran, los métodos get() bloquearían la aplicación hasta que tuvieran resultados.

Tiempo de espera futuro

Si no hubo controles para la finalización de tareas futuras:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public static void main(String[] args) throws InterruptedException, ExecutionException {
    ExecutorService executorService = Executors.newFixedThreadPool(2);

    Future<String> dataReadFuture = executorService.submit(new DataReader());
    Future<String> dataProcessFuture = executorService.submit(new DataProcessor());

    System.out.println("Doing another task in anticipation of the results.");
    // Simulating another task
    TimeUnit.SECONDS.sleep(1);
    System.out.println(dataReadFuture.get());
    System.out.println(dataProcessFuture.get());
}

El tiempo de ejecución seguiría siendo ~5s, sin embargo, nos enfrentaríamos a un gran problema. Se tarda 1 segundo en completar una tarea adicional y 5 en completar las otras dos.

¿Suena como la última vez?

4 de 5 segundos en este programa son de bloqueo. Hemos intentado obtener el resultado del futuro antes de que se devolviera y hemos bloqueado 4 segundos hasta que regresen.

Establezcamos una restricción para obtener estos métodos. Si no regresan dentro de un cierto período de tiempo esperado, generarán excepciones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
String dataReadResult = null;
String dataProcessResult = null;

try {
    dataReadResult = dataReadFuture.get(4, TimeUnit.SECONDS);
    dataProcessResult = dataProcessFuture.get(0, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
    e.printStackTrace();
}

System.out.println(dataReadResult);
System.out.println(dataProcessResult);

Ambos toman 5s cada uno. Con una espera inicial de un segundo de la otra tarea, dataReadFuture se devuelve dentro de 4 segundos adicionales. El resultado del proceso de datos se devuelve al mismo tiempo y este código funciona bien.

Si le dimos un tiempo de ejecución poco realista (menos de 5 s en total), seríamos recibidos con:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Reading data...
Doing another task in anticipation of the results.
Processing data...
java.util.concurrent.TimeoutException
    at java.util.concurrent.FutureTask.get(FutureTask.java:205)
    at FutureTutorial.Main.main(Main.java:21)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:497)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)
null
null

Por supuesto, no imprimiríamos simplemente el seguimiento de la pila en una aplicación real, sino que redirigiríamos la lógica para manejar el estado excepcional.

Cancelación de futuros

En algunos casos, es posible que desee cancelar un futuro. Por ejemplo, si no recibe un resultado dentro de n segundos, puede decidir no usar el resultado en absoluto. En ese caso, no hay necesidad de tener un subproceso aún ejecutándose y empaquetando el resultado ya que no lo usará.

De esta manera, libera un espacio para otra tarea en la cola o simplemente libera los recursos asignados a una operación costosa innecesaria:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
boolean cancelled = false;
if (dataReadFuture.isDone()) {
    try {
        dataReadResult = dataReadFuture.get();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }
} else {
cancelled = dataReadFuture.cancel(true);
}
if (!cancelled) {
    System.out.println(dataReadResult);
} else {
    System.out.println("Task was cancelled.");
}

Si la tarea se ha realizado, obtenemos el resultado y lo empaquetamos en nuestra cadena de resultados. De lo contrario, lo cancelamos(). Si no fue cancelado, imprimimos el valor del String resultante. Por el contrario, notificamos al usuario que la tarea fue cancelada en caso contrario.

Lo que vale la pena señalar es que el método cancel() acepta un parámetro booleano. Este booleano define si permitimos que el método cancel() interrumpa la ejecución de la tarea o no. Si lo configuramos como falso, existe la posibilidad de que la tarea no se cancele.

También tenemos que asignar el valor de retorno del método cancel() a un booleano. El valor devuelto indica si el método se ejecutó correctamente o no. Si no puede cancelar una tarea, el booleano se establecerá como falso.

Ejecutar este código producirá:

1
2
3
Reading data...
Processing data...
Task was cancelled.

Y si intentamos obtener los datos de una tarea cancelada, se genera una CancellationException:

1
2
3
if (dataReadFuture.cancel(true)) {
    dataReadFuture.get();
}

Ejecutar este código producirá:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Processing data...
Exception in thread "main" java.util.concurrent.CancellationException
    at java.util.concurrent.FutureTask.report(FutureTask.java:121)
    at java.util.concurrent.FutureTask.get(FutureTask.java:192)
    at FutureTutorial.Main.main(Main.java:34)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:497)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

Limitaciones del futuro

Java Future fue un buen paso hacia la programación asíncrona. Pero, como ya puede haber avisos, es rudimentario:

  • Futures no se puede completar explícitamente (estableciendo su valor y estado).
  • No tiene un mecanismo para crear etapas de procesamiento que se encadenen entre sí.
  • No existe un mecanismo para ejecutar Futures en paralelo y luego combinar sus resultados.
  • El ‘Futuro’ no tiene construcciones de manejo de excepciones.

Afortunadamente, Java proporciona implementaciones futuras concretas que brindan estas características (CompletableFuture, CountedCompleter, ForkJoinTask, FutureTask, etc.).

Conclusión

Cuando necesite esperar a que se complete otro proceso sin bloquear, puede ser útil ir de forma asíncrona. Este enfoque ayuda a mejorar la usabilidad y el rendimiento de las aplicaciones.

Java incluye construcciones específicas para la concurrencia. El básico es Java Future que representa el resultado de la computación asincrónica y proporciona métodos básicos para manejar el proceso.