Come ottimizzare la performance del codice Go

Ottimizzare in Go significa soprattutto misurare, capire dove si spende tempo e memoria, e intervenire sui colli di bottiglia reali. L’obiettivo non è “rendere tutto più veloce”, ma ridurre latenza e consumo di risorse nei punti critici mantenendo semplicità e correttezza. Questo articolo guida passo-passo: strumenti di profiling e benchmarking, tecniche per ridurre allocazioni e GC, scelta di strutture dati, uso consapevole di concorrenza, e una checklist finale per il lavoro quotidiano.

1) Misura prima di cambiare: benchmark e profiling

Benchmark riproducibili con go test

I benchmark in Go sono veloci da scrivere e si integrano nella suite di test. Per evitare falsi positivi: usa input realistici, evita I/O esterno, isola l’unità di lavoro, e usa -benchmem per osservare allocazioni.

package foo

import "testing"

func BenchmarkParse(b *testing.B) {
    input := []byte("a,b,c,d,e,f,g,h,i,j")
    b.ReportAllocs()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        _ = Parse(input)
    }
}
go test -run '^$' -bench BenchmarkParse -benchmem ./...

Quando confronti due implementazioni, usa benchstat (tool esterno) per valutare differenze statisticamente significative. Evita di “fidarti dell’occhio” su numeri rumorosi.

Profiling CPU e memoria con pprof

La coppia più produttiva è: (1) benchmark o carico reale, (2) profilo CPU e heap, (3) intervento mirato, (4) nuova misura. Con benchmark puoi generare profili direttamente da go test.

go test -run '^$' -bench BenchmarkParse -cpuprofile cpu.out -memprofile mem.out ./...
go tool pprof -http=:0 cpu.out
go tool pprof -http=:0 mem.out

In pprof, privilegia viste “flame graph” o “top” e cerca hot path ripetuti. Concentrati sulle funzioni con costo cumulativo alto (non solo quelle in cima alla lista “flat”).

Profiling in produzione: net/http/pprof

Per servizi HTTP puoi esporre endpoint di profiling e raccogliere profili durante traffico reale o test di carico. Proteggi questi endpoint (rete interna, autenticazione, feature flag): contengono informazioni sensibili.

import (
    "log"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
    }()
    // avvio del tuo server principale...
}
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/profile?seconds=30

Tracing e diagnostica concorrenza

Se il problema è latenza intermittente, contesa su lock, scheduling di goroutine o pause di GC, il trace può essere più informativo del solo pprof.

go test -run '^$' -bench BenchmarkParse -trace trace.out ./...
go tool trace trace.out

2) Le cause più comuni di lentezza in Go

  • Allocazioni e garbage collector: molte piccole allocazioni aumentano lavoro del GC e pressione sulla memoria.
  • Conversioni e copie: passaggi ripetuti tra []byte e string, concatenazioni inefficienti, slicing improprio.
  • Uso eccessivo di interfacce: dispatch dinamico e perdita di ottimizzazioni (inlining, escape analysis meno efficace).
  • Mappe e riflessione: mappe con chiavi pesanti, hashing costoso, uso di reflect o encoding generico.
  • Concorrenza non necessaria: troppe goroutine, canali come collo di bottiglia, contesa su mutex.
  • I/O e syscall: round trip frequenti, buffer piccoli, formattazione e parsing “generici”.

3) Ridurre allocazioni e lavoro del GC

3.1) Usa strutture e pattern “allocation-friendly”

Molti miglioramenti arrivano da piccoli accorgimenti: preallocare slice e mappe, riusare buffer, evitare creazioni temporanee.

Preallocare slice

// Male: crescita incrementale e riallocazioni
var out []int
for _, v := range in {
    out = append(out, f(v))
}

// Bene: capacità nota o stimabile
out := make([]int, 0, len(in))
for _, v := range in {
    out = append(out, f(v))
}

Preallocare mappe

// Se conosci (o stimi) la cardinalità, passa la size a make.
m := make(map[string]int, 1_000)

Riuso di buffer con bytes.Buffer o strings.Builder

Per concatenazioni ripetute, evita + in loop: crea molte stringhe temporanee.

import "strings"

func JoinTokens(tokens []string) string {
    var b strings.Builder
    b.Grow(estimateSize(tokens)) // opzionale ma utile se stimabile
    for i, t := range tokens {
        if i > 0 {
            b.WriteByte(',')
        }
        b.WriteString(t)
    }
    return b.String()
}

3.2) Capire l’escape analysis

In Go, un valore “scappa” sul heap quando il compilatore non può garantirne la vita sullo stack. Più valori sul heap significano più lavoro per il GC. Puoi ispezionare l’escape analysis con i flag del compilatore.

go test -c -gcflags="all=-m=2" ./... 2> escape.txt

