Geschichte
Der Speicher-Allocator von Go stammt von TCMalloc, dem thread-caching malloc von Google, das für C++-Multi-Threaded-Server entwickelt wurde. Die Laufzeit implementiert einen mehrstufigen Cache, der speziell dazu dient, die Lock-Kontention in konkurrierenden Programmen zu beseitigen. Dieses Design priorisiert Durchsatz über Speichereffizienz im schnellen Pfad für kleine Objekte.
Das Problem
In hochgradig konkurrierenden Diensten würde es, wenn jede Zuweisung eine globale Heap-Sperre erfordert, Goroutinen seriell machen und den Durchsatz zerstören. Die Herausforderung besteht darin, eine O(1) Zuweisungslatenz ohne Synchronisierung für den häufigen Fall bereitzustellen und dabei Sicherheit zu gewährleisten. Traditionelle malloc-Implementierungen leidet unter Cache-Line-Bouncing, wenn mehrere CPUs um dasselbe Sperrwort konkurrieren.
Die Lösung
Die Laufzeit verwaltet einen pro-P-Cache (mcache), der Bereiche für jede der 67 Größenklassen enthält. Wenn eine Goroutine ein kleines Objekt (≤32KB) zuweist, erhöht sie entweder einen Grenzzeiger oder entnimmt von einer thread-lokalen Freiliste innerhalb ihres mcache, was keine atomaren Operationen erfordert. Die kritische Invarianz ist, dass ein mcache zu jedem Zeitpunkt ausschließlich von einem P besessen wird, und Zuweisungen niemals die Grenzen von P überschreiten, wodurch geteilte veränderbare Zustände vermieden werden.
type PriceTick struct { Symbol uint32 Price float64 } func ProcessTick() { // Zuweist 16 Bytes aus mcache ohne zu sperren tick := &PriceTick{} _ = tick }
Eine Hochfrequenz-Handelsplattform verarbeitete 500.000 Marktdatenereignisse pro Sekunde, wobei jedes Ereignis temporäre 24-Byte-Strukturen für die Preisnormalisierung erforderte. Die ursprüngliche Implementierung nutzte einen globalen sync.Pool für diese Objekte, die unter Last zu einem schweren Kontentionspunkt wurden und 35% der CPU-Zeit in atomaren Operationen und Cache-Kohärenz-Traffic verbrauchten.
Lösung A: Manuelle Pool-Sharding
Das Team erwog, den Pool manuell in 256 interne Unterpools zu shardieren, die durch den Hash-ID einer Goroutine ausgewählt wurden. Vorteile: Verteilung der Kontention über Cache-Linien. Nachteile: Ungleichmäßige Nutzung führt zu Speicheraufblähungen in inaktiven Shards, und es ist eine komplexe Hungerbehandlung erforderlich, wenn ein lokaler Shard leer wird, während andere freie Objekte enthalten.
Lösung B: Per-Arbeiter-Arenen
Sie evaluierten die Vor-Allokation großer Speicherarenen pro Arbeiter-Goroutine mit Bump-Pointer-Zuweisung. Vorteile: Keine Kontention und extrem schneller Zuweisungspfad. Nachteile: Erfordert manuelle Speicherverwaltung, birgt das Risiko des Speicherauslaufens, wenn Rücksetzzeiger unsachgemäß behandelt werden, und kompliziert die Nachverfolgung der objektbezogenen Lebensdauern über asynchrone Grenzen hinweg.
Lösung C: Stapelzuweisung und Batching
Der gewählte Ansatz restrukturierte den Ereignisprozessor, um Werte-Strukturen anstelle von Zeigern zu verwenden, wobei die Daten nach Möglichkeit auf dem Stapel gehalten werden, und die Verarbeitung von Ereignissen in Batches von 1000, um die Zuweisungen zu amortisieren. Vorteile: Eliminierung des Heap-Drucks für kurzlebige Daten und keine Anforderungen an Synchronisationsprimitive. Nachteile: Erforderte eine erhebliche Umstrukturierung der Schnittstellen, die zuvor Zeigersemantiken erwarteten, und erhöhte die Stapelnutzung pro Goroutine.
Ergebnis
Durch die Implementierung von Lösung C konnte der Dienst 99 % der Heap-Zuweisungen im heißen Pfad eliminieren. Die P99-Latenz fiel von 12 Millisekunden auf 180 Mikrosekunden, und die Garbage-Collection-Zyklen verringerten sich um 85 %, was dem Dienst ermöglichte, seine Anforderungen an die Sub-Millisekunden-SLA zu erfüllen.
Wie grenzt Go die Speicherfragmentierung bei der Zuweisung von Objekten unterschiedlicher Größen aus festen Größenbereichen ein?
Go verwendet 67 verschiedene Größenklassen mit spezifischer Granularität (8-Byte-Schritte bis zu 512 Bytes, dann größere Intervalle). Objekte werden auf die nächste Klassengröße aufgerundet, was die interne Fragmentierung auf etwa 12,5 % begrenzt. Die externe Fragmentierung wird minimiert, da jeder mspan Objekte genau einer Größenklasse enthält, was verhindert, dass kleine Objekte große Speichermengen blockieren.
Warum löscht die Laufzeit die Heap-Bitmaps anstelle von benutzer-sichtbarem Speicher während der Zuweisung?
Der Allocator verwaltet Typinformationen und Zeiger-Bitmaps in heapArena-Metadatenstrukturen statt in Objektkopfzeilen. Wenn Speicher zugewiesen wird, werden nur die Bitmaps, die die Zeigerslots anzeigen, bei Bedarf auf Null gesetzt; der Datenspeicher wird nach Bedarf vom Mutator oder während der gleichzeitigen Sweep-Phase auf Null gesetzt. Dieser Ansatz schiebt die Arbeit auf, verbessert die Cache-Lokalisierung und reduziert die Speicherbandbreite, die während der Zuweisung erforderlich ist.
Was zwingt einen Bereich, während der Garbage Collection von mcache zurück zu mcentral zu wechseln?
Während der Sweep-Phase der GC prüft die Laufzeit die Bereiche, die in mcache-Instanzen gehalten werden. Wenn ein Bereich keine zugewiesenen Objekte enthält (alle Slots freigegeben), gibt das P ihn an mcentral zurück, anstatt ihn zu behalten. Dies verhindert das Horten von Speicher und sorgt für eine ausgewogene Verteilung von freiem Speicher über die Prozessoren, obwohl dabei die Kosten für die Wiedererlangung der zentralen Sperre anfallen.