Introduction aux pools de threads en Java

Introduction aux pools de threads en Java

1. introduction

Cet article est un aperçu des pools de threads en Java - en commençant par les différentes implémentations de la bibliothèque Java standard, puis en examinant la bibliothèque Guava de Google.

2. Le pool de threads

En Java, les threads sont mappés aux threads au niveau du système qui sont des ressources du système d'exploitation. Si vous créez des threads de manière incontrôlable, vous risquez de manquer rapidement de ces ressources.

La commutation de contexte entre les threads est également effectuée par le système d'exploitation - afin d'émuler le parallélisme. Une vue simpliste est que - plus vous créez de threads, moins chaque thread passe de temps à effectuer le travail réel.

Le modèle de pool de threads permet d’économiser des ressources dans une application multithread et de contenir le parallélisme dans certaines limites prédéfinies.

Lorsque vous utilisez un pool de threads, vouswrite your concurrent code in the form of parallel tasks and submit them for execution to an instance of a thread pool. Cette instance contrôle plusieurs threads réutilisés pour l'exécution de ces tâches. 2016-08-10_10-16-52-1024x572

Le modèle vous permet decontrol the number of threads the application is creating, de leur cycle de vie, ainsi que de planifier l'exécution des tâches et de conserver les tâches entrantes dans une file d'attente.

3. Pools de threads en Java

3.1. Executors,Executor etExecutorService

La classe d'assistanceExecutors contient plusieurs méthodes de création d'instances de pool de threads préconfigurées pour vous. Ces cours sont un bon point de départ - utilisez-les si vous n'avez pas besoin d'appliquer de réglages personnalisés.

Les interfacesExecutor etExecutorService sont utilisées pour travailler avec différentes implémentations de pool de threads en Java. Habituellement, vous devezkeep your code decoupled from the actual implementation of the thread pool et utiliser ces interfaces dans toute votre application.

L'interfaceExecutor a une seule méthodeexecute pour soumettre les instancesRunnable pour exécution.

Here’s a quick example de la façon dont vous pouvez utiliser l'APIExecutors pour acquérir une instanceExecutor sauvegardée par un pool de threads unique et une file d'attente illimitée pour l'exécution séquentielle des tâches. Ici, nous exécutons une seule tâche qui imprime simplement «Hello World» à l'écran. La tâche est soumise en tant que lambda (une fonctionnalité Java 8) qui est supposée êtreRunnable.

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));

L'interfaceExecutorService contient un grand nombre de méthodes pourcontrolling the progress of the tasks and managing the termination of the service. À l'aide de cette interface, vous pouvez soumettre les tâches pour exécution et également contrôler leur exécution à l'aide de l'instanceFuture renvoyée.

In the following example, nous créons unExecutorService, soumettons une tâche puis utilisons la méthodeget renvoyée deFuture pour attendre que la tâche soumise soit terminée et que la valeur soit renvoyée:

ExecutorService executorService = Executors.newFixedThreadPool(10);
Future future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();

Bien sûr, dans un scénario réel, vous ne voulez généralement pas appelerfuture.get() tout de suite, mais différez de l'appeler jusqu'à ce que vous ayez réellement besoin de la valeur du calcul.

La méthodesubmit est surchargée pour prendre soitRunnable soitCallable, qui sont tous deux des interfaces fonctionnelles et peuvent être passés en tant que lambdas (à partir de Java 8).

La méthode unique deRunnable ne lève pas d'exception et ne renvoie pas de valeur. L'interface deCallable peut être plus pratique, car elle permet de lever une exception et de renvoyer une valeur.

Enfin - pour permettre au compilateur de déduire le typeCallable, renvoyez simplement une valeur à partir du lambda.

Pour plus d'exemples sur l'utilisation de l'interface et des futuresExecutorService, jetez un œil à «A Guide to the Java ExecutorService».

3.2. ThreadPoolExecutor

LeThreadPoolExecutor est une implémentation de pool de threads extensible avec beaucoup de paramètres et de hooks pour un réglage fin.

Les principaux paramètres de configuration dont nous parlerons ici sont:corePoolSize,maximumPoolSize etkeepAliveTime.

