Leistung ist mir immer wichtig. Ich weiß nicht genau warum. Aber ich bin nur sauer auf langsame Dienste und Programme. Sieht so aus, als wäre
ich nicht allein .
In den A / B-Tests haben wir versucht, die Ausgabe von Seiten in Schritten von 100 Millisekunden zu verlangsamen, und festgestellt, dass selbst sehr kleine Verzögerungen zu einem erheblichen Umsatzrückgang führen. - Greg Linden, Amazon.com
Erfahrungsgemäß zeigt sich eine geringe Produktivität auf zwei Arten:
- Vorgänge, die im kleinen Maßstab gut funktionieren, werden mit zunehmender Anzahl von Benutzern unrentabel. Normalerweise sind dies O (N) - oder O (N²) -Operationen. Wenn die Benutzerbasis klein ist, funktioniert alles einwandfrei. Das Produkt hat es eilig, auf den Markt zu bringen. Wenn die Basis wächst, treten immer mehr unerwartete pathologische Situationen auf - und der Dienst stoppt.
- Viele einzelne Quellen suboptimaler Arbeit, "Tod durch tausend Schnitte".
Während des größten Teils meiner Karriere habe ich entweder Data Science bei Python studiert oder Services on Go erstellt. Im zweiten Fall habe ich viel mehr Erfahrung in der Optimierung. Go ist normalerweise kein Engpass bei den von mir geschriebenen Diensten - Datenbankprogramme werden häufig durch E / A eingeschränkt. In den von mir entwickelten Batch-Pipelines für maschinelles Lernen ist das Programm jedoch häufig durch die CPU begrenzt. Wenn Go den Prozessor zu oft verwendet, gibt es verschiedene Strategien.
In diesem Artikel werden einige Methoden erläutert, mit denen die Produktivität ohne großen Aufwand erheblich gesteigert werden kann. Ich ignoriere bewusst Methoden, die erheblichen Aufwand oder große Änderungen in der Struktur des Programms erfordern.
Bevor Sie anfangen
Nehmen Sie sich Zeit, um eine geeignete Basislinie für den Vergleich zu erstellen, bevor Sie Änderungen am Programm vornehmen. Wenn Sie dies nicht tun, wandern Sie im Dunkeln und fragen sich, ob die vorgenommenen Änderungen von Nutzen sind. Schreiben Sie zunächst Benchmarks und nehmen Sie
Profile zur Verwendung in pprof.
Schreiben Sie den Benchmark am besten
auch auf Go : Dies erleichtert die Verwendung von pprof und des Profilspeichers. Verwenden Sie auch Benchcmp: ein nützliches Tool zum Vergleichen von Leistungsunterschieden zwischen Tests.
Wenn der Code nicht sehr gut mit Benchmarks kompatibel ist, beginnen Sie einfach mit etwas, das gemessen werden kann. Sie können den Code manuell mit
runtime / pprof profilieren .
Also fangen wir an!
Verwenden Sie sync.Pool, um zuvor ausgewählte Objekte wiederzuverwenden
sync.Pool implementiert
eine Versionsliste. Auf diese Weise können Sie zuvor zugewiesene Strukturen wiederverwenden und die Verteilung des Objekts über viele Verwendungszwecke amortisieren, wodurch die Arbeit des Garbage Collectors reduziert wird. Die API ist sehr einfach. Implementieren Sie eine Funktion, die eine neue Instanz eines Objekts zuweist. Die API gibt den Zeigertyp zurück.
var bufpool = sync.Pool{ New: func() interface{} { buf := make([]byte, 512) return &buf }}
Danach können Sie
Get()
-Objekte aus dem Pool
Put()
und sie zurücksetzen, wenn Sie fertig sind.
Es gibt Nuancen. Vor Go 1.13 wurde der Pool bei jeder Speicherbereinigung gelöscht. Dies kann die Leistung von Programmen beeinträchtigen, die viel Speicher zuweisen. Ab 1.13
scheinen nach dem GC mehr Objekte zu überleben .
!!! Stellen Sie vor dem Zurückgeben eines Objekts an den Pool sicher, dass Sie die Strukturfelder zurücksetzen.Wenn Sie dies nicht tun, können Sie ein schmutziges Objekt aus dem Pool abrufen, das Daten aus der vorherigen Verwendung enthält. Dies ist ein ernstes Sicherheitsrisiko!
type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp)
Ein sicherer Weg, um immer null Speicher zu garantieren, besteht darin, dies explizit zu tun:
Der einzige Fall, in dem dies kein Problem darstellt, besteht darin, dass Sie genau den Speicher verwenden, in den Sie geschrieben haben. Zum Beispiel:
var ( r io.Reader w io.Writer )
Vermeiden Sie die Verwendung von Strukturen mit Zeigern als Schlüssel für eine große Karte
Fuh, ich war zu wortreich. Ich bitte um Entschuldigung. Sie sprachen oft (einschließlich meines ehemaligen Kollegen
Phil Pearl ) über die Go-Leistung mit einer
großen Heap-Größe . Während der Speicherbereinigung durchsucht die Laufzeit Objekte mit Zeigern und verfolgt sie. Wenn Sie eine sehr große Map
map[string]int
, sollte GC jede Zeile überprüfen. Dies geschieht bei jeder Speicherbereinigung, da die Zeilen Zeiger enthalten.
In diesem Beispiel schreiben wir 10 Millionen Elemente,
map[string]int
und die Dauer der Speicherbereinigung zu messen. Wir ordnen unsere Karte im Paketbereich zu, um die Speicherzuordnung vom Heap zu gewährleisten.
package main import ( "fmt" "runtime" "strconv" "time" ) const ( numElements = 10000000 ) var foo = map[string]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[strconv.Itoa(i)] = i } for { timeGC() time.Sleep(1 * time.Second) } }
Wenn Sie das Programm ausführen, sehen Sie Folgendes:
inthash → gehe installieren && inthash
gc dauerte: 98,726321 ms
gc dauerte: 105,524633 ms
gc dauerte: 102,829451ms
gc dauerte: 102,71908 ms
gc dauerte: 103.084104ms
gc dauerte: 104,821989 ms
Dies ist eine ziemlich lange Zeit in einem Computerland!
Was kann zur Optimierung getan werden? Das Entfernen von Zeigern überall ist eine gute Idee, um den Garbage Collector nicht zu laden.
Es gibt Zeiger in den Zeilen ; Implementieren wir dies also als
map[int]int
.
package main import ( "fmt" "runtime" "time" ) const ( numElements = 10000000 ) var foo = map[int]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[i] = i } for { timeGC() time.Sleep(1 * time.Second) } }
Wenn Sie das Programm erneut ausführen, sehen Sie:
inthash → gehe installieren && inthash
gc dauerte: 3.608993ms
gc dauerte: 3,926913ms
gc dauerte: 3,955706ms
gc dauerte: 4.063795ms
gc dauerte: 3.91519ms
gc dauerte: 3,75226ms
Viel besser. Wir haben die Speicherbereinigung um das 35-fache beschleunigt. Bei der Verwendung in der Produktion müssen die Zeichenfolgen vor dem Einsetzen in die Karte in Ganzzahlen gehasht werden.
Übrigens gibt es noch viele weitere Möglichkeiten, GC zu vermeiden. Wenn Sie gigantische Arrays bedeutungsloser Strukturen, Ints oder Bytes zuweisen, scannt der
GC dies nicht. Das heißt, Sie sparen GC-Zeit. Solche Methoden erfordern normalerweise eine umfassende Überarbeitung des Programms, daher werden wir uns heute nicht mit diesem Thema befassen.
Wie bei jeder Optimierung kann der Effekt variieren. Im
Tweets-Thread von Damian Gryski finden Sie ein interessantes Beispiel dafür, wie das Löschen von Zeilen aus einer großen Karte zugunsten einer intelligenteren Datenstruktur
den Speicherverbrauch tatsächlich
erhöht . Lesen Sie im Allgemeinen alles, was er veröffentlicht.
Marshaling-Codegenerierung zur Vermeidung von Laufzeitreflexionen
Das Marshalling und Unmarshaling Ihrer Struktur in verschiedene Serialisierungsformate wie JSON ist ein typischer Vorgang, insbesondere beim Erstellen von Microservices. Für viele Microservices ist dies im Allgemeinen die einzige Aufgabe. Funktionen wie
json.Marshal
und
json.Unmarshal
auf der
Reflexion in der Laufzeit, um Strukturfelder in Bytes zu serialisieren und umgekehrt. Dies kann langsam funktionieren: Reflexion ist nicht so effizient wie expliziter Code.
Es gibt jedoch Optimierungsoptionen. Die JSON-Marshalling-Mechanik sieht ungefähr so aus:
package json // Marshal take an object and returns its representation in JSON. func Marshal(obj interface{}) ([]byte, error) { // Check if this object knows how to marshal itself to JSON // by satisfying the Marshaller interface. if m, is := obj.(json.Marshaller); is { return m.MarshalJSON() } // It doesn't know how to marshal itself. Do default reflection based marshallling. return marshal(obj) }
Wenn wir den Marshalling-Prozess in JSON kennen, haben wir einen Hinweis, um eine Reflexion in der Laufzeit zu vermeiden. Wir möchten jedoch nicht den gesamten Marshalling-Code manuell schreiben. Was sollen wir also tun? Lassen Sie den Computer diesen Code generieren!
Codegeneratoren wie
easyjson betrachten die Struktur und generieren hochoptimierten Code, der vollständig mit vorhandenen Marshalling-Schnittstellen wie
json.Marshaller
.
Laden Sie das Paket herunter und schreiben Sie den folgenden Befehl in
$file.go
, der die Strukturen enthält, für die Sie Code generieren möchten.
easyjson -all $ file.go
Die Datei
$file_easyjson.go
sollte generiert werden. Da
easyjson
die
json.Marshaller
Schnittstelle für Sie
easyjson
, werden diese Funktionen standardmäßig anstelle von Reflection aufgerufen. Herzlichen Glückwunsch: Sie haben Ihren JSON-Code gerade dreimal beschleunigt. Es gibt viele Tricks, um die Produktivität weiter zu steigern.
Ich empfehle dieses Paket, weil ich es zuvor selbst und erfolgreich verwendet habe. Aber sei vorsichtig. Bitte nehmen Sie dies nicht als Einladung, mit mir aggressive Debatten über die schnellsten JSON-Pakete zu beginnen.
Stellen Sie sicher, dass Sie den Marshalling-Code neu generieren, wenn sich die Struktur ändert. Wenn Sie dies vergessen, werden die neu hinzugefügten Felder nicht serialisiert, was zu Verwirrung führen kann! Sie können
go generate
für diese Aufgaben verwenden. Um die Synchronisation mit den Strukturen aufrechtzuerhalten, platziere ich
generate.go
lieber im Stammverzeichnis des Pakets, wodurch alle Paketdateien
go generate
werden. Dies kann hilfreich sein, wenn Sie viele Dateien haben, die solchen Code generieren müssen. Der Haupttipp: Um sicherzustellen, dass die Strukturen aktualisiert werden, rufen Sie
go generate
in CI auf und prüfen Sie, ob es keinen Unterschied zum registrierten Code gibt.
Verwenden Sie strings.Builder, um Zeichenfolgen zu erstellen
In Go sind Zeichenfolgen unveränderlich: Stellen Sie sie sich als schreibgeschützte Bytes vor. Dies bedeutet, dass Sie jedes Mal, wenn Sie eine Zeichenfolge erstellen, Speicher zuweisen und möglicherweise mehr Arbeit für den Garbage Collector erstellen.
Go 1.10 implementierte Strings.
Builder als effiziente Methode zum Erstellen von Strings. Intern schreibt es in einen Bytepuffer. Nur beim Aufrufen von
String()
im Builder wird tatsächlich ein String erstellt. Er stützt sich auf einige unsichere Tricks, um die zugrunde liegenden Bytes als Zeichenfolge mit einer Nullzuweisung zurückzugeben: Weitere Studien dazu finden Sie in
diesem Blog .
Vergleichen Sie die Leistung der beiden Ansätze:
Hier sind die Ergebnisse auf meinem Macbook Pro:
strbuild -> go test -bench =. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/sjwhitworth/perfblog/strbuild
BenchmarkStringBuildNaive-8 5.000.000 255 ns / op 216 B / op 8 Allokationen / op
BenchmarkStringBuildBuilder-8 20.000.000 54,9 ns / op 64 B / op 1 Allokationen / op
Wie Sie sehen können, ist
strings.Builder
4,7-mal schneller, verursacht achtmal weniger Zuweisungen und benötigt viermal weniger Speicher.
Wenn Leistung wichtig ist, verwenden Sie
strings.Builder
. Im Allgemeinen empfehle ich, es überall zu verwenden, außer in den trivialsten Fällen, in denen Zeichenfolgen erstellt werden.
Verwenden Sie strconv anstelle von fmt
fmt ist eines der bekanntesten Pakete in Go. Sie haben es wahrscheinlich in Ihrem ersten Programm verwendet, um "Hallo Welt" anzuzeigen. Aber wenn es darum geht, ganze Zahlen und Floats in Strings umzuwandeln, ist es nicht so effizient wie sein jüngerer Bruder
strconv . Dieses Paket zeigt eine anständige Leistung mit sehr wenigen Änderungen an der API.
fmt
grundsätzlich die
interface{}
als Funktionsargumente. Es gibt zwei Nachteile:
- Sie verlieren die Typensicherheit. Für mich ist es sehr wichtig.
- Dies kann die Menge der benötigten Sekrete erhöhen. Das Übergeben eines Typs ohne Zeiger als
interface{}
normalerweise zu einer Heap-Zuordnung. Dieser Blog-Beitrag erklärt, warum dies so ist. - Das folgende Programm zeigt den Leistungsunterschied:
Benchmarks auf Macbook Pro:
strfmt → go test -bench =. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/sjwhitworth/perfblog/strfmt
BenchmarkStrconv-8 30.000.000 39,5 ns / op 32 B / op 1 Allokationen / op
BenchmarkFmt-8 10.000.000 143 ns / op 72 B / op 3 Allokationen / op
Wie Sie sehen können, ist die Option strconv 3,5-mal schneller, verursacht dreimal weniger Zuweisungen und belegt halb so viel Speicher.
Ordnen Sie den Scheibentank mit make zu, um eine Umverteilung zu vermeiden
Bevor wir mit der Verbesserung der Leistung fortfahren, aktualisieren wir schnell die geschnittenen Informationen im Speicher. Ein Slice ist ein sehr nützliches Konstrukt in Go. Es bietet ein skalierbares Array mit der Möglichkeit, verschiedene Ansichten im selben Basisspeicher ohne Neuzuweisung zu akzeptieren. Wenn Sie unter die Haube schauen, besteht die Scheibe aus drei Elementen:
type slice struct {
Was sind diese Felder?
data
: Zeiger auf die zugrunde liegenden Daten im Slice
len
: Aktuelle Anzahl der Elemente im Slice
cap
: Anzahl der Elemente, auf die ein Slice vor der Neuverteilung anwachsen kann
Abschnitte unter der Haube sind Arrays fester Länge. Wenn der Maximalwert ( cap
) erreicht ist, wird ein neues Array mit einem doppelten Wert zugewiesen, der Speicher wird vom alten Slice in den neuen kopiert und das alte Array wird verworfen.
Ich sehe oft so etwas wie diesen Code, bei dem ein Slice mit einer Grenzkapazität von Null zugewiesen wird, wenn die Slice-Kapazität im Voraus bekannt ist:
var userIDs []string for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) }
In diesem Fall beginnt die Schicht mit der Nullgröße len
und der cap
. Nachdem wir die Antwort erhalten haben, fügen wir die Elemente dem Slice hinzu und erreichen gleichzeitig die Grenzkapazität: Ein neues Basisarray wird ausgewählt, bei dem die cap
verdoppelt und die Daten darauf kopiert werden. Wenn die Antwort 8 Elemente enthält, führt dies zu 5 Umverteilungen.
Die folgende Methode ist viel effizienter:
userIDs := make([]string, 0, len(rsp.Users)) for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) }
Hier haben wir die Kapazität für das Slice explizit mit make zugewiesen. Jetzt können wir dort sicher Daten hinzufügen, ohne sie weiter zu verteilen und zu kopieren.
Wenn Sie nicht wissen, wie viel Speicher zugewiesen werden soll, weil die Kapazität dynamisch ist oder später im Programm berechnet wird, messen Sie die endgültige Verteilung der Slice-Größe, nachdem das Programm ausgeführt wurde. Normalerweise nehme ich das 90. oder 99. Perzentil und codiere den Wert im Programm fest. In Fällen, in denen die CPU für Sie teurer als RAM ist, stellen Sie diesen Wert höher ein, als Sie für erforderlich halten.
Der Tipp gilt auch für Maps: make(map[string]string, len(foo))
genügend Speicher zu, um eine Umverteilung zu vermeiden.
In diesem Artikel erfahren Sie, wie Slices tatsächlich funktionieren.
Verwenden Sie Methoden, um Byte-Slices zu übertragen
Verwenden Sie bei der Verwendung von Paketen Methoden, die die Übertragung eines Byte-Slice ermöglichen: Diese Methoden bieten normalerweise mehr Kontrolle über die Verteilung.
Ein gutes Beispiel ist der Vergleich von time.Format und time.AppendFormat . Der erste gibt eine Zeichenfolge zurück. Unter der Haube wählt dies ein neues Byte-Slice aus und ruft time.AppendFormat
auf. Der zweite nimmt einen Bytepuffer, schreibt eine formatierte Zeitdarstellung und gibt einen erweiterten Byte-Slice zurück. Dies ist häufig in anderen Paketen in der Standardbibliothek zu finden: siehe strconv.AppendFloat oder bytes.NewBuffer .
Warum erhöht dies die Produktivität? Nun können Sie die von sync.Pool
empfangenen Byte-Slices sync.Pool
, anstatt jedes Mal einen neuen Puffer sync.Pool
. Oder Sie können die anfängliche Puffergröße auf einen Wert erhöhen, der für Ihr Programm besser geeignet ist, um die Anzahl der wiederholten Kopien des Slice zu verringern.
Zusammenfassung
Sie können alle diese Methoden auf Ihre Codebasis anwenden. Im Laufe der Zeit werden Sie ein mentales Modell erstellen, um über die Leistung in Go-Programmen nachzudenken. Dies wird bei ihrer Gestaltung sehr hilfreich sein.
Verwenden Sie sie jedoch je nach Situation. Dies sind Ratschläge, nicht das Evangelium. Messen und überprüfen Sie alles mit Benchmarks.
Und wissen, wann Sie aufhören müssen. Die Steigerung der Produktivität ist eine gute Übung: Die Aufgabe ist interessant und die Ergebnisse sind sofort sichtbar. Der Nutzen einer Produktivitätssteigerung hängt jedoch stark von der jeweiligen Situation ab. Wenn Ihr Dienst innerhalb von 10 ms eine Antwort gibt und die Netzwerkverzögerung 90 ms beträgt, sollten Sie wahrscheinlich nicht versuchen, diese 10 ms auf 5 ms zu reduzieren: Sie haben noch 95 ms. Selbst wenn Sie den Dienst auf maximal 1 ms optimieren, beträgt die Gesamtverzögerung immer noch 91 ms. Essen Sie wahrscheinlich größere Fische.
Mit Bedacht optimieren!
Referenzen
Wenn Sie weitere Informationen wünschen, finden Sie hier großartige Inspirationsquellen: