Contactez-nous

Méthodes asynchrones avec `@Async` et `CompletableFuture` (alternative non-réactive)

Découvrez comment exécuter des tâches de manière asynchrone en Spring Boot avec l'annotation @Async et gérer les résultats via CompletableFuture, une alternative impérative à la programmation réactive.

Pourquoi l'exécution asynchrone ? Au-delà du modèle bloquant

Dans les applications web traditionnelles, le modèle "un thread par requête" est courant. Un thread est pris dans un pool pour traiter une requête HTTP de bout en bout. Si ce traitement implique des opérations longues (appels réseau, accès base de données, calculs intensifs), le thread reste bloqué, incapable de traiter d'autres requêtes pendant ce temps. Cela peut rapidement épuiser le pool de threads et dégrader la réactivité et la capacité de l'application à monter en charge.

L'exécution asynchrone vise à résoudre ce problème. Au lieu de bloquer le thread principal (celui qui gère la requête initiale), les tâches longues sont déléguées à des threads d'arrière-plan. Le thread principal est ainsi libéré rapidement et peut retourner traiter d'autres requêtes, améliorant la réactivité globale et l'utilisation des ressources. Spring Boot propose plusieurs façons d'implémenter l'asynchronisme.

Tandis que la programmation réactive (avec WebFlux et Project Reactor) offre une approche complète et end-to-end non-bloquante, elle introduit un paradigme différent. Pour des besoins plus ciblés d'exécution de tâches en arrière-plan ou pour une transition plus douce depuis un code impératif, Spring fournit l'annotation @Async combinée à l'utilisation de CompletableFuture de Java. C'est une approche asynchrone puissante mais qui reste dans le modèle de programmation impératif.

Activation du support @Async

Par défaut, Spring ne traite pas l'annotation @Async. Pour activer le support de l'exécution asynchrone des méthodes, vous devez explicitement l'ajouter à votre configuration Spring.

La manière la plus simple est d'ajouter l'annotation @EnableAsync sur une de vos classes de configuration (annotée avec @Configuration) ou directement sur votre classe principale annotée avec @SpringBootApplication (bien qu'une classe de configuration dédiée soit souvent préférable pour la clarté).

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync // Active le support @Async
public class AsyncConfig {
    // Configuration optionnelle du pool de threads (voir plus loin)
}

Une fois @EnableAsync ajouté, Spring détectera les méthodes publiques annotées avec @Async sur vos beans et les exécutera de manière asynchrone en utilisant un pool de threads (par défaut un SimpleAsyncTaskExecutor, mais configurable).

Utilisation basique de @Async pour les méthodes void

Le cas d'usage le plus simple de @Async concerne les méthodes qui ne retournent aucun résultat (type de retour void). Ces méthodes sont typiquement utilisées pour des tâches de type "fire-and-forget" : envoyer un email, déclencher un traitement en arrière-plan, enregistrer une notification, etc., où l'appelant n'a pas besoin d'attendre la fin de l'opération ni de recevoir un résultat direct.

Il suffit d'annoter la méthode publique avec @Async. Spring interceptera l'appel à cette méthode, la soumettra à un pool de threads pour exécution, et retournera immédiatement le contrôle à l'appelant.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {

    private static final Logger log = LoggerFactory.getLogger(NotificationService.class);

    @Async // Cette méthode s'exécutera dans un thread séparé
    public void envoyerEmailConfirmationAsync(String destinataire, String contenu) {
        log.info("Début de l'envoi de l'email à {} sur le thread: {}", destinataire, Thread.currentThread().getName());
        try {
            // Simule une opération longue (appel à un service SMTP)
            Thread.sleep(3000); // Ne faites jamais sleep en production, c'est pour l'exemple!
            log.info("Email envoyé avec succès à {}.", destinataire);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Erreur lors de l'envoi de l'email à {}.", destinataire, e);
        } catch (Exception e) {
             log.error("Erreur inattendue lors de l'envoi de l'email à {}.", destinataire, e);
        }
    }

    public void envoyerBienvenue(String user) {
        log.info("Préparation de l'envoi de bienvenue pour {} sur le thread: {}", user, Thread.currentThread().getName());
        // Appel asynchrone: cette ligne retourne immédiatement
        envoyerEmailConfirmationAsync(user + "@example.com", "Bienvenue !");
        log.info("Fin de la méthode envoyerBienvenue pour {} (l'email est envoyé en arrière-plan)", user);
    }
}

