Utilitaire Java Concurrency avec JCTools

Utilitaire Java Concurrency avec JCTools

1. Vue d'ensemble

Dans ce didacticiel, nous allons présenter la bibliothèqueJCTools (Java Concurrency Tools).

En termes simples, cela fournit un certain nombre de structures de données utilitaires adaptées au travail dans un environnement multithread.

2. Algorithmes non bloquants

Traditionally, multi-threaded code which works on a mutable shared state uses locks pour assurer la cohérence des données et les publications (modifications apportées par un thread qui sont visibles par un autre).

Cette approche présente un certain nombre d'inconvénients:

  • les threads peuvent être bloqués lors d'une tentative d'acquisition d'un verrou, ne faisant aucun progrès tant que l'opération d'un autre thread n'est pas terminée - cela empêche effectivement le parallélisme

  • plus le conflit entre verrous est important, plus la JVM consacre de temps à traiter les threads de planification, de gestion des conflits et des files d'attente de threads en attente, et moins de travail réel est accompli

  • les blocages sont possibles si plus d'un verrou est impliqué et qu'ils sont acquis / libérés dans le mauvais ordre

  • un risquepriority inversion est possible - un thread de haute priorité est verrouillé pour tenter d'obtenir un verrou maintenu par un thread de basse priorité

  • la plupart du temps, les verrous à grain grossier sont utilisés, ce qui nuit beaucoup au parallélisme - le verrouillage à grain fin nécessite une conception plus minutieuse, augmente le temps de verrouillage et est davantage sujet aux erreurs

Une alternative consiste à utiliser unnon-blocking algorithm, i.e. an algorithm where failure or suspension of any thread cannot cause failure or suspension of another thread.

Un algorithme non bloquant estlock-free si au moins l'un des threads impliqués est assuré de progresser sur une période de temps arbitraire, c'est-à-dire les blocages ne peuvent pas survenir pendant le traitement.

De plus, ces algorithmes sontwait-free s'il existe également une progression garantie par thread.

Voici un exemple non bloquant deStack tiré de l'excellent livreJava Concurrency in Practice; il définit l'état de base:

public class ConcurrentStack {

    AtomicReference> top = new AtomicReference>();

    private static class Node  {
        public E item;
        public Node next;

        // standard constructor
    }
}

Et aussi quelques méthodes API:

public void push(E item){
    Node newHead = new Node(item);
    Node oldHead;

    do {
        oldHead = top.get();
        newHead.next = oldHead;
    } while(!top.compareAndSet(oldHead, newHead));
}

public E pop() {
    Node oldHead;
    Node newHead;
    do {
        oldHead = top.get();
        if (oldHead == null) {
            return null;
        }
        newHead = oldHead.next;
    } while (!top.compareAndSet(oldHead, newHead));

    return oldHead.item;
}

Nous pouvons voir que l'algorithme utilise des instructions de comparaison et d'échange (CAS) à granularité fine et qu'il estlock-free (même si plusieurs threads appellenttop.compareAndSet() simultanément, l'un d'eux est garanti réussi) mais paswait-free car il n'y a aucune garantie que CAS réussisse finalement pour un thread particulier.

3. Dépendance

Tout d'abord, ajoutons la dépendance JCTools à nospom.xml:


    org.jctools
    jctools-core
    2.1.2

Veuillez noter que la dernière version disponible est disponible surMaven Central.

4. Files d'attente JCTools

La bibliothèque offre un certain nombre de files d’attente à utiliser dans un environnement multithread, c.-à-d. un ou plusieurs threads écrivent dans une file d'attente et un ou plusieurs threads la lisent de manière sécurisée sans thread.

L'interface commune pour toutes les implémentations deQueue estorg.jctools.queues.MessagePassingQueue.

4.1. Types de files d'attente

Toutes les files d'attente peuvent être classées selon leurs politiques de production / consommation:

  • single producer, single consumer – ces classes sont nommées en utilisant le préfixeSpsc, par exemple SpscArrayQueue

  • single producer, multiple consumers – utilise le préfixeSpmc, par ex. SpmcArrayQueue

  • multiple producers, single consumer – utilise le préfixeMpsc, par ex. MpscArrayQueue

  • multiple producers, multiple consumers – utilise le préfixeMpmc, par ex. MpmcArrayQueue

Il est important de noter quethere are no policy checks internally, i.e. a queue might silently misfunction in case of incorrect usage.

E.g. le test ci-dessous remplit une file d'attentesingle-producer à partir de deux threads et réussit même si le consommateur n'est pas assuré de voir les données de différents producteurs:

