Spring: métodos asíncronos con @Async, Future y TaskExecutor

Última actualización: 02/12/2022
logo spring

Spring Framework permite la ejecución asíncrona de métodos públicos de los beans. Se ejecutarán en su propio hilo o hebra sin bloquear al código que los llama. Si utilizas esta capacidad donde resulte adecuada, impulsarás el rendimiento de tu código sin esfuerzo alguno. Tal es el poder de la anotación @Async que veremos en este tutorial. ¡Sácale partido! 🚀

>> Read this post in English here <<

Tabla de contenidos

  1. Haz (bien) varias cosas a la vez
  2. Tareas asíncronas con @Async
  3. La respuesta de las tareas asíncronos con Future
  4. Spring Data y @Async
  5. Creando TaskExecutor personalizados
  6. TaskExecutor en Spring Boot

Haz (bien) varias cosas a la vez

Los humanos somos nefastos realizando varias tareas al mismo tiempo. En cambio, es una de las cosas que mejor se les da a las computadoras y máquinas similares. A veces nos olvidamos de ello y pasamos por alto mejoras que pueden aumentar el rendimiento de nuestros programas.

Imagina que debes implementar un proceso de negocio que genere un informe: recopilas la información con dos consultas un tanto lentas a bases de datos, ejecutas una, luego la otra, y rellenas el informe. Si cada consulta tarda dos segundos, la tarea completa de obtención de los datos consume cuatro segundos.

La siguiente ilustración muestra la secuencia temporal de la generación del informe. El bloque amarillo representa la duración total; los azules reflejan la tardanza de las consultas.

Hasta aquí, todo normal (y obvio). ¿Y si las consultas fueran independientes? Es decir, no se necesita conocer el resultado de una para poder ejecutar la otra. Esto implica que puedes realizar cada consulta de manera separada en un hilo de ejecución propio. Las pides y aguardas a la recepción de los datos.

Veamos si este nuevo enfoque es mejor.

Hemos conseguido que la barra amarilla sea más corta porque ahora los dos segundos de cada consulta coinciden, más o menos, en el tiempo. La obtención de los datos tardará menos de cuatro segundos. No se reducirá a dos debido a que la gestión de la concurrencia consume tiempo, pero si lo hacemos bien y el caso de uso se presta a ello, esta demora nos compensa sobremanera.

Sin entrar en detalles, me gustaría aclarar que un hilo es un pequeño proceso que se ejecuta bajo el paraguas del proceso que representa a toda la aplicación, por lo que puede usar sus recursos. En Java, los hilos se modelan con la clase Thread y se trabajan con las clases e interfaces del paquete java.util.concurrent. Trataremos varias de ellas.

Otro escenario típico. Tienes un proceso de negocio con un paso que envía un correo electrónico, acción que suele tardar varios segundos. Imagina que esta acción no es importante y su resultado te da igual. Así que mejor solicitar el envío del correo y seguir con lo tuyo, sin esperar a que se efectúe (o falle).

¡Genial! Otra barra amarilla que acortamos.

En el resto del tutorial abordaremos cómo aplicar esta técnica en proyectos desarrollados con Spring. Al final, revisaremos algunas funcionalidades adicionales ofrecidas por Spring Boot.

Importante. La programación multihilo puede ser bastante compleja y dificulta la depuración y el testeo. Siempre debes comprobar con sumo cuidado si su aplicación resulta beneficiosa. En nuestro ejemplo, no tendría sentido paralelizar consultas que se mueven en el orden de los milisegundos. Y si las consultas son lentas, lo primero será revisarlas a fondo por si pudieras optimizarlas.

Tareas asíncronas con @Async

En Spring Framework las situaciones anteriores son fáciles de resolver: conseguir que un método público de un bean se ejecute en un nuevo hilo es tan sencillo como añadirle la anotación @Async.

@Service
public class AsyncService {
	