Segnali tipici: restituisci puntatori a variabili locali, catturi variabili in closure, passi valori a interfacce (specialmente se implicano boxing), o usi append su slice che vengono poi riferite altrove.

3.3) Evita conversioni costose e copie inutili

Le conversioni tra []byte e string spesso implicano copie (dipende dal contesto e dalla versione del compilatore). In percorsi caldi, tieni i dati in una rappresentazione coerente.

func ParseBytes(b []byte) int {
    // Lavora su []byte se il dato nasce come []byte (I/O, rete, file).
    // Convertire a string solo se serve davvero e una sola volta.
    return len(b)
}

3.4) sync.Pool per oggetti temporanei

sync.Pool è utile per ridurre allocazioni di oggetti grandi o molto frequenti (buffer, strutture di lavoro), ma va usato con criterio: gli oggetti nel pool possono essere liberati dal GC in qualsiasi momento. Consideralo come un “cache best-effort”.

import (
    "bytes"
    "sync"
)

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func Encode(dst []byte, v any) ([]byte, error) {
    b := bufPool.Get().(*bytes.Buffer)
    b.Reset()
    defer bufPool.Put(b)

    // scrivi su b...
    dst = append(dst, b.Bytes()...)
    return dst, nil
}

Attenzione: se metti nel pool buffer enormi, rischi di trattenere memoria. Puoi “capare” la capacità prima di rimettere l’oggetto nel pool.

4) Ottimizzare CPU: inlining, hot loop e funzioni piccole

4.1) Inlining e chiamate in percorsi caldi

Il compilatore può inlineare funzioni piccole, riducendo overhead e abilitando ulteriori ottimizzazioni. Se un hot path chiama molte funzioni minuscole, valuta se l’inlining avviene davvero (flag -m) o se una piccola riscrittura lo sblocca.

go test -c -gcflags="all=-m" ./... 2> inline.txt

Non serve appiattire tutto: cerca le chiamate che appaiono nei profili CPU con elevata frequenza.

4.2) Evita interfacce nel cuore dell’algoritmo

Le interfacce sono fondamentali in Go, ma nei loop più caldi possono introdurre dispatch dinamico e impedire ottimizzazioni. Strategia comune: separare API pubblica (interfacce) dall’implementazione interna (tipi concreti), mantenendo i percorsi caldi su tipi concreti.

type Hasher interface {
    Sum64([]byte) uint64
}

type fastHasher struct{ /* ... */ }

func (h fastHasher) Sum64(b []byte) uint64 { /* ... */ return 0 }

// API pubblica: prende interfaccia
func HashAll(h Hasher, items [][]byte) []uint64 {
    // Internamente: se possibile, specializza
    if fh, ok := h.(fastHasher); ok {
        return hashAllFast(fh, items)
    }
    out := make([]uint64, len(items))
    for i, it := range items {
        out[i] = h.Sum64(it)
    }
    return out
}

func hashAllFast(h fastHasher, items [][]byte) []uint64 {
    out := make([]uint64, len(items))
    for i, it := range items {
        out[i] = h.Sum64(it)
    }
    return out
}

4.3) Scegli algoritmi e strutture dati adatti

Prima di micro-ottimizzare, verifica la complessità: passare da O(n²) a O(n log n) vale più di qualunque micro-tweak. In Go, le strutture standard sono ottime; quando non bastano, valuta implementazioni mirate o librerie affidabili.

5) Mappe, slice e layout della memoria

5.1) Mappe: chiavi, carico e pattern d’uso

  • Preferisci chiavi semplici e piccole (int, string breve) rispetto a struct grandi.
  • Evita di creare stringhe chiave temporanee nel loop; riusa o normalizza prima.
  • Prealloca con make(map[T]U, n) quando possibile.
  • Se l’accesso è read-mostly, considera snapshot immutabili o mappe per shard per ridurre contesa.

5.2) Slice: attenzione a capienza e “memory retention”

Un sottoslice mantiene un riferimento all’array originale: se quell’array è grande, potresti trattenere molta memoria. Se devi conservare solo una piccola parte a lungo, copia i dati.

// src è grande, keep punta allo stesso array sottostante.
keep := src[:small]

// Se keep vive a lungo, meglio copiare:
keep2 := append([]byte(nil), src[:small]...)

5.3) Evita pointer-chasing quando conta

Strutture con molti puntatori (liste concatenate, alberi con nodi sparsi) possono soffrire di cache miss. Quando serve throughput, un layout compatto (slice di struct) spesso è più veloce.

type Node struct {
    Key   int
    Value int
    Next  int // indice nel slice, -1 se fine
}

type List struct {
    nodes []Node
    head  int
}

6) Concorrenza: più goroutine non significa più velocità

6.1) Limita la parallelizzazione