Le pool est constitué d'un nombre fixe de threads principaux conservés à l'intérieur, ainsi que d'un nombre excessif de threads pouvant être créés puis terminés lorsqu'ils ne sont plus nécessaires. Le paramètrecorePoolSize est la quantité de threads principaux qui seront instanciés et conservés dans le pool. Si tous les threads principaux sont occupés et que davantage de tâches sont soumises, le pool est autorisé à augmenter jusqu'à unmaximumPoolSize.

Le paramètrekeepAliveTime est l'intervalle de temps pendant lequel les threads excessifs (i.e. les threads instanciés au-delà descorePoolSize) sont autorisés à exister à l'état inactif.

Ces paramètres couvrent un large éventail de cas d'utilisation, maisthe most typical configurations are predefined in the Executors static methods.

La méthodeFor example,newFixedThreadPool crée unThreadPoolExecutor avec des valeurs de paramètrecorePoolSize etmaximumPoolSize égales et un zérokeepAliveTime. Cela signifie que le nombre de threads dans ce pool de threads est toujours le même:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(2, executor.getPoolSize());
assertEquals(1, executor.getQueue().size());

Dans l'exemple ci-dessus, nous instancions unThreadPoolExecutor avec un nombre de threads fixe de 2. Cela signifie que si le nombre de tâches simultanément exécutées est inférieur ou égal à deux à tout moment, elles sont exécutées immédiatement. Sinonsome of these tasks may be put into a queue to wait for their turn.

Nous avons créé trois tâchesCallable qui imitent le travail lourd en dormant pendant 1000 millisecondes. Les deux premières tâches seront exécutées en même temps et la troisième devra attendre dans la file d'attente. Nous pouvons le vérifier en appelant les méthodesgetPoolSize() etgetQueue().size() immédiatement après avoir soumis les tâches.

Un autreThreadPoolExecutor préconfiguré peut être créé avec la méthodeExecutors.newCachedThreadPool(). Cette méthode ne reçoit aucun nombre de threads. LecorePoolSize est en fait mis à 0, et lemaximumPoolSize est mis àInteger.MAX_VALUE pour cette instance. LekeepAliveTime est de 60 secondes pour celui-ci.

Ces valeurs de paramètre signifient quethe cached thread pool may grow without bounds to accommodate any amount of submitted tasks. Mais lorsque les threads ne sont plus nécessaires, ils seront éliminés après 60 secondes d'inactivité. Un cas d'utilisation typique est lorsque vous avez beaucoup de tâches de courte durée dans votre application.

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newCachedThreadPool();
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(3, executor.getPoolSize());
assertEquals(0, executor.getQueue().size());

La taille de la file d'attente dans l'exemple ci-dessus sera toujours égale à zéro car une instanceSynchronousQueue est utilisée en interne. Dans unSynchronousQueue, des paires d'opérationsinsert etremove se produisent toujours simultanément, donc la file d'attente ne contient jamais rien.

L'APIExecutors.newSingleThreadExecutor() crée une autre forme typique deThreadPoolExecutor contenant un seul thread. The single thread executor is ideal for creating an event loop. Les paramètrescorePoolSize etmaximumPoolSize sont égaux à 1 et lekeepAliveTime est égal à zéro.

Les tâches de l'exemple ci-dessus seront exécutées de manière séquentielle, de sorte que la valeur de l'indicateur sera de 2 une fois la tâche terminée:

AtomicInteger counter = new AtomicInteger();

ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
    counter.set(1);
});
executor.submit(() -> {
    counter.compareAndSet(1, 2);
});

En outre, ceThreadPoolExecutor est décoré avec un wrapper immuable, il ne peut donc pas être reconfiguré après sa création. Notez que c'est aussi la raison pour laquelle nous ne pouvons pas le convertir enThreadPoolExecutor.

3.3. ScheduledThreadPoolExecutor

LeScheduledThreadPoolExecutor étend la classeThreadPoolExecutor et implémente également l'interfaceScheduledExecutorService avec plusieurs méthodes supplémentaires:

  • La méthodeschedule permet d'exécuter une tâche une fois après un délai spécifié;

  • La méthodescheduleAtFixedRate permet d'exécuter une tâche après un délai initial spécifié, puis de l'exécuter à plusieurs reprises avec une certaine période; l'argumentperiod est le tempsmeasured between the starting times of the tasks, donc le taux d'exécution est fixe;

  • La méthodescheduleWithFixedDelay est similaire àscheduleAtFixedRate en ce qu'elle exécute à plusieurs reprises la tâche donnée, mais le délai spécifié estmeasured between the end of the previous task and the start of the next; le taux d'exécution peut varier en fonction du temps nécessaire pour exécuter une tâche donnée.