Lorsque envoyerBienvenue est appelée, l'appel à envoyerEmailConfirmationAsync déclenche l'exécution asynchrone. La méthode envoyerBienvenue continue et se termine sans attendre les 3 secondes de l'envoi simulé de l'email. Les logs montreront que l'envoi de l'email s'exécute sur un thread différent du pool.

Attention : Pour que @Async fonctionne via l'interception par proxy de Spring, l'appel doit provenir d'un autre bean. Un appel à une méthode @Async depuis la même classe (self-invocation) ne sera pas intercepté et s'exécutera de manière synchrone.

Retourner des résultats avec CompletableFuture

Très souvent, une tâche asynchrone doit retourner un résultat ou signaler sa complétion (succès ou échec). Les méthodes void ne permettent pas cela. Pour gérer les résultats des méthodes @Async, le type de retour doit être un java.util.concurrent.CompletableFuture (ou, pour des raisons de compatibilité plus ancienne, un Future ou ListenableFuture de Spring, mais CompletableFuture est l'approche moderne recommandée).

CompletableFuture est une implémentation puissante de Future introduite en Java 8. Elle représente une valeur qui sera disponible dans le futur et offre une riche API pour composer des opérations asynchrones, gérer les résultats et les erreurs de manière non-bloquante (via des callbacks comme thenApply, thenAccept, exceptionally, etc.).

Votre méthode @Async crée et retourne une instance de CompletableFuture. L'implémentation de la méthode effectue le travail long, puis complète le CompletableFuture avec le résultat (via complete()) ou une exception (via completeExceptionally()).

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;

@Service
public class DataFetcherService {

    private static final Logger log = LoggerFactory.getLogger(DataFetcherService.class);

    @Async
    public CompletableFuture rechercherDonneesExternes(String query) {
        log.info("Recherche asynchrone pour '{}' sur le thread: {}", query, Thread.currentThread().getName());
        try {
            // Simule un appel API externe long
            Thread.sleep(2000);
            String result = "Résultat pour " + query;
            log.info("Données trouvées pour '{}'", query);
            // Complète le Future avec le résultat
            return CompletableFuture.completedFuture(result);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Recherche interrompue pour '{}'", query, e);
            CompletableFuture future = new CompletableFuture<>();
            future.completeExceptionally(e);
            return future;
        } catch (Exception e) {
             log.error("Erreur lors de la recherche pour '{}'", query, e);
             CompletableFuture future = new CompletableFuture<>();
            future.completeExceptionally(e);
            return future;
        }
    }
}

// Exemple d'appelant
@Service
class OrchestrateurService {
     private final DataFetcherService dataFetcherService;

    public OrchestrateurService(DataFetcherService dataFetcherService) {
        this.dataFetcherService = dataFetcherService;
    }

    public void traiterRequete(String terme) {
        log.info("Début traitement requête pour '{}' sur le thread: {}", terme, Thread.currentThread().getName());
        CompletableFuture futureResultat = dataFetcherService.rechercherDonneesExternes(terme);

        // Utiliser le résultat de manière asynchrone (non bloquant)
        futureResultat.thenAccept(resultat -> {
            log.info("Résultat reçu (callback) pour '{}': {} sur le thread: {}", terme, resultat, Thread.currentThread().getName());
            // Faire quelque chose avec le résultat...
        }).exceptionally(ex -> {
            log.error("Erreur reçue (callback) pour '{}': {}", terme, ex.getMessage());
            return null; // Gérer l'erreur
        });

        log.info("Fin traitement requête pour '{}' (recherche en cours...)", terme);
        // NOTE: Pour attendre le résultat de manière bloquante (à éviter dans un thread de requête web!):
        // String resultatBloquant = futureResultat.join(); // ou futureResultat.get();
    }
}

L'appelant reçoit immédiatement le CompletableFuture et peut enregistrer des callbacks (thenAccept, exceptionally) pour traiter le résultat ou l'erreur lorsqu'il sera disponible, sans bloquer le thread courant. Ou, si nécessaire (mais souvent déconseillé dans un contexte serveur), il peut attendre le résultat de manière bloquante avec join() ou get().

Configuration du pool de threads (@Async Executor)

Par défaut, Spring Boot utilise un SimpleAsyncTaskExecutor qui crée un nouveau thread pour chaque tâche asynchrone, sans réutiliser les threads et sans limite. Ce n'est généralement pas souhaitable en production. Il est fortement recommandé de configurer un pool de threads (ThreadPoolTaskExecutor) plus robuste.

Vous pouvez définir votre propre bean Executor dans une classe de configuration. Si un bean de type TaskExecutor ou Executor existe, Spring l'utilisera par défaut pour les méthodes @Async.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "taskExecutor") // Nom du bean par défaut recherché par @Async
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);   // Nombre de threads à garder actifs
        executor.setMaxPoolSize(10);  // Nombre maximum de threads
        executor.setQueueCapacity(25); // Taille de la file d'attente avant rejet
        executor.setThreadNamePrefix("AsyncThread-"); // Préfixe pour les noms de threads
        executor.initialize();
        return executor;
    }

    // Vous pouvez définir d'autres beans Executor avec des noms différents
    @Bean(name = "emailExecutor")
    public Executor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("EmailAsyncThread-");
        executor.initialize();
        return executor;
    }
}