SpscArrayQueue queue = new SpscArrayQueue<>(2);

Thread producer1 = new Thread(() -> queue.offer(1));
producer1.start();
producer1.join();

Thread producer2 = new Thread(() -> queue.offer(2));
producer2.start();
producer2.join();

Set fromQueue = new HashSet<>();
Thread consumer = new Thread(() -> queue.drain(fromQueue::add));
consumer.start();
consumer.join();

assertThat(fromQueue).containsOnly(1, 2);

4.2. Implémentations de la file d'attente

En résumant les classifications ci-dessus, voici la liste des files d'attente JCTools:

  • SpscArrayQueue producteur unique, consommateur unique, utilise un tableau en interne, capacité liée

  • SpscLinkedQueue producteur unique, consommateur unique, utilise la liste chaînée en interne, capacité non consolidée

  • SpscChunkedArrayQueue producteur unique, consommateur unique, commence avec la capacité initiale et augmente jusqu'à la capacité maximale

  • SpscGrowableArrayQueue producteur unique, consommateur unique, commence avec la capacité initiale et augmente jusqu'à la capacité maximale. C'est le même contrat queSpscChunkedArrayQueue, la seule différence est la gestion des blocs internes. Il est recommandé d'utiliserSpscChunkedArrayQueue car sa mise en œuvre est simplifiée

  • SpscUnboundedArrayQueue producteur unique, consommateur unique, utilise une baie en interne, capacité non liée

  • Producteur unique deSpmcArrayQueue, plusieurs consommateurs, utilise un tableau en interne, capacité liée

  • MpscArrayQueue plusieurs producteurs, un seul consommateur, utilise un tableau en interne, capacité liée

  • MpscLinkedQueue plusieurs producteurs, un seul consommateur, utilise une liste chaînée en interne, capacité non consolidée

  • MpmcArrayQueue plusieurs producteurs, plusieurs consommateurs, utilise un tableau en interne, capacité liée

4.3. Files d'attente atomiques

Toutes les files d'attente mentionnées dans la section précédente utilisentsun.misc.Unsafe. Cependant, avec l'avènement de Java 9 et desJEP-260, cette API devient inaccessible par défaut.

Il existe donc des files d'attente alternatives qui utilisentjava.util.concurrent.atomic.AtomicLongFieldUpdater (API publique, moins performante) au lieu desun.misc.Unsafe.

Ils sont générés à partir des files d'attente ci-dessus et leurs noms ont le motAtomic inséré entre les deux, par ex. SpscChunkedAtomicArrayQueue ouMpmcAtomicArrayQueue.

Il est recommandé d'utiliser des files d'attente «régulières» si possible et de recourir àAtomicQueues uniquement dans les environnements oùsun.misc.Unsafe est interdit / inefficace comme HotSpot Java9 + et JRockit.

4.4. Capacité

Toutes les files d'attente JCTools peuvent également avoir une capacité maximale ou être non liées. When a queue is full and it’s bound by capacity, it stops accepting new elements.

Dans l'exemple suivant, nous:

  • remplir la file d'attente

  • s'assurer qu'il cesse d'accepter de nouveaux éléments après cela

  • vidangez-en et assurez-vous qu'il est possible d'ajouter plus d'éléments par la suite

Veuillez noter que quelques instructions de code sont supprimées pour des raisons de lisibilité. L'implémentation complète peut être trouvée suron GitHub:

SpscChunkedArrayQueue queue = new SpscChunkedArrayQueue<>(8, 16);
CountDownLatch startConsuming = new CountDownLatch(1);
CountDownLatch awakeProducer = new CountDownLatch(1);

Thread producer = new Thread(() -> {
    IntStream.range(0, queue.capacity()).forEach(i -> {
        assertThat(queue.offer(i)).isTrue();
    });
    assertThat(queue.offer(queue.capacity())).isFalse();
    startConsuming.countDown();
    awakeProducer.await();
    assertThat(queue.offer(queue.capacity())).isTrue();
});

producer.start();
startConsuming.await();

Set fromQueue = new HashSet<>();
queue.drain(fromQueue::add);
awakeProducer.countDown();
producer.join();
queue.drain(fromQueue::add);

assertThat(fromQueue).containsAll(
  IntStream.range(0, 17).boxed().collect(toSet()));

5. Autres structures de données JCTools

JCTools propose également deux structures de données non-file d'attente.