Parallelizzare costa: scheduling, contesa, cache incoerente, overhead di canali e lock. In molte pipeline, un numero moderato di worker (in funzione di core e workload) è migliore di migliaia di goroutine.

func workerPool(n int, jobs <-chan Job) {
    for i := 0; i < n; i++ {
        go func() {
            for j := range jobs {
                process(j)
            }
        }()
    }
}

6.2) Riduci contesa su mutex

  • Riduci la sezione critica: calcola fuori dal lock, aggiorna dentro.
  • Shard dei dati: più mappe/lock indipendenti invece di uno globale.
  • Preferisci sync.RWMutex solo se c’è davvero prevalenza di letture e la contesa lo giustifica.
  • Per contatori semplici, valuta sync/atomic (con attenzione alla semantica e ai false sharing).
type ShardedCounter struct {
    shards []uint64
}

func (c *ShardedCounter) Add(i int, delta uint64) {
    // esempio: shard per goroutine/worker id
    // usa atomic se accesso concorrente sullo stesso shard
    c.shards[i] += delta
}

6.3) Canali: usa buffer e direzioni con criterio

I canali sono ottimi per coordinamento e correttezza, ma in hot path possono diventare un collo di bottiglia. Un buffer adeguato riduce sincronizzazione, ma non sostituisce un design efficiente.

jobs := make(chan Job, 1024) // buffer per ridurre blocchi frequenti

7) I/O, serializzazione e formattazione

7.1) Bufferizza

In lettura e scrittura su rete/file, usa buffer. In particolare, evitare scritture piccole ripetute riduce syscall e overhead.

import "bufio"

w := bufio.NewWriterSize(conn, 32*1024)
defer w.Flush()

// scritture multiple su w

7.2) Serializzazione: preferisci percorsi “zero-copy” quando possibile

JSON è comodo ma spesso costoso. Quando il formato è un collo di bottiglia: valuta formati binari, protocolli più efficienti, o almeno parsing mirato evitando riflessione.

7.3) Evita fmt nei loop caldi

fmt.Sprintf e simili sono molto flessibili ma relativamente lenti. Per stringhe semplici, usa builder, conversioni dedicate (strconv), e scritture su buffer.

import "strconv"

func itoaFast(n int) string {
    return strconv.Itoa(n)
}

8) Flag, build e impostazioni del runtime

8.1) Ottimizzazioni del compilatore

Di default, Go compila con ottimizzazioni abilitate. Evita di distribuire binari compilati con -gcflags="all=-N -l" (tipico per debugging) perché disabilitano ottimizzazioni e inlining.

8.2) GOMAXPROCS e container

In ambienti containerizzati, assicurati che il processo veda correttamente il numero di CPU disponibili. In test di performance, esplicita GOMAXPROCS e rendi l’ambiente stabile (stessa macchina, stessi limiti).

GOMAXPROCS=4 go test -run '^$' -bench . ./...

8.3) GOGC e memoria

GOGC controlla aggressività del garbage collector. Valori più alti riducono frequenza del GC (meno overhead CPU) ma aumentano memoria. Valori più bassi riducono memoria ma possono aumentare CPU e pause. Non cambiare a caso: misura con profili heap e latenza.

GOGC=100 ./your-service    # default tipico
GOGC=200 ./your-service    # meno GC, più memoria (da verificare con misure)

9) Una strategia pratica di ottimizzazione

  1. Definisci metriche: latenza p95/p99, throughput, memoria RSS, CPU, allocazioni/op.
  2. Riproduci: benchmark o test di carico che esprime il problema.
  3. Profilo: CPU + heap (e mutex/block/trace se serve).
  4. Intervieni sul 20% che pesa l’80%: riduci allocazioni, semplifica hot loop, cambia struttura dati o algoritmo.
  5. Rimisura: confronta con baseline usando strumenti statistici e carichi equivalenti.
  6. Proteggi: aggiungi benchmark di regressione e metriche in runtime.

10) Checklist rapida

  • Ho un benchmark o carico reale riproducibile?
  • Ho un profilo CPU e un profilo heap del caso problematico?
  • Sto allocando in un loop caldo? Posso preallocare o riusare?
  • Sto facendo conversioni []byte/string ripetute?
  • Sto concatenando stringhe con + in un loop?
  • Sto usando fmt in percorsi caldi?
  • Sto usando interfacce o riflessione in punti dove potrei usare tipi concreti?
  • La mia concorrenza riduce davvero il tempo totale o aumenta contesa?
  • Sto trattenendo grandi array tramite sottoslice?
  • Ho considerato un cambio di algoritmo prima di micro-ottimizzare?

Se applichi questi principi con disciplina di misura, in Go spesso ottieni miglioramenti sostanziali con modifiche relativamente piccole. La regola d’oro resta: ottimizza ciò che hai misurato, e misura ciò che hai ottimizzato.

Torna su