Si vous définissez plusieurs beans Executor, vous pouvez spécifier lequel utiliser pour une méthode @Async particulière en passant le nom du bean en argument de l'annotation :

@Async("emailExecutor") // Utilise le bean nommé 'emailExecutor'
public void envoyerEmailConfirmationAsync(String destinataire, String contenu) {
    // ...
}

@Async // Utilise le bean 'taskExecutor' par défaut ou celui configuré par Spring Boot
public CompletableFuture rechercherDonneesExternes(String query) {
    // ...
}

Une autre façon de configurer l'executor par défaut est d'implémenter l'interface AsyncConfigurer dans votre classe @Configuration.

Gestion des exceptions dans les méthodes @Async

La gestion des exceptions diffère selon le type de retour de la méthode @Async.

Pour les méthodes void : Lorsqu'une exception non interceptée (uncaught exception) se produit dans une méthode @Async retournant void, elle ne peut pas être retournée à l'appelant (puisque l'appelant a déjà continué son chemin). Par défaut, l'exception est simplement logguée. Pour personnaliser ce comportement, vous pouvez fournir un AsyncUncaughtExceptionHandler. La manière la plus simple est d'implémenter l'interface AsyncConfigurer dans votre classe de configuration et de surcharger la méthode getAsyncUncaughtExceptionHandler().

import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.scheduling.annotation.AsyncConfigurer;
// ... autres imports ...

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);

    @Override
    @Bean(name = "taskExecutor")
    public Executor getAsyncExecutor() { // Renommé pour correspondre à AsyncConfigurer
       // ... configuration du ThreadPoolTaskExecutor ...
       return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            log.error("Exception non interceptée dans la méthode asynchrone '{}'", method.getName(), ex);
            // Ajouter une logique de notification ou de traitement d'erreur spécifique ici
        };
    }
}

Pour les méthodes retournant CompletableFuture : Si une exception se produit dans une méthode @Async retournant un CompletableFuture, cette exception ne déclenche pas l'AsyncUncaughtExceptionHandler. A la place, le CompletableFuture retourné est complété exceptionnellement avec cette exception. Il incombe à l'appelant qui a reçu le CompletableFuture de gérer cette éventualité, typiquement en utilisant les méthodes exceptionally() ou handle() sur le Future.

futureResultat
    .thenApply(resultat -> "Traitement: " + resultat) // S'exécute si succès
    .exceptionally(ex -> {
        log.error("Echec de l'opération asynchrone: {}", ex.getMessage());
        return "Résultat par défaut en cas d'erreur"; // Fournit une valeur de secours
    })
    .thenAccept(finalResult -> log.info("Résultat final: {}", finalResult));

Conclusion : @Async comme outil d'asynchronisme impératif

L'annotation @Async, associée à CompletableFuture, offre un moyen accessible et puissant d'introduire l'asynchronisme dans les applications Spring Boot sans nécessiter une refonte complète vers un modèle réactif. C'est particulièrement utile pour décharger des tâches longues du thread de requête principal, améliorant la réactivité perçue par l'utilisateur.

Cependant, il est crucial de comprendre ses limites. Utiliser @Async ne rend pas l'ensemble de la chaîne de traitement non-bloquante de bout en bout comme le ferait WebFlux. Si le thread principal qui a initié l'appel @Async finit par attendre le résultat du CompletableFuture de manière bloquante (avec join() ou get()), le bénéfice de l'asynchronisme est en partie perdu pour ce thread-là (même si d'autres threads du pool sont libérés). De plus, la gestion de la propagation du contexte (sécurité, MDC, transactions) entre les threads nécessite une attention particulière.

En résumé, @Async est un excellent outil pour l'offloading de tâches spécifiques et l'introduction de parallélisme simple dans un contexte Spring Boot traditionnel. Pour des applications nécessitant une haute concurrence, une gestion fine de la contre-pression (backpressure) et un traitement I/O non-bloquant de bout en bout, l'écosystème réactif avec Spring WebFlux et Project Reactor reste l'approche la plus adaptée et performante.