Profiling Go avec pprof : trouver les points chauds CPU, mémoire et goroutine
1. Vue d’ensemble
Quand un service Go s’exécute lentement ou consomme beaucoup de RAM, deviner au hasard coûte du temps et se trompe souvent. Go possède un outil pprof dans la bibliothèque standard. Cet outil vous dit exactement quelle fonction consomme du CPU, où la mémoire est allouée et quelle goroutine est bloquée.
Après avoir lu cet article, vous saurez comment activer pprof pour un service HTTP, collecter des profils à partir d’un benchmark et d’un programme CLI, lire les résultats avec go tool pprof, et comparer deux profils pour vérifier une optimisation.
2. Qu’est-ce que pprof et quels sont les types de profils
pprof se compose de deux parties :
- Côté collecte : les packages
runtime/pprofetnet/http/pprofdans la bibliothèque standard. Ils enregistrent les profils au format protobuf. - Côté analyse : la commande
go tool pprof. Cette commande lit un fichier de profil ou une URL, puis affiche les résultats sous forme de tableau, de code source ou d’interface web.
Les types de profils couramment utilisés :
| Profil | Mesure quoi | Activé par défaut ? |
|---|---|---|
profile (CPU) |
Temps CPU par fonction, échantillonné 100 fois/sec | Oui, à la demande |
heap |
Mémoire vivante et total alloué | Oui |
allocs |
Comme heap, mais affiche par défaut l’allocation totale |
Oui |
goroutine |
Stack trace de toutes les goroutines actuelles | Oui |
block |
Temps d’attente sur canal, select, sync.Cond |
Non |
mutex |
Contention sur sync.Mutex/RWMutex |
Non |
trace |
Trace d’événements détaillée (utilise go tool trace) |
Oui, à la demande |
3. Activer pprof pour un service HTTP
Le moyen le plus rapide est d’importer net/http/pprof pour obtenir l’effet de bord. Ce package enregistre les gestionnaires /debug/pprof/* auprès de http.DefaultServeMux.
package main
import (
"log"
"net/http"
_ "net/http/pprof" // enregistre /debug/pprof/* auprès de DefaultServeMux
)
func main() {
// Serveur pprof séparé, écoute seulement sur localhost
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Serveur d'application avec mux séparé, n'expose pas pprof
appMux := http.NewServeMux()
appMux.HandleFunc("GET /invoice", invoiceHandler)
log.Fatal(http.ListenAndServe(":8080", appMux))
}
Quelques points à noter :
- La ligne
_ "net/http/pprof"enregistre seulement les gestionnaires, vous n’appelez aucune fonction de ce package. - Le serveur pprof passe
nilcomme gestionnaire, c’est-à-dire utiliseDefaultServeMux, où se trouvent les routes pprof. - L’application principale utilise
appMuxséparément, donc le port8080n’a pas/debug/pprof.
Vérification rapide :
curl -s http://localhost:6060/debug/pprof/ | head -n 20
Attention : N’exposez jamais
/debug/pprofsur Internet. Cet endpoint révèle les stack traces et la structure du code. Un attaquant peut même l’utiliser pour surcharger le service (un profil CPU de 30 secondes consomme des ressources). Bindez surlocalhost, ou placez-le derrière un réseau interne et une couche d’authentification.
4. Collecter et analyser un profil CPU
Supposons que le service ait une fonction pour générer des factures :
type Item struct {
SKU string
Price float64
Qty int
}
type Order struct {
ID int
Items []Item
}
func BuildInvoice(o Order) string {
s := ""
for _, it := range o.Items {
s += fmt.Sprintf("%s x%d = %.2f\n", it.SKU, it.Qty, it.Price*float64(it.Qty))
}
return s
}
À chaque itération, s += ... crée une nouvelle chaîne et copie tout le contenu précédent. Avec une commande de plusieurs lignes, le coût augmente en O(n²).
Pendant que le service reçoit de la charge (par exemple vous envoyez des requêtes avec hey ou wrk vers /invoice), collectez un profil CPU pendant 30 secondes :
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=30"
Une fois la collecte terminée, vous entrez en mode interactif. Tapez top :
(pprof) top 8
Showing nodes accounting for 2.41s, 85.46% of 2.82s total
flat flat% sum% cum cum%
0.92s 32.62% 32.62% 0.92s 32.62% runtime.memmove
0.41s 14.54% 47.16% 1.38s 48.94% runtime.concatstrings
0.33s 11.70% 58.87% 0.33s 11.70% runtime.memclrNoHeapPointers
0.27s 9.57% 68.44% 0.71s 25.18% runtime.mallocgc
0.18s 6.38% 74.82% 0.52s 18.44% fmt.(*pp).doPrintf
0.13s 4.61% 79.43% 0.13s 4.61% strconv.fmtF
0.10s 3.55% 82.98% 2.47s 87.59% main.BuildInvoice
0.07s 2.48% 85.46% 0.21s 7.45% runtime.scanobject
Comment lire les deux colonnes les plus importantes :
- flat : le temps CPU passé directement dans cette fonction.
- cum (cumulative) : le temps de cette fonction plus toutes les fonctions qu’elle appelle.
Ici main.BuildInvoice a un flat faible mais un cum proche de 88%. La plupart du temps provient de memmove et concatstrings, c’est-à-dire la concaténation de chaînes. Pour voir les détails ligne par ligne, utilisez list :
(pprof) list BuildInvoice
ROUTINE ======================== main.BuildInvoice in /app/invoice.go
100ms 2.47s (flat, cum) 87.59% of Total
. . 18:func BuildInvoice(o Order) string {
. . 19: s := ""
10ms 10ms 20: for _, it := range o.Items {
90ms 2.46s 21: s += fmt.Sprintf("%s x%d = %.2f\n", ...)
. . 22: }
La ligne 21 est le coupable. Si vous voulez voir un flame graph, ouvrez l’interface web :
go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"
Votre navigateur ouvrira les vues Top, Graph, Flame Graph et Source. Dans le flame graph, plus un bloc est large, plus il consomme de CPU.
La version corrigée utilisant strings.Builder et fmt.Fprintf :
func BuildInvoiceFast(o Order) string {
var b strings.Builder
b.Grow(len(o.Items) * 32) // estimez à l'avance, évitez les croissances répétées
for _, it := range o.Items {
fmt.Fprintf(&b, "%s x%d = %.2f\n", it.SKU, it.Qty, it.Price*float64(it.Qty))
}
return b.String()
}
5. Profil heap : qui retient la mémoire ?
go tool pprof http://localhost:6060/debug/pprof/heap
Un profil heap contient quatre “index d’échantillon”. Vous sélectionnez l’index avec le drapeau -sample_index :
inuse_space(par défaut) : nombre d’octets en vie au moment du GC précédent. Utilisez ceci pour trouver les fuites mémoire.inuse_objects: nombre d’objets en vie.alloc_space: nombre total d’octets alloués depuis le démarrage du programme. Utilisez ceci pour réduire la pression sur le GC.alloc_objects: nombre total d’objets alloués.
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top 3
flat flat% sum% cum cum%
1.84GB 91.20% 91.20% 1.84GB 91.20% main.BuildInvoice
0.12GB 5.95% 97.15% 0.12GB 5.95% fmt.Sprintf
0.03GB 1.49% 98.64% 0.03GB 1.49% encoding/json.Marshal
Le résultat montre que BuildInvoice a alloué au total 1.84GB, bien que la quantité en vie peut être très faible. C’est le signe que le GC doit travailler dur. Cette situation correspond à la quantité de runtime.mallocgc et runtime.scanobject que vous avez vue dans le profil CPU à la section 4.
Remarque : Le profil heap est aussi un échantillon (par défaut environ 1 échantillon pour 512KB alloués), donc les chiffres sont une estimation. Ne comparez pas chaque octet, regardez les proportions.
6. Profil goroutine : détecter les fuites de goroutine
Si le nombre de goroutines augmente constamment, vous pouvez vérifier rapidement via le navigateur ou curl sans avoir besoin de go tool pprof :
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" | head -n 15
goroutine profile: total 10432
10398 @ 0x43a5d6 0x4071c5 0x406d78 0x6b2f45 0x46c0e1
# 0x6b2f44 main.(*PaymentService).notify+0x64 /app/payment.go:57
Il y a 10.398 goroutines bloquées au même endroit dans payment.go:57. C’est quasi certainement une fuite. Le code fautif ressemble généralement à :
func (s *PaymentService) notify(ctx context.Context, p Payment) {
result := make(chan error) // non bufferisé
go func() {
result <- s.gateway.Send(p) // se bloque indéfiniment si personne ne lit
}()
select {
case err := <-result:
log.Println(err)
case <-ctx.Done():
return // la goroutine interne est orpheline
}
}
Quand ctx expire en premier, personne n’attend plus sur result, donc la goroutine interne reste bloquée indéfiniment sur l’envoi. Le correctif simple est d’utiliser make(chan error, 1) pour que l’envoi ne soit jamais bloquant.
Avec debug=2, l’endpoint affiche les stack traces complètes de chaque goroutine, avec les temps d’attente (par exemple [chan send, 42 minutes]). Cette information est très utile lors d’une enquête sur un deadlock.
7. Profils block et mutex
Ces deux profils sont désactivés par défaut car ils ont un coût. Vous les activez dans main :
import "runtime"
func init() {
// Enregistrez environ 1 événement de contention mutex sur 5
runtime.SetMutexProfileFraction(5)
// Échantillonnez 1 événement de blocage pour tous les 10µs bloqués
runtime.SetBlockProfileRate(10_000)
}
Ensuite, collectez les profils comme d’habitude :
go tool pprof http://localhost:6060/debug/pprof/mutex
go tool pprof http://localhost:6060/debug/pprof/block
Le profil mutex vous dit quel lock force les goroutines à attendre les unes après les autres, par exemple un sync.Mutex protégeant un cache map[int]Customer partagé. Le profil block vous dit où les goroutines attendent longtemps sur un canal ou un select.
Remarque :
SetBlockProfileRate(1)enregistre chaque événement de blocage. Ce niveau n’est approprié que pour debuguer localement. En production, utilisez un taux plus élevé, ou activez-le seulement temporairement.
8. Profiling à partir d’un benchmark
Pour une fonction spécifique, la manière la plus simple est d’écrire un benchmark puis de laisser go test générer un profil. Le code ci-dessous utilise b.Loop() donc il faut Go 1.24 ou plus récent.
package main
import (
"fmt"
"testing"
)
func sampleOrder(n int) Order {
items := make([]Item, n)
for i := range items {
items[i] = Item{SKU: fmt.Sprintf("SKU-%04d", i), Price: 19.99, Qty: i%5 + 1}
}
return Order{ID: 1, Items: items}
}
func TestBuildInvoiceFastMatchesOriginal(t *testing.T) {
order := sampleOrder(200)
if got, want := BuildInvoiceFast(order), BuildInvoice(order); got != want {
t.Fatalf("résultats différents:\n got=%q\nwant=%q", got, want)
}
}
func BenchmarkBuildInvoice(b *testing.B) {
order := sampleOrder(500)
b.ReportAllocs()
for b.Loop() {
BuildInvoice(order)
}
}
func BenchmarkBuildInvoiceFast(b *testing.B) {
order := sampleOrder(500)
b.ReportAllocs()
for b.Loop() {
BuildInvoiceFast(order)
}
}
Le test TestBuildInvoiceFastMatchesOriginal assure que la version optimisée produit exactement le même résultat que l’ancienne. Ne sautez pas cette étape : optimiser en se trompant n’a aucun sens. Exécutez le benchmark avec profil :
go test -run='^$' -bench=BuildInvoice -cpuprofile=cpu.out -memprofile=mem.out
BenchmarkBuildInvoice-8 1543 772410 ns/op 6342817 B/op 1002 allocs/op
BenchmarkBuildInvoiceFast-8 19821 60318 ns/op 36472 B/op 504 allocs/op
La version utilisant strings.Builder est environ 12 fois plus rapide et alloue environ 170 fois moins. Le drapeau -run='^$' ignore les tests ordinaires pour ne lancer que les benchmarks. Les fichiers cpu.out et mem.out s’ouvrent avec :
go tool pprof -http=:8081 cpu.out
9. Profiling d’un programme CLI avec runtime/pprof
Pour un outil qui s’exécute une fois puis s’arrête, par exemple un job d’import de commandes, utilisez directement runtime/pprof :
package main
import (
"log"
"os"
"runtime"
"runtime/pprof"
)
func main() {
cpuFile, err := os.Create("cpu.out")
if err != nil {
log.Fatal(err)
}
defer cpuFile.Close()
if err := pprof.StartCPUProfile(cpuFile); err != nil {
log.Fatal(err)
}
defer pprof.StopCPUProfile()
importOrders("orders.csv") // travail principal
memFile, err := os.Create("mem.out")
if err != nil {
log.Fatal(err)
}
defer memFile.Close()
runtime.GC() // mettez à jour les statistiques inuse avant d'écrire
if err := pprof.WriteHeapProfile(memFile); err != nil {
log.Fatal(err)
}
}
Deux erreurs courantes ici :
- Appeler
log.Fatalouos.ExitaprèsStartCPUProfilecontourne lesdefer. Dans ce casStopCPUProfilene s’exécute pas et le fichier profile est vide ou corrompu. - Oublier
runtime.GC()avantWriteHeapProfile. Les statistiquesinuse_*ne sont mises à jour qu’après chaque GC, donc le profil peut refléter un état ancien.
10. Comparer deux profils
Après optimisation, vous devez mesurer à nouveau plutôt que de vous fier à vos impressions. Le drapeau -diff_base soustrait l’ancien profil du nouveau :
go tool pprof -diff_base=cpu_before.out cpu_after.out
(pprof) top 3
flat flat% sum% cum cum%
-0.89s 31.56% 31.56% -0.89s 31.56% runtime.memmove
-0.40s 14.18% 45.74% -1.35s 47.87% runtime.concatstrings
-0.25s 8.87% 54.61% -0.66s 23.40% runtime.mallocgc
Les valeurs négatives signifient que le temps a diminué. Pour que les résultats aient du sens, les deux profils doivent être collectés dans les mêmes conditions : même charge, même durée, même machine.
De plus, depuis Go 1.21, le profil CPU sert aussi pour Profile-Guided Optimization (PGO). Vous collectez un profil CPU représentatif de la production, le nommez default.pgo dans le répertoire du package main, puis go build l’utilisera automatiquement pour inliner et optimiser les chemins de code chauds. L’amélioration est généralement de quelques pourcents, et vous n’avez à modifier aucune ligne de code.
11. Erreurs courantes et quand ne pas utiliser
- Profil trop court ou sans charge. Le profil CPU échantillonne à 100Hz. Si vous collectez seulement 2 secondes quand le service est au repos, vous ne verrez que
runtimeet du bruit. - Confondre
flatetcum. Une fonction avec uncumélevé n’est pas forcément le coupable, elle peut simplement appeler le coupable. - Confondre
inuse_spaceetalloc_space. Pour trouver une fuite, consultezinuse. Pour réduire la pression sur le GC, consultezalloc. - Mesurer l’I/O avec le profil CPU. Le temps où une goroutine attend une base de données ou HTTP ne consomme pas de CPU donc n’apparaît pas dans le profil CPU. Utilisez le profil block,
go tool trace, ou un tracing distribué. - Exposer pprof publiquement sur le port de l’application. Revoyez la section 3.
Remarque : pprof répond à la question “où ça coûte”, pas “faut-il optimiser”. Si un endpoint atteint déjà son SLA et une fonction consomme seulement 2% du CPU, la réécrire plus complexe n’en vaut généralement pas la peine.
12. Conclusion
Dans cet article, vous avez :
- activé
net/http/pprofde manière sécurisée sur un serveur séparé surlocalhost; - lu un profil CPU avec
topetlist, et distinguéflatdecum; - utilisé un profil heap avec
inuse_spaceetalloc_space; - détecté les fuites de goroutine via
debug=1etdebug=2; - activé les profils block et mutex ;
- généré des profils à partir d’un benchmark et d’un programme CLI ;
- comparé avant/après avec
-diff_base.
Le processus le plus efficace reste : mesurer → trouver les points chauds → corriger → écrire un test pour préserver le comportement → mesurer à nouveau. Avec pprof fourni dans la bibliothèque standard, vous n’avez rien à installer pour commencer.