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 typesany, 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) :
| Approche | Temps (ns/op) | Mémoire (B/op) | Allocs (allocs/op) |
|---|---|---|---|
| Sans Pool | ~45 ns | 64 B | 1 |
| Avec Pool | ~12 ns | 0 B | 0 |
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 ?