	@Async
	public void executeAsync() {
		System.out.println("Hello, I'm async!!!");
	}

A este tipo de métodos se les denomina tareas asíncronas.

@Async es aplicable a métodos de interfaces. También a clases e interfaces completas, de tal modo que todos los métodos (públicos) recibirán la anotación.

Tanto si usas Spring Boot como si no, debes activar el procesamiento de @Async marcando con @EnableAsync una clase de configuración (esto es, una anotada con @Configuration o @SpringBootApplication). De lo contrario, @Async será ignorada.

@Configuration
@EnableAsync
public class AsyncApplication {

La equivalencia en XML.

<task:annotation-driven />

No se requiere nada más. Los métodos @Async serán asíncronos, pero solo cuando se llamen desde fuera del bean. Invocando un método @Async desde otro situado en su misma clase no obtendremos la deseada asincronía. En estos casos, la solución más sencilla consiste en refactorizar para ubicar los métodos en clases distintas.

Toda la magia se la debemos a que una implementación de TaskExecutor ejecuta la tarea. Spring crea de manera automática un bean de este tipo de la clase SimpleAsyncTaskExecutor. Cada vez que haya que ejecutar una tarea asíncrona, este TaskExecutor lo hará dentro de un nuevo Thread. La creación del hilo es costosa; veremos cómo optimizar esto más adelante.

Esta forma de proceder es la mejor manera de ejecutar código asíncrono en Java. Procura no crear a mano un nuevo Thread, es una acción de «bajo nivel>>. En vez de ello, utiliza un Executor para ejecutar en un Runnable o Callable tu código asíncrono. Puedes obtener un Executor con los métodos estáticos de Executors.

Executors.newSingleThreadExecutor().execute(() -> System.out.println("Hello, I'm Async!!!!"));

La respuesta de las tareas asíncronos con Future

Un aspecto fundamental es el retorno de las tareas asíncronas. Pueden devolver cualquier cosa o void, pero si queremos recibir el resultado, envolveremos la respuesta en un objeto de tipo Future.

Contamos con varias implementaciones, como FutureTask o la poderosa CompletableFuture (*) introducida en Java 8. Asimismo, Spring provee una implementación llamada AsyncResult. Si bien sus constructores son privados, tiene un método estático que devuelve un ListenableFuture. De todas formas, AsyncResult y ListenableFuture se declararon obsoletas en Spring 6 (noviembre de 2022) para favorecer el empleo de CompletableFuture.

(*) Esta clase es mucho más que un Future: trae a Java el estilo de programación asíncrona basado en promesas (promises) tan común en el mundo JavaScript. El genial Venkat lo explica en una de sus magníficas presentaciones.

Veamos un primer ejemplo.

@Async
Future<String> executeAsync() {		
    return CompletableFuture.completedFuture("Hi there!!");
}
Future<String> resultFuture = asyncService.executeAsync();
String message = resultFuture.get();
System.out.println(message);

El objeto resultFuture representa el resultado de la tarea asíncrona, el cual no será conocido hasta que la tarea termine. El método get devuelve ese resultado (más adelante hablaré de las excepciones que puede lanzar). Si cuando lo invocamos todavía no existe, quedamos a la espera de recibirlo. La ejecución del código queda bloqueada el tiempo que sea necesario (lo que tarde la tarea).

Debido a esto último, el fragmento de código ejemplifica un uso incorrecto de las tareas asíncronas. Date cuenta de que si justo después de llamar a una tarea asíncrona esperamos a leer su resultado, no obtenemos beneficio de la asincronía. Equivale a trabajar de forma síncrona añadiendo un hándicap: la gestión de la asincronía añade una demora.

Sin embargo, se me ocurre un escenario en el que lo anterior puede tener sentido: acotar la duración máxima de la ejecución de un método con un timeout. Esto es posible gracias al otro método get presente en Future.

V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;

Además de dos argumentos que establecen el valor del timeout, la signatura del método añade la excepción TimeoutException, de nombre sospechoso…

Future<String> resultFuture = asyncService.executeAsync();
String message;
try {
        message = resultFuture.get(1, TimeUnit.SECONDS);
} catch (TimeoutException e) {
        message = "timeout!!!";
}
System.out.println(message);

El código es claro: si en el plazo de un segundo la tarea no finalizó, get lanzará un TimeoutException. Al ser una checked exception (no es de tipo Runtime), no podemos ignorarla: la capturamos o la declaramos en el método. Lo más probable es que queramos capturarla y seguir adelante.

Hablando de excepciones, los métodos get lanzan ExecutionException si la tarea finalizó por motivo de una excepción.

@Async
Future<String> executeAsyncException() {
	throw new RuntimeException("ha ha ha");
}
Future<String> stringFuture = asyncService.executeAsyncException();
String message;
try {
        message = stringFuture.get();
} catch (ExecutionException e) {
        message = e.getCause().getClass() + " " + e.getCause().getMessage();
}
System.out.println(message);

La causa de ExecutionException contiene la excepción lanzada por executeAsyncException. El código imprimirá lo siguiente.

class java.lang.RuntimeException ha ha ha

La otra excepción que pueden lanzar los get es InterruptedException. Se produce cuando se llama al método Thread#interrupt para solicitarse la detención del hilo correspondiente a ese objeto Thread, petición que puede o no hacerse efectiva. En Future, la interrupción se pide invocando a cancel, dándole a su único argumento el valor true. No obstante, en función de la implementación de Future, es posible que interrupt nunca sea llamado. Es el caso de AsyncResult.

@Override
public boolean cancel(boolean mayInterruptIfRunning) {
	return false;
}

Spring Data y @Async

Merece la pena indicar que los repositorios de Spring Data son compatibles con @Async y Future.

@Async
Future<User> findByFirstname(String firstname);

@Async
CompletableFuture<List<User>> findAll(String firstname);

Podemos declarar el retorno de los métodos que definen consultas derivadas con las clases FutureTask, CompletableFuture y AsyncResult (recuerda que está obsoleta en Spring Boot 3 \ Spring 6). También se puede usar la interfazFuture, en cuyo caso recibiremos la implementación AsyncResult (Spring Boot 2) o CompletableFuture (Spring Boot 3).

Creando TaskExecutor personalizados

Por lo común, vamos a querer configurar un TaskExecutor de acuerdo a las necesidades de cada proyecto. Lo que haremos es crear uno como un bean de Spring en un método factoría. Se trata de un método ni privado ni final, anotado con @Bean, perteneciente a una clase de configuración (@Configuration o @SpringBootAplication) y que retornará el bean. El nombre de un bean construido de este modo coincide con el del método, salvo que lo nombremos empleando la propiedad name de @Bean.

Manos a la obra. En el siguiente ejemplo, he creado con ThreadPoolTaskExecutor un TaskExecutor personalizado. A diferencia de SimpleAsyncTaskExecutor, esta implementación contiene un pool o conjunto de hilos reutilizables con el fin de ahorrar el coste de su creación. Es la misma técnica que Spring Boot usa con las conexiones a las bases de datos.

@Bean
TaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2); //default: 1
    executor.setMaxPoolSize(10); //default: Integer.MAX_VALUE
    executor.setQueueCapacity(20); // default: Integer.MAX_VALUE
    executor.setKeepAliveSeconds(120); // default: 60 seconds
    executor.initialize();
   return executor;
}

