paint-brush
Un guide sur le réapprentissage des primitives de thread Javapar@shai.almog
761 lectures
761 lectures

Un guide sur le réapprentissage des primitives de thread Java

par Shai Almog8m2023/04/11
Read on Terminal Reader

Trop long; Pour lire

La synchronisation était révolutionnaire et a toujours de grandes utilisations. Mais il est temps de passer à de nouvelles primitives de thread et potentiellement de repenser notre logique de base.
featured image - Un guide sur le réapprentissage des primitives de thread Java
Shai Almog HackerNoon profile picture

J'ai codé en Java depuis la première bêta ; même à l'époque, les fils de discussion figuraient en tête de ma liste de fonctionnalités préférées. Java a été le premier langage à introduire le support des threads dans le langage lui-même ; c'était une décision controversée à l'époque.


Au cours de la dernière décennie, chaque langage s'est empressé d'inclure async/wait, et même Java avait un support tiers pour cela … Mais Java a zigouillé au lieu de zag et a introduit les threads virtuels bien supérieurs (projet Loom) . Ce post ne parle pas de ça.


Je pense que c'est merveilleux et prouve la puissance de base de Java. Pas seulement en tant que langue mais en tant que culture. Une culture de changements délibérés au lieu de se précipiter dans la tendance à la mode.


Dans cet article, je souhaite revisiter les anciennes méthodes de threading en Java. J'ai l'habitude de synchronized , wait , notify , etc. Mais cela fait longtemps qu'ils n'ont pas été l'approche supérieure pour les threads en Java.


je fais partie du problème; Je suis toujours habitué à ces approches et j'ai du mal à m'habituer à certaines API qui existent depuis Java 5. C'est une force d'habitude. J


Il existe de nombreuses excellentes API pour travailler avec les threads dont je parle dans les vidéos ici, mais je veux parler des verrous qui sont basiques mais importants.

Synchronisé contre ReentrantLock

Une réticence que j'avais à quitter la synchronisation est que les alternatives ne sont pas beaucoup mieux. La principale motivation pour le quitter aujourd'hui est qu'à l'heure actuelle, la synchronisation peut déclencher l'épinglage de threads dans Loom, ce qui n'est pas idéal.


JDK 21 pourrait résoudre ce problème (lorsque Loom passe en GA), mais il est toujours logique de le laisser.


Le remplacement direct de synchronisé est ReentrantLock. Malheureusement, ReentrantLock présente très peu d'avantages par rapport à la synchronisation, de sorte que l'avantage de la migration est au mieux douteux.


En fait, il a un inconvénient majeur ; pour avoir une idée de cela, regardons un exemple. Voici comment nous utiliserions synchronisé :

 synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }


Le premier inconvénient de ReentrantLock est la verbosité. Nous avons besoin du bloc try car si une exception se produit dans le bloc, le verrou restera. Poignées synchronisées qui de manière transparente pour nous.


Il y a une astuce que certaines personnes utilisent pour envelopper la serrure avec AutoClosable qui ressemble à peu près à ceci :

 public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }


Remarquez que je n'implémente pas l'interface Lock qui aurait été idéale. C'est parce que la méthode lock renvoie l'implémentation à fermeture automatique au lieu de void .


