Retour à l'accueil

Synchronisation dans Go : atomiques et barrières mémoire

Analyse des mécanismes de synchronisation au niveau du processeur : réordonnancement des instructions et de la mémoire, barrières mémoire, opérations atomiques. Exemple pratique d'implémentation de atomic.And sur Go Assembler.

Synchronisation de bas niveau dans Go : du CPU aux atomiques
Advertisement 728x90

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 :

Google AdInline article slot
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.

Google AdInline article slot

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 :

Google AdInline article slot
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

Advertisement 728x90

Lire ensuite