La configuración equivalente en XML es la siguiente.

<bean id="taskExecutor" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
    <property name="corePoolSize" value="2"/>
    <property name="maxPoolSize" value="10"/>
    <property name="queueCapacity" value="20"/>
    <property name="keepAliveSeconds" value="120"/>
</bean>

Nota. Es recomendable definir los parámetros de configuración fuera del código siguiendo el tutorial Ficheros de propiedades en Spring.

El ejemplo configura un pool de hilos dinámico con un tamaño comprendido entre 2 y 10, y una cola de peticiones limitada a 20. Un hilo sin uso permanecerá en el pool un máximo de 120 segundos.

La ejecución de una tarea asíncrona requiere un hilo del pool. Si hay alguno desocupado, se usa. Si no, se crea uno nuevo, siempre y cuando no provoque que el pool contenga más hilos de los permitidos.

Esto significa que no pueden coincidir más de 10 tareas en ejecución (el tamaño máximo del pool). Al llegarse a ese límite, las nuevas peticiones quedan en la cola en espera de la liberación de algún hilo. Y cuando la cola acumule 20, las siguientes solicitudes se descartarán.

¿Necesitas varios TaskExecutor distintos? No es problema. Puedes crear los que necesites…

@Bean
TaskExecutor taskExecutor2() {
       ....
 }

…e indicar en @Async el que quieres usar.

@Async("taskExecutor2")
public void doSomething() {
    ...
}

Si no declaras el nombre en @Async, Spring tomará el TaskExecutor predeterminado. Cuando exista más de un TaskExecutor, el predeterminado será aquel denominado «taskExecutor» o bien el marcado con @Primary.

TaskExecutor en Spring Boot

En Spring Boot puedes configurar el TaskExecutor predeterminado con las propiedades de tipo spring.task.execution.pool en el fichero application.properties (o application.yml).

spring.task.execution.pool.core-size=2
spring.task.execution.pool.max-size=10
spring.task.execution.pool.queue-capacity=20
spring.task.execution.pool.keep-alive=120

También podemos crear los TaskExecutor que queramos con métodos factoría. Pero ahora tenemos la pequeña ayuda de la clase TaskExecutorBuilder.

@Bean
public TaskExecutor taskExecutor(TaskExecutorBuilder builder) {
  return builder.corePoolSize(2)
			.maxPoolSize(10)
			.queueCapacity(20)
            .keepAlive(Duration.ofSeconds(120))
			.build();
}

Como podrás deducir por los parámetros de configuración, en los dos ejemplos anteriores se definió un TaskExecutor basado en un pool de hilos.

Otros tutoriales relacionados con Spring

Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA

Spring Boot: Gestión de errores en aplicaciones web y REST

Testing en Spring Boot con JUnit 45. Mockito, MockMvc, REST Assured, bases de datos embebidas

Spring JDBC Template: simplificando el uso de SQL

Persistencia en BD con Spring Data JPA

Ficheros .properties en Spring IoC

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.