Go gebruikt een tri-kleurige gelijktijdige garbage collector waarbij objecten van wit (niet gemarkeerd) naar grijs (in de wachtrij) naar zwart (volledig gescand) gaan. De fundamentele invariant tijdens het markeren is dat zwarte objecten nooit pointers naar witte objecten mogen bevatten, omdat dit de collector zou kunnen toelaten om per ongeluk bereikbare geheugen vrij te geven. Om dit te handhaven zonder de wereld te stoppen, gebruikt Go een write barrier— een door de compiler ingevoegde haak die wordt geactiveerd bij elke pointer schrijfactie naar de heap. Wanneer een mutator goroutine een pointer schrijft, controleert de barrier of het doelobject wit is; als dat zo is, kleurt het onmiddellijk het doel grijs voordat het de schrijfactie voltooien, waarmee de invariant atomisch wordt behouden.
We observeerden ernstige tail latency in een real-time analytics pipeline die miljoenen evenementen per seconde verwerkte. Het systeem gebruikte een complexe grafstructuur waarbij knooppunten vaak referenties naar kindknooppunten bijwerkten op basis van streaminggegevens, wat zorgde voor enorme pointer churn tijdens de GC-cycli van Go.
Eerste oplossing overwogen: We probeerden dit te verlichten door GOGC tot 200% te verhogen om verzamelingen te vertragen. Voordelen: Verminderde de frequentie van GC-cycli, waardoor het totale aantal barrierexecutes in de loop van de tijd daalde. Nadelen: Dit verhoogde de piek heapgrootte drastisch, wat risico op OOM-crashes met zich meebracht op onze geheugengeconstrueerde containers, en stelde alleen de latentiepieken uit in plaats van ze op te lossen.
Tweede oplossing overwogen: We experimenteerden met object pooling met behulp van sync.Pool om knoopstructuren opnieuw te gebruiken en allocaties te verminderen. Voordelen: Verminderde allocatiedruk en de snelheid van nieuwe witte objecten die werden aangemaakt. Nadelen: De overhead van de write barrier bleef hoog omdat we nog steeds pointers mutateerden binnen bestaande (vaak al gescande) zwarte objecten met dezelfde snelheid; pooling loste de kosten van barrier-executies bij pointer-updates niet op.
Derde oplossing overwogen: We herstructureren de grafiek om gehele getallen indices in een grote slice te gebruiken in plaats van directe pointers voor knooprelaties. Voordelen: Gehele getallentoewijzingen zijn geen pointerwrites, waardoor de write barriermechanisme volledig wordt omzeild en de bijbehorende CPU-kosten tijdens het markeren worden geëlimineerd. Nadelen: Dit vereiste het implementeren van handmatig geheugenbeheer voor de slice (om gaten, compactie te behandelen) en maakte de code minder idiomatisch en moeilijker te onderhouden.
Kies oplossing: We namen de op index gebaseerde aanpak voor de kern grafiek met hoge churn, terwijl we pointers behielden voor statische metadata. Dit elimineerde direct het write barrier warme pad terwijl de grafiekconnectiviteitsemantiek werd behouden.
Resultaat: De tail latency tijdens GC daalde met 90%, van 15 ms naar 1,5 ms, en de algehele doorvoer nam toe met 40% door verminderd GC-assistent werk dat CPU van mutators stal.
Waarom kleurt de write barrier het object dat verwezen wordt in plaats van het object dat wordt aangepast?
Kandidaten gaan vaak ten onrechte ervan uit dat de barrier het bronobject (het object dat geschreven wordt) als nodig voor her-scan moet markeren. Echter, de bron is al ofwel grijs of zwart; als het zwart is, zou het opnieuw scannen duur zijn en het vereist het bijhouden van al zijn uitgaande pointers. In tegenstelling daarmee, het grijs maken van het doel (de nieuwe pointerwaarde) grijs voldoet onmiddellijk aan de tri-kleur invariant: als de bron zwart is en het doel wit was, wordt de rand zwart-naar-grijs, wat veilig is. Dit onderscheid is cruciaal omdat het het werk minimaliseert (alleen het nieuwe doel wordt in de wachtrij geplaatst) in plaats van te vereisen dat potentieel grote bronobjecten opnieuw worden gescand.
Hoe interageert de write barrier met stackallocaties, en waarom kunnen stacks opnieuw scannen vereisen?
Hoewel write barriers voornamelijk heap pointerwrites onderscheppen, moet Go ook omgaan met pointers van stacks naar de heap. Als een goroutine een pointer naar een wit heapobject in een zwart stackframe schrijft, wordt de write barrier uitgevoerd om het doel te schaduwen. Echter, omdat stacks kunnen groeien, krimpen en worden gekopieerd, is het onderhouden van precieze zwart/wit staten voor elke stackslot complex. Go lost dit op door stacks te beschouwen als wortels die mogelijk opnieuw gescand moeten worden aan het einde van de markeerfase als ze actief waren tijdens het markeren. Kandidaten missen vaak dat stack herkanselen een noodzakelijke fallback is wanneer write barriers op stacks de invariant niet kunnen garanderen vanwege gelijktijdige uitvoering, en dat deze laatste stop-de-wereld fase meestal kort maar essentieel is voor de correctheid.
Wat is het verschil tussen de Dijkstra write barrier en de Yuasa write barrier, en welke gebruikt Go?
De Dijkstra barrier schaduwt het doelobject wanneer een pointer wordt geïnstalleerd (zwarte mutator, witte doel), waardoor de zwart-naar-wit rand nooit kan bestaan. De Yuasa barrier registreert daarentegen de oude pointerwaarde die wordt overschreven en schaduwt die, waardoor de "snapshot-op-het-begin" eigenschap behouden blijft. Go gebruikt een hybride Dijkstra barrier omdat het eenvoudiger is en ervoor zorgt dat de sterke tri-kleur invariant onmiddellijk wordt gegarandeerd, hoewel het zwevende garbage kan veroorzaken als een wit object onmiddellijk onbereikbaar wordt nadat het is geshadowed. Kandidaten verwarren deze vaak of geloven dat Go Yuasa gebruikt vanwege de conservatieve stackbehandeling, maar het begrijpen van de Dijkstra keuze verklaart waarom Go's barrier synchroon is met de schrijfactie in plaats van op logging gebaseerde.