Il compilatore di Go utilizza una tecnica chiamata GCshape stenciling quando compila le generiche introdotte nella versione 1.18. Storicamente, i linguaggi implementavano le generiche attraverso la monomorfizzazione completa, generando codice macchina separato per ogni istanza di tipo, causando ingombro binario, o attraverso il boxing, eliminando i tipi a costo di un sovraccarico di runtime e allocazione. Il problema che Go affrontava era sostenere la programmazione di sistemi ad alte prestazioni in cui la dimensione del binario conta, senza sacrificare del tutto la velocità di esecuzione.
La soluzione prevede il raggruppamento di tipi concreti in base alla loro forma GC, definita dalla loro dimensione e dalla bitmap dei puntatori (il pattern dei puntatori all'interno del tipo). Il compilatore genera un'unica istanza di funzione per tutti i tipi che condividono la stessa forma GC, passando un dizionario di runtime contenente metadati di tipo come parametro implicito.
// Sia *int che *string condividono la stessa istanza // perché hanno la stessa forma GC (unico puntatore). func Identity[T any](x T) T { return x } func main() { Identity((*int)(nil)) // Usa l'istanza #1 Identity((*string)(nil)) // Usa l'istanza #1 (stessa forma) Identity(42) // Usa l'istanza #2 (scalare, nessun puntatore) }
Il nostro team stava costruendo un pipeline di elaborazione eventi ad alta capacità utilizzando handler middleware generici Handler[T Event]. Dovevamo elaborare cinquanta tipi di eventi distinti mantenendo bassa latenza e una dimensione binaria ragionevole per il deployment in container.
Il primo approccio utilizzava interface{} con asserzioni di tipo, facendo affidamento su switch di tipo a runtime. Questo forniva flessibilità e funzionava nelle versioni precedenti di Go, ma introduceva un notevole sovraccarico di allocazione—ogni evento avvolto in un'interfaccia richiedeva allocazione nell'heap—ed eliminava la sicurezza di tipo a tempo di compilazione, portando a panico in produzione quando i tipi si allineavano male.
Il secondo approccio prevedeva la generazione di codice a tempo di compilazione usando go generate con strumenti di terze parti per creare HandlerClickEvent, HandlerPurchaseEvent, ecc. Questo forniva prestazioni ottimali senza sovraccarico di runtime, ma ingombrava la nostra dimensione binaria di 40MB quando supportavamo cinquanta tipi di eventi, e creava incubi di manutenzione quando si aggiornavano i template del generatore.
Abbiamo scelto il terzo approccio: generici Go nativi con attenzione alla forma GC. Abbiamo assicurato che i nostri tipi di eventi fossero puntatori a strutture (forma GC uniforme), permettendo al compilatore di riutilizzare le istanze. Abbiamo accettato il lieve sovraccarico delle ricerche nel dizionario per le chiamate ai metodi in cambio di un aumento della dimensione binaria di sole 2MB. Il risultato è stata una riduzione della latenza del 15% rispetto a interface{} e un'impronta binaria gestibile rispetto alla generazione completa del codice.
Come fornisce il dizionario di runtime informazioni specifiche di tipo alle istanze generiche condivise?
Il dizionario è una struct contenente puntatori ai descrittori di tipo (_type), tabelle dei metodi (itab) e metadati GC. Quando il compilatore genera codice per una funzione generica come func Print[T any](x T), passa il dizionario come primo argomento implicito. Per chiamare un metodo x.String(), il codice generato cerca il puntatore del metodo nel dizionario piuttosto che compilare una chiamata diretta, consentendo allo stesso codice macchina di gestire T=bytes.Buffer e T=strings.Builder nonostante diverse implementazioni di metodo.
Perché due tipi di puntatore distinti potrebbero condividere un'istanza generica mentre i loro tipi di elemento richiedono istanze separate?
Go classifica i tipi in base alla GCshape, che si preoccupa solo del layout di memoria rilevante per il garbage collector e l'allocatore. Sia *int che *string consistono di una singola parola macchina contenente un puntatore, collocandoli nella stessa classe di forma. Al contrario, int non contiene puntatori e si allinea a una dimensione specifica, mentre string è una struct a due parole contenente un puntatore e una lunghezza. Poiché i loro layout di memoria differiscono, richiedono percorsi di codice generato separati per gestire una corretta raccolta dei rifiuti e l'indirizzamento della memoria.
Qual è l'impatto sulle prestazioni dell'utilizzo di ricevitori per valore rispetto ai ricevitori per puntatore nei vincoli generici?
Quando una funzione generica chiama un metodo su un parametro di tipo T, il compilatore deve generare codice che funzioni per qualsiasi possibile T. Se il vincolo richiede un ricevitore per valore func (T) Method(), ma il tipo concreto è grande, il compilatore potrebbe essere costretto a passare dizionari ed eseguire chiamate indirette che impediscono l'inlining. L'utilizzo di ricevitori per puntatore func (*T) Method() consente spesso una migliore ottimizzazione perché i tipi puntatore condividono più frequentemente le forme GC, e il compilatore può più facilmente devirtualizzare le chiamate quando il tipo concreto è noto a tempo di compilazione in contesti di istanziazione specifici.