Cache HTML Lock-Free en Go avec atomic.Pointer
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 simpleMOVavec 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 processeurCMPXCHG).
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 viac.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 :
- Découvrez notre étude comparative Benchmark Caching : PostgreSQL vs Valkey vs In-Memory.
- Apprenez à mesurer l'absence d'allocations grâce à notre guide sur le Profilage Mémoire en Go avec pprof.