Une instruction unique peut ressembler à une action indivisible alors qu'elle n'en est rien. Prenons l'exemple de l'ajout de cinq à une valeur en mémoire :
add qword [rel counter], 5
En réalité, le processeur ne sait pas ajouter directement à la mémoire. Il découpe cette unique instruction en trois étapes plus petites, appelées micro-opérations :
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5 ; 2. add five
mov qword [rel counter], rax ; 3. store the result back
C'est un schéma courant appelé read-modify-write (RMW).
Note que la lecture (ou load) et l'écriture (ou store) sont des événements distincts, ce qui laisse une fenêtre de temps entre les deux.
On ne le remarque généralement pas, car sur un cœur unique, chaque instruction a la garantie de produire son effet en entier avant la suivante.
Cette fenêtre est donc invisible, et add se comporte comme une seule unité.
Cependant, les processeurs modernes ont rarement un seul cœur, et les applications s'exécutent souvent sur plusieurs cœurs à la fois, sans ordre défini entre eux.
Lorsque plusieurs cœurs tournent au même instant, un autre cœur peut lire ou écrire counter à l'intérieur de la fenêtre, après le load de ce cœur et avant son store.
Deux threads chargent chacun la même ancienne valeur, y ajoutent chacun cinq, et enregistrent chacun leur résultat.
Deux additions ont eu lieu, mais la valeur n'a augmenté que de cinq.
Une mise à jour a été silencieusement perdue.
C'est une course de données, un problème courant dans le code multi-thread.
Toutes les valeurs ne sont pas exposées de cette façon.
Chaque thread possède ses propres registres et sa propre pile, si bien qu'une valeur conservée dans un registre, ou une variable locale sur la pile d'un thread, est propre à ce thread et ne peut pas entrer en course.
Seule la mémoire que les threads partagent, comme counter ci-dessus, doit être protégée.
x86-64 propose un ensemble d'instructions pour résoudre ce problème, en rendant une instruction indivisible. Elle se comporte comme une seule unité, non seulement pour le cœur qui la traite, mais aussi pour tous les autres cœurs. Une opération qui tient d'un seul bloc, qu'aucun autre cœur ne peut scinder, est dite atomique.
On désigne souvent comme multi-thread les applications qui s'exécutent sur plusieurs cœurs. Cependant, un thread n'est pas la même chose qu'un cœur. Deux threads peuvent s'exécuter de façon concurrente sur le même cœur, en s'entrelaçant, ou en parallèle sur des cœurs différents.
Des threads entrelacés peuvent déjà entrer en course sur un read-modify-write s'il est découpé en plusieurs instructions, puisque le système d'exploitation peut basculer d'un thread à l'autre entre deux instructions quelconques. Cependant, la fenêtre à l'intérieur d'une seule instruction n'est exposée que par du vrai code parallèle. Comme le système d'exploitation ne bascule d'un thread à l'autre qu'entre deux instructions, jamais à l'intérieur d'une seule, une instruction unique est intrinsèquement sûre sur un cœur unique.
C'est une véritable atomicité sur plusieurs cœurs que fournissent les instructions ci-dessous.
L'instruction xchg échange deux opérandes.
L'opérande de destination prend la valeur précédente de l'opérande source, tandis que l'opérande source prend la valeur précédente de l'opérande de destination.
Conceptuellement, on peut la voir comme deux instructions mov qui se produisent en même temps.
Comme toujours, on peut l'utiliser avec deux opérandes de registre, ou avec un opérande mémoire et un opérande de registre :
mov eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx ; edx = 2, ecx = 3
Lorsqu'elle est utilisée avec un opérande mémoire, xchg est toujours atomique.
xchg est automatiquement atomique quand l'un des opérandes est un emplacement mémoire.
Cela signifie aussi que l'opération est bien plus lente dans ce cas.
Si tu n'as pas besoin d'atomicité, effectue plutôt l'échange via un registre libre, avec de simples instructions mov.
La façon la plus courante de rendre une instruction atomique en x86-64 est d'ajouter le préfixe lock.
Il fusionne la lecture, la modification et l'écriture en une seule étape indivisible.
Le cœur détient ainsi la mémoire de façon exclusive pendant tout le processus, si bien qu'aucun autre cœur ne peut lire ni écrire cet emplacement entre-temps.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
lock ne fonctionne que lorsque la destination est en mémoire, et uniquement sur les instructions qui effectuent un read-modify-write sur cette mémoire :
add, sub, inc, dec, neg ;and, or, xor, not ;bts, btr, btc ;xadd et cmpxchg, décrites plus bas.Réserver un emplacement de façon exclusive et en tenir tous les autres cœurs à l'écart n'est pas gratuit.
Une opération préfixée par lock est nettement plus lente que sa forme simple, et plus lente encore lorsque plusieurs cœurs se disputent le même emplacement.
Ce préfixe doit être réservé à la mémoire susceptible d'être modifiée par plus d'un thread. Évite de l'utiliser si la mémoire n'est pas partagée ou si on ne fait que la lire.
Un simple lock add met à jour la mémoire, mais jette l'ancienne valeur.
Souvent, cette ancienne valeur est précisément ce que l'on veut, par exemple pour attribuer à chaque thread un numéro de ticket distinct.
L'instruction xadd (le x pour échange) renvoie la valeur précédente tout en effectuant l'addition.
Elle écrit la somme dans la destination et laisse la valeur d'origine de la destination dans le registre source.
mov rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
; rax = the old value of [rdi]
Avec le préfixe lock, il s'agit d'un fetch-and-add atomique.
Exécuté par de nombreux threads sur le même compteur, chaque appel renvoie une ancienne valeur différente.
Comme avec lock add, le compteur finit à exactement le nombre d'appels.
Cependant, contrairement à lock add, chaque valeur intermédiaire est également renvoyée, une à chaque appelant.
xadd additionne et xchg écrase, mais ni l'une ni l'autre ne peut faire dépendre la nouvelle valeur de la valeur actuelle et ne l'appliquer que si rien n'a changé entre-temps.
C'est cette mise à jour conditionnelle que fournit cmpxchg, compare-and-exchange, la plus générale de ces primitives.
cmpxchg dest, src utilise rax comme accumulateur implicite et le compare à dest :
dest == rax, alors dest = src et ZF = 1.dest != rax, alors rax = dest et ZF = 0.Note que dest n'est modifiée que si elle est égale à la valeur attendue, préalablement chargée dans rax.
Cette égalité garantit que dest contient toujours la valeur à partir de laquelle la nouvelle a été calculée, de sorte qu'une mise à jour fondée sur une lecture obsolète n'est jamais appliquée.
Cela fait de cmpxchg la brique de base d'une mise à jour atomique, également appelée compare-and-swap (CAS) :
mov rax, qword [rdi] ; rax = the value we expect to find
.retry:
lea rcx, [rax + 10] ; rcx = the new value we want to install
lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
; otherwise reload rax with the current value, clear ZF
jnz .retry ; ZF is cleared, so another thread won the race. Recompute and retry
Cette boucle de nouvelle tentative est le cœur des mises à jour sans verrou.
La fenêtre entre la lecture et le compare-and-exchange est précisément le moment où un autre thread pourrait intervenir, et cmpxchg y remédie en refusant de stocker une valeur calculée à partir d'une lecture obsolète.
Jusqu'ici, chaque opération n'a touché qu'un seul emplacement. Lorsque des threads se coordonnent via plusieurs emplacements, une nouvelle question se pose : dans quel ordre les écritures d'un thread deviennent-elles visibles pour un autre ? Les règles qui répondent à cette question constituent l'ordonnancement mémoire du processeur.
Le concept sur le code sans branchement a introduit l'idée qu'un cœur moderne ne déroule pas les instructions une par une, laborieusement. Il en garde beaucoup en vol en même temps et prend de l'avance quand il le peut. Une écriture peut donc devenir visible aux autres cœurs plus tard que ne le suggère le programme, pendant que les instructions qui la suivent ont déjà été exécutées en avance.
x86-64 conserve un ordonnancement mémoire fort entre les loads et les stores ordinaires, si bien que sur chaque cœur :
Le seul réordonnancement possible est qu'un store semble se terminer après un load ultérieur portant sur une adresse différente.
Une instruction préfixée par lock, ou un xchg avec un opérande mémoire, constitue une barrière complète : rien ne semble pouvoir la franchir, dans un sens comme dans l'autre.
C'est pourquoi elles suffisent à garantir un ordre complet dans la plupart des situations.
pause
Les instructions qui positionnent un drapeau tout en renvoyant son état précédent sont appelées test-and-set. Elles peuvent servir de base à un verrou tournant, garantissant qu'un cœur dispose d'un accès exclusif à une partie du code.
Voici l'algorithme général, qui utilise l'instruction xchg avec un drapeau binaire :
0.1.1, cela signifie que le verrou est détenu par un autre cœur.
Le cœur courant attend alors et essaie à nouveau d'acquérir le verrou.0, cela signifie que le verrou était libre.
xchg l'a maintenant mis à 1, et les autres cœurs attendront que ce cœur le libère.0 libère le verrou.acquire:
mov eax, 1
xchg dword [rdi], eax ; try to take the lock; eax = its old value
test eax, eax
jnz .held ; old value was 1: someone else holds it
ret ; old value was 0: the lock is ours
.held:
pause ; wait before trying again
jmp acquire
L'instruction pause dans la boucle d'attente ne change rien à ce que calcule le code.
Elle indique au processeur qu'il s'agit d'une attente active.
Le processeur peut alors réduire la consommation d'énergie du thread en attente et céder la place à un thread frère qui partage le même cœur.
Une boucle d'attente active sans pause reste correcte, mais elle gaspille des ressources.
Une fois que le thread a terminé son travail, il peut libérer le verrou par un simple store de 0 avec mov.
Un simple mov suffit, car sur x86-64 les loads et les stores ne sont jamais réordonnés après un store ultérieur.
On dit qu'un store qui ne double jamais les accès qui le précèdent a un ordonnancement de libération, et sur x86 tout store ordinaire en bénéficie.
N'importe quelle instruction qui teste et positionne de façon atomique un emplacement mémoire peut servir à un verrou tournant.
Par exemple, on peut utiliser lock bts à la place de xchg, pour positionner un bit précis tout en vérifiant s'il était déjà positionné.
Note que les drapeaux, comme le CF modifié par bts, font partie de rflags, un registre.
Ils sont donc propres à chaque thread.
C'est la soirée d'ouverture au Grand Marquee, et toute l'équipe s'active en même temps. La foule afflue par toutes les portes, plusieurs guichets vendent à partir du même rouleau de billets, la première affiche presque complet, et là-haut, dans la cabine de projection, les bobines doivent continuer de tourner.
Tout ce que le personnel partage réside à une adresse mémoire, et les tests appellent tes fonctions depuis plusieurs fils d'exécution en même temps. Une valeur qui est lue, modifiée puis réécrite en étapes distinctes perdra des mises à jour sous cette charge, donc chaque fonction doit rendre sa mise à jour atomique.
Dans cet exercice, toutes les valeurs partagées sont des entiers signés sur 64 bits.
Tu as cinq tâches.
Les mises à jour de cet exercice doivent être effectuées avec des instructions atomiques. Les tests exécutent tes fonctions en parallèle et vérifient qu'aucune mise à jour n'est perdue, donc une lecture-modification-écriture non atomique a de très grandes chances de les faire échouer.
Les portes sont ouvertes, et à chacune d'elles un ouvreur déchire les billets. Chaque groupe qui passe est comptabilisé dans un compteur d'entrées partagé, et même si toutes les portes comptent en même temps, le total des entrées doit rester juste.
Implémente la fonction admit_group, qui ajoute de façon atomique la taille d'un groupe au compteur.
Cette fonction prend comme arguments, dans cet ordre :
admissions : adresse mémoire du compteur d'entrées partagé, un entier signé sur 64 bits.group_size : le nombre de personnes du groupe, un entier signé sur 64 bits, toujours supérieur à 0.admissions = 120, group_size = 4
// admissions is now 124
Les tests font entrer des groupes par plusieurs portes à la fois. Le compteur final doit être égal à la somme de tous les groupes admis.
Cette fonction n'a pas de valeur de retour. Le compteur doit être mis à jour sur place.
Le projecteur ne peut accueillir qu'une seule bobine. Monter la bobine suivante et retirer celle qui est en place doit être un seul geste : avec deux projectionnistes devant la machine, deux étapes distinctes pourraient donner la même bobine aux deux, ou en faire tomber une par terre.
Implémente la fonction change_reel, qui monte de façon atomique la bobine suivante et renvoie celle qui était montée.
Cette fonction prend comme arguments, dans cet ordre :
mounted : adresse mémoire de l'identifiant de la bobine montée, un entier signé sur 64 bits.next_reel : l'identifiant de la bobine à monter, un entier signé sur 64 bits.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
Cette fonction renvoie l'identifiant de la bobine qui était montée auparavant, sous forme d'un entier signé sur 64 bits. Les tests font échanger des bobines par plusieurs fils d'exécution sur le même emplacement à la fois, et vérifient que chaque bobine déposée ressort exactement une fois.
Chaque guichet vend à partir d'un compteur partagé de billets vendus. Ajouter à un compteur, comme le font les portes, ne suffit pas ici : chaque vente doit aussi apprendre le numéro qui lui a été attribué. Deux ventes ne peuvent pas partager un numéro, et après la ruée, le compteur doit être exactement égal au nombre de billets vendus.
Implémente la fonction sell_ticket, qui ajoute de façon atomique 1 au compteur et renvoie le numéro du billet qui vient d'être vendu.
Le numéro du billet est la valeur du compteur après cette vente : quand le compteur valait 41, cette vente est le billet 42.
Cette fonction prend comme argument :
tickets_sold : adresse mémoire du compteur partagé de billets vendus, un entier signé sur 64 bits.tickets_sold = 0
// => 1, and tickets_sold is now 1
tickets_sold = 41
// => 42, and tickets_sold is now 42
Cette fonction renvoie le numéro du billet sous forme d'un entier signé sur 64 bits. Les tests vendent depuis plusieurs guichets à la fois : chaque numéro renvoyé doit être distinct, et le compteur final doit être égal au nombre de ventes.
La première dispose d'un nombre fixe de places, et tous les guichets la vendent. Une vente ne peut aboutir que tant qu'il reste une place, et la séance ne doit jamais être survendue, pas même si tous les guichets se disputent la dernière place au même instant. Vérifier le compteur puis y ajouter en deux étapes distinctes laisse une faille entre les deux, donc la vérification et la mise à jour doivent réussir ou échouer ensemble.
Implémente la fonction claim_seat, qui réserve de façon atomique la place suivante tant qu'il en reste une de libre.
Quand une place est réservée, la fonction renvoie son numéro, qui est le nombre de places prises après cette réservation.
Quand la séance est complète, elle renvoie 0 et laisse le compteur inchangé.
Cette fonction prend comme arguments, dans cet ordre :
seats_taken : adresse mémoire du nombre de places déjà prises, un entier signé sur 64 bits, jamais supérieur à capacity.capacity : le nombre de places de la séance, un entier signé sur 64 bits, toujours supérieur à 0.seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1
seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83
seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150
seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150
Cette fonction renvoie le numéro de la place, ou 0 quand la séance est complète, sous forme d'un entier signé sur 64 bits.
La cabine de projection ne peut accueillir qu'une personne.
Un mot en mémoire indique si elle est occupée : 0 pour libre, 1 pour occupée.
Le personnel passe toute la nuit, chacun avec une tâche à y accomplir : signer le registre, charger le projecteur, ranger une bobine.
Quelle que soit la tâche, la règle de la cabine reste la même, et c'est à toi de la faire respecter : une seule personne à l'intérieur à la fois, du début de sa tâche jusqu'à la fin.
Implémente la fonction visit_booth, qui :
Tant que la cabine est occupée par quelqu'un d'autre, la fonction doit attendre poliment avec pause.
Cette fonction prend comme arguments, dans cet ordre :
booth : adresse mémoire du mot d'occupation, un entier signé sur 64 bits qui vaut soit 0 soit 1.task : la tâche du visiteur, une fonction qui ne prend aucun paramètre et n'a pas de valeur de retour.À noter que les tests exécutent des tâches qui observent la cabine et un registre partagé. Cela signifie que la cabine doit rester occupée pendant toute la durée de la tâche, que chaque visite doit exécuter sa tâche exactement une fois, et qu'avec plusieurs visiteurs à la fois, le registre doit rester exact.
La tâche du visiteur est une fonction ordinaire qui suit la convention d'appel :
Une violation de l'une ou l'autre de ces règles peut se manifester par un plantage plutôt que par une vérification qui échoue.
Inscris-toi sur Exercism pour apprendre et maîtriser x86-64 Assembly avec 22 concepts130 exercices, et un vrai mentorat humain, le tout gratuitement.