GoProgrammazioneSviluppatore Go Senior

Valuta il meccanismo mediante il quale il runtime di Go recupera la memoria in eccesso dello stack delle goroutine, specificando la soglia di utilizzo che attiva la deallocazione e il destino finale delle aree rilasciate?

Supera i colloqui con l'assistente IA Hintsage

Risposta alla domanda.

Storia della domanda

Prima della Go 1.3, il runtime utilizzava stack segmentati che si dividevano in pezzi collegati ai confini delle chiamate di funzione. Questo design causava gravi "hot split" in performance quando il confine dello stack veniva superato frequentemente durante cicli stretti. La Go 1.3 ha sostituito questo con stack contigui che vengono copiati in aree più grandi e contigue durante la crescita. Tuttavia, le prime implementazioni di stack contigui non rilasciavano mai memoria di nuovo nell'heap, causando una crescita permanente della RSS per le goroutine che avevano temporaneamente bisogno di stack di chiamate profondi durante l'inizializzazione o l'elaborazione in batch. La Go 1.5 ha introdotto un processo automatico di riduzione dello stack per recuperare la memoria dello stack inutilizzata durante i cicli di raccolta dei rifiuti, completando il ciclo di gestione della memoria per gli stack delle goroutine.

Il problema

Senza un meccanismo di riduzione, una goroutine che entra temporaneamente in una profonda ricorsione (ad esempio, l'elaborazione di un documento JSON profondamente annidato o la traversata di un complesso albero di dipendenze) mantenerebbe la propria allocazione di stack di picco indefinitamente anche dopo essere tornata a un ciclo evento inattivo. Questo porta a un gonfiore di memoria in applicazioni che funzionano a lungo, in particolare quelle che utilizzano pool di lavoratori in cui le goroutine alternano tra attività ad alto stack e stati inattivi. La sfida consiste nell'identificare in modo sicuro quando uno stack è veramente sottoutilizzato e nel rilocare i frame attivi in una regione di memoria più piccola senza corrompere i calcoli in corso, i puntatori allocati nello stack o violare i requisiti dell'ABI per le convenzioni di chiamata.

La soluzione

Il runtime di Go riduce gli stack durante la fase di marcatura della GC quando esamina gli insiemi radice. Analizza l'uso dello stack di ciascuna goroutine; se il livello massimo della parte utilizzata scende al di sotto di un quarto (25%) della dimensione attualmente allocata dello stack, il runtime alloca un nuovo stack di metà dimensione rispetto a quello attuale (ma mai più piccolo del minimo di 2KB). Il runtime interrompe quindi in modo asincrono la goroutine target a un punto sicuro, copia i frame dello stack attivi nella nuova regione più piccola, utilizza le mappe dei puntatori generate dal compilatore per aggiornare tutti i puntatori interni che fanno riferimento agli indirizzi dello stack e libera la memoria del vecchio stack di nuovo all'allocatore mheap del runtime.

Situazione della vita reale

Gestivamo un servizio di elaborazione log ad alto throughput in cui ciascuna goroutine gestiva il parsing di payload JSON potenzialmente profondamente annidati (fino a 10.000 livelli di profondità durante attacchi di input malformati). Dopo l'elaborazione, queste goroutine tornavano a un sync.Pool per attendere nuove connessioni. Abbiamo osservato che la memoria RSS del servizio cresceva linearmente con il numero di goroutine in pool, senza mai rilasciare memoria anche durante i periodi inattivi, provocando infine uccisioni OOM su contenitori con limiti di 4GB nonostante il set di lavoro effettivo fosse solo di 200MB.

Abbiamo considerato di uccidere forzatamente le goroutine in pool dopo un numero stabilito di richieste elaborate e generare nuove sostituzioni. Questo avrebbe garantito il rilascio della memoria dello stack poiché le nuove goroutine partono con stack minimi di 2KB. Tuttavia, questo approccio ha introdotto un significativo sovraccarico CPU a causa della costante creazione e distruzione di goroutine, ha interrotto le ottimizzazioni del pooling delle connessioni TCP e ha causato una maggiore latenza dei picchi a causa di avvii a freddo della cache.

Implementare un limite rigido sulla crescita dello stack tramite debug.SetMaxStack avrebbe impedito allocazioni eccessive durante gli eventi di profonda ricorsione. Anche se ciò proteggeva dagli OOM, causava inaspettate ma legittime operazioni di parsing profondo per andare in panico con runtime: il goroutine stack supera il limite di 1000000000 byte. Questo ha portato a perdita di dati dei clienti e errori di servizio che violavano i nostri SLA di affidabilità, rendendo inaccettabile l'utilizzo in produzione.

Abbiamo valutato di chiamare periodicamente runtime.GC() seguito da debug.FreeOSMemory() ogni 30 secondi per forzare la scansione e la riduzione dello stack. Questo ha ridotto con successo la RSS ma ha introdotto pause stop-the-world di 5-10 ms a ogni invocazione, il che ha violato i nostri requisiti di latenza p99 di <2 ms per il livello API e ha aumentato l'utilizzo della CPU del 15% a causa delle raccolte forzate complete.

Alla fine ci siamo affidati al meccanismo di riduzione dello stack nativo di Go assicurandoci di eseguire Go 1.20+ e di regolare GOGC per attivare collezioni di rifiuti più frequenti (impostandolo su 50 invece di 100). Questo ha aumentato la frequenza delle opportunità di riduzione dello stack senza intervento manuale. Abbiamo anche ristrutturato il parser per utilizzare un approccio iterativo con uno stack allocato nell'heap esplicito per il tracciamento dei percorsi, riducendo la profondità massima di ricorsione da 10.000 a 100. La combinazione ha permesso di mantenere naturale la riduzione sufficientemente frequente da mantenere la memoria limitata.

La RSS del servizio si è stabilizzata a circa 800MB sotto carico, rispetto al precedente limite di 3,8GB. I profili dello stack delle goroutine mostravano che il 95% dei lavoratori in pool manteneva la dimensione minima dello stack di 2KB tra le richieste, con picchi che si verificavano solo durante il parsing attivo. Le uccisioni OOM sono cessate del tutto e la latenza p99 è rimasta sotto 1,5 ms poiché abbiamo evitato pause manuali della GC e churn delle goroutine.

Cosa spesso perdono i candidati

La riduzione dello stack avviene immediatamente quando una funzione restituisce e il puntatore dello stack diminuisce?

No, il runtime non monitora i decrementi del puntatore dello stack in tempo reale per attivare la deallocazione immediata. La riduzione viene eseguita esclusivamente durante la fase di marcatura della raccolta di rifiuti quando lo scheduler esamina tutti gli stack delle goroutine. Il runtime controlla il livello massimo di utilizzo dello stack dalla ultima GC. Se questo livello massimo è sotto il 25% dell'allocazione fisica attuale, solo allora viene eseguita la logica di riduzione. Questa valutazione pigra ammortizza il costo del copia stack tra tutte le goroutine durante un periodo in cui il mondo è già in pausa per la marcatura, sebbene la copia effettiva richieda di fermare la goroutine individuale.

Qual è esattamente il rapporto di riduzione e la dimensione minima, e il runtime rilascia mai memoria al sistema operativo?

Quando uno stack è idoneo alla riduzione, il runtime alloca un nuovo stack con la metà delle dimensioni di quello attuale. Questa riduzione geometrica previene il thrashing in cui una goroutine oscilla leggermente sopra e sotto una soglia crescerebbe e si ridurrebbe costantemente. La nuova dimensione è limitata al di sotto dalla dimensione minima dello stack della piattaforma, tipicamente di 2KB su sistemi a 64 bit. La memoria del vecchio stack viene restituita all'mheap del runtime, non direttamente al sistema operativo. Il sistema operativo recupera questa memoria fisica solo se il recuperatore determina che l'heap è stato inattivo ed eccede l'obiettivo, o se viene invocato debug.FreeOSMemory().

La goroutine viene fermata durante la riduzione dello stack, e come vengono aggiornati i puntatori?

Sì, la riduzione richiede di fermare la goroutine target a un punto sicuro, simile alla crescita dello stack. Il runtime deve copiare frame attivi in una nuova posizione di memoria e aggiornare tutti i puntatori che fanno riferimento a variabili allocate nello stack. Il compilatore genera mappe di puntatori che identificano quali parole in ciascun frame sono puntatori. Durante la riduzione, il runtime utilizza queste mappe per trovare e regolare i puntatori interni per puntare ai nuovi indirizzi dello stack. Questa operazione non è concorrente; la goroutine non può essere eseguita durante la copia, ma altre goroutine continuano a funzionare.