Go Structs: Tecniche di Ottimizzazione della Memoria

banner

Nello scorso articolo abbiamo visto quanto un piccolo accorgimento sul codice Go possa influire sulle prestazioni dei nostri programmi in termini di memoria occupata e velocità. Questa volta proviamo a scendere un po’ più nel dettaglio. Aggiungiamo qualche altro accorgimento tecnico e parliamo di qualche buona tecnica di programmazione. L’obiettivo sarà ancora quello di dare un boost al codice che scriviamo.

Vediamolo come una puntata due (qui il link al primo articolo).

Riutilizzeremo comunque lo stesso spirito pratico e dimostrativo che Go ci consente di adottare con facilità.

Partiamo dal solito assunto: Go è progettato per essere veloce, per natura del linguaggio. Tuttavia le tecniche di programmazione di cui parleremo fanno la differenza tra un programma veloce (questo è semplice) e un programma efficiente (meno semplice).

Prima di partire, è fondamentale chiarire il nostro obiettivo: mettere in condizioni ottimali il garbage collector di lavorare, affinché subisca meno pressione possibile e riduca al minimo le sue (seppure brevissime) pause stop-the-world. Per fare questo non lo dobbiamo combattere, dobbiamo lavorarci insieme, capirlo e aiutarlo (non è una seduta terapeutica).

Per questo partiamo dal concetto alla base del GC di Go, ovvero il suo algoritmo concurrent mark-and-sweep. Senza entrare nei dettagli implementativi (che sono piuttosto complessi), il funzionamento di base si articola in due fasi:

  • Mark: a partire dalle radici (variabili globali, stack delle goroutine) segue tutti i puntatori agli oggetti e li marca come raggiungibili (cioè ancora in uso).
  • Sweep: scansiona tutta la memoria allocata e libera gli oggetti che non sono stati marcati (cioè non più raggiungibili).

La caratteristica principale è che lavora in modo concorrente con il programma, riducendo le pause stop-the-world a pochi microsecondi. Il prezzo di questa semplicità è che, a differenza di algoritmi più complessi come quelli di Java, non c’è praticamente nulla da configurare e non esiste il concetto di generazioni. Efficiente di fabbrica, appunto.

Questo per dire che il resto del lavoro lo deve fare chi sviluppa, quindi chiediamoci sempre: come possiamo noi aiutare il GC a lavorare in maniera ancora più efficiente?

Vediamo qualche accorgimento.

Preallocazione di Slice e Map

Corso di fondamenti di informatica 1 all’università: scrivi un algoritmo che consenta di memorizzare una serie di interi dati in input, in un array. L’assunto è che l’utente può dare in input un intero alla volta e non si sa quanti siano i numeri da gestire. La soluzione didattica è quella di istanziare un array (magari di dimensioni piccole inizialmente) e quando lo spazio finisce crearne un altro di dimensione maggiore copiando tutti i dati, così iterativamente. Operazione costosa ovviamente. Di fatto, evitare questo tipo di situazioni rende un programma enormemente più efficiente.

In Go esiste un tipo di dato chiamato slice che altro non è che una vista su un array: contiene il puntatore al primo elemento dell’array a cui fa riferimento, la lunghezza e la capacità totale di questo array. Molto spesso le slice vengono allocate con dimensione zero usando il comando make, ad esempio:

slice := make([]int, 0)

La proprietà di questa struttura è che se si aggiungono elementi, automaticamente Go la rialloca aumentando la dimensione man mano, in maniera trasparente. Stesso discorso vale per le mappe:

m := make(map[string]any, 0)

Ogni append (o put) ha un costo molto alto proprio per quanto descritto all’inizio e produce una notevole quantità di memoria allocata e non più utilizzata (spazzatura) che il GC dovrà gestire.

Per aiutare il collector possiamo specificare una dimensione per queste strutture, e allocarle già con la dimensione finale, senza incappare in continue riallocazioni, ad esempio:

slice := make([]int, 0, 10)

Non è sempre possibile ovviamente, ma se le condizioni lo permettono vi garantisco che migliora di molto le prestazioni. Vediamo qualche numero usando i benchmark:

package main

import "testing"

const num_elements = 10000

// Allocazione di una slice con dimensione zero
func BenchmarkWithoutPreallocation(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := make([]int, 0)
		for j := 0; j < num_elements; j++ {
			s = append(s, j)
		}
	}
}

