W3docs

Concurrence structurée en Java

Traitez des sous-tâches concurrentes comme une unité de travail en Java avec la concurrence structurée (StructuredTaskScope).

La concurrence structurée traite un groupe de sous-tâches concurrentes comme une seule unité de travail : elles sont lancées ensemble, elles se terminent ensemble, et si l'une échoue ou si l'appelant est annulé, les autres le sont aussi — aucun thread orphelin ne survit au bloc qui les a démarrés. Le modèle est fourni par java.util.concurrent.StructuredTaskScope (une API en prévisualisation introduite dans Java 21) et repose sur les mêmes threads virtuels abordés plus tôt dans cette partie. L'objectif est simple : rendre le code concurrent aussi facile à lire, déboguer et raisonner qu'une méthode séquentielle ordinaire.

Ce chapitre explique pourquoi le terme « structuré » est important, l'anatomie d'un scope de tâches, les deux politiques d'arrêt intégrées, la propagation des délais et des annulations, ainsi qu'un exemple pratique. Il suppose que vous êtes à l'aise avec le framework executor et Callable/Future.

Pourquoi « structuré » ?

Les pools de threads classiques sont non structurés : vous soumettez une tâche à un ExecutorService partagé et récupérez un Future dont la durée de vie est indépendante de la méthode qui l'a créé. Une tâche peut survivre à son appelant, une erreur dans une tâche est invisible pour ses tâches sœurs, et l'annulation doit être câblée manuellement. Le résultat : des threads fuités et une gestion des erreurs enchevêtrée.

La concurrence structurée emprunte la discipline du flux de contrôle structuré : tout comme un bloc try délimite ses instructions, un scope de tâches confine ses sous-tâches. Les sous-tâches lancées à l'intérieur d'un bloc doivent toutes se terminer avant que le bloc ne se ferme. Les durées de vie s'imbriquent proprement, ce qui fait qu'un vidage de threads et une trace de pile vous indiquent réellement qui a démarré quoi.

ProblématiqueNon structuré (pool partagé ExecutorService)Structuré (StructuredTaskScope)
Durée de vie des sous-tâchesIndépendante de l'appelantDélimitée par le bloc englobant
Erreur dans une sous-tâcheCachée dans un Future jusqu'à l'appel de getPeut court-circuiter tout le scope
AnnulationManuelle, facile à oublierAutomatique en cas d'échec ou d'interruption
Nettoyage des ressourcesÀ votre chargeclose() attend toutes les sous-tâches

La forme d'un scope

Un scope est un AutoCloseable, il vit donc dans un bloc try-with-resources. Vous forkez des sous-tâches (chacune renvoie un handle Subtask), appelez join() pour les attendre, puis lisez chaque résultat. La politique ShutdownOnFailure annule les sous-tâches restantes dès que l'une d'elles lève une exception :

import java.util.concurrent.StructuredTaskScope;

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    StructuredTaskScope.Subtask<String> user  = scope.fork(() -> fetchUser(id));
    StructuredTaskScope.Subtask<Integer> order = scope.fork(() -> fetchOrderCount(id));

    scope.join();            // wait for both branches
    scope.throwIfFailed();   // rethrow if either branch failed

    return new Profile(user.get(), order.get());
}   // close() guarantees both subtasks have ended before we leave

Si fetchUser lève une exception, ShutdownOnFailure interrompt le fetchOrderCount encore en cours, join() retourne, et throwIfFailed() relance la cause originale encapsulée dans une ExecutionException. Vous ne laissez jamais fuiter de thread.

Politiques d'arrêt intégrées

Les deux politiques fournies couvrent les cas courants ; vous sous-classez StructuredTaskScope pour tout autre cas.

PolitiqueSe termine quandUtilisation
ShutdownOnFailureToutes réussissent, ou l'une échoueFan-out où vous avez besoin de chaque résultat (le cas courant)
ShutdownOnSuccess<T>Premier succès, ou toutes échouentMise en compétition de sources redondantes ; prendre la réponse la plus rapide

ShutdownOnSuccess renvoie le gagnant via result() et annule les perdants :

try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> queryMirrorA());
    scope.fork(() -> queryMirrorB());
    scope.join();
    return scope.result();   // the first one to return; the slower is cancelled
}

Propagation des délais et des annulations

Un scope peut être joint avec un délai limite ; lorsqu'il s'écoule, les sous-tâches non terminées sont annulées :

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> slowService());
    scope.joinUntil(Instant.now().plusSeconds(2));  // throws TimeoutException if late
    scope.throwIfFailed();
}

L'annulation est coopérative et se propage vers le bas : si le thread propriétaire du scope est interrompu, chaque sous-tâche est interrompue à son tour. Étant donné que chaque sous-tâche s'exécute sur son propre thread virtuel, en lancer des milliers est bon marché — le scope, et non une taille de pool fixe, est l'unité sur laquelle vous raisonnez.

Exemple pratique : fan-out, échec et jonction d'une liste

StructuredTaskScope est une fonctionnalité en prévisualisation ; pour que cet exemple soit exécutable sur un JDK stable, nous modélisons la même idée avec un executor un-thread-virtuel-par-tâche : un bloc try-with-resources qui délimite un groupe de sous-tâches et ne se ferme que lorsque chaque thread de sous-tâche s'est terminé. Il lance deux appels en parallèle, puis montre comment un échec court-circuite l'unité de travail et comment invokeAll joint une liste entière en une seule opération.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • Les deux sous-tâches ont signalé is virtual : true — chaque submit s'est exécuté sur son propre thread virtuel, le même porteur léger qu'utilise StructuredTaskScope.fork, donc créer un thread par sous-tâche est peu coûteux.
  • Le bloc du chemin heureux a affiché ran concurrently (<320ms): true alors que les deux fetch dorment 120ms et 200ms : ils se sont chevauchés, donc le temps réel correspond à la branche la plus lente (~200ms), et non à la somme (320ms). Ce chevauchement est tout l'intérêt du fan-out.
  • Quitter le bloc try-with-resources a appelé close(), qui a bloqué jusqu'à ce que chaque thread de sous-tâche se termine — le scope est l'unité de durée de vie, exactement la discipline qu'impose StructuredTaskScope par construction.
  • Dans la section d'échec, le programme a affiché caught: IllegalStateException -> upstream said no : une erreur levée à l'intérieur d'une sous-tâche remonte au point de jonction encapsulée dans ExecutionException, et getCause() vous restitue l'exception originale.
  • Après avoir capturé l'échec, il a affiché sibling cancelled: true — nous avons annulé la branche good encore en cours d'exécution afin qu'aucun orphelin ne survive au bloc, ce qui est précisément ce que ShutdownOnFailure fait automatiquement pour vous ; ici nous l'avons fait à la main pour montrer le mécanisme.

Sujets connexes

Pratique

Pratique
Avec StructuredTaskScope.ShutdownOnFailure, que se passe-t-il avec les autres sous-tâches lancées lorsque l'une d'elles lève une exception ?
Avec StructuredTaskScope.ShutdownOnFailure, que se passe-t-il avec les autres sous-tâches lancées lorsque l'une d'elles lève une exception ?
Was this page helpful?