Synchronisation de bas niveau en Go : Opérations atomiques, barrières et réorganisation des instructions
Comprendre les mécanismes de synchronisation au niveau du processeur n’est pas un luxe académique — c’est une nécessité pratique pour les développeurs qui conçoivent des applications Go multi-threads. Dans cet article, nous décomposons la façon dont le CPU optimise l’exécution du code, pourquoi cela perturbe la logique attendue dans les programmes concurrents, et quelles primitives matérielles permettent de rétablir l’ordre.
Comment le processeur dupe le programmeur : Réorganisation et exécution hors ordre
Quand vous écrivez x = 1; ready = true, vous attendez un ordre séquentiel strict. Le processeur ? Pas du tout. Il réorganise les instructions indépendantes pour combler les arrêts du pipeline. C’est ce qu’on appelle la réorganisation d'instructions. L’objectif : maximiser l’utilisation des unités d’exécution et masquer les latences mémoire.
Les CPU utilisent l’exécution hors ordre : les instructions s’exécutent non pas dans l’ordre du programme, mais dès que leurs opérandes sont prêts. Par exemple :
MOVQ A(SB), AX ; load from memory
MOVQ B(SB), BX ; load from memory
ADDQ DX, CX ; add registers
Le processeur peut commencer par ADDQ car les opérandes sont déjà dans les registres, tout en envoyant les chargements en arrière-plan — ils prendront des centaines de cycles. Le résultat logique pour le thread courant reste le même, mais d’autres threads pourraient voir autre chose.
Étapes du pipeline où le chaos apparaît :
- Fetch → Decode → Register Rename → Dispatch → Execute → Commit
Étape clé : Dispatch. Ici, le planificateur sélectionne les instructions dont les opérandes sont prêts, en ignorant l’ordre original. Le tampon de réorganisation (Reorder Buffer, ROB) garantit que les commits d’instructions vers l’état architectural se font dans la bonne séquence — mais seulement pour le cœur courant.
Réorganisation mémoire : Quand le tampon d'écriture vous trahit
Le problème s’aggrave au niveau mémoire. Les écritures en RAM ne sont pas instantanées — elles atterrissent dans le tampon d’écriture, puis sont vidées de manière asynchrone vers le cache. Pour un cœur :
x = 1
ready = true
Pour un autre cœur, cela pourrait ressembler à :
ready = true
x = 0
Parce que l’écriture ready=true pourrait quitter le tampon d’écriture plus tôt. C’est la réorganisation mémoire — réorganisation des opérations mémoire visible par d’autres threads. Exemple classique de data race :
G1:
x = 1
ready = true
G2:
if ready {
print(x) // might print 0!
}
Barrières mémoire : MFENCE et autres outils pour rétablir l’ordre
Solution : les barrières mémoire (memory fences). Ces instructions forcent la synchronisation de la visibilité des opérations entre les cœurs. Sur x86, la principale barrière est MFENCE :
MOVQ $1, x(SB)
MFENCE ; barrier
MOVQ $1, ready(SB)
MFENCE garantit que toutes les écritures avant elle sont visibles par tous les cœurs avant que les opérations après elle ne commencent à s’exécuter. Elle interdit quatre types de réorganisation :
- Store → Store
- Load → Load
- Load → Store
- Store → Load
Important : x86 a un modèle mémoire relativement fort — de nombreuses réorganisations sont déjà interdites par le matériel. Mais Store→Load reste possible, donc MFENCE reste essentiel pour une synchronisation stricte.
Types de barrières sur différentes architectures :
- x86 : MFENCE (complète), SFENCE (store→store), LFENCE (load→load)
- ARM : DMB (Data Memory Barrier), ISH (inner shareable domain)
- RISC-V : FENCE (flexible, avec spécificateurs de direction)
Opérations atomiques : Abstraction de haut niveau sur LOCK
Go masque les barrières derrière les opérations atomiques du package sync/atomic. En interne, elles utilisent le préfixe LOCK, qui rend les opérations read-modify-write indivisibles sur tous les cœurs. Exemple XADDQ :
TEXT ·Xadd64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX
MOVQ delta+8(FP), AX
MOVQ AX, CX
LOCK
XADDQ AX, 0(BX) ; atomically: tmp=*ptr; *ptr+=AX; AX=tmp
ADDQ CX, AX
MOVQ AX, ret+16(FP)
RET
LOCK fonctionne en verrouillant la ligne de cache dans le protocole MESI :
- Le cœur acquiert les droits exclusifs sur la ligne de cache
- Effectue l’opération
- Libère la ligne — les changements deviennent visibles pour les autres cœurs
Atomicité ≠ ordonnancement ! Une opération atomique garantit l’indivisibilité mais ne crée pas nécessairement une barrière mémoire. Sur x86, les load/store simples sont atomiques mais ne fournissent pas d’ordonnancement.
Écrivons notre propre opération atomique AND : Pratique avec l’assembleur Go
La bibliothèque standard de Go ne fournit pas atomic.And. Implémentons-la nous-mêmes. Créez le fichier Xand8_x86.s :
#include "textflag.h"
TEXT ·And8(SB), NOSPLIT, $0-9
MOVQ ptr+0(FP), BX // BX = pointer
MOVB mask+8(FP), AX // AX = mask (8 bits)
LOCK
ANDB AX, 0(BX) // *ptr &= mask
RET
Et le main.go correspondant :
package main
import "C"
func And8(ptr *uint8, mask uint8)
func main() {
var smth uint8 = 3
And8(&smth, uint8(2))
println("Result:", smth) // outputs 2
}
Compilez et exécutez avec go run . — le fichier ASM sera détecté automatiquement. Note : nous utilisons ANDB avec le préfixe LOCK, même si x86 n’a pas d’instruction XAND. Cela fonctionne — LOCK est compatible avec les opérations logiques de base.
Points clés à retenir
- Réorganisation d'instructions — optimisation CPU qui modifie l’ordre d’exécution des instructions indépendantes pour améliorer les performances.
- Réorganisation mémoire — désynchronisation de la visibilité des opérations d’écriture entre cœurs due au tampon d’écriture.
- Barrières mémoire — instructions qui synchronisent de force l’ordre des opérations mémoire entre threads.
- Opérations atomiques — actions read-modify-write indivisibles, implémentées via LOCK et encapsulant des barrières.
- LOCK + ANDB — une façon fonctionnelle d’implémenter une opération atomique bitwise manquante dans la bibliothèque standard de Go.
— Editorial Team
Aucun commentaire pour le moment.