//Allocazione di una slice con dimensione diversa da zero
func BenchmarkWithPreallocation(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := make([]int, 0, num_elements)
		for j := 0; j < num_elements; j++ {
			s = append(s, j)
		}
	}
}

Il risultato:

go test -bench=. -benchmem

goos: darwin
goarch: arm64
cpu: Apple M4 Pro
BenchmarkWithoutPreallocation-12    	   46605	     24890 ns/op	  357628 B/op	      19 allocs/op
BenchmarkWithPreallocation-12       	  184802	      6456 ns/op	   81920 B/op	       1 allocs/op

Cioè:

  • Caso senza preallocazione: ogni operazione ha impiegato circa 0,02489 ms e per ognuna è stata fatta un’operazione di allocazione di 357,628KB e19 allocazioni per operazione.
  • Caso con preallocazione: ogni operazione ha impiegato circa 0,006456 ms e per ognuna è stata fatta un’operazione di allocazione di circa 81,92KB e 1 allocazione per operazione, quella iniziale.

Riuso degli oggetti

In casi di alta concorrenza, le operazioni di allocazione di buffer o worker temporanei possono influire sulle prestazioni dei servizi che sviluppiamo. Go mette a disposizione degli strumenti che facilitano il riuso sicuro degli oggetti allocati in memoria, o meglio, che minimizzano le operazioni di allocazione degli oggetti (perché quando ci vuole, ci vuole). Si tratta dei sync.Pool. Vediamo alcuni esempi:

  • Funzione che alloca un buffer a ogni esecuzione:
func processDataWithoutPool(data []string) string {
	var buf bytes.Buffer

	for i, item := range data {
		if i > 0 {
			buf.WriteString(", ")
		}
		buf.WriteString(fmt.Sprintf("processed_%s", item))
	}

	return buf.String()
}
  • Funzione che recupera oggetti inutilizzati da un pool e li riutilizza:
var bufferPool = sync.Pool{
	New: func() interface{} {
		return bytes.NewBuffer(make([]byte, 0, 1024))
	},
}

func processDataWithPool(data []string) string {
	buf := bufferPool.Get().(*bytes.Buffer)
	buf.Reset() // provate per divertimento a commentare questa riga e fate un po' di test ...

	for i, item := range data {
		if i > 0 {
			buf.WriteString(", ")
		}
		buf.WriteString(fmt.Sprintf("processed_%s", item))
	}

	result := buf.String()
	bufferPool.Put(buf)

	return result
}

Vero, abbiamo scritto qualche riga di codice in più, ma vediamo il guadagno:

const num_ops = 100000

func BenchmarkBufferWithPool(b *testing.B) {
	data := []string{"item1", "item2", "item3", "item4", "item5"}

	b.ResetTimer()
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		for j := 0; j < num_ops; j++ {
			_ = processDataWithPool(data)
		}
	}
}

func BenchmarkBufferWithoutPool(b *testing.B) {
	data := []string{"item1", "item2", "item3", "item4", "item5"}

	b.ResetTimer()
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		for j := 0; j < num_ops; j++ {
			_ = processDataWithoutPool(data)
		}
	}
}

Ecco i risultati:

go test -bench=. -benchmem

goos: darwin
goarch: arm64
cpu: Apple M4 Pro
BenchmarkBufferWithPool-12       	     504	   2155717 ns/op	 2562615 B/op	  110003 allocs/op
BenchmarkBufferWithoutPool-12    	     476	   2548649 ns/op	 4482365 B/op	  130004 allocs/op

In altre parole:

  • CPU: ~15% più veloce

    • Con pool: 2.15ms per operazione

    • Senza pool: 2.54ms per operazione

  • Memoria allocata: ~43% in meno (il GC ringrazia)

    • Con pool: 2.5MB per operazione

    • Senza pool: 4.4MB per operazione

  • Numero di allocazioni: ~15% in meno

    • Con pool: 110,003 allocazioni

    • Senza pool: 130,004 allocazioni

Tra l’altro la soluzione con pool è anche più veloce: nello stesso tempo di esecuzione del test sono state fatte 504 iterazioni rispetto alle 476 della soluzione senza pool.

Puntatori

I puntatori sono uno degli aspetti più ostici, soprattutto per chi viene da linguaggi di più alto livello come Java, ad esempio. Tuttavia, basta capirne gli effetti per usarli con consapevolezza e dare uno sprint decisamente importante ai nostri programmi. Il concetto chiave sta tutto nel capire la differenza tra stack e heap:

  • Stack: area di memoria molto veloce, autogestita e che non compete al GC. In pratica limitata al contesto di una funzione dalla dichiarazione al return.
  • Heap: area di memoria dinamica, più duratura, dove il GC lavora per liberare spazio.

