Guía para la interfaz del futuro en Java

G

Introducción

En este artículo, repasaremos la funcionalidad de la interfaz Future como una de las construcciones de concurrencia de Java. También veremos varias formas de crear una tarea asincrónica, porque un Future es solo una forma de representar el resultado de un cálculo asincrónico.

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

Una java Future es muy similar a JavaScript Promise.

Motivación

Una tarea común para el código asincrónico es proporcionar una interfaz de usuario receptiva en una aplicación que ejecuta un cálculo costoso o una operación de lectura / escritura de datos.

Tener una pantalla congelada o ninguna indicación de que el proceso está en progreso resulta en 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 demorarse o ser lenta en general. La lectura de un archivo enorme puede resultar lenta. Esperar un resultado de microservicios en cascada puede ser lento. En arquitecturas síncronas, la aplicación que espera el resultado espera a que se completen todos estos procesos antes de continuar.

En arquitecturas asincrónicas, continúa haciendo las cosas que puede sin el resultado devuelto mientras tanto.

Implementación

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

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

Para enviar nuestro Callable para la ejecución concurrente, usaremos el ExecutorService. La forma más sencilla de crear un ExecutorService es utilizar uno de los métodos de fábrica del Executors clase. Después de la creación de la tarea asincrónica, Java Future el objeto es devuelto por el ejecutor.

Si desea leer más sobre The Executor Framework, tenemos un artículo detallado sobre eso.

La interfaz del futuro

los Future interfaz es una interfaz que representa un resultado que eventualmente se devolverá en el futuro. Podemos comprobar si un Future ha recibido el resultado, si está esperando un resultado o si ha fallado antes de intentar acceder a él, lo cual cubriremos en las próximas secciones.

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

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)
}

los get() método recupera el resultado. Si el resultado aún no se ha devuelto a un Future ejemplo, el 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 timeout después de lo cual el get() El método lanzará una excepción si el resultado aún no se ha devuelto, lo que evitará grandes cuellos de botella.

los cancel() El método 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 debido a otras razones.

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

La interfaz invocable

Creemos una tarea que tarde un poco en completarse. Definiremos un DataReader ese implements Callable:

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 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:

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 en ejecutarse. Si tuviéramos que llamar uno tras otro sincrónicamente, la lectura y el procesamiento tomarían ~ 10 segundos.

Ejecución de tareas futuras

Ahora, para llamar a estos métodos desde otro, crearemos una instancia de un ejecutor y enviaremos nuestro DataReader y DataProcessor lo. El ejecutor devuelve un Future, así que empaquetaremos el resultado en un Future-objeto envuelto:

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 hilos en el grupo ya que tenemos dos tareas. Puedes usar el newSingularThreadExecutor() para crear una sola 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 aparezca un lugar libre.

Ejecutar este fragmento de código producirá:

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 segundos, no ~ 10 segundos, ya que ambos se ejecutaban simultáneamente al mismo tiempo. Tan pronto como hayamos enviado las clases al albacea, su call() Se han llamado métodos. Incluso teniendo un Thread.sleep() de un segundo cinco veces no afecta mucho al 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ó de manera redundante por algo que no tenía que hacer y realizó otras tareas mientras tanto.

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

Tiempo de espera futuro

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

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 aún sería de ~ 5 segundos, 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 cada 5 segundos en este programa están bloqueando. Intentamos obtener el resultado del futuro antes de que se devolviera y bloqueamos 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, lanzarán excepciones:

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 5 chelines cada uno. Con una espera inicial de un segundo de la otra tarea, el dataReadFuture se devuelve en 4 segundos adicionales. El resultado del proceso de datos se devuelve al mismo tiempo y este código se ejecuta bien.

Si le dáramos un tiempo poco realista para ejecutarse (menos de 5 segundos en total), seríamos recibidos con:

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, es posible que decida no utilizar el resultado en absoluto. En ese caso, no es necesario que un hilo aún se ejecute y empaque 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:

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, nosotros cancel() eso. Si no fuera cancelled, imprimimos el valor de la cadena resultante. Por el contrario, notificamos al usuario que la tarea se canceló de otro modo.

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

Tenemos que asignar el valor de retorno del cancel() método a un boolean también. El valor devuelto significa si el método se ejecutó correctamente o no. Si no puede cancelar una tarea, el boolean se establecerá como false.

Ejecutar este código producirá:

Reading data...
Processing data...
Task was cancelled.

Y si intentamos obtener los datos de una tarea cancelada, CancellationException es generado:

if (dataReadFuture.cancel(true)) {
    dataReadFuture.get();
}

Ejecutar este código producirá:

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

El java Future fue un buen paso hacia la programación asincrónica. Pero, como ya puede tener 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 estén encadenadas.
  • No hay mecanismo para ejecutar Futures en paralelo y después para combinar sus resultados.
  • los Future 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 volverse asincrónico. 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 el Java Future que representa el resultado del cálculo asincrónico y proporciona métodos básicos para manejar el proceso.

About the author

Ramiro de la Vega

Bienvenido a Pharos.sh

Soy Ramiro de la Vega, Estadounidense con raíces Españolas. Empecé a programar hace casi 20 años cuando era muy jovencito.

Espero que en mi web encuentres la inspiración y ayuda que necesitas para adentrarte en el fantástico mundo de la programación y conseguir tus objetivos por difíciles que sean.

Add comment

Sobre mi

Últimos Post

Etiquetas

Esta web utiliza cookies propias para su correcto funcionamiento. Al hacer clic en el botón Aceptar, aceptas el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Más información
Privacidad