Der Compiler von Go verwendet eine Technik namens GCshape Stenciling, wenn er die in Version 1.18 eingeführten Generika kompiliert. Historisch gesehen implementierten Sprachen Generika entweder durch vollständige Monomorphisierung – das Erzeugen separater Maschinencodes für jede Typinstanziierung, was zu einer Vergrößerung der Binärdatei führte – oder durch Boxing – das Löschen von Typen auf Kosten von Laufzeitüberhead und Zuweisungen. Das Problem, mit dem Go konfrontiert war, war die Unterstützung von Hochleistungs-Systemprogrammierung, bei der die BinGröße wichtig ist, ohne die Ausführungsgeschwindigkeit vollständig zu opfern.
Die Lösung besteht darin, konkrete Typen nach ihrem GC-Shape zu gruppieren, der durch ihre Größe und das Pointer-Bitmap (das Muster der Pointer im Typ) definiert ist. Der Compiler generiert eine einzelne Funktionsinstanziierung für alle Typen, die das gleiche GC-Shape teilen, und übergibt ein Laufzeit-Dictionary, das Metadaten zu den Typen als impliziten Parameter enthält.
// Sowohl *int als auch *string teilen sich die gleiche Instanziierung // weil sie identisches GC-Shape (einzelner Pointer) haben. func Identity[T any](x T) T { return x } func main() { Identity((*int)(nil)) // Verwendet Instanziierung #1 Identity((*string)(nil)) // Verwendet Instanziierung #1 (gleiche Form) Identity(42) // Verwendet Instanziierung #2 (skalare, keine Pointer) }
Unser Team baute eine Hochdurchsatz-Eventverarbeitungs-Pipeline unter Verwendung generischer Middleware-Handler Handler[T Event]. Wir mussten fünfzig verschiedene Ereignistyps verarbeiten und gleichzeitig eine geringe Latenz und eine angemessene Binärgröße für die containerisierte Bereitstellung aufrechterhalten.
Der erste Ansatz verwendete interface{} mit Typprüfungen und vertraute auf Laufzeit-Typ-Umschaltungen. Dies bot Flexibilität und funktionierte in älteren Versionen von Go, führte jedoch zu erheblichen Zuweisungsüberhead – jedes Ereignis, das in einem Interface eingewickelt war, erforderte eine Heap-Zuweisung – und beseitigte die Typsicherheit zur Kompilierungszeit, was zu Panik in der Produktion führte, wenn die Typen nicht übereinstimmten.
Der zweite Ansatz beinhaltete die Codegenerierung zur Kompilierungszeit unter Verwendung von go generate mit Drittanbieter-Tools zum Erstellen von HandlerClickEvent, HandlerPurchaseEvent usw. Dies lieferte optimale Leistungen ohne Laufzeitüberhead, vergrößerte jedoch unsere Binärgröße um 40 MB, als wir fünfzig Ereignistypen unterstützten, und schuf Wartungsprobleme beim Aktualisieren der Generatorvorlagen.
Wir wählten den dritten Ansatz: native Go-Generika mit sorgfältiger Beachtung der GC-Formen. Wir stellten sicher, dass unsere Ereignistypen Pointer auf Strukturen waren (einheitliches GC-Shape), was dem Compiler ermöglichte, Instanziierungen wiederzuverwenden. Wir akzeptierten den geringfügigen Overhead von Wörterbuch-Abfragen für Methodenddispatches im Austausch für einen Anstieg der Binärgröße um nur 2 MB. Das Ergebnis war eine Senkung der Latenz um 15 % im Vergleich zu interface{} und ein handhabbarer Binärfußabdruck im Vergleich zur vollständigen Codegenerierung.
Wie bietet das Laufzeit-Dictionary typenspezifische Informationen für gemeinsame generische Instanziierungen?
Das Dictionary ist eine Struktur, die Zeiger auf Typbeschreibungen (_type), Methodentabellen (itab) und GC-Metadaten enthält. Wenn der Compiler Code für eine generische Funktion wie func Print[T any](x T) generiert, übergibt er das Dictionary als impliziten ersten Parameter. Um eine Methode x.String() aufzurufen, sucht der generierte Code den Methodenzeiger im Dictionary, anstatt einen direkten Aufruf zu kompilieren, was es dem gleichen Maschinencode ermöglicht, mit T=bytes.Buffer und T=strings.Builder umzugehen, obwohl die Methodenimplementierungen unterschiedlich sind.
Warum könnten zwei unterschiedliche Pointertypen eine generische Instanziierung teilen, während ihre Elementtypen separate benötigen?
Go klassifiziert Typen nach GCshape, was sich nur um das Speicherlayout kümmert, das für den Garbage Collector und den Zuweisungsalgorithmus relevant ist. Sowohl *int als auch *string bestehen aus einem einzelnen Maschinenwort, das einen Zeiger enthält, und gehören daher derselben Shapes-Klasse an. Im Gegensatz dazu enthält int keine Pointer und richtet sich nach einer bestimmten Größe, während string eine Struktur mit zwei Wörtern ist, die einen Zeiger und eine Länge enthält. Da sich ihre Speicherlayouts unterscheiden, erfordern sie separate generierte Codepfade, um die ordnungsgemäße Garbage Collection und Speicheradressierung zu gewährleisten.
Was ist die Leistungsimplikation der Verwendung von Wertempfängern im Vergleich zu Pointerempfängern in generischen Einschränkungen?
Wenn eine generische Funktion eine Methode auf einem Typparameter T aufruft, muss der Compiler Code generieren, der für jedes mögliche T funktioniert. Wenn die Einschränkung einen Wertempfänger func (T) Method() erfordert, der konkrete Typ jedoch groß ist, könnte der Compiler gezwungen sein, Dictionaries zu übergeben und indirekte Aufrufe auszuführen, die das Inlining verhindern. Die Verwendung von Pointerempfängern func (*T) Method() ermöglicht oft eine bessere Optimierung, da Pointertypen häufiger GC-Formen teilen und der Compiler Aufrufe einfacher devirtualisieren kann, wenn der konkrete Typ zur Kompilierungszeit in spezifischen Instanziierungskontexten bekannt ist.