La méthodeExecutors.newScheduledThreadPool() est généralement utilisée pour créer unScheduledThreadPoolExecutor avec uncorePoolSize donné, unmaximumPoolSize illimité et zérokeepAliveTime. Voici comment planifier une tâche pour une exécution en 500 millisecondes:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
executor.schedule(() -> {
    System.out.println("Hello World");
}, 500, TimeUnit.MILLISECONDS);

Le code suivant montre comment exécuter une tâche après un délai de 500 millisecondes, puis la répéter toutes les 100 millisecondes. Après avoir planifié la tâche, nous attendons qu'elle se déclenche trois fois en utilisant le verrouCountDownLatch, puis l'annulons en utilisant la méthodeFuture.cancel().

CountDownLatch lock = new CountDownLatch(3);

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
ScheduledFuture future = executor.scheduleAtFixedRate(() -> {
    System.out.println("Hello World");
    lock.countDown();
}, 500, 100, TimeUnit.MILLISECONDS);

lock.await(1000, TimeUnit.MILLISECONDS);
future.cancel(true);

3.4. ForkJoinPool

ForkJoinPool est la partie centrale du frameworkfork/join introduit dans Java 7. Il résout un problème courant despawning multiple tasks in recursive algorithms. En utilisant un simpleThreadPoolExecutor, vous serez rapidement à court de threads, car chaque tâche ou sous-tâche nécessite son propre thread pour s'exécuter.

Dans une structurefork/join, toute tâche peut générer (fork) un certain nombre de sous-tâches et attendre leur achèvement en utilisant la méthodejoin. L'avantage du frameworkfork/join est qu'ildoes not create a new thread for each task or subtask, implémentant l'algorithme Work Stealing à la place. Ce framework est décrit en détail dans l'article «Guide to the Fork/Join Framework in Java»

Examinons un exemple simple d'utilisation deForkJoinPool pour traverser un arbre de nœuds et calculer la somme de toutes les valeurs de feuille. Voici une implémentation simple d'une arborescence composée d'un nœud, d'une valeurint et d'un ensemble de nœuds enfants:

static class TreeNode {

    int value;

    Set children;

    TreeNode(int value, TreeNode... children) {
        this.value = value;
        this.children = Sets.newHashSet(children);
    }
}

Maintenant, si nous voulons additionner toutes les valeurs dans un arbre en parallèle, nous devons implémenter une interfaceRecursiveTask<Integer>. Chaque tâche reçoit son propre nœud et ajoute sa valeur à la somme des valeurs de seschildren. Pour calculer la somme des valeurschildren, l'implémentation de la tâche effectue les opérations suivantes:

  • diffuse l'ensemble dechildren,

  • mappe sur ce flux, créant un nouveauCountingTask pour chaque élément,

  • exécute chaque tâche en la forçant,

  • collecte les résultats en appelant la méthodejoin sur chaque tâche fourchue,

  • additionne les résultats en utilisant le collecteurCollectors.summingInt.

public static class CountingTask extends RecursiveTask {

    private final TreeNode node;

    public CountingTask(TreeNode node) {
        this.node = node;
    }

    @Override
    protected Integer compute() {
        return node.value + node.children.stream()
          .map(childNode -> new CountingTask(childNode).fork())
          .collect(Collectors.summingInt(ForkJoinTask::join));
    }
}

Le code pour exécuter le calcul sur une arborescence réelle est très simple:

TreeNode tree = new TreeNode(5,
  new TreeNode(3), new TreeNode(2,
    new TreeNode(2), new TreeNode(8)));

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
int sum = forkJoinPool.invoke(new CountingTask(tree));

4. Implémentation de Thread Pool dans Guava

Guava est une bibliothèque d'utilitaires Google populaire. Il a de nombreuses classes de concurrence utiles, y compris plusieurs implémentations pratiques deExecutorService. Les classes d'implémentation ne sont pas accessibles pour l'instanciation directe ou le sous-classement, donc le seul point d'entrée pour créer leurs instances est la classe d'assistanceMoreExecutors.

4.1. Ajouter Guava en tant que dépendance Maven

