Bienvenue ! Quel est votre objectif aujourd'hui ?

Sélectionnez votre profil pour adapter l'arborescence et accéder directement aux contenus pertinents.

Freelance & Tech

Je suis un recruteur / Tech Lead

Vous cherchez un développeur freelance Senior Go / Fullstack, un renfort d'équipe ou une expertise d'architecture.

Voir le profil Recruteur →
Studio & Logiciel

Je souhaite construire mon logiciel

Vous avez un besoin métier sur-mesure, une application web/mobile à concevoir ou un MVP à concrétiser.

Voir le profil Sur-Mesure →
Cache HTML Lock-Free en Go avec atomic.Pointer

Cache HTML Lock-Free en Go avec atomic.Pointer

Résumé
Pour servir des données à très haut débit sans verrou, atomic.Pointer combiné au patron Copy-On-Write (RCU) offre des performances exceptionnelles. Cet article détaille son fonctionnement processeur et l'élimination des verrous pour atteindre des coûts opératoires très faibles et 0 allocation.

Sur les applications web et services Go à fort trafic, la gestion de la concurrence mémoire est au cœur de l'optimisation des performances. Lorsqu'une structure de données est lue des millions de fois et très rarement modifiée (comme un cache de rendu de templates, un dictionnaire de configuration ou une table de routage), l'utilisation de verrous classiques (sync.Mutex ou sync.RWMutex) introduit de la contention CPU, des inversions de priorité et des coûts d'arrêt de goroutines.

En exploitant le type générique sync/atomic.Pointer[T] introduit en Go 1.19 et le patron Read-Copy-Update (RCU), il est possible de construire un système de cache Lock-Free, 0-Allocation et extrêmement rapide.

Qu'est-ce qu'un atomic.Pointer en Go ?

En informatique système, une opération atomique est une instruction processeur indivisible. Elle s'exécute entièrement ou pas du tout, sans qu'aucun autre thread CPU ne puisse observer un état intermédiaire ou incomplet.

Le type sync/atomic.Pointer[T] est une structure générique du paquet standard sync/atomic qui fournit une manipulation atomique sécurisée pour les pointeurs de type *T.

Mécanique au niveau processeur et Barrières Mémoire

Sous le capot, lorsque vous utilisez atomic.Pointer[T] :

  • Load() (Lecture atomique) : Exécute une instruction de lecture atomique (sur x86-64, un simple MOV avec sémantique Acquire). Cette instruction empêche le processeur ou le compilateur de réordonner les lectures de mémoire après cette ligne. Le thread lecteur récupère l'adresse exacte du pointeur en 1 cycle d'horloge CPU.
  • Store() (Écriture atomique) : Exécute une instruction d'écriture atomique avec sémantique Release. Elle garantit que toutes les modifications apportées à la structure pointée sont physiquement visibles et propagées dans les caches L1/L2/L3 des autres cœurs CPU avant que le nouveau pointeur ne devienne lisible.
  • CompareAndSwap() (CAS) : Remplace l'adresse mémoire de manière atomique uniquement si l'adresse actuelle correspond à la valeur attendue (instruction processeur CMPXCHG).

Pourquoi est-ce Lock-Free et Wait-Free ?

Contrairement à un sync.RWMutex qui doit modifier des compteurs de lecteurs internes, exécuter des opérations d'attente et parfois suspendre la goroutine sur un sémaphore du runtime, atomic.Pointer.Load() ne bloque jamais.

Les lectures sont dites Wait-Free : chaque goroutine lectrice termine son accès au cache en un nombre fixe et déterministe d'instructions processeur, indépendamment du nombre de goroutines concurrentes.

Le problème du boxing d'interface avec sync.Map