Quando scegliamo tra il ritornare un valore rispetto o un puntatore stiamo facendo proprio questo: stiamo determinando dove verrà allocata la memoria.

So far so good.

Peccato che le variabili “scappino”. Nel senso che se una variabile all’interno della funzione viene ritornata come puntatore automaticamente il runtime ne individua la pubblica utilità e decide di spostarla dallo stack (della funzione) allo heap! Ecco, basta saperlo.

Facciamo un esempio:

  • variabile che non scappa dallo stack, la funzione ritorna una copia del valore:
func onStack() int {
   x := 42        
   return x
}
  • variabile che scappa dallo stack, la funzione ritorna un riferimento al valore:
func onHeap() *int {
    x := 42        
    return &x      
}

Anche in questo caso possiamo fare qualche semplice benchmark:

type Data struct {
	Question string
	Answer   int
}

func createOnStack() Data {
	return Data{
		Question:  "the ultimate question of life, the universe, and everything",
		Answer: 42,
	}
}

func createOnHeap() *Data {
	return &Data{
		Question:  "the ultimate question of life, the universe, and everything",
		Answer: 42,
	}
}

var result Data
var resultPtr *Data

func BenchmarkStackAllocation(b *testing.B) {
	b.ReportAllocs()
	var r Data

	for i := 0; i < b.N; i++ {
		r = createOnStack() 
	}

	result = r
}

func BenchmarkHeapAllocation(b *testing.B) {
	b.ReportAllocs()
	var r *Data

	for i := 0; i < b.N; i++ {
		r = createOnHeap() 
	}

	resultPtr = r 
}

Ancora una volta, risultati più che parlanti:

go test -bench=. -benchmem

goos: darwin
goarch: arm64
cpu: Apple M4 Pro
BenchmarkStackAllocation-12     1000000000               0.2303 ns/op          0 B/op          0 allocs/op
BenchmarkHeapAllocation-12      100000000               10.01 ns/op           24 B/op          1 allocs/op

Cioè:

  • allocare sullo stack è 43 volte più veloce che allocare sulla memoria heap;
  • come ci aspettavamo, nel caso di allocazione stack non vengono fatte allocazioni heap perché la variabile restituita come valore non scappa sullo heap.

Doverosamente concludiamo dicendo che ottimizzare il codice Go significa scrivere programmi che lavorino insieme al garbage collector, non contro di lui. Le tecniche che abbiamo visto non sono micro-ottimizzazioni, ma pattern che fanno la differenza quando l’applicazione scala.

Quando sviluppate API ad alto traffico o servizi real-time, la differenza tra 2ms e 2.5ms per operazione moltiplicata per migliaia di richieste al secondo diventa il discrimine tra un servizio performante e uno che fa fatica.

Go ci dà meno configurazioni ma ci chiede più attenzione al codice. È una responsabilità che ripaga con prestazioni consistenti e predicibili. E soddisfazione personale.

Alla prossima!

Post correlati

TheRedCode.it - Il mondo #tech a piccoli #bit

Partners

Community, aziende e persone che supportano attivamente il blog

Logo di Codemotion
Logo di GrUSP
Logo di Python Milano
Logo di Schrodinger Hat
Logo di Python Biella Group
Logo di Fuzzy Brains
Logo di Django Girls
Logo di Improove
Logo del libro open source
Logo di NgRome
Logo de La Locanda del Tech
Logo di Tomorrow Devs
Logo di DevDojo
Logo di Cloud Native Days 2025

Non perderti gli ultimi aggiornamenti, iscriviti a TheRedCode Digest!

La tecnologia corre, e tu devi correre più veloce per rimanere sempre sul pezzo! 🚀

Riceverai una volta al mese (o anche meno) con codici sconto per partecipare agli eventi del settore, quiz per vincere dei gadget e i recap degli articoli più interessanti pubblicati sul blog

Ci sto!

Vuoi diventare #tech content creator? 🖊️

Se vuoi raccontare la tua sul mondo #tech con dei post a tema o vuoi condividere la tua esperienza con la community, sei nel posto giusto! 😉

Manda una mail a collaborazioni[at]theredcode.it con la tua proposta e diventa la prossima penna del blog!

Ma sì, facciamolo!