Ajoutez la dépendance suivante à votre fichier Maven pom pour inclure la bibliothèque Guava à votre projet. Vous pouvez trouver la dernière version de la bibliothèque Guava dans le référentielMaven Central:


    com.google.guava
    guava
    19.0

4.2. Service exécuteur direct et exécuteur direct

Parfois, vous souhaitez exécuter la tâche dans le thread actuel ou dans un pool de threads, en fonction de certaines conditions. Vous préférez utiliser une seule interfaceExecutor et changer simplement l'implémentation. Bien qu'il ne soit pas si difficile de trouver une implémentation deExecutor ouExecutorService qui exécute les tâches dans le thread actuel, il faut quand même écrire du code standard.

Heureusement, Guava nous fournit des instances prédéfinies.

Here’s an example qui montre l'exécution d'une tâche dans le même thread. Bien que la tâche fournie dorme pendant 500 millisecondes, elleblocks the current thread et le résultat est disponible immédiatement après la fin de l'appel deexecute:

Executor executor = MoreExecutors.directExecutor();

AtomicBoolean executed = new AtomicBoolean();

executor.execute(() -> {
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    executed.set(true);
});

assertTrue(executed.get());

L'instance renvoyée par la méthodedirectExecutor() est en fait un singleton statique, donc l'utilisation de cette méthode n'entraîne aucune surcharge sur la création d'objet.

Vous devriez préférer cette méthode auxMoreExecutors.newDirectExecutorService(), car cette API crée une implémentation de service d'exécuteur à part entière à chaque appel.

4.3. Sortie des services exécuteur

Un autre problème courant estshutting down the virtual machine alors qu'un pool de threads exécute toujours ses tâches. Même avec un mécanisme d'annulation en place, rien ne garantit que les tâches se comporteront correctement et arrêteront leur travail à la fermeture du service de l'exécuteur. La JVM peut se bloquer indéfiniment pendant que les tâches continuent de fonctionner.

Pour résoudre ce problème, Guava introduit une famille de services d’exécuteur existants. Ils sont basés surdaemon threads which terminate together with the JVM.

Ces services ajoutent également un hook d'arrêt avec la méthodeRuntime.getRuntime().addShutdownHook() et empêchent la machine virtuelle de s'arrêter pendant une durée configurée avant d'abandonner les tâches bloquées.

Dans l'exemple suivant, nous soumettons la tâche qui contient une boucle infinie, mais nous utilisons un service d'exécuteur sortant avec une durée configurée de 100 millisecondes pour attendre les tâches à l'arrêt de la VM. Sans lesexitingExecutorService en place, cette tâche entraînerait le blocage de la VM indéfiniment:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
ExecutorService executorService =
  MoreExecutors.getExitingExecutorService(executor,
    100, TimeUnit.MILLISECONDS);

executorService.submit(() -> {
    while (true) {
    }
});

4.4. Décorateurs d'écoute

Les décorateurs d'écoute vous permettent d'encapsuler lesExecutorService et de recevoir les instances deListenableFuture lors de la soumission de la tâche au lieu de simples instances deFuture. L'interfaceListenableFuture étendFuture et possède une seule méthode supplémentaireaddListener. Cette méthode permet d’ajouter un écouteur appelé lorsqu’il sera terminé ultérieurement.

Vous souhaiterez rarement utiliser directement la méthodeListenableFuture.addListener(), mais c'estessential to most of the helper methods in the Futures utility class. Par exemple, avec la méthodeFutures.allAsList(), vous pouvez combiner plusieurs instancesListenableFuture en un seulListenableFuture qui se termine après la réussite de tous les contrats à terme combinés:

ExecutorService executorService = Executors.newCachedThreadPool();
ListeningExecutorService listeningExecutorService =
  MoreExecutors.listeningDecorator(executorService);

ListenableFuture future1 =
  listeningExecutorService.submit(() -> "Hello");
ListenableFuture future2 =
  listeningExecutorService.submit(() -> "World");

String greeting = Futures.allAsList(future1, future2).get()
  .stream()
  .collect(Collectors.joining(" "));
assertEquals("Hello World", greeting);

5. Conclusion

Dans cet article, nous avons présenté le modèle Thread Pool et ses implémentations dans la bibliothèque Java standard et dans la bibliothèque Guava de Google.

Le code source de l'article est disponibleover on GitHub.