Une fois que nous avons fait cela, nous pouvons écrire un code plus concis tel que celui-ci :

 try(LOCK.lock()) { // safe code }


J'aime la verbosité réduite, mais c'est un concept problématique car try-with-resource est conçu à des fins de nettoyage et nous réutilisons les verrous. Il invoque close, mais nous invoquerons à nouveau cette méthode sur le même objet.


Je pense qu'il pourrait être intéressant d'étendre l'essai avec la syntaxe des ressources pour prendre en charge l'interface de verrouillage. Mais jusqu'à ce que cela se produise, ce n'est peut-être pas une astuce valable.

Avantages de ReentrantLock

La principale raison d'utiliser ReentrantLock est la prise en charge de Loom. Les autres avantages sont intéressants, mais aucun d'entre eux n'est une "fonctionnalité tueuse".


Nous pouvons l'utiliser entre les méthodes plutôt que dans un bloc continu. C'est probablement une mauvaise idée car vous voulez minimiser la zone de verrouillage, et l'échec peut être un problème. Je ne considère pas cette caractéristique comme un avantage.


Il a l'option de l'équité. Cela signifie qu'il servira d'abord le premier thread qui s'est arrêté à un verrou. J'ai essayé de penser à un cas d'utilisation réaliste et non alambiqué où cela aura de l'importance, et je dessine des blancs.


Si vous écrivez un planificateur complexe avec de nombreux threads constamment mis en file d'attente sur une ressource, vous pouvez créer une situation dans laquelle un thread est "affamé" car d'autres threads continuent d'arriver. Mais de telles situations sont probablement mieux servies par d'autres options dans le package de concurrence .


Peut-être qu'il me manque quelque chose ici…


lockInterruptibly() nous permet d'interrompre un thread pendant qu'il attend un verrou. C'est une fonctionnalité intéressante, mais encore une fois, difficile de trouver une situation où cela ferait une différence réaliste.


Si vous écrivez du code qui doit être très réactif aux interruptions, vous devrez utiliser l'API lockInterruptibly() pour obtenir cette capacité. Mais combien de temps passez-vous en moyenne dans la méthode lock() ?


Il y a des cas extrêmes où cela compte probablement, mais ce n'est pas quelque chose que la plupart d'entre nous rencontreront, même en faisant du code multithread avancé.

ReadWriteReentrantLock

Une bien meilleure approche est le ReadWriteReentrantLock . La plupart des ressources suivent le principe des lectures fréquentes et peu d'opérations d'écriture. Étant donné que la lecture d'une variable est thread-safe, il n'y a pas besoin de verrou sauf si nous sommes en train d'écrire dans la variable.


Cela signifie que nous pouvons optimiser la lecture à l'extrême tout en ralentissant légèrement les opérations d'écriture.


En supposant qu'il s'agisse de votre cas d'utilisation, vous pouvez créer un code beaucoup plus rapide. Lorsque vous travaillez avec un verrou en lecture-écriture, nous avons deux verrous ; un verrou de lecture comme on peut le voir dans l'image suivante. Il laisse passer plusieurs threads et est effectivement un "gratuit pour tous".


Une fois que nous avons besoin d'écrire dans la variable, nous devons obtenir un verrou en écriture comme nous pouvons le voir dans l'image suivante. Nous essayons de demander le verrou en écriture, mais il y a encore des threads qui lisent à partir de la variable, nous devons donc attendre.


Une fois que les threads ont fini de lire, toutes les lectures seront bloquées et l'opération d'écriture ne peut se produire qu'à partir d'un seul thread, comme le montre l'image suivante. Une fois que nous aurons relâché le verrou d'écriture, nous reviendrons à la situation "gratuit pour tous" de la première image.


Il s'agit d'un modèle puissant que nous pouvons exploiter pour accélérer les collectes. Une liste synchronisée typique est remarquablement lente. Il se synchronise sur toutes les opérations, lecture ou écriture. Nous avons une CopyOnWriteArrayList qui est rapide en lecture, mais toute écriture est très lente.


En supposant que vous puissiez éviter de renvoyer des itérateurs à partir de vos méthodes, vous pouvez encapsuler des opérations de liste et utiliser cette API.


Par exemple, dans le code suivant, nous exposons la liste des noms en lecture seule, mais lorsque nous devons ajouter un nom, nous utilisons le verrou en écriture. Cela peut facilement surpasser les listes synchronized :

 private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }

StampedLock

La première chose que nous devons comprendre à propos de StampedLock est qu'il n'est pas réentrant. Supposons que nous ayons ce bloc :

 synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }

Cela fonctionnera. Étant donné que la synchronisation est réentrante. Nous détenons déjà le verrou, donc aller dans methodB() à partir de methodA() ne bloquera pas. Cela fonctionne également avec ReentrantLock en supposant que nous utilisons le même verrou ou le même objet synchronisé.


StampedLock renvoie un tampon que nous utilisons pour libérer le verrou. De ce fait, il a certaines limites. Mais ça reste très rapide et puissant. Il comprend également un tampon de lecture et d'écriture que nous pouvons utiliser pour protéger une ressource partagée.


Mais contrairement au ReadWriteReentrantLock, il nous permet de mettre à jour le verrou. Pourquoi aurions-nous besoin de faire cela?

Regardez la méthode addName() d'avant… Et si je l'invoque deux fois avec « Shai » ?


Oui, je pourrais utiliser un Set… Mais pour le but de cet exercice, disons que nous avons besoin d'une liste… Je pourrais écrire cette logique avec le ReadWriteReentrantLock :

 public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }


C'est nul. J'ai "payé" un verrou en écriture uniquement pour vérifier contains() dans certains cas (en supposant qu'il existe de nombreux doublons). Nous pouvons appeler isInList(name) avant d'obtenir le verrou en écriture. Ensuite, nous ferions :


  • Saisissez le verrou de lecture


  • Libérer le verrou de lecture


  • Saisissez le verrou en écriture


  • Libérer le verrou en écriture


Dans les deux cas d'accaparement, nous pourrions être mis en file d'attente, et cela ne vaut peut-être pas la peine de s'embêter davantage.


Avec un StampedLock , nous pouvons mettre à jour le verrou de lecture en verrou d'écriture et faire le changement sur place si nécessaire comme tel :

 public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }

C'est une optimisation puissante pour ces cas.

Enfin

Je couvre de nombreux sujets similaires dans la série de vidéos ci-dessus ; vérifiez-le et dites-moi ce que vous en pensez.


J'atteins souvent les collections synchronisées sans y penser à deux fois. Cela peut parfois être raisonnable, mais pour la plupart, c'est probablement sous-optimal. En passant un peu de temps avec les primitives liées aux threads, nous pouvons améliorer considérablement nos performances.


Cela est particulièrement vrai lorsqu'il s'agit de Loom où le conflit sous-jacent est beaucoup plus sensible. Imaginez la mise à l'échelle des opérations de lecture sur 1 million de threads simultanés… Dans ces cas, l'importance de réduire les conflits de verrous est bien plus importante.


Vous pourriez penser, pourquoi les collections synchronized ne peuvent-elles pas utiliser ReadWriteReentrantLock ou même StampedLock ?


Ceci est problématique car la surface de l'API est si grande qu'il est difficile de l'optimiser pour un cas d'utilisation générique. C'est là que le contrôle des primitives de bas niveau peut faire la différence entre un débit élevé et un code bloquant.