Ceci est mieux illustré par un exemple.
Supposons que nous ayons une tâche simple que nous voulons effectuer plusieurs fois en parallèle et que nous voulons garder une trace globale du nombre de fois que la tâche a été exécutée, par exemple, compter les hits sur une page Web.
Lorsque chaque thread arrive au point où il incrémente le nombre, son exécution ressemble à ceci:
- Lire le nombre de hits de la mémoire dans un registre de processeur
- Augmentez ce nombre.
- Réécrivez ce numéro en mémoire
N'oubliez pas que chaque thread peut se suspendre à tout moment de ce processus. Donc, si le thread A exécute l'étape 1, puis est suspendu, suivi par le thread B effectuant les trois étapes, lorsque le thread A reprend, ses registres auront le mauvais nombre de hits: ses registres seront restaurés, il incrémentera heureusement l'ancien numéro de hits et stocker ce nombre incrémenté.
En outre, un nombre illimité d'autres threads ont pu s'exécuter pendant la période de suspension du thread A, de sorte que le nombre de threads d'écriture A à la fin peut être bien inférieur au nombre correct.
Pour cette raison, il est nécessaire de s'assurer que si un thread exécute l'étape 1, il doit effectuer l'étape 3 avant que tout autre thread soit autorisé à effectuer l'étape 1, ce qui peut être accompli par tous les threads attendant d'obtenir un seul verrou avant de commencer ce processus. et libérer le verrou uniquement une fois le processus terminé, afin que cette "section critique" de code ne puisse pas être entrelacée de manière incorrecte, ce qui entraîne un nombre incorrect.
Et si l'opération était atomique?
Oui, au pays des licornes et des arcs-en-ciel magiques, où l'opération d'incrémentation est atomique, le verrouillage ne serait pas nécessaire pour l'exemple ci-dessus.
Il est important de réaliser, cependant, que nous passons très peu de temps dans le monde des licornes et des arcs-en-ciel magiques. Dans presque tous les langages de programmation, l'opération d'incrémentation se décompose en trois étapes. En effet, même si le processeur prend en charge une opération d'incrémentation atomique, cette opération est nettement plus coûteuse: il doit lire dans la mémoire, modifier le nombre et l'écrire en mémoire ... et généralement l'opération d'incrémentation atomique est une opération qui peut échouer, ce qui signifie que la séquence simple ci-dessus doit être remplacée par une boucle (comme nous le verrons ci-dessous).
Étant donné que, même dans le code multithread, de nombreuses variables sont conservées localement sur un seul thread, les programmes sont beaucoup plus efficaces s'ils supposent que chaque variable est locale sur un seul thread et laissent les programmeurs prendre soin de protéger l'état partagé entre les threads. Surtout étant donné que les opérations atomiques ne sont généralement pas suffisantes pour résoudre les problèmes de threading, comme nous le verrons plus tard.
Variables volatiles
Si nous voulions éviter les verrous pour ce problème particulier, nous devons d'abord réaliser que les étapes décrites dans notre premier exemple ne sont pas réellement ce qui se passe dans le code compilé moderne. Étant donné que les compilateurs supposent qu'un seul thread modifie la variable, chaque thread conservera sa propre copie en cache de la variable, jusqu'à ce que le registre du processeur soit nécessaire pour autre chose. Tant qu'il a la copie en cache, il suppose qu'il n'a pas besoin de revenir en mémoire et de la relire (ce qui coûterait cher). Ils n'écriront pas non plus la variable en mémoire tant qu'elle est conservée dans un registre.
Nous pouvons revenir à la situation que nous avons donnée dans le premier exemple (avec tous les mêmes problèmes de thread que nous avons identifiés ci-dessus) en marquant la variable comme volatile , ce qui indique au compilateur que cette variable est en cours de modification par d'autres, et doit donc être lue à partir de ou écrit en mémoire chaque fois qu'il est consulté ou modifié.
Une variable marquée comme volatile ne nous emmènera donc pas au pays des opérations d'incrémentation atomique, elle ne nous rapprochera que de ce que nous pensions déjà.
Rendre l'incrément atomique
Une fois que nous utilisons une variable volatile, nous pouvons rendre notre opération d'incrémentation atomique en utilisant une opération de définition conditionnelle de bas niveau prise en charge par la plupart des processeurs modernes (souvent appelée comparer et définir ou comparer et échanger ). Cette approche est prise, par exemple, dans la classe AtomicInteger de Java :
197 /**
198 * Atomically increments by one the current value.
199 *
200 * @return the updated value
201 */
202 public final int incrementAndGet() {
203 for (;;) {
204 int current = get();
205 int next = current + 1;
206 if (compareAndSet(current, next))
207 return next;
208 }
209 }
La boucle ci-dessus effectue à plusieurs reprises les étapes suivantes, jusqu'à ce que l'étape 3 réussisse:
- Lisez la valeur d'une variable volatile directement depuis la mémoire.
- Augmentez cette valeur.
- Modifiez la valeur (dans la mémoire principale) si et seulement si sa valeur actuelle dans la mémoire principale est la même que la valeur que nous lisons initialement, en utilisant une opération atomique spéciale.
Si l'étape 3 échoue (car la valeur a été modifiée par un thread différent après l'étape 1), il lit à nouveau la variable directement à partir de la mémoire principale et réessaye.
Bien que l'opération de comparaison et d'échange soit coûteuse, elle est légèrement meilleure que l'utilisation du verrouillage dans ce cas, car si un thread est suspendu après l'étape 1, les autres threads qui atteignent l'étape 1 n'ont pas à bloquer et à attendre le premier thread, ce qui peut empêcher un changement de contexte coûteux. Lorsque le premier thread reprendra, il échouera lors de sa première tentative d'écriture de la variable, mais pourra continuer en relisant la variable, ce qui est encore probablement moins cher que le changement de contexte qui aurait été nécessaire avec le verrouillage.
Ainsi, nous pouvons accéder au pays des incréments atomiques (ou d'autres opérations sur une seule variable) sans utiliser de verrous réels, via la comparaison et l'échange.
Alors, quand le verrouillage est-il strictement nécessaire?
Si vous devez modifier plusieurs variables dans une opération atomique, le verrouillage sera nécessaire, vous ne trouverez pas d'instructions de processeur spéciales pour cela.
Tant que vous travaillez sur une seule variable et que vous êtes prêt à tout travail que vous avez fait pour échouer et devoir lire la variable et recommencer, la comparaison et l'échange seront cependant assez bons.
Prenons un exemple où chaque thread ajoute d'abord 2 à la variable X, puis multiplie X par deux.
Si X est initialement un et que deux threads s'exécutent, nous nous attendons à ce que le résultat soit (((1 + 2) * 2) + 2) * 2 = 16.
Cependant, si les threads s'entrelacent, nous pourrions, même si toutes les opérations sont atomiques, que les deux additions se produisent en premier et que les multiplications viennent après, ce qui donne (1 + 2 + 2) * 2 * 2 = 20.
Cela se produit car la multiplication et l'addition ne sont pas des opérations commutatives.
Donc, les opérations elles-mêmes étant atomiques ne suffisent pas, il faut faire la combinaison des opérations atomiques.
Nous pouvons le faire soit en utilisant le verrouillage pour sérialiser le processus, soit en utilisant une variable locale pour stocker la valeur de X lorsque nous avons commencé notre calcul, une deuxième variable locale pour les étapes intermédiaires, puis en utilisant la fonction de comparaison et d'échange pour définissez une nouvelle valeur uniquement si la valeur actuelle de X est la même que la valeur d'origine de X. Si nous échouons, nous devrons recommencer en lisant X et en effectuant à nouveau les calculs.
Plusieurs compromis sont impliqués: à mesure que les calculs s'allongent, il devient beaucoup plus probable que le thread en cours d'exécution soit suspendu et la valeur sera modifiée par un autre thread avant de reprendre, ce qui signifie que les échecs deviennent beaucoup plus probables, conduisant à un gaspillage temps processeur. Dans le cas extrême d'un grand nombre de threads avec des calculs très longs, nous pouvons avoir 100 threads lisant la variable et engagés dans des calculs, auquel cas seul le premier à terminer réussira à écrire la nouvelle valeur, les 99 autres encore terminer leurs calculs, mais découvrir à la fin qu'ils ne peuvent pas mettre à jour la valeur ... à quel point ils liront chacun la valeur et recommenceront le calcul. Nous aurions probablement les 99 threads restants répéter le même problème, gaspillant de grandes quantités de temps processeur.
La sérialisation complète de la section critique via des verrous serait bien meilleure dans cette situation: 99 threads se suspendraient lorsqu'ils n'obtiendraient pas le verrou, et nous exécuterions chaque thread par ordre d'arrivée au point de verrouillage.
Si la sérialisation n'est pas critique (comme dans notre cas d'incrémentation) et que les calculs qui seraient perdus si la mise à jour du nombre échoue sont minimes, il peut y avoir un avantage significatif à tirer de l'utilisation de l'opération de comparaison et d'échange, car cette opération est moins cher que le verrouillage.