Il est fréquent de se tourner vers sync.Map pour stocker des éléments en mémoire sans gérer manuellement de verrous. Cependant, l'API de sync.Map manipule des interfaces vides (any) :

 1type CacheKey struct {
 2    IsHTMX bool
 3    Path   string
 4}
 5
 6// sync.Map manipule le type `any`
 7var store sync.Map
 8
 9func Get(path string, isHTMX bool) ([]byte, bool) {
10    key := CacheKey{IsHTMX: isHTMX, Path: path}
11    val, ok := store.Load(key) // Interface Boxing !
12    if !ok { return nil, false }
13    return val.([]byte), true
14}

L'inconvénient : Passer la struct key à store.Load(any) force l'encapsulation de la struct dans un header d'interface (interface boxing). Le compilateur Go ne pouvant prouver l'absence d'échappement, la clé échappe sur le Heap (tas mémoire) lors de chaque lecture, provoquant 32 octets d'allocation par requête.

(Pour approfondir le comportement de l'Escape Analysis et la différence d'allocation entre la Pile et le Tas, consultez notre article sur les Pointeurs vs Valeurs en Go).

Implémentation pratique du patron Copy-On-Write (RCU)

Pour bénéficier du typage fort natif de Go et annuler tout boxing d'interface, nous encapsulons une map[Key][]byte sous un atomic.Pointer. Les lectures deviennent directes et l'écriture du dictionnaire repose sur le modèle Read-Copy-Update (RCU).

 1package cache
 2
 3import (
 4    "sync"
 5    "sync/atomic"
 6)
 7
 8type Key struct {
 9    Path   string
10    IsHTMX bool
11}
12
13type TemplateCache struct {
14    mu    sync.Mutex
15    store atomic.Pointer[map[Key][]byte]
16}
17
18func (c *TemplateCache) Get(isHTMX bool, path string) ([]byte, bool) {
19    m := c.store.Load()
20    if m == nil { return nil, false }
21    val, ok := (*m)[Key{Path: path, IsHTMX: isHTMX}]
22    return val, ok
23}
24
25func (c *TemplateCache) Set(isHTMX bool, path string, content []byte) {
26    c.mu.Lock()
27    defer c.mu.Unlock()
28
29    oldMap := c.store.Load()
30    newMap := make(map[Key][]byte, len(*oldMap)+1)
31    for k, v := range *oldMap {
32        newMap[k] = v
33    }
34    newMap[Key{Path: path, IsHTMX: isHTMX}] = content
35    c.store.Store(&newMap)
36}

Les propriétés clés de cette architecture :

  • Lectures (Get) : Totalement Lock-Free et Wait-Free. La méthode c.store.Load() récupère l'adresse du pointeur en un mot mémoire atomique. La goroutine accède à la map (*m)[Key{...}] sans aucun verrou de lecture ni allocation d'interface.
  • Écritures (Set) : Rares (lors de l'hydratation initiale au démarrage du serveur). Le verrou mu.Lock() isole uniquement les opérations d'écriture concurrentes en dupliquant la map existante puis en permutant le pointeur atomique via c.store.Store(&newMap).

Benchmarks et Analyse Comparative

Les mesures ont été réalisées avec le profil de benchmark Go natif (go test -bench=. -benchmem) sur processeur Intel Core i7-8665U :

Implémentation du Cache Temps / Opération Mémoire allouée Allocations / Op Débit (req/s/cœur)
Rendu dynamique (html/template) 749,30 ns/op 992 B/op 8 allocs/op ~1,33 million
sync.Map (Interface Boxing) 17,59 ns/op 32 B/op 2 allocs/op ~63,6 millions
atomic.Pointer (Lock-Free RCU) 11,99 ns/op 0 B/op 0 alloc/op ~101,8 millions

Conclusion

Pour les structures de données lues de manière intensive et peu modifiées, sync/atomic.Pointer[T] est un outil de premier choix en Go. En combinant la sémantique Read-Copy-Update (RCU) avec des maps typées Go natif, il est possible d'exécuter des lectures à 11,99 nanosecondes sans la moindre allocation mémoire, réduisant à néant la pression sur le Garbage Collector.

Pour compléter votre lecture sur la gestion de la mémoire et du cache :