La thread-safety est la propriété d’un objet qui garantit une exécution sécurisée par plusieurs threads en même temps. Comme les threads s’exécutent dans un environnement partagé et un espace mémoire commun, comment empêcher deux threads d’interférer l’un avec l’autre? Nous devons organiser l’accès aux données de façon à ne pas obtenir des résultats invalides ou inattendus.
Dans cette partie du chapitre, nous montrons comment utiliser diverses techniques pour protéger les données, y compris les classes atomiques, les blocs synchronisés, le framework Lock, et les barrières cycliques.
Comprendre la Thread-Safety
Imaginons que notre zoo dispose d’un programme pour compter les moutons, de préférence un qui ne va pas endormir les employés du zoo! Chaque employé du zoo court vers un champ, ajoute un nouveau mouton au troupeau, compte le nombre total de moutons, et revient vers nous pour rapporter les résultats. Nous présentons le code suivant pour représenter cela conceptuellement, en choisissant une taille de pool de threads permettant à toutes les tâches d’être exécutées simultanément:
import java.util.concurrent.*;
public class GestionMoutons {
private int compteurMoutons = 0;
private void incrementerEtRapporter() {
System.out.print((++compteurMoutons)+" ");
}
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(20);
try {
GestionMoutons gestionnaire = new GestionMoutons();
for(int i = 0; i < 10; i++)
service.submit(() -> gestionnaire.incrementerEtRapporter());
} finally {
service.shutdown();
}
}
}
Que produit ce programme? Vous pourriez penser qu’il affichera les nombres de 1 à 10 dans l’ordre, mais ce n’est pas du tout garanti. Il peut afficher dans un ordre différent. Pire encore, il peut imprimer certains nombres deux fois et ne pas imprimer d’autres nombres du tout! Voici des sorties possibles de ce programme:
1 2 3 4 5 6 7 8 9 10
1 9 8 7 3 6 6 2 4 5
1 8 7 3 2 6 5 4 2 9
Alors, qu’est-ce qui a mal tourné? Dans cet exemple, nous utilisons l’opérateur de pré-incrémentation (++) pour mettre à jour la variable compteurMoutons. Un problème survient lorsque deux threads exécutent tous deux la partie droite de l’expression, lisant l’«ancienne» valeur avant que l’un ou l’autre des threads n’écrive la «nouvelle» valeur dans la variable. Les deux affectations deviennent redondantes; elles assignent toutes deux la même nouvelle valeur, un thread écrasant les résultats de l’autre.
Vous pouvez voir que les deux threads lisent et écrivent les mêmes valeurs, ce qui fait qu’une des deux opérations ++compteurMoutons est perdue. Par conséquent, l’opérateur d’incrémentation ++ n’est pas thread-safe. Comme vous le verrez plus tard dans ce chapitre, le résultat inattendu de deux tâches s’exécutant en même temps est appelé une condition de course.
Conceptuellement, l’idée ici est que certains employés du zoo peuvent courir plus vite en allant au champ mais plus lentement en revenant et rapporter tard. D’autres travailleurs peuvent arriver au champ en dernier mais, d’une façon ou d’une autre, être les premiers à revenir pour rapporter les résultats.
Accéder aux Données avec volatile
Le mot-clé volatile
est utilisé pour garantir que l’accès aux données en mémoire est cohérent. Par exemple, il est possible (bien qu’improbable) que notre exemple GestionMoutons utilisant ++compteurMoutons renvoie une valeur inattendue en raison d’un accès mémoire invalide pendant que le code exécute une section critique. Conceptuellement, cela correspond à l’un de nos employés du zoo qui trébuche sur le chemin du retour et quelqu’un qui lui demande le nombre actuel de moutons alors qu’il est encore en train d’essayer de se relever!
L’attribut volatile
garantit qu’un seul thread modifie une variable à la fois et que les données lues entre plusieurs threads sont cohérentes. De cette manière, nous n’interrompons pas l’un de nos employés du zoo en pleine course. Alors, volatile
fournit-il la thread-safety? Pas exactement. Considérons ce remplacement à notre application précédente:
private volatile int compteurMoutons = 0;
private void incrementerEtRapporter() {
System.out.print((++compteurMoutons)+" ");
}
Malheureusement, ce code n’est pas thread-safe et pourrait toujours entraîner la perte de certains nombres:
2 6 1 7 5 3 2 9 4 8
La raison pour laquelle ce code n’est pas thread-safe est que ++compteurMoutons est toujours composé de deux opérations distinctes. Autrement dit, si l’opérateur d’incrémentation représente l’expression compteurMoutons = compteurMoutons + 1, alors chaque opération de lecture et d’écriture est thread-safe, mais l’opération combinée ne l’est pas. Pour revenir à notre exemple de moutons, nous n’interrompons pas un employé pendant qu’il court, mais nous pourrions toujours avoir plusieurs personnes dans le champ au même moment.
En pratique, volatile
est rarement utilisé.
Protéger les Données avec les Classes Atomiques
Dans nos applications précédentes de GestionMoutons, les mêmes valeurs étaient imprimées deux fois, avec le compteur le plus élevé étant 9 au lieu de 10. Comme nous l’avons vu, l’opérateur d’incrémentation ++ n’est pas thread-safe, même lorsque volatile
est utilisé. Il n’est pas thread-safe car l’opération n’est pas atomique, réalisant deux tâches, lecture et écriture, qui peuvent être interrompues par d’autres threads.
L’atomicité est la propriété d’une opération d’être exécutée comme une seule unité d’exécution sans aucune interférence d’un autre thread. Une version atomique thread-safe de l’opérateur d’incrémentation effectuerait la lecture et l’écriture de la variable comme une seule opération, ne permettant à aucun autre thread d’accéder à la variable pendant l’opération.
Dans ce cas, tout thread essayant d’accéder à la variable compteurMoutons pendant qu’une opération atomique est en cours devra attendre que l’opération atomique sur la variable soit terminée. Conceptuellement, c’est comme établir une règle pour nos employés du zoo selon laquelle il ne peut y avoir qu’un seul employé dans le champ à la fois, bien qu’ils puissent ne pas rapporter leurs résultats dans l’ordre.
Comme l’accès aux primitives et aux références est courant en Java, l’API Concurrency inclut de nombreuses classes utiles dans le package java.util.concurrent.atomic
. Le tableau 13.6 liste les classes atomiques avec lesquelles vous devriez vous familiariser. Comme beaucoup des classes de l’API Concurrency, ces classes existent pour vous faciliter la vie.
Nom de la classe | Description |
---|---|
AtomicBoolean | Une valeur booléenne qui peut être mise à jour atomiquement |
AtomicInteger | Une valeur int qui peut être mise à jour atomiquement |
AtomicLong | Une valeur long qui peut être mise à jour atomiquement |
Comment utilisons-nous une classe atomique? Chaque classe inclut de nombreuses méthodes qui sont équivalentes à beaucoup d’opérateurs primitifs intégrés que nous utilisons sur les primitives, comme l’opérateur d’affectation (=) et les opérateurs d’incrémentation (++). Nous décrivons les méthodes atomiques courantes que vous devriez connaître dans le Tableau 13.7. Le type est déterminé par la classe.
Dans l’exemple suivant, nous importons le package atomic et mettons à jour notre classe GestionMoutons avec un AtomicInteger:
private AtomicInteger compteurMoutons = new AtomicInteger(0);
private void incrementerEtRapporter() {
System.out.print(compteurMoutons.incrementAndGet()+" ");
}
Méthode | Description |
---|---|
get() | Récupère la valeur actuelle |
set(type nouvelleValeur) | Définit la valeur donnée, équivalent à l’opérateur d’affectation = |
getAndSet(type nouvelleValeur) | Définit atomiquement la nouvelle valeur et renvoie l’ancienne valeur |
incrementAndGet() | Pour les classes numériques, opération atomique de pré-incrémentation équivalente à ++valeur |
getAndIncrement() | Pour les classes numériques, opération atomique de post-incrémentation équivalente à valeur++ |
decrementAndGet() | Pour les classes numériques, opération atomique de pré-décrémentation équivalente à –valeur |
getAndDecrement() | Pour les classes numériques, opération atomique de post-décrémentation équivalente à valeur– |
En quoi cette implémentation diffère-t-elle de nos exemples précédents? Lorsque nous exécutons cette modification, nous obtenons des sorties variables, comme les suivantes:
2 3 1 4 5 6 7 8 9 10
1 4 3 2 5 6 7 8 9 10
1 4 3 5 6 2 7 8 10 9
Contrairement à nos exemples de sortie précédents, les nombres de 1 à 10 seront toujours imprimés, bien que l’ordre ne soit toujours pas garanti. Ne vous inquiétez pas; nous aborderons ce problème bientôt. L’essentiel dans cette section est que l’utilisation des classes atomiques garantit que les données sont cohérentes entre les travailleurs et qu’aucune valeur n’est perdue en raison de modifications concurrentes.
Améliorer l’Accès avec les Blocs synchronized
Alors que les classes atomiques sont excellentes pour protéger une seule variable, elles ne sont pas particulièrement utiles si vous devez exécuter une série de commandes ou appeler une méthode. Par exemple, nous ne pouvons pas les utiliser pour mettre à jour deux variables atomiques en même temps. Comment améliorer les résultats pour que chaque travailleur puisse incrémenter et rapporter les résultats dans l’ordre?
La technique la plus courante consiste à utiliser un moniteur pour synchroniser l’accès. Un moniteur, également appelé verrou, est une structure qui prend en charge l’exclusion mutuelle, qui est la propriété qu’au plus un thread exécute un segment particulier de code à un moment donné.
En Java, tout objet peut être utilisé comme moniteur, avec le mot-clé synchronized
, comme le montre l’exemple suivant:
var gestionnaire = new GestionMoutons();
synchronized(gestionnaire) {
// Travail à effectuer par un thread à la fois
}
Cet exemple est appelé un bloc synchronized. Chaque thread qui arrive vérifiera d’abord si des threads exécutent déjà le bloc. Si le verrou n’est pas disponible, le thread passera à l’état BLOCKED jusqu’à ce qu’il puisse “acquérir le verrou”. Si le verrou est disponible (ou si le thread détient déjà le verrou), le thread unique entrera dans le bloc, empêchant tous les autres threads d’entrer. Une fois que le thread a terminé l’exécution du bloc, il libérera le verrou, permettant à l’un des threads en attente de continuer.
Pour synchroniser l’accès à travers plusieurs threads, chaque thread doit avoir accès au même objet. Si chaque thread se synchronise sur des objets différents, le code n’est pas thread-safe.
Revisitant notre exemple GestionMoutons qui utilisait ++compteurMoutons, voyons si nous pouvons améliorer les résultats pour que chaque travailleur incrémente et affiche le compteur dans l’ordre. Disons que nous avons remplacé notre boucle for() par l’implémentation suivante:
for(int i = 0; i < 10; i++) {
synchronized(gestionnaire) {
service.submit(() -> gestionnaire.incrementerEtRapporter());
}
}
Cette solution résout-elle le problème? Non, ce n’est pas le cas! Pouvez-vous identifier le problème? Nous avons synchronisé la création des threads mais pas l’exécution des threads. Dans cet exemple, les threads seraient créés un par un, mais ils pourraient tous quand même s’exécuter et effectuer leur travail simultanément, ce qui donnerait le même type de sortie que vous avez vu plus tôt. Nous avons dit que diagnostiquer et résoudre les problèmes de threads est difficile en pratique!
Voici maintenant une version corrigée de la classe GestionMoutons qui ordonne les travailleurs:
import java.util.concurrent.*;
public class GestionMoutons {
private int compteurMoutons = 0;
private void incrementerEtRapporter() {
synchronized(this) {
System.out.print((++compteurMoutons)+" ");
}
}
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(20);
try {
var gestionnaire = new GestionMoutons();
for(int i = 0; i < 10; i++)
service.submit(() -> gestionnaire.incrementerEtRapporter());
} finally {
service.shutdown();
}
}
}
Quand ce code s’exécute, il produira systématiquement ce qui suit:
1 2 3 4 5 6 7 8 9 10
Bien que tous les threads soient toujours créés et exécutés en même temps, ils attendent chacun au bloc synchronized
que le travailleur incrémente et rapporte le résultat avant d’entrer. De cette manière, chaque employé du zoo attend que l’employé précédent revienne avant de courir sur le terrain. Bien qu’il soit aléatoire de savoir quel employé du zoo sortira ensuite, il est garanti qu’il y aura au plus un sur le terrain et que les résultats seront rapportés dans l’ordre.
Nous aurions pu synchroniser sur n’importe quel objet, tant qu’il s’agissait du même objet. Par exemple, l’extrait de code suivant fonctionnerait également:
private final Object troupeau = new Object();
private void incrementerEtRapporter() {
synchronized(troupeau) {
System.out.print((++compteurMoutons)+" ");
}
}
Bien que nous n’ayons pas eu besoin de rendre la variable troupeau final
, cela garantit qu’elle n’est pas réassignée après que les threads aient commencé à l’utiliser.
Synchroniser sur les Méthodes
Dans l’exemple précédent, nous avons établi notre moniteur en utilisant synchronized(this)
autour du corps de la méthode. Java fournit une amélioration de compilateur plus pratique pour ce faire. Nous pouvons ajouter le modificateur synchronized
à toute méthode d’instance pour synchroniser automatiquement sur l’objet lui-même. Par exemple, les deux définitions de méthode suivantes sont équivalentes:
void chanter() {
synchronized(this) {
System.out.print("La la la!");
}
}
synchronized void chanter() {
System.out.print("La la la!");
}
La première utilise un bloc synchronized, tandis que la seconde utilise le modificateur de méthode synchronized. Lequel vous utilisez dépend entièrement de vous.
Nous pouvons également appliquer le modificateur synchronized
aux méthodes statiques. Quel objet est utilisé comme moniteur lorsque nous synchronisons sur une méthode statique? L’objet classe, bien sûr! Par exemple, les deux méthodes suivantes sont équivalentes pour la synchronisation statique dans notre classe GestionMoutons:
static void danser() {
synchronized(GestionMoutons.class) {
System.out.print("C'est l'heure de danser!");
}
}
static synchronized void danser() {
System.out.print("C'est l'heure de danser!");
}
Comme auparavant, la première utilise un bloc synchronized, tandis que la seconde utilise le modificateur de méthode synchronized. Vous pouvez utiliser la synchronisation statique si vous avez besoin d’ordonner l’accès des threads à travers toutes les instances plutôt qu’une seule instance.
Comprendre le Framework Lock
Un bloc synchronized ne prend en charge qu’un ensemble limité de fonctionnalités. Par exemple, que faire si nous voulons vérifier si un verrou est disponible et, s’il ne l’est pas, effectuer une autre tâche? De plus, si le verrou n’est jamais disponible et que nous nous synchronisons dessus, nous pourrions attendre indéfiniment.
L’API Concurrency inclut l’interface Lock
, qui est conceptuellement similaire à l’utilisation du mot-clé synchronized
mais avec beaucoup plus de fonctionnalités. Au lieu de synchroniser sur n’importe quel objet, nous ne pouvons “verrouiller” que sur un objet qui implémente l’interface Lock
.
Appliquer un ReentrantLock
L’interface Lock
est assez facile à utiliser. Lorsque vous devez protéger un morceau de code contre le traitement multi-thread, créez une instance de Lock
à laquelle tous les threads ont accès. Chaque thread appelle alors lock()
avant d’entrer dans le code protégé et appelle unlock()
avant de quitter le code protégé.
Pour contraste, voici deux implémentations, l’une avec un bloc synchronized et l’autre avec une instance de Lock
. Bien que plus longue, la solution Lock
offre un certain nombre de fonctionnalités non disponibles pour le bloc synchronized.
// Implémentation #1 avec un bloc synchronized
Object objet = new Object();
synchronized(objet) {
// Code protégé
}
// Implémentation #2 avec un Lock
Lock verrou = new ReentrantLock();
try {
verrou.lock();
// Code protégé
} finally {
verrou.unlock();
}
Ces deux implémentations sont conceptuellement équivalentes. La classe ReentrantLock
est un simple moniteur qui implémente l’interface Lock
et prend en charge l’exclusion mutuelle. En d’autres termes, au plus un thread est autorisé à détenir un verrou à un moment donné.
Bien que ce ne soit certainement pas obligatoire, c’est une bonne pratique d’utiliser un bloc try/finally avec les instances de Lock
. Cela garantit que tous les verrous acquis sont correctement libérés.
La classe ReentrantLock
garantit qu’une fois qu’un thread a appelé lock()
et obtenu le verrou, tous les autres threads qui appellent lock()
attendront jusqu’à ce que le premier thread appelle unlock()
. Quel thread obtient le verrou ensuite dépend des paramètres utilisés pour créer l’objet Lock
.
La classe ReentrantLock
inclut un constructeur qui prend un seul booléen et définit un paramètre d’”équité”. Si le paramètre est défini à true
, le verrou sera généralement accordé à chaque thread dans l’ordre dans lequel il a été demandé. Il est false
par défaut lors de l’utilisation du constructeur sans argument. En pratique, vous ne devriez activer l’équité que lorsque l’ordonnancement est absolument nécessaire, car cela pourrait entraîner un ralentissement significatif.
En plus de toujours vous assurer de libérer un verrou, vous devez également vous assurer que vous ne libérez qu’un verrou que vous possédez. Si vous tentez de libérer un verrou que vous ne possédez pas, vous obtiendrez une exception au moment de l’exécution.
Lock verrou = new ReentrantLock();
verrou.unlock(); // IllegalMonitorStateException
L’interface Lock
comprend quatre méthodes que vous devriez connaître, comme indiqué dans le Tableau 13.8.
Tenter d’Acquérir un Verrou
Bien que la classe ReentrantLock
vous permette d’attendre un verrou, elle souffre jusqu’à présent du même problème qu’un bloc synchronized. Un thread pourrait finir par attendre indéfiniment pour obtenir un verrou. Heureusement, le Tableau 13.8 inclut deux méthodes supplémentaires qui rendent l’interface Lock
beaucoup plus sûre à utiliser qu’un bloc synchronized.
Méthode | Description |
---|---|
void lock() | Demande le verrou et bloque jusqu’à ce que le verrou soit acquis. |
void unlock() | Libère le verrou. |
boolean tryLock() | Demande le verrou et revient immédiatement. Renvoie un booléen indiquant si le verrou a été acquis avec succès. |
boolean tryLock(long timeout, TimeUnit unit) | Demande le verrou et bloque pendant le temps spécifié ou jusqu’à ce que le verrou soit acquis. Renvoie un booléen indiquant si le verrou a été acquis avec succès. |
Pour des raisons de commodité, nous utilisons la méthode imprimerBonjour()
suivante pour le code de cette section:
public static void imprimerBonjour(Lock verrou) {
try {
verrou.lock();
System.out.println("Bonjour");
} finally {
verrou.unlock();
}
}
tryLock()
La méthode tryLock()
tentera d’acquérir un verrou et renverra immédiatement un résultat booléen indiquant si le verrou a été obtenu. Contrairement à la méthode lock()
, elle n’attend pas si un autre thread détient déjà le verrou. Elle revient immédiatement, quel que soit le verrou disponible.
Voici un exemple d’implémentation utilisant la méthode tryLock()
:
Lock verrou = new ReentrantLock();
new Thread(() -> imprimerBonjour(verrou)).start();
if(verrou.tryLock()) {
try {
System.out.println("Verrou obtenu, entrant dans le code protégé");
} finally {
verrou.unlock();
}
} else {
System.out.println("Impossible d'acquérir le verrou, faisant autre chose");
}
Lorsque vous exécutez ce code, il pourrait produire soit le message if, soit le message else, en fonction de l’ordre d’exécution. Il imprimera toujours Bonjour, cependant, car l’appel à lock()
dans imprimerBonjour()
attendra indéfiniment que le verrou devienne disponible. Un exercice amusant consiste à insérer quelques délais Thread.sleep()
dans cet extrait pour encourager l’affichage d’un message particulier.
Comme lock()
, la méthode tryLock()
doit être utilisée avec un bloc try/finally. Heureusement, vous ne devez libérer le verrou que s’il a été acquis avec succès. Pour cette raison, il est courant d’utiliser la sortie de tryLock()
dans une instruction if, de sorte que unlock()
ne soit appelé que lorsque le verrou est obtenu.
Il est impératif que votre programme vérifie toujours la valeur de retour de la méthode tryLock()
. Elle indique à votre programme s’il est sûr de procéder à l’opération et si le verrou doit être libéré plus tard.
tryLock(long,TimeUnit)
L’interface Lock
inclut une version surchargée de tryLock(long,TimeUnit)
qui agit comme un hybride de lock()
et tryLock()
. Comme les deux autres méthodes, si un verrou est disponible, elle le renverra immédiatement. Si un verrou n’est pas disponible, elle attendra jusqu’à la limite de temps spécifiée pour le verrou.
L’extrait de code suivant utilise la version surchargée de tryLock(long,TimeUnit)
:
Lock verrou = new ReentrantLock();
new Thread(() -> imprimerBonjour(verrou)).start();
if(verrou.tryLock(10,TimeUnit.SECONDS)) {
try {
System.out.println("Verrou obtenu, entrant dans le code protégé");
} finally {
verrou.unlock();
}
} else {
System.out.println("Impossible d'acquérir le verrou, faisant autre chose");
}
Le code est le même qu’avant, sauf que cette fois, l’un des threads attend jusqu’à 10 secondes pour acquérir le verrou.
Acquérir le Même Verrou Deux Fois
La classe ReentrantLock
maintient un compteur du nombre de fois qu’un verrou a été accordé avec succès à un thread. Pour libérer le verrou pour que d’autres threads l’utilisent, unlock()