Storia
L'allocatore di memoria di Go discende da TCMalloc, il malloc a caching per thread di Google progettato per server multi-thread C++. Il runtime implementa una cache multi-livello specificamente per eliminare la contesa dei blocchi nei programmi concorrenti. Questo design dà priorità al throughput piuttosto che all'efficienza della memoria nel percorso veloce per i piccoli oggetti.
Il Problema
Nei servizi altamente concorrenti, richiedere che ogni allocazione acquisisca un blocco globale della heap serializzerebbe i goroutine e distruggerebbe il throughput. La sfida consiste nel fornire una latenza di allocazione O(1) senza sincronizzazione per il caso comune, mantenendo la sicurezza. Le implementazioni tradizionali di malloc soffrono di rimbalzo delle linee di cache quando più CPU competono per la stessa parola di blocco.
La Soluzione
Il runtime mantiene una cache per P (mcache) contenente intervalli per ognuna delle 67 classi di dimensione. Quando un goroutine alloca un piccolo oggetto (≤32KB), incrementa un puntatore di confine o preleva da una lista libera locale per thread all'interno del suo mcache, senza richiedere operazioni atomiche. L'invariante critica è che un mcache è posseduto esclusivamente da un P in un dato momento, e le allocazioni non attraversano i confini di P, evitando così uno stato mutabile condiviso.
type PriceTick struct { Symbol uint32 Price float64 } func ProcessTick() { // Allocates 16 bytes from mcache without locking tick := &PriceTick{} _ = tick }
Una piattaforma di trading ad alta frequenza elaborava 500.000 eventi di dati di mercato al secondo, con ciascun evento che richiedeva strutture temporanee di 24 byte per la normalizzazione dei prezzi. L'implementazione iniziale utilizzava un sync.Pool globale per questi oggetti, che divenne un grave punto di contesa sotto carico, consumando il 35% del tempo della CPU in operazioni atomiche e traffico di coerenza della cache.
Soluzione A: Suddivisione Manuale del Pool
Il team ha considerato l'idea di suddividere manualmente il pool in 256 sotto-pool interni selezionati tramite l'hash dell'ID del goroutine. Pro: distribuisce la contesa tra le linee di cache. Contro: l'utilizzazione irregolare crea un grosso problema di memoria nelle sotto-partizioni inattive e richiede una gestione complessa della fame quando una sotto-partizione locale si svuota mentre altre contengono oggetti liberi.
Soluzione B: Arenas per Lavoratore
Hanno valutato la preallocazione di grandi aree di memoria per ciascun goroutine lavoratore con allocazione a puntatore incrementale. Pro: zero contesa e percorso di allocazione estremamente veloce. Contro: richiede una gestione manuale della memoria, rischia di trascurare la memoria se i puntatori di ripristino non vengono gestiti correttamente e complica il tracciamento del ciclo di vita degli oggetti tra i confini asincroni.
Soluzione C: Allocazione su Stack e Batching
L'approccio scelto ha ristrutturato il processore di eventi per utilizzare strutture di valore invece di puntatori, mantenendo i dati nello stack dove possibile e processando eventi in batch di 1000 per ammortizzare le allocazioni. Pro: elimina completamente la pressione della heap per i dati a breve termine e non richiede primitive di sincronizzazione. Contro: ha richiesto una significativa rifattorizzazione delle interfacce che prima si aspettavano semantiche di puntatore e ha aumentato l'uso dello stack per goroutine.
Risultato
Implementando la Soluzione C, il servizio ha eliminato il 99% delle allocazioni nella heap nel percorso principale. La latenza P99 è scesa da 12 millisecondi a 180 microsecondi, e i cicli di raccolta dei rifiuti sono diminuiti dell'85%, consentendo al servizio di soddisfare i suoi requisiti di SLA sotto il millisecondo.
Come limita Go la frammentazione della memoria quando alloca oggetti di dimensioni variabili da intervalli di dimensioni fisse?
Go impiega 67 classi di dimensione distinte con specifica granularità (passi di 8 byte fino a 512 byte, poi intervalli più grandi). Gli oggetti vengono arrotondati alla dimensione di classe più vicina, il che limita la frammentazione interna a circa il 12,5%. La frammentazione esterna è minimizzata poiché ciascun mspan contiene oggetti della stessa classe di dimensione, impedendo agli oggetti piccoli di bloccare grandi blocchi di memoria.
Perché il runtime pulisce le bitmap della heap piuttosto che la memoria visibile all'utente durante l'allocazione?
L'allocatore mantiene informazioni sui tipi e bitmap dei puntatori nelle strutture di metadati di heapArena piuttosto che negli header degli oggetti. Quando viene allocata memoria, solo le bitmap che indicano gli slot dei puntatori vengono azzerate se necessario; la memoria dei dati viene azzerata su richiesta dal mutatore o durante la pulizia concorrente. Questo approccio differisce il lavoro, migliora la località della cache e riduce la larghezza di banda della memoria richiesta durante l'allocazione.
Cosa costringe uno span a tornare da mcache a mcentral durante la raccolta dei rifiuti?
Durante la fase di pulizia della GC, il runtime esamina gli span mantenuti nelle istanze di mcache. Se uno span non contiene oggetti allocati (tutti gli slot liberati), il P lo restituisce a mcentral piuttosto che trattenerlo. Questo previene l'accumulo di memoria e garantisce una distribuzione bilanciata della memoria libera tra i processori, anche se comporta il costo di riacquisire il blocco centrale.