Tous sont énumérés ci-dessous:

  • NonBlockingHashMap une alternativeConcurrentHashMap sans verrouillage avec de meilleures propriétés d'échelle et des coûts de mutation généralement inférieurs. Il est implémenté viasun.misc.Unsafe, il n'est donc pas recommandé d'utiliser cette classe dans un environnement HotSpot Java9 + ou JRockit

  • NonBlockingHashMapLong commeNonBlockingHashMap mais utilise les clés primitiveslong

  • NonBlockingHashSet un simple wrapper autour deNonBlockingHashMap like du JDKjava.util.Collections.newSetFromMap()

  • NonBlockingIdentityHashMap commeNonBlockingHashMap mais compare les clés par identité.

  • NonBlockingSetInt – est un ensemble de vecteurs de bits multi-thread implémenté comme un tableau de primitiveslongs. Fonctionne de manière inefficace en cas d'autoboxing silencieux

6. Test de performance

UtilisonsJMH pour comparer lesArrayBlockingQueue du JDK et Performances de la file d'attente JCTools. JMH est un framework de micro-benchmark open source de gourous JVM Sun / Oracle qui nous protège de l’indéterminisme des algorithmes d’optimisation du compilateur / JVM). N'hésitez pas à obtenir plus de détails à ce sujet dansthis article.

Notez que l'extrait de code ci-dessous manque quelques instructions afin d'améliorer la lisibilité. Veuillez trouver le code source complet surGitHub:

public class MpmcBenchmark {

    @Param({PARAM_UNSAFE, PARAM_AFU, PARAM_JDK})
    public volatile String implementation;

    public volatile Queue queue;

    @Benchmark
    @Group(GROUP_NAME)
    @GroupThreads(PRODUCER_THREADS_NUMBER)
    public void write(Control control) {
        // noinspection StatementWithEmptyBody
        while (!control.stopMeasurement && !queue.offer(1L)) {
            // intentionally left blank
        }
    }

    @Benchmark
    @Group(GROUP_NAME)
    @GroupThreads(CONSUMER_THREADS_NUMBER)
    public void read(Control control) {
        // noinspection StatementWithEmptyBody
        while (!control.stopMeasurement && queue.poll() == null) {
            // intentionally left blank
        }
    }
}

Résultats (extrait pour le 95e centile, nanosecondes par opération):

MpmcBenchmark.MyGroup:MyGroup·p0.95 MpmcArrayQueue sample 1052.000 ns/op
MpmcBenchmark.MyGroup:MyGroup·p0.95 MpmcAtomicArrayQueue sample 1106.000 ns/op
MpmcBenchmark.MyGroup:MyGroup·p0.95 ArrayBlockingQueue sample 2364.000 ns/op

On peut voir que MpmcArrayQueue performs just slightly better than MpmcAtomicArrayQueue and ArrayBlockingQueue is slower by a factor of two.

7. Inconvénients de l'utilisation de JCTools

L'utilisation de JCTools a un inconvénient important -it’s not possible to enforce that the library classes are used correctly. Par exemple, considérons une situation où nous commençons à utiliserMpscArrayQueue dans notre grand projet mature (notez qu'il doit y avoir un seul consommateur).

Malheureusement, le projet étant volumineux, il est possible que quelqu'un commette une erreur de programmation ou de configuration et la file d'attente est à présent lue à partir de plusieurs threads. Le système semble fonctionner comme avant, mais il est maintenant possible que les consommateurs passent à côté de certains messages. C'est un problème réel qui pourrait avoir un impact important et qui est très difficile à déboguer.

Dans l'idéal, il devrait être possible de faire fonctionner un système avec une propriété système particulière, ce qui oblige JCTools à garantir la stratégie d'accès aux threads. E.g. local/test/staging environments (but not production) might have it turned on. Malheureusement, JCTools ne fournit pas une telle propriété.

Une autre considération est que même si nous nous sommes assurés que JCTools est nettement plus rapide que l’équivalent du JDK, cela ne signifie pas que notre application gagne la même vitesse que nous commençons à utiliser les implémentations de file d’attente personnalisées. La plupart des applications n'échangent pas beaucoup d'objets entre les threads et sont principalement liées aux E / S.

8. Conclusion

Nous avons maintenant une compréhension de base des classes d’utilitaires offertes par JCTools et avons vu à quel point elles fonctionnent, par rapport aux homologues du JDK sous forte charge.

En conclusion,it’s worth to use the library only if we exchange a lot of objects between threads and even then it’s necessary to be very careful to preserve thread access policy.

Comme toujours, le code source complet des exemples ci-dessus peut être trouvéover on GitHub.