Recyclage de structures en Go avec sync.Pool pour la Performance

Le Garbage Collector (GC) de Go est performant, mais l'allocation massive d'objets temporaires sur la Heap reste son principal goulot d'étranglement pour la performance mémoire. Le recyclage d'objets via sync.Pool permet de réutiliser des structures existantes au lieu d'en créer de nouvelles, réduisant drastiquement la pression sur le GC et optimisant l'usage de la mémoire en Go.

Le Concept

sync.Pool est un réservoir d'objets temporaires.

  • Voisinage CPU : Contrairement à un channel, chaque processeur possède sa propre liste d'objets (Local Storage). Cela évite la contention (verrous) entre les cœurs.
  • Éphémère : Le pool est vidé automatiquement lors des cycles de GC. Il est conçu pour des objets à cycle de vie court.
  • Interface any : Il stocke des types any, ce qui implique une assertion de type à la sortie et un risque de boxing s'il est mal utilisé.

Implémentation Idiomatique

1. Injection de Dépendances

Il est préférable d'encapsuler le pool dans une structure pour faciliter les tests unitaires.

 1
 2import "example/internal/domain"
 3
 4type UserResponsePool struct {
 5    pool *sync.Pool
 6}
 7
 8func NewUserResponsePool() *UserResponsePool {
 9    return &UserResponsePool{
10        pool: &sync.Pool{
11            New: func() any { return new(UserResponse) },
12        },
13    }
14}
15

Cycle de vie : Get, Reset, Put

Get

 1func (p *UserResponsePool) Serialize(user domain.User) *UserResponse {
 2    resp := p.pool.Get().(*UserResponse)
 3
 4    resp.ID = user.ID
 5    resp.FirstName = user.FirstName
 6    resp.LastName = user.LastName
 7    resp.Email = user.Email
 8    resp.CreatedAt = user.CreatedAt
 9    resp.UpdatedAt = user.UpdatedAt
10    resp.DeletedAt = user.DeletedAt
11    resp.IsActive = user.IsActive
12    resp.Role = user.Role
13    resp.Permissions = user.Permissions
14    resp.ProfilePictureURL = user.ProfilePictureURL
15    resp.LastLoginAt = user.LastLoginAt
16    
17    return resp
18}

Reset

Le nettoyage de l'objet est la responsabilité de l'utilisateur du pool.

1func (p *UserResponsePool) Release(resp *UserResponse) {
2    resp = &UserResponse{}
3    p.pool.Put(resp)
4}

Cas d'usages Concrets & Middleware

API

 1func main() {
 2    userResponsePool := NewUserResponsePool()
 3    userRepository := NewUserRepository()
 4
 5    http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
 6        user := userRepository.GetByID(r.URL.Query().Get("id"))
 7        res := userResponsePool.Serialize(user)
 8        json.NewEncoder(w).Encode(res)
 9        userResponsePool.Release(res)
10    })
11    
12    http.ListenAndServe(":8080", nil)
13}

Middleware de Logging (Buffer Pooling)

Dans une API à fort trafic, créer un bytes.Buffer ou une chaîne de caractères pour chaque log est coûteux.

 1var logPool = sync.Pool{
 2    New: func() any { return new(bytes.Buffer) },
 3}
 4
 5func LoggingMiddleware(next http.Handler) http.Handler {
 6    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 7        buf := logPool.Get().(*bytes.Buffer)
 8        buf.Reset()
 9        defer logPool.Put(buf)
10
11        buf.WriteString("[INFO] Path: ")
12        buf.WriteString(r.URL.Path)
13        fmt.Println(buf.String())
14
15        next.ServeHTTP(w, r)
16    })
17}

Preuve par le Benchmark

Comparaison allocation classique vs usage du pool (go test -bench=. -benchmem) :

ApprocheTemps (ns/op)Mémoire (B/op)Allocs (allocs/op)
Sans Pool~45 ns64 B1
Avec Pool~12 ns0 B0

Analyse : On élimine totalement l'allocation sur la Heap par opération. Sur 1 million de requêtes, on économise 64 Mo de RAM et des milliers de cycles GC.

Les Pièges à éviter

Le Boxing

Si vous stockez une valeur et non un pointeur, Go doit "emballer" (boxer) la valeur dans l'interface any, ce qui provoque une allocation sur la Heap.

❌ pool.Put(myStruct) -> Allocation !
✅ pool.Put(&myStruct) -> Zéro allocation.

Le "Dirty Object" (Objet sale)

Si vous oubliez le Reset(), la routine suivante récupérera les données de la précédente.

Danger : Cela peut causer des fuites de données sensibles entre deux utilisateurs d'une API.

Les objets trop légers

Si l'objet est minuscule (ex: un int), le coût CPU du Get/Put est plus élevé que le gain mémoire. Utilisez le pool pour des objets complexes, des slices ou des buffers.

Checklist d'implémentation

  • L'objet est-il alloué très fréquemment (> 1000/sec) ?
  • La fonction New retourne-t-elle bien un pointeur ?
  • L'objet est-il systématiquement réinitialisé après le Get ?
  • Le pool est-il encapsulé pour éviter l'état global ?
  • Avez-vous vérifié l'absence d'allocations avec -benchmem ?