1. Introduction
Quand les threads jouent chacun leur propre partie
La programmation multithread classique ressemble souvent à une répétition sans chef d’orchestre. Chaque thread — comme un musicien — joue sa mélodie sans écouter les autres. L’un a terminé plus tôt et est parti fumer, un autre est resté bloqué sur un accord, un troisième s’est trompé de notes et a produit une erreur. Le résultat n’est pas une symphonie, mais une cacophonie : comprendre où chacun s’est trompé est presque impossible, et arrêter tout le monde d’un coup — c’est une vraie quête.
Structured Concurrency résout ce problème. Elle transforme des threads dispersés en un véritable ensemble : toutes les tâches sont réunies sous un seul « chef d’orchestre ». S’il donne le signal d’arrêt — l’orchestre se tait. Si un musicien se plante — les autres s’arrêtent proprement, sans briser l’harmonie générale. Tous les résultats et erreurs sont collectés de manière centralisée, au lieu d’être éparpillés dans les recoins du code.
Imaginez : vous ne laissez pas les musiciens jouer chacun de leur côté, mais vous les rassemblez dans une salle. Il y a un chef, une partition, et même si la trompette sonne faux — l’orchestre ne déraille pas et termine l’exécution proprement.
Ce que Structured Concurrency apporte
- Un « scope » unique de tâche : toutes les sous-tâches vivent dans un même bloc de code, leur cycle de vie est borné par ce bloc.
- Une terminaison prévisible : le thread parent ne se termine pas tant que toutes les sous-tâches ne sont pas terminées.
- Annulation centralisée : si une tâche échoue ou si le parent décide de terminer — toutes les sous-tâches sont annulées correctement.
- Gestion cohérente des erreurs : les erreurs des sous-tâches sont agrégées, on peut obtenir un « arbre des causes » (tree of causes).
- Code propre et lisible : pas de threads « pendus », pas de tâches oubliées, pas de courses à l’annulation.
Structured Concurrency n’est pas seulement une nouvelle API, c’est une nouvelle façon de penser : les tâches doivent être structurées comme des blocs de code ordinaires (par exemple, try-with-resources).
2. Statut de Structured Concurrency dans Java
Au moment de la rédaction, Structured Concurrency est au statut Preview (Java 21–23), mais l’on s’attend à une évolution vers GA (General Availability) dans Java 24/25. L’API se trouve dans le package jdk.incubator.concurrent. Avant toute utilisation en production, vérifiez impérativement les notes de version de votre JDK !
Classes principales :
- StructuredTaskScope — classe de base pour gérer un groupe de tâches.
- Variantes : StructuredTaskScope.ShutdownOnFailure, StructuredTaskScope.ShutdownOnSuccess — politiques de terminaison des tâches.
Concepts clés de StructuredTaskScope
Modèle : fork, join et petite revue des résultats
Quand le chef (c’est-à-dire la tâche parente) donne le signal — les sous-tâches partent jouer leurs parties. Ce moment s’appelle le fork — comme si vous envoyiez les musiciens jouer leurs morceaux dans différentes salles.
Vient ensuite le join — le chef lève la baguette, et tous reviennent pour jouer l’accord final ensemble.
Après quoi, vous pouvez demander à chaque participant comment cela s’est passé :
- via resultNow() obtenir immédiatement le résultat si tout s’est joué sans erreur ;
- via throwIfFailed() — s’assurer que personne n’a joué faux. Si quelqu’un s’est malgré tout emmêlé dans les notes, une exception unique est levée — comme si le chef disait : « Nous avons une défaillance dans l’orchestre, on recommence ».
Politiques de terminaison
Tout chef a sa règle pour décider quand arrêter la musique. Dans Structured Concurrency, cela se définit par une politique de terminaison :
- ShutdownOnFailure — si un musicien sort du rythme, le chef fait signe : « Stop ! On recommence ». Tous les autres cessent immédiatement de jouer.
- ShutdownOnSuccess — à l’inverse, dès qu’un musicien a parfaitement joué sa partie, le chef est satisfait : « Ça suffit, inutile d’aller plus loin, nous avons déjà un gagnant ». Les autres se taisent — politique du premier résultat réussi.
Travail avec les threads virtuels
Chaque sous-tâche StructuredTaskScope est lancée dans un thread virtuel. C’est comme si vous aviez un orchestre où chaque musicien est docile et rapide, sans caprices ni exigences de scène. Vous pouvez en créer des centaines, des milliers — ce ne sont pas des threads lourds, mais des notes presque sans poids qui résonnent exactement quand il faut.
3. Exemple : agrégateur de requêtes HTTP
Considérons un cas pratique : nous avons trois sources de données (par exemple, trois serveurs différents), et nous voulons soit obtenir la réponse de toutes (et l’agréger), soit celle du premier qui répond avec succès.
Variante 1 : « Tous doivent réussir » (ShutdownOnFailure)
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
public class AggregatorAllSuccess {
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchFromSource1());
Future<String> f2 = scope.fork(() -> fetchFromSource2());
Future<String> f3 = scope.fork(() -> fetchFromSource3());
scope.join(); // on attend la fin de toutes les tâches
scope.throwIfFailed(); // si au moins une a échoué — on lève une exception
// Toutes les tâches ont réussi — on peut agréger les résultats
String result = f1.resultNow() + f2.resultNow() + f3.resultNow();
System.out.println("Résultat agrégé : " + result);
}
}
static String fetchFromSource1() { /* ... */ return "A"; }
static String fetchFromSource2() { /* ... */ return "B"; }
static String fetchFromSource3() { /* ... */ return "C"; }
}
Ce qui se passe :
- Les trois tâches sont lancées en parallèle (dans des threads virtuels).
- Si au moins l’une échoue — les autres sont annulées, une exception est levée.
- Si toutes réussissent — on peut agréger les résultats en toute sécurité.
Variante 2 : « Succès au premier valide » (ShutdownOnSuccess)
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
public class AggregatorFirstSuccess {
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Future<String> f1 = scope.fork(() -> fetchFromSource1());
Future<String> f2 = scope.fork(() -> fetchFromSource2());
Future<String> f3 = scope.fork(() -> fetchFromSource3());
scope.join(); // on attend le premier succès
scope.throwIfFailed(); // si tous ont échoué — on lève une exception
String result = scope.result(); // résultat de la première tâche réussie
System.out.println("Premier résultat réussi : " + result);
}
}
static String fetchFromSource1() { /* ... */ return "A"; }
static String fetchFromSource2() { /* ... */ return "B"; }
static String fetchFromSource3() { /* ... */ return "C"; }
}
Ce qui se passe :
- Dès qu’une tâche se termine avec succès — les autres sont annulées.
- Si toutes échouent — une exception est levée.
4. Annulation automatique et dégradation
StructuredTaskScope s’occupe lui-même d’annuler les tâches restantes si la politique l’exige. Par exemple, si une tâche a échoué (ShutdownOnFailure) ou si l’une s’est terminée avec succès (ShutdownOnSuccess), les autres reçoivent un signal d’annulation (interrupt).
Exemple : terminaison correcte avec délai d’expiration
import jdk.incubator.concurrent.StructuredTaskScope;
import java.time.Instant;
import java.util.concurrent.Future;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchWithTimeout());
Future<String> f2 = scope.fork(() -> fetchWithTimeout());
scope.joinUntil(Instant.now().plusSeconds(2)); // on attend au plus 2 secondes
scope.throwIfFailed();
String result = f1.resultNow() + f2.resultNow();
System.out.println(result);
}
Si les tâches ne se sont pas terminées en 2 secondes — une exception sera levée et toutes les tâches seront annulées.
5. Erreurs et gestion des exceptions
Comment les exceptions des sous-tâches sont routées vers le scope
Parfois, pendant le concert, quelqu’un se trompe quand même de note — StructuredTaskScope ne fait pas semblant que rien ne s’est passé. Il enregistre soigneusement qui a joué à côté, puis transmet au chef un rapport complet. Quand vous appelez throwIfFailed(), il lance une exception agrégée — une sorte de rapport récapitulatif : « Voici la liste de ceux qui ont joué faux aujourd’hui ». Si nécessaire, vous pouvez dérouler cet « arbre des causes » et voir qui a exactement failli. Et si vous voulez savoir pour un exécuteur précis — Future.exceptionNow() dira comment sa partie s’est terminée.
Quand l’annulation n’est pas un échec
Important : l’annulation d’une tâche ne signifie pas toujours une erreur. Si le chef a dit « c’est fini, le concert est terminé », alors les musiciens rangent simplement leurs instruments — c’est cancelled, mais pas failed. Une erreur, c’est uniquement quand quelqu’un a réellement joué autre chose, et cette exception se retrouvera dans le rapport global.
Exemple : arbre des causes
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> { throw new RuntimeException("Erreur 1"); });
Future<String> f2 = scope.fork(() -> { throw new RuntimeException("Erreur 2"); });
scope.join();
scope.throwIfFailed(); // lèvera une exception avec les deux causes
} catch (Exception e) {
e.printStackTrace();
// On peut obtenir les exceptions supprimées via e.getSuppressed()
}
6. Comparaison avec CompletableFuture
StructuredTaskScope et CompletableFuture — tous deux permettent de lancer des tâches parallèles, mais :
- StructuredTaskScope est pratique quand les tâches sont logiquement liées et doivent se terminer/s’annuler ensemble (hiérarchie de tâches).
- CompletableFuture est adapté pour la composition de tâches sans hiérarchie (par exemple, des chaînes de transformations, des scénarios réactifs).
Quand StructuredTaskScope simplifie le code :
- Quand il faut garantir que toutes les sous-tâches sont terminées avant de sortir du bloc.
- Quand une annulation centralisée et une gestion des erreurs sont nécessaires.
- Quand il est important de ne laisser aucune tâche « pendue ».
Quand CompletableFuture est plus pratique :
- Quand les tâches ne sont pas liées et peuvent vivre leur propre vie.
- Quand une composition complexe est nécessaire (thenCombine, thenCompose, etc.).
7. Pratique : agrégateur de requêtes HTTP
Tâche : envoyer des requêtes à 3 sources, obtenir la première réponse réussie
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
public class HttpAggregator {
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Future<String> f1 = scope.fork(() -> httpRequest("https://api1.example.com"));
Future<String> f2 = scope.fork(() -> httpRequest("https://api2.example.com"));
Future<String> f3 = scope.fork(() -> httpRequest("https://api3.example.com"));
scope.join();
scope.throwIfFailed();
String result = scope.result();
System.out.println("Première réponse réussie : " + result);
}
}
static String httpRequest(String url) throws Exception {
// Simulation de la requête (on peut utiliser HttpClient)
Thread.sleep((long) (Math.random() * 1000));
if (Math.random() < 0.3) throw new RuntimeException("Erreur de requête : " + url);
return "Réponse de " + url;
}
}
Tâche : si une sous-tâche a échoué — éteindre correctement les autres
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> httpRequest("https://api1.example.com"));
Future<String> f2 = scope.fork(() -> httpRequest("https://api2.example.com"));
scope.join();
scope.throwIfFailed();
String result = f1.resultNow() + f2.resultNow();
System.out.println("Les deux réponses : " + result);
} catch (Exception e) {
System.err.println("Erreur dans l’une des tâches : " + e.getMessage());
}
8. Erreurs typiques avec StructuredTaskScope
Erreur n° 1 : vous avez oublié d’appeler join() ou throwIfFailed().
Si vous n’appelez pas join(), les tâches peuvent ne pas se terminer avant la sortie du bloc. Si vous n’appelez pas throwIfFailed(), les erreurs des sous-tâches passeront inaperçues.
Erreur n° 2 : tentative d’obtenir le résultat avant la fin de la tâche.
Appeler resultNow() avant la fin de la tâche lèvera IllegalStateException. Attendez d’abord la fin via join().
Erreur n° 3 : ignorer l’annulation.
Si une tâche a été annulée (par exemple, à cause de la politique du scope), n’essayez pas d’en obtenir le résultat — une exception sera levée.
Erreur n° 4 : mélanger différentes politiques de terminaison.
Il ne faut pas essayer d’annuler manuellement des tâches à l’intérieur d’un scope — utilisez les politiques ShutdownOnFailure ou ShutdownOnSuccess.
Erreur n° 5 : lancer de longues tâches gourmandes CPU dans des threads virtuels.
StructuredTaskScope utilise par défaut des threads virtuels — ils sont idéaux pour les tâches I/O-bound, mais n’accélèrent pas les calculs lourds.
Erreur n° 6 : oublier de fermer le scope (pas de try-with-resources).
StructuredTaskScope implémente AutoCloseable — utilisez toujours try-with-resources pour garantir la terminaison de toutes les tâches.
GO TO FULL VERSION