Go nutzt einen tri-color parallelen Garbage Collector, bei dem Objekte von weiß (nicht markiert) über grau (in der Warteschlange) zu schwarz (vollständig gescannt) übergehen. Die grundlegende Invarianz während der Markierung besteht darin, dass schwarze Objekte niemals Zeiger auf weiße Objekte enthalten dürfen, da dies dem Collector erlauben würde, versehentlich erreichbaren Speicher freizugeben. Um dies ohne Stopp der Welt durchzusetzen, verwendet Go eine Schreibbarriere – einen vom Compiler eingefügten Hook, der bei jedem Zeiger-Schreibvorgang im Heap ausgelöst wird. Wenn ein mutierender Goroutine einen Zeiger schreibt, überprüft die Barriere, ob das Zielobjekt weiß ist; falls ja, wird das Ziel sofort grau schattiert, bevor der Schreibvorgang abgeschlossen wird, wodurch die Invarianz atomar bewahrt wird.
Wir haben eine starke verzögerte Latenz in einer Echtzeitanalytik-Pipeline beobachtet, die Millionen von Ereignissen pro Sekunde verarbeitet. Das System verwendete eine komplexe Graphstruktur, bei der Knoten häufig Referenzen auf untergeordnete Knoten basierend auf Streaming-Daten aktualisierten, was während der Go GC-Zyklen zu massiven Zeigerwechseln führte.
Erste in Betracht gezogene Lösung: Wir haben versucht, dies zu mildern, indem wir GOGC auf 200% erhöhten, um Bereinigungen zu verzögern. Vorteile: Verringerte die Häufigkeit der GC-Zyklen und senkte die Anzahl der Barriereschreibvorgänge über die Zeit. Nachteile: Dies erhöhte die maximale Heap-Größe drastisch, was das Risiko von OOM-Abstürzen auf unseren speicherbeschränkten Containern erhöhte, und verschob lediglich die Latenzspitzen, anstatt sie zu lösen.
Zweite in Betracht gezogene Lösung: Wir experimentierten mit Objektenpools unter Verwendung von sync.Pool, um Knotenstrukturen wiederzuverwenden und die Zuweisungen zu reduzieren. Vorteile: Verringerte den Zuweisungsdruck und die Rate neuer weißer Objekte. Nachteile: Der Overhead der Schreibbarriere blieb hoch, da wir innerhalb bestehender (oft bereits gescannter) schwarzer Objekte weiterhin Zeiger veränderten; das Pooling adressierte nicht die Kosten der Barrieausführung bei Zeigeraktualisierungen.
Dritte in Betracht gezogene Lösung: Wir haben den Graph umgestaltet, um ganze Zahlenindizes in einem großen Slice anstelle von direkten Zeigern für die Beziehungen zwischen Knoten zu verwenden. Vorteile: Ganzzahlzuweisungen sind keine Zeiger-Schreibvorgänge, umgehen die Schreibbarriere vollständig und eliminieren die damit verbundenen CPU-Kosten während der Markierung. Nachteile: Dies erforderte die Implementierung einer manuellen Speicherverwaltung für den Slice (Bearbeitung von Löchern, Kompaktierung) und machte den Code weniger idiomatisch und schwerer wartbar.
Ausgewählte Lösung: Wir haben den indexbasierten Ansatz für den hochfrequenten Kerngraph übernommen, während wir Zeiger für statische Metadaten beibehalten haben. Dies beseitigte direkt den heißen Pfad der Schreibbarriere und bewahrte die Semantik der Graphverbindungen.
Ergebnis: Die Latenz bei der GC sank um 90%, von 15 ms auf 1,5 ms, und der Gesamtdurchsatz stieg um 40% aufgrund der reduzierten GC-Hilfsarbeit, die CPU von den Mutatoren stahl.
Warum schattiert die Schreibbarriere das Objekt, auf das zugegriffen wird, und nicht das Objekt, das modifiziert wird?
Kandidaten nehmen oft fälschlicherweise an, die Barriere sollte das Quellobjekt (das, das geschrieben wird) als needing re-scanning markieren. Das Quellobjekt ist jedoch bereits entweder grau oder schwarz; wenn es schwarz ist, wäre es teuer, es erneut zu scannen, und es müsste alle seine ausgehenden Zeiger verfolgt werden. Im Gegensatz dazu erfüllt das sofortige Schattieren des Ziels (dem neuen Zeigerwert) grau sofort die Tri-Color-Invarianz: Wenn die Quelle schwarz ist und das Ziel weiß war, wird die Kante schwarz-zu-grau, was sicher ist. Diese Unterscheidung ist entscheidend, da sie die Arbeit minimiert (nur das neue Ziel wird in die Warteschlange gestellt), anstatt möglicherweise große Quellobjekte neu scannen zu müssen.
Wie interagiert die Schreibbarriere mit Stapelzuweisungen und warum müssen Stapel möglicherweise erneut gescannt werden?
Während Schreibbarrieren hauptsächlich Heap-Zeiger-Schreibvorgänge abfangen, muss Go auch Zeiger von Stapeln zum Heap behandeln. Wenn ein Goroutine einen Zeiger auf ein weißes Heap-Objekt in einen schwarzen Stapelrahmen schreibt, wird die Schreibbarriere aktiviert, um das Ziel zu schattieren. Aufgrund der Möglichkeit, dass Stapel wachsen, schrumpfen und kopiert werden, ist es jedoch komplex, genaue schwarz/weiß-Zustände für jeden Stapelslot aufrechtzuerhalten. Go löst dies, indem es Stapel als Wurzeln behandelt, die am Ende der Markierungsphase möglicherweise erneut gescannt werden müssen, wenn sie während der Markierung aktiv waren. Kandidaten übersehen häufig, dass das erneute Scannen des Stapels ein notwendiger Rückgriff ist, wenn Schreibbarrieren auf Stapeln die Invarianz aufgrund gleichzeitiger Ausführung nicht garantieren können, und dass diese letzte Stopp-der-Welt-Phase normalerweise kurz, aber entscheidend für die Korrektheit ist.
Was ist der Unterschied zwischen der Dijkstra-Schreibbarriere und der Yuasa-Schreibbarriere, und welche verwendet Go?
Die Dijkstra-Barriere schattiert das Zielobjekt, wenn ein Zeiger installiert wird (schwarzer Mutator, weißes Ziel) und verhindert, dass jemals eine schwarze-zu-weiße Kante existiert. Die Yuasa-Barriere hingegen speichert den alten Zeigerwert, der überschrieben wird, und schattiert diesen und bewahrt die "Snapshot-am-Beginn"-Eigenschaft. Go verwendet eine hybride Dijkstra-Barriere, da sie einfacher ist und sofort die starke Tri-Color-Invarianz gewährleistet, obwohl sie schwebenden Müll verursachen kann, wenn ein weißes Objekt sofort nach dem Schattieren unerreichbar wird. Kandidaten verwechseln dies oft oder glauben, dass Go Yuasa aufgrund seiner konservativen Stapelbehandlung verwendet, aber das Verständnis der Dijkstra-Wahl erklärt, warum Gos Barriere synchron mit dem Schreiben und nicht auf Protokollbasis ist.