Kapitel 4. Wie Go die CPU-Ressource (oder zwei) nutzt
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Eine der nützlichsten Abstraktionen, die wir machen können, ist, die Eigenschaften unserer Hardware und Infrastruktursysteme als Ressourcen zu betrachten. CPU, Arbeitsspeicher, Datenspeicherung und das Netzwerk ähneln den Ressourcen in der natürlichen Welt: Sie sind endlich, sie sind physische Objekte in der realen Welt und sie müssen zwischen den verschiedenen Akteuren im Ökosystem verteilt und geteilt werden.
Susan J. Fowler, Produktionsfähige Microservices (O'Reilly, 2016)
Wie du unter in "Hinter der Leistung" erfahren hast , hängt die Effizienz von Software davon ab, wie unser Programm die Hardwareressourcen nutzt. Wenn die gleiche Funktionalität weniger Ressourcen benötigt, steigt unsere Effizienz und die Anforderungen und Nettokosten für die Ausführung eines solchen Programms sinken. Wenn wir zum Beispiel weniger CPU-Zeit (CPU-"Ressource") oder weniger Ressourcen mit langsamerer Zugriffszeit (z. B. Festplatte) verwenden, verringern wir in der Regel die Latenzzeit unserer Software.
Das mag einfach klingen, aber in modernen Computern interagieren diese Ressourcen auf eine komplexe, nicht triviale Weise miteinander. Außerdem werden diese Ressourcen von mehr als einem Prozess genutzt, sodass unser Programm sie nicht direkt verwendet. Stattdessen werden diese Ressourcen von einem Betriebssystem für uns verwaltet. Als wäre das nicht schon komplex genug, wird die Hardware vor allem in Cloud-Umgebungen oft noch weiter "virtualisiert", damit sie von vielen einzelnen Systemen isoliert genutzt werden kann. Das bedeutet, dass es Methoden gibt, mit denen "Hosts" einem "Gast"-Betriebssystem, das denkt, es sei die gesamte Hardware, Zugriff auf einen Teil einer einzelnen CPU oder Festplatte geben können. Letztendlich bilden Betriebssysteme und Virtualisierungsmechanismen eine Ebene zwischen unserem Programm und den tatsächlichen physischen Geräten, die unsere Daten speichern oder berechnen.
Um zu verstehen, wie wir effizienten Code schreiben oder die Effizienz unseres Programms effektiv verbessern können, müssen wir die Eigenschaften, den Zweck und die Grenzen der typischen Computerressourcen wie CPU, verschiedene Arten von Speicherung und Netzwerk kennen. Hier gibt es keine Abkürzung. Außerdem müssen wir verstehen, wie diese physischen Komponenten vom Betriebssystem und typischen Virtualisierungsschichten verwaltet werden.
In diesem Kapitel werden wir unsere Programmausführung aus der Sicht der CPU untersuchen. Wir werden besprechen, wie Go die CPUs für Einzel- und Mehrkernaufgaben nutzt.
Hinweis
Wir werden nicht alle Arten von Computerarchitekturen mit allen Mechanismen aller existierenden Betriebssysteme behandeln, da dies unmöglich in ein Buch, geschweige denn in ein Kapitel passen würde. Stattdessen konzentriert sich dieses Kapitel auf eine typische x86-64-CPU-Architektur mit Intel oder AMD, ARM-CPUs und das moderne Betriebssystem Linux. Das sollte dir den Einstieg erleichtern und dir einen Anhaltspunkt geben, wenn du dein Programm einmal auf anderen, einzigartigen Hardwaretypen oder Betriebssystemen laufen lassen willst.
Wir beginnen mit der Erforschung der CPU in einer modernen Computerarchitektur, um zu verstehen, wie moderne Computer aufgebaut sind, wobei wir uns vor allem auf die CPU, den Prozessor, konzentrieren. Dann stelle ich die Assemblersprache vor, die uns hilft zu verstehen, wie der CPU-Kern Anweisungen ausführt. Danach werden wir uns mit dem Go-Compiler beschäftigen, um zu verstehen, was passiert, wenn wir eine go build
ausführen. Außerdem werden wir uns mit dem CPU- und Memory-Wall-Problem befassen und dir zeigen, warum moderne CPU-Hardware so komplex ist. Dieses Problem wirkt sich direkt auf das Schreiben von effizientem Code auf diesen ultrakritischen Pfaden aus. Schließlich werden wir uns mit Multitasking befassen und erklären, wie das Zeitplannungsprogramm des Betriebssystems versucht, Tausende von Programmen auf die überzähligen CPU-Kerne zu verteilen, und wie das Zeitplannungsprogramm von Go dies ausnutzt, um ein effizientes Gleichzeitigkeits-Framework zu implementieren. Abschließend fassen wir zusammen, wann wir Gleichzeitigkeit nutzen sollten.
Mechanische Sympathie
Zu Beginn könnte dieses Kapitel überwältigend sein, vor allem, wenn du neu in der Low-Level-Programmierung bist. Aber das Wissen um die Vorgänge hilft uns, die Optimierungen zu verstehen. Konzentriere dich also darauf, die Muster und Eigenschaften der einzelnen Ressourcen zu verstehen (z. B. wie das Zeitplannungsprogramm von Go funktioniert). Wir müssen nicht wissen, wie man manuell Maschinencode schreibt oder wie man mit verbundenen Augen einen Computer herstellt.
Stattdessen sollten wir neugierig darauf sein, wie die Dinge unter dem Computergehäuse im Allgemeinen funktionieren. Mit anderen Worten: Wir müssen mechanisches Verständnis haben.
Um zu verstehen, wie die CPU-Architektur funktioniert, müssen wir erklären, wie moderne Computer funktionieren. Damit befassen wir uns also im nächsten Abschnitt.
CPU in einer modernen Computerarchitektur
Alles, was wir beim Programmieren in Go tun, ist, eine Reihe von Anweisungen zu erstellen, die dem Computer Schritt für Schritt sagen, was er tun soll. Mit vordefinierten Sprachkonstrukten wie Variablen, Schleifen, Kontrollmechanismen, Arithmetik und E/A-Operationen können wir beliebige Algorithmen implementieren, die mit Daten interagieren, die in verschiedenen Medien gespeichert sind. Deshalb kann Go, wie viele andere beliebte Programmiersprachen, als imperativ bezeichnet werden - als Entwickler müssen wir beschreiben, wie das Programm funktionieren soll. So ist auch die Hardware heutzutage konzipiert - sie ist ebenfalls imperativ. Sie wartet auf Programmanweisungen, optionale Eingabedaten und den gewünschten Ort für die Ausgabe.
Programmieren war nicht immer so einfach. Bevor es Allzweckmaschinen gab, mussten Ingenieure Hardware entwickeln und programmieren, um die gewünschten Funktionen zu erreichen, z. B. einen Tischrechner. Um eine Funktion hinzuzufügen, einen Fehler zu beheben oder zu optimieren, mussten dieSchaltkreise geändert und neue Geräte hergestellt werden. Wahrscheinlich nicht die einfachste Zeit, um ein"Programmierer" zu sein!
Glücklicherweise erkannten in den 1950er Jahren einige Erfinder auf der ganzen Welt die Möglichkeit einer Universalmaschine, die mit einer Reihe von vordefinierten, im Speicher abgelegten Anweisungen programmiert werden konnte. Einer der ersten, der diese Idee dokumentierte, war der große Mathematiker John von Neumann und sein Team von .
Es liegt auf der Hand, dass die Maschine in der Lage sein muss, nicht nur die digitalen Informationen zu speichern, die für eine bestimmte Berechnung benötigt werden ..., die Zwischenergebnisse der Berechnung (die unterschiedlich lange benötigt werden können), sondern auch die Anweisungen, die die eigentliche Routine für die numerischen Daten steuern. ... Bei einer Allzweckmaschine muss es möglich sein, das Gerät anzuweisen, jede beliebige Berechnung durchzuführen, die sich in numerischen Begriffen formulieren lässt.
Arthur W. Burks, Herman H. Goldstine, und John von Neumann, Preliminary Discussion of the Logical Design of an Electronic Computing Instrument (Institute for Advanced Study, 1946)
Bemerkenswert ist, dass die meisten modernen Allzweckcomputer (z. B. PCs, Laptops und Server) auf John von Neumanns Design basieren. Dieser geht davon aus, dass Programmanweisungen gespeichert und abgerufen werden können, ähnlich wie das Speichern und Lesen von Programmdaten (Befehlseingabe und -ausgabe). Wir holen sowohl die auszuführende Anweisung (z. B. add
) als auch Daten (z. B. Additionsoperanden), indem wir Bytes von einer bestimmten Speicheradresse im Hauptspeicher (oder in den Caches) lesen. Das klingt jetzt zwar nicht nach einer neuen Idee, aber damit wurde festgelegt, wie Allzweckmaschinen funktionieren. Wir nennen dies die Von-Neumann-Computerarchitektur, und du kannst ihre moderne, weiterentwickelte Variante in Abbildung 4-1 sehen.1
Das Herzstück der modernen Architektur ist eine CPU, die aus mehreren Kernen besteht (vier bis sechs physische Kerne sind in den PCs der 2020er Jahre die Norm). Jeder Kern kann die gewünschten Befehle ausführen, wobei bestimmte Daten im Arbeitsspeicher (RAM) oder in anderen Speicherschichten wie Registern oder L-Caches (siehe unten) gespeichert werden.
Der Arbeitsspeicher (RAM), der in Kapitel 5 erklärt wird, erfüllt die Aufgabe des schnellen, flüchtigen Hauptspeichers, der unsere Daten und unseren Programmcode speichern kann, solange der Computer mit Strom versorgt wird. Außerdem sorgt der Speichercontroller dafür, dass der Arbeitsspeicher mit einem konstanten Stromfluss versorgt wird, damit die Informationen auf den RAM-Chips erhalten bleiben. Schließlich kann die CPU mit verschiedenen externen oder internen Eingabe-/Ausgabegeräten (E/A) interagieren. Unter einem E/A-Gerät versteht man alles, was einen Bytestrom senden oder empfangen kann, z. B. Maus, Tastatur, Lautsprecher, Monitor, HDD- oder SSD-Festplatte, Netzwerkschnittstelle, Grafikprozessor und viele mehr.
Grob gesagt sind CPU, RAM und gängige E/A-Geräte wie Festplatten und Netzwerkschnittstellen die wesentlichen Bestandteile der Computerarchitektur. Das sind die "Ressourcen", die wir in unseren RAERs (siehe "Effizienzanforderungen sollten formalisiert werden") verwenden und für die wir in der Regel unsereSoftwareentwicklung optimieren.
In diesem Kapitel konzentrieren wir uns auf das Gehirn unserer Allzweckmaschinen - die CPU. Wann sollten wir uns um die CPU-Ressourcen kümmern? Unter dem Gesichtspunkt der Effizienz sollten wir uns die CPU-Ressourcen unseres Go-Prozesses ansehen, wenn einer der folgenden Fälle eintritt:
-
Unser Rechner kann keine anderen Aufgaben erledigen, weil unser Prozess die gesamte verfügbare Rechenkapazität der CPU-Ressource nutzt.
-
Unser Prozess läuft unerwartet langsam, während wir einen höheren CPU-Verbrauch feststellen.
Es gibt viele Techniken, um diese Symptome zu beheben, aber zuerst müssen wir die interne Arbeitsweise der CPU und die Grundlagen der Programmausführung verstehen. Das ist der Schlüssel zur effizienten Go-Programmierung. Außerdem erklärt es die zahlreichen Optimierungstechniken, die uns anfangs vielleicht überraschen. Weißt du zum Beispiel, warum wir in Go (und anderen Sprachen) keine verknüpften Listen wie Strukturen verwenden sollten, wenn wir vorhaben, viel über sie zu iterieren, trotz ihrer theoretischen Vorteile wie schnelles Einfügen undLöschen?
Bevor wir erfahren, warum, müssen wir verstehen, wie der CPU-Kern unsere Programme ausführt. Überraschenderweise habe ich festgestellt, dass sich das am besten erklären lässt, wenn man lernt, wie die Assemblersprache funktioniert. Vertrau mir; es ist vielleicht einfacher, als du denkst!
Montage
Der CPU-Kern kann indirekt Programme ausführen, die wir schreiben. Betrachte zum Beispiel den einfachen Go-Code in Beispiel 4-1.
Beispiel 4-1. Einfache Funktion, die Zahlen aus einer Datei liest und die Gesamtsumme zurückgibt
func
Sum
(
fileName
string
)
(
ret
int64
,
_
error
)
{
b
,
err
:=
os
.
ReadFile
(
fileName
)
if
err
!=
nil
{
return
0
,
err
}
for
_
,
line
:=
range
bytes
.
Split
(
b
,
[
]
byte
(
"\n"
)
)
{
num
,
err
:=
strconv
.
ParseInt
(
string
(
line
)
,
10
,
64
)
if
err
!=
nil
{
return
0
,
err
}
ret
+=
num
}
return
ret
,
nil
}
Diese Sprache ist zwar weit entfernt von, sagen wir, gesprochenem Englisch, aber leider immer noch zu komplex und unverständlich für die CPU. Es ist kein "maschinenlesbarer" Code. Zum Glück gibt es für jede Programmiersprache ein spezielles Werkzeug, einen Compiler2 der (neben anderen Dingen, die in "Go Compiler verstehen" besprochen werden ) unseren High-Level-Code in Maschinencode übersetzt. Vielleicht kennst du den Befehl go build
, der einen Standard-Go-Compiler aufruft.
Der Maschinencode ist eine Folge von Anweisungen, die im Binärformat geschrieben sind (berühmte Nullen und Einsen). Im Prinzip wird jede Anweisung durch eine Zahl (opcode
) dargestellt, gefolgt von optionalen Operanden in Form eines konstanten Wertes oder einer Adresse im Hauptspeicher. Wir können uns auch auf einige CPU-Kernregister beziehen, die winzige "Steckplätze" direkt auf dem CPU-Chip sind, die zum Speichern von Zwischenergebnissen verwendet werden können. Bei der AMD64-CPU gibt es zum Beispiel sechzehn 64-Bit-Allzweckregister, die als RAX, RBX, RCX, RDX, RBP, RSI, RDI, RSP und R8-R15 bezeichnet werden.
Bei der Übersetzung in Maschinencode fügt der Compiler oft zusätzlichen Code hinzu, z. B. zusätzliche Prüfungen der Speichersicherheit. Er ändert unseren Code automatisch für bekannte Effizienzmuster für eine bestimmte Architektur. Manchmal ist das nicht das, was wir erwarten. Deshalb ist es manchmal sinnvoll, den resultierenden Maschinencode bei der Fehlersuche nach Effizienzproblemen zu überprüfen. Ein weiteres Beispiel dafür, dass Menschen Maschinencode lesen müssen, ist das Reverse Engineering von Programmen ohne Quellcode.
Leider ist Maschinencode für Menschen unmöglich zu lesen, es sei denn, du bist ein Genie. Es gibt jedoch ein großartiges Werkzeug, das wir in solchen Situationen nutzen können. Wir können den Code von Beispiel 4-1 in Assembler statt in Maschinencode kompilieren. Außerdem können wir den kompilierten Maschinencode in Assembler disassemblieren. Die Assemblersprache stellt die niedrigste Codeebene dar, die praktisch von menschlichen Entwicklern gelesen und (theoretisch) geschrieben werden kann. Sie repräsentiert auch gut, was die CPU interpretieren wird, wenn sie in Maschinencode umgewandelt wird.
Es ist erwähnenswert, dass wir kompilierten Code in verschiedene Assembly-Dialekte disassemblieren können. Zum Beispiel:
-
Zur Intel-Syntax mit dem Standard-Linux-Tool
objdump -d -M intel <binary>
-
Zur AT&T-Syntax mit dem ähnlichen Befehl
objdump -d -M att <binary>
-
Zu Go "Pseudo"-Assemblersprache mit Go-Tooling
go tool objdump -S <binary>
Alle drei Dialekte werden in den verschiedenen Tools verwendet, und ihre Syntax ist unterschiedlich. Um dir die Arbeit zu erleichtern, solltest du immer darauf achten, welche Syntax dein Disassembler-Tool verwendet. Die Go-Assembly ist ein Dialekt, der versucht, so portabel wie möglich zu sein, daher entspricht er vielleicht nicht genau dem Maschinencode. Dennoch ist er in der Regel konsistent und für unsere Zwecke nah genug. Er kann alle Kompilierungsoptimierungen aufweisen, die im Abschnitt "Den Go-Compiler verstehen" beschrieben werden. Aus diesem Grund werden wir in diesem Buch Go Assembly verwenden.
Muss ich die Montage verstehen?
Du musst nicht wissen, wie man in Assembly programmiert, um effizienten Go-Code zu schreiben. Dennoch sind ein grobes Verständnis von Assembly und der Dekompilierungsprozess wichtige Werkzeuge, die oft versteckte, untergeordnete Berechnungen aufdecken können. Praktisch gesehen ist es vor allem für fortgeschrittene Optimierungen nützlich, wenn wir bereits alle einfacheren Optimierungen angewendet haben. Assembler ist auch nützlich, um die Änderungen zu verstehen, die der Compiler bei der Übersetzung in Maschinencode an unserem Code vornimmt. Manchmal können diese uns überraschen! Schließlich erfahren wir auch, wie die CPU funktioniert.
In Beispiel 4-2 sehen wir einen winzigen, disassemblierten Teil des kompilierten Beispiels 4-1 (mit go tool objdump -S
), der die Anweisung ret += num
darstellt.3
Beispiel 4-2. Zusatzteil des Codes in Go Assembler, dekompiliert aus dem kompilierten Beispiel 4-1
//
go
tool
objdump
-S
sum.test
ret
+=
num
0x4f9b6d
488b742450
MOVQ
0x50
(
SP
)
,
SI
0x4f9b72
4801c6
ADDQ
AX,
SI
Die erste Zeile stellt einen Quadword (64 Bit) MOV-Befehl dar, der die CPU anweist, den 64-Bit-Wert aus dem Speicher unter der im Register
SP
gespeicherten Adresse plus 80 Bytes zu kopieren und in das RegisterSI
zu übertragen.4 Der Compiler hat entschieden, dassSI
den Anfangswert des Rückgabearguments in unserer Funktion speichert, also die Ganzzahlvariableret
für die Operationret+=num
.Als zweite Anweisung weisen wir die CPU an, einen Quadword-Wert aus dem Register
AX
in das RegisterSI
zu übertragen. Der Compiler hat das RegisterAX
verwendet, um die Integer-Variablenum
zu speichern, die wir in den vorherigen Anweisungen (außerhalb dieses Schnipsels) aus dem Registerstring
geparst haben.
Das vorangegangene Beispiel zeigt die Befehle MOVQ
und ADDQ
. Um die Sache noch komplizierter zu machen, erlaubt jede CPU-Implementierung einen anderen Satz von Befehlen, mit einer anderen Speicheradressierung usw. Die Industrie hat die Instruction Set Architecture (ISA) entwickelt, um eine strikte, übertragbare Schnittstelle zwischen Software und Hardware zu schaffen. Dank der ISA können wir unser Programm z. B. in Maschinencode kompilieren, der mit der ISA für die x86-Architektur kompatibel ist, und es auf jeder x86-CPU ausführen.5 Die ISA definiert Datentypen, Register, die Verwaltung des Hauptspeichers, einen festen Satz von Anweisungen, eine eindeutige Identifizierung, ein Ein- und Ausgabemodell usw. Es gibt verschiedene ISAs für verschiedene Arten von CPUs. So verwenden z. B. sowohl die 32-Bit- als auch die 64-Bit-Prozessoren von Intel und AMD den x86 ISA und ARM seinen ARM ISA (z. B. verwenden die neuen Apple M-Chips ARMv8.6-A).
Für Go-Entwicklerinnen und -Entwickler definiert der ISA eine Reihe von Anweisungen und Registern, die unser kompilierter Maschinencode verwenden kann. Um ein portables Programm zu erstellen, kann ein Compiler unseren Go-Code in Maschinencode umwandeln, der mit einer bestimmten ISA (Architektur) und dem Typ des gewünschten Betriebssystems kompatibel ist. Im nächsten Abschnitt schauen wir uns an, wie der Standard-Go-Compiler funktioniert. Auf dem Weg dorthin werden wir Mechanismen entdecken, die dem Go-Compiler helfen, effizienten und schnellen Maschinencode zu erzeugen.
Den Go Compiler verstehen
Das Thema der Erstellung effektiver Compiler kann einige Bücher füllen. In diesem Buch werden wir jedoch versuchen, die Go-Compiler-Grundlagen zu verstehen, die wir als Go-Entwickler, die an effizientem Code interessiert sind, kennen müssen. Im Allgemeinen sind viele Dinge daran beteiligt, den Go-Code, den wir schreiben, auf einem typischen Betriebssystem auszuführen, nicht nur die Kompilierung. Zunächst müssen wir ihn mit einem Compiler kompilieren und dann mit einem Linker verschiedene Objektdateien miteinander verknüpfen, darunter möglicherweise auch gemeinsam genutzte Bibliotheken. Diese Kompilier- und Verknüpfungsvorgänge, die oft auch als " building" bezeichnet werden, erzeugen die ausführbare Datei ("binary"), die das Betriebssystem ausführen kann. Beim ersten Start, dem sogenannten Laden, können auch andere gemeinsam genutzte Bibliotheken dynamisch geladen werden (z. B. Go-Plug-ins).
Es gibt viele Methoden zur Erstellung von Go-Code, die für unterschiedliche Zielumgebungen entwickelt wurden. Tiny Go zum Beispiel ist optimiert, um Binärdateien für Mikrocontroller zu erzeugen, gopherjs erzeugt JavaScript für die Ausführung im Browser und android erzeugt Programme, die auf Android-Betriebssystemen ausgeführt werden können. Dieses Buch konzentriert sich jedoch auf den standardmäßigen und beliebtesten Go-Compiler und Linking-Mechanismus, der unter go build
verfügbar ist. Der Compiler selbst ist in Go geschrieben (ursprünglich in C). Die grobe Dokumentation und den Quellcode findest du hier.
Die go build
kann unseren Code in viele verschiedene Ausgaben umwandeln. Wir können ausführbare Dateien erstellen, die Systembibliotheken benötigen, die beim Start dynamisch gelinkt werden. Wir können Shared Libraries oder sogar C-kompatible Shared Libraries erstellen. Die gebräuchlichste und empfohlene Art, Go zu verwenden, ist jedoch, ausführbare Dateien zu erstellen, bei denen alle Abhängigkeiten statisch gelinkt sind. Dies bietet eine viel bessere Erfahrung, da der Aufruf unserer Binärdatei keine Systemabhängigkeit von einer bestimmten Version in einem bestimmten Verzeichnis benötigt. Es ist ein Standard-Build-Modus für Code mit einer main
Funktion, die auch explizit mit go build -buildmode=exe
aufgerufen werden kann.
Der Befehl go build
ruft sowohl die Kompilierung als auch die Verknüpfung auf. Während die Linking-Phase auch bestimmte Optimierungen und Prüfungen durchführt, übernimmt der Compiler wahrscheinlich die komplexeste Aufgabe. Der Go-Compiler konzentriert sich jeweils auf ein einzelnes Paket. Er kompiliert den Quellcode des Pakets in den nativen Code, den die Zielarchitektur und die Betriebssysteme unterstützen. Darüber hinaus validiert und optimiert er diesen Code und bereitet wichtige Metadaten für die Fehlersuche vor. Wir müssen mit dem Compiler (und dem Betriebssystem und der Hardware) "zusammenarbeiten", um effizientes Go zu schreiben und nicht gegen ihn zu arbeiten.
Ich sage jedem: Wenn du dir nicht sicher bist, wie du etwas machen sollst, stelle die Frage, wie du es in Go am idiomatischsten machen kannst. Denn viele dieser Antworten sind bereits darauf abgestimmt, dass sie mit dem Betriebssystem der Hardware übereinstimmen.
Bill Kennedy, "Bill Kennedy über mechanische Sympathie"
Um die Sache noch interessanter zu machen, bietet go build
auch einen speziellen Cross-Compilations-Modus, wenn du einen Mix aus Go-Code kompilieren willst, der Funktionen verwendet, die in C, C++ oder sogar Fortran implementiert sind! Das ist möglich, wenn du einen Modus aktivierst, der cgo
aktivierst, der eine Mischung aus C (oder C++) Compiler und Go Compiler verwendet. Leider ist cgo
nicht zu empfehlen und sollte nach Möglichkeit vermieden werden. Er macht den Build-Prozess langsam, die Leistung der Datenübergabe zwischen C und Go ist fragwürdig und die Kompilierung ohnecgo
ist bereits leistungsfähig genug, um Binärdateien für verschiedene Architekturen und Betriebssysteme zu kompilieren. Glücklicherweise sind die meisten Bibliotheken entweder reines Go oder verwenden Teile von Assembly, die ohne cgo
in die Go-Binärdatei eingebunden werden können.
Um zu verstehen, wie sich der Compiler auf unseren Code auswirkt, siehst du in Abbildung 4-2 die Schritte, die der Go-Compiler durchführt. Während go build
eine solche Kompilierung beinhaltet, können wir mit go tool compile
nur die Kompilierung (ohne Linken) allein auslösen.
Wie bereits erwähnt, dreht sich der gesamte Prozess um die Pakete, die du in deinem Go-Programm verwendest. Jedes Paket wird separat kompiliert, was eine parallele Kompilierung und eine Trennung der einzelnen Bereiche ermöglicht. Der in Abbildung 4-2 dargestellte Kompilierungsfluss funktioniert wie folgt:
-
Der Go-Quellcode wird zunächst tokenisiert und geparst. Die Syntax wird überprüft. Der Syntaxbaum verweist auf Dateien und Dateipositionen, um aussagekräftige Fehler- und Debugging-Informationen zu erhalten.
-
Ein abstrakter Syntaxbaum (AST) wird erstellt. Ein solcher Baum ist eine gängige Abstraktion, die es Entwicklern ermöglicht, Algorithmen zu erstellen, die geparste Anweisungen einfach umwandeln oder überprüfen. In der AST-Form wird der Code zunächst auf seinen Typ hin überprüft. Deklarierte, aber nicht verwendete Elemente werden erkannt.
-
Der erste Durchgang der Optimierung wird durchgeführt. Zum Beispiel wird der anfängliche tote Code eliminiert, sodass die Binärgröße kleiner sein kann und weniger Code kompiliert werden muss. Dann wird eine Escape-Analyse (siehe "Go Memory Management") durchgeführt, um zu entscheiden, welche Variablen auf dem Stack platziert werden können und welche auf dem Heap zugewiesen werden müssen. Außerdem findet in dieser Phase das Funktions-Inlining für einfache und kleine Funktionen statt.
Funktion Inlining
Funktionen6 in Programmiersprachen ermöglichen es uns, Abstraktionen zu schaffen, Komplexitäten zu verbergen und wiederholten Code zu reduzieren. Die Kosten für die Ausführung von Funktionsaufrufen sind jedoch nicht Null. Der Aufruf einer Funktion mit einem einzigen Argument erfordert zum Beispiel ~10 zusätzliche CPU-Anweisungen.7 Auch wenn die Kosten feststehen und sich in der Regel im Bereich von Nanosekunden bewegen, können sie doch ins Gewicht fallen, wenn wir Tausende dieser Aufrufe im heißen Pfad haben und der Funktionskörper klein genug ist, dass dieser Ausführungsaufruf von Bedeutung ist.
Es gibt noch weitere Vorteile des Inlinings. Zum Beispiel kann der Compiler andere Optimierungen in Code mit weniger Funktionen effektiver anwenden und muss keinen Heap oder großen Stack-Speicher (mit Kopie) verwenden, um Argumente zwischen Funktionsbereichen zu übergeben. Heap und Stack werden in "Go Memory Management" erklärt .
Der Compiler ersetzt einige Funktionsaufrufe automatisch durch die exakte Kopie des Funktionskörpers. Das nennt man Inlining oder Inline-Erweiterung. Die Logik ist ziemlich clever. Ab Go 1.9 kann der Compiler zum Beispiel sowohl Leaf- als auch Mid-Stack-Funktionen inlinen.
Manuelles Inlining ist nur selten nötig
Für Anfänger ist es verlockend, durch manuelles Inlining einiger Funktionen eine Mikrooptimierung durchzuführen. In den Anfängen der Programmierung mussten die Entwickler dies jedoch tun. Heute ist diese Funktion eine grundlegende Aufgabe des Compilers, der in der Regel besser weiß, wann und wie eine Funktion zu inlinen ist. Nutze diese Tatsache, indem du dich bei der Wahl der Funktionen zuerst auf die Lesbarkeit und Wartbarkeit deines Codes konzentrierst. Inlinen Sie nur als letzten Ausweg und immer nach Maß.
-
Nach den ersten Optimierungen am AST wird der Baum in die Form der statischen Einzelzuweisung (SSA) umgewandelt. Diese explizitere Darstellung auf niedriger Ebene macht es einfacher, weitere Optimierungsschritte mit Hilfe von Regeln durchzuführen. Mit Hilfe der SSA kann der Compiler zum Beispiel leicht Stellen mit unnötigen Variablenzuweisungen finden.8
-
Der Compiler wendet weitere, maschinenunabhängige Optimierungsregeln an. So werden z. B. Anweisungen wie
y := 0*x
zuy :=0
vereinfacht. Die vollständigeListe der Regeln ist enorm und bestätigt nur, wie komplex dieser Bereich ist. Außerdem können einige Codestücke durch einen funktionsstarkoptimierten äquivalenten Code ersetzt werden (z. B. in RawAssembly). -
Basierend auf den Umgebungsvariablen
GOARCH
undGOOS
ruft der Compiler diegenssa
Funktion auf, die SSA in den Maschinencode für die gewünschte Architektur (ISA) und das Betriebssystem umwandelt. -
Weitere ISA- und betriebssystemspezifische Optimierungen werden angewendet.
-
Paketmaschinencode, der nicht tot ist, wird in eine einzelne Objektdatei (mit der Endung .o ) und Debug-Informationen eingebaut.
Die endgültige "Objektdatei" wird in eine tar
Datei komprimiert, die als Go-Archiv bezeichnet wird und in der Regel die Dateiendung .a trägt.9 Solche Archivdateien für jedes Paket können vom Go-Linker (oder anderen Linkern) verwendet werden, um sie zu einer einzigen ausführbaren Datei zusammenzufassen, die gemeinhin als Binärdatei bezeichnet wird. Je nach Betriebssystem hat eine solche Datei ein bestimmtes Format, das dem System mitteilt, wie sie ausgeführt und verwendet werden soll. Bei Linux ist das in der Regel ein Executable and Linkable Format (ELF). Unter Windows kann es ein Portable Executable (PE) sein.
Der Maschinencode ist nicht der einzige Teil einer solchen Binärdatei. Sie enthält auch die statischen Daten des Programms, wie globale Variablen und Konstanten. Die ausführbare Datei enthält außerdem viele Debugging-Informationen, die einen beträchtlichen Teil der Binärdatei einnehmen können, wie z. B. eine einfache Symboltabelle, grundlegende Typinformationen (zur Reflexion) und die PC-Zeilen-Zuordnung (Adresse der Anweisung zu der Zeile im Quellcode, in der der Befehl stand). Diese zusätzlichen Informationen ermöglichen es wertvollen Debugging-Tools, den Maschinencode mit dem Quellcode zu verknüpfen. Viele Debugging-Tools nutzen diese Informationen, z. B. "Profiling in Go" und das bereits erwähnte Tool objdump
.
Um mit Debugging-Software wie Delve oder GDBkompatibel zu sein, wird die DWARF-Tabelle auch an die Binärdatei angehängt.10
Zusätzlich zu der ohnehin schon langen Liste von Aufgaben muss der Go-Compiler weitere Schritte durchführen, um die Speichersicherheit von Go zu gewährleisten. Zum Beispiel kann der Compiler während der Kompilierung oft feststellen, dass einige Befehle einen Speicherbereich verwenden, der sicher ist (eine erwartete Datenstruktur enthält und für unser Programm reserviert ist). Es gibt jedoch Fälle, in denen dies während der Kompilierung nicht festgestellt werden kann, sodass zur Laufzeit zusätzliche Prüfungen durchgeführt werden müssen, z. B. zusätzliche Bound-Checks oder Nil-Checks.
Wir werden dies im Abschnitt "Go-Speicherverwaltung" näher erläutern , aberfür unser Gespräch über die CPU müssen wir anerkennen, dass solche Prüfungen wertvolle CPU-Zeit kosten können. Der Go-Compiler versucht zwar, diese Prüfungen zu eliminieren, wenn sie unnötig sind (z. B. in der Phase der Eliminierung von Bound Checks während der SSA-Optimierung), aber es kann Fälle geben, in denen wir unseren Code so schreiben müssen, dass der Compiler einige Prüfungen eliminieren kann.11
Es gibt viele verschiedene Konfigurationsoptionen für den Go-Build-Prozess. Die erste große Gruppe von Optionen kann über go build -ldflags="<flags>"
übergeben werden, was Linker-Befehlsoptionen darstellt (das Präfix ld
steht traditionell für Linux Linker). Zum Beispiel:
-
Wir können die DWARF-Tabelle weglassen und so die Größe der Binärdatei reduzieren, indem wir
-ldflags="-w"
(empfohlen für den Produktionsbau, wenn du dort keine Debugger verwendest). -
Wir können die Größe mit
-ldflags= "-s -w"
weiter reduzieren, indem wir die DWARF- und Symboltabellen mit anderen Debug-Informationen entfernen. Ich würde diese Option nicht empfehlen-w
Option nicht empfehlen, da Nicht-DWARF-Elemente wichtige Laufzeit-Goroutinen, wie das Sammeln von Profilen, ermöglichen.
In ähnlicher Weise steht go build -gcflags="<flags>"
für Go-Compiler-Optionen (gc
steht für Go Compiler
; nicht zu verwechseln mit GC, was für Speicherbereinigung steht, wie in "Garbage Collection" erklärt ). Ein Beispiel:
-
-gcflags="-S"
druckt Go Assembly aus dem Quellcode. -
-gcflags="-N"
schaltet alle Compiler-Optimierungen aus. -
-gcflags="-m=<number>"
baut den Code auf und gibt dabei die wichtigsten Optimierungsentscheidungen aus, wobei die Zahl den Detaillierungsgrad angibt. In Beispiel 4-3 siehst du die automatischen Compiler-Optimierungen für unsere FunktionSum
in Beispiel 4-1.
Beispiel 4-3. Ausgabe von go build -gcflags="-m=1" sum.go
zu Beispiel 4-1 Code
# command-line-arguments
./sum.go:10:27:
inlining
call
to
os.ReadFile
./sum.go:15:34:
inlining
call
to
bytes.Split
./sum.go:9:10:
leaking
param:
fileName
./sum.go:15:44:
(
[
]
byte
)
(
"\n"
)
does
not
escape
./sum.go:16:38:
string
(
line
)
escapes
to
heap
os.ReadFile
undbytes.Split
Funktionen sind kurz genug, damit der Compiler ihren gesamten Körper in die FunktionSum
kopieren kann.Das Argument
fileName
ist "undicht", d.h. diese Funktion behält ihren Parameter am Leben, nachdem sie zurückgekehrt ist (er kann sich aber immer noch auf dem Stapel befinden).Der Speicher für
[]byte("\n")
wird auf dem Stack zugewiesen. Meldungen wie diese helfen beim Debuggen der Escape-Analyse. Mehr darüber erfährst du hier.Der Speicher für
string(line)
wird in einem teureren Heap zugewiesen.
Der Compiler gibt mehr Details mit einer erhöhten -m
Nummer aus. Unter -m=3
wird zum Beispiel erklärt, warum bestimmte Entscheidungen getroffen wurden. Diese Option ist praktisch, wenn wir bestimmte Optimierungen (Inlining oder Beibehaltung von Variablen auf dem Stack) erwarten, aber beim Benchmarking in unserem TFBO-Zyklus ("Efficiency-Aware Development Flow") trotzdem einen Overhead sehen.
Die Go-Compiler-Implementierung ist sehr gut getestet und ausgereift, aber es gibt unendlich viele Möglichkeiten, die gleiche Funktionalität zu schreiben. Es kann Kanten geben, in denen unsere Implementierung den Compiler verwirrt, sodass er bestimmte naive Implementierungen nicht anwendet. Benchmarking, wenn es ein Problem gibt, Profiling des Codes und die Bestätigung mit der Option -m
helfen. Mit weiteren Optionen können auch detailliertere Optimierungen ausgegeben werden. Die Option -gcflags="-d=ssa/check_bce/debug=1"
gibt zum Beispiel alle Optimierungen zur Beseitigung von gebundenen Prüfungen aus.
Je einfacher der Code, desto effektiversind die Compiler-Optimierungen
Zu schlauer Code ist schwer zu lesen und erschwert die Aufrechterhaltung der programmierten Funktionalität. Aber er kann auch den Compiler verwirren, der versucht, Muster mit ihren optimierten Entsprechungen abzugleichen. Wenn du idiomatischen Code verwendest und deine Funktionen und Schleifen einfach hältst, erhöht sich die Wahrscheinlichkeit, dass der Compiler die Optimierungen anwendet, damit du es nicht tun musst!
Die Kenntnis der Compiler-Interna ist hilfreich, vor allem wenn es um fortgeschrittene Optimierungstricks geht, die den Compilern unter anderem helfen, unseren Code zu optimieren. Leider bedeutet das auch, dass unsere Optimierungen in Bezug auf die Portabilität zwischen verschiedenen Compiler-Versionen etwas anfällig sein können. Das Go-Team behält sich das Recht vor, die Compiler-Implementierung und die Flags zu ändern, da sie nicht Teil einer Spezifikation sind. Das kann bedeuten, dass die Art und Weise, wie du eine Funktion geschrieben hast, die automatisches Inline durch den Compiler zulässt, in der nächsten Version des Go-Compilers möglicherweise kein Inline mehr auslöst. Deshalb ist es umso wichtiger, einen Benchmark durchzuführen und die Effizienz deines Programms genau zu beobachten, wenn du zu einer anderen Go-Version wechselst.
Zusammenfassend lässt sich sagen, dass der Kompilierungsprozess eine entscheidende Rolle dabei spielt, Programmierer von ziemlich mühsamer Arbeit zu entlasten. Ohne Compiler-Optimierungen müssten wir mehr Code schreiben, um die gleiche Effizienz zu erreichen, und dabei Abstriche bei der Lesbarkeit und Portabilität machen. Wenn du dich stattdessen darauf konzentrierst, deinen Code einfach zu gestalten, kannst du darauf vertrauen, dass der Go-Compiler seine Arbeit gut genug macht. Wenn du die Effizienz für einen bestimmten heißen Pfad erhöhen musst, kann es von Vorteil sein, noch einmal zu überprüfen, ob der Compiler das getan hat, was du erwartet hast. Es könnte zum Beispiel sein, dass der Compiler unseren Code nicht mit den üblichen Optimierungen abgleicht; es gibt eine zusätzliche Speichersicherheitsprüfung, die der Compiler noch eliminieren könnte, oder eine Funktion, die inlined werden könnte, aber nicht wurde. In sehr extremen Fällen kann es sogar sinnvoll sein, einen eigenen Assembler-Code zu schreiben und ihn aus dem Go-Code zu importieren.12
Der Go-Erstellungsprozess konstruiert aus unserem Go-Quellcode vollständig ausführbaren Maschinencode. Das Betriebssystem lädt den Maschinencode in den Speicher und schreibt die erste Befehlsadresse in das Programmzählerregister (PC), wenn sie ausgeführt werden soll. Von dort aus kann der CPU-Kern jede Anweisung einzeln berechnen. Auf den ersten Blick könnte das bedeuten, dass die CPU eine relativ einfache Aufgabe zu erledigen hat. Aber leider veranlasst das Problem der Speicherwand die CPU-Hersteller dazu, ständig an zusätzlichen Hardware-Optimierungen zu arbeiten, die die Art und Weise, wie diese Anweisungen ausgeführt werden, verändern. Wenn wir diese Mechanismen verstehen, können wir die Effizienz und Geschwindigkeit unserer Go-Programme noch besser kontrollieren. Lass uns dieses Problem im nächsten Abschnitt aufdecken.
CPU- und Speicherwall-Problem
Um die Memory Wall und ihre Folgen zu verstehen, müssen wir kurz in die Interna des CPU-Kerns eintauchen. Die Details und die Implementierung des CPU-Kerns ändern sich im Laufe der Zeit, um die Effizienz zu verbessern (und werden in der Regel immer komplexer), aber die Grundlagen bleiben gleich. Im Prinzip verwaltet eine Steuereinheit (siehe Abbildung 4-1) die Lesevorgänge aus dem Speicher über verschiedene L-Caches (vom kleinsten bis zum schnellsten), dekodiert die Programmanweisungen, koordiniert ihre Ausführung in der Arithmetic Logic Unit (ALU) und kümmert sich umUnterbrechungen.
Eine wichtige Tatsache ist, dass die CPU in Zyklen arbeitet. Die meisten CPUs können in einem Zyklus eine Anweisung mit einem Satz kleiner Daten ausführen. Dieses Muster wird in Flynns Taxonomie als Single Instruction Single Data (SISD) bezeichnet und ist der Schlüsselaspekt der von-Neumann-Architektur. Einige CPUs ermöglichen auch Single Instruction Multiple Data (SIMD)13 Verarbeitung mit speziellen Anweisungen wie SSE, die dieselbe arithmetische Operation auf vier Gleitkommazahlen in einem Zyklus ermöglicht. Leider sind diese Befehle in Go nicht so einfach zu verwenden und werden daher nur selten eingesetzt.
Die Register sind die schnellste lokale Speicherung, die dem CPU-Kern zur Verfügung steht. Da sie kleine Schaltkreise sind, die direkt mit der ALU verdrahtet sind, dauert es nur einen CPU-Zyklus, um ihre Daten zu lesen. Leider gibt es auch nur wenige von ihnen (je nach CPU, typischerweise 16 für den allgemeinen Gebrauch), und ihre Größe ist normalerweise nicht größer als 64 Bit. Das bedeutet, dass sie in unserer Programmlaufzeit als Kurzzeitvariablen verwendet werden. Einige der Register können für unseren Maschinencode verwendet werden. Andere sind für die Verwendung durch die CPU reserviert. Das PC-Register zum Beispiel enthält die Adresse der nächsten Anweisung, die die CPU abrufen, dekodieren und ausführen soll.
Beim Rechnen dreht sich alles um die Daten. Wie wir in Kapitel 1 gelernt haben, gibt es heutzutage eine Menge Daten, die über verschiedene Speichermedien verstreut sind - unvergleichlich mehr, als in einem einzigen CPU-Register gespeichert werden können. Außerdem ist ein einziger CPU-Zyklus schneller als der Zugriff auf Daten aus dem Hauptspeicher (RAM) - im Durchschnitt hundertmal schneller, wie wir aus unserer groben Berechnung der Latenzzeiten in Anhang A ablesen können, die wir in diesem Buch durchgehend verwenden werden. Wie in dem Missverständnis "Hardware wird immer schneller und billiger" beschrieben , ermöglicht es uns die Technologie, CPU-Kerne mit dynamischer Taktrate zu schaffen, wobei das Maximum immer bei 4 GHz liegt. Lustigerweise ist die Tatsache, dass wir keine schnelleren CPU-Kerne herstellen können, nicht das wichtigste Problem, denn unsere CPU-Kerne sind bereits... zu schnell! Die Tatsache, dass wir keinen schnelleren Arbeitsspeicher bauen können, ist das Hauptproblem bei den heutigen CPUs.
Wir können ungefähr 36 Milliarden Anweisungen pro Sekunde ausführen. Leider wird die meiste Zeit damit verbracht, auf Daten zu warten. In fast jeder Anwendung sind das etwa 50 % der Zeit. In manchen Anwendungen werden sogar 75% der Zeit damit verbracht, auf Daten zu warten, anstatt Anweisungen auszuführen. Wenn dich das erschreckt, gut. Das sollte es auch.
Chandler Carruth, "Effizienz mit Algorithmen, Leistung mit Datenstrukturen"
Das oben erwähnte Problem wird oft als "Memory Wall"-Problem bezeichnet. Als Folge dieses Problems riskieren wir, Dutzende, wenn nicht Hunderte von CPU-Zyklen für eine einzige Anweisung zu verschwenden, da das Abrufen der Anweisung und der Daten (und das anschließende Speichern der Ergebnisse) ewig dauert.
Dieses Problem ist so gravierend, dass es in jüngster Zeit Diskussionen über eine Überarbeitung der von-Neumann-Architektur ausgelöst hat, da maschinelles Lernen (ML) (z. B. neuronale Netze) für künstliche Intelligenz (KI) immer beliebter wird. Diese Workloads sind besonders von dem Memory Wall Problem betroffen, weil die meiste Zeit mit komplexen mathematischen Berechnungen verbracht wird, die große Mengen an Speicher benötigen.14
Das Problem der Memory Wall schränkt die Geschwindigkeit ein, mit der unsere Programme ihre Arbeit erledigen. Es wirkt sich auch auf die Gesamtenergieeffizienz aus, die für mobile Anwendungen wichtig ist. Nichtsdestotrotz ist es die beste gängige Allzweck-Hardware, die es heutzutage gibt. Die Industrie hat viele dieser Probleme entschärft, indem sie einige wichtige CPU-Optimierungen entwickelt hat, auf die wir im Folgenden eingehen werden: das hierarchische Cache-System, Pipelining, Out-of-Order Execution und Hyperthreading. Diese Optimierungen wirken sich direkt auf die Effizienz unseres Go-Codes aus, insbesondere darauf, wie schnell unser Programm ausgeführt werden kann.
Hierachisches Cache-System
Alle modernen CPUs verfügen über lokale, schnelle, kleine Caches für häufig genutzte Daten. L1-, L2- und L3-Caches (und manchmal auch L4-Caches) sind statische Direktzugriffsspeicherschaltungen (SRAM) auf dem Chip. SRAM verwendet eine andere Technologie, um Daten schneller zu speichern als unser Hauptspeicher RAM, ist aber viel teurer in der Anwendung und in der Herstellung großer Kapazitäten (Hauptspeicher wird in "Physikalischer Speicher" erklärt ). Deshalb werden die L-Caches als erstes angewählt, wenn die CPU Anweisungen oder Daten für eine Anweisung aus dem Hauptspeicher (RAM) holen muss. Die Art und Weise, wie die CPU die L-Caches nutzt, ist in Abbildung 4-3 dargestellt.15 In diesem Beispiel verwenden wir einen einfachen CPU-Befehl MOVQ
, der in Beispiel 4-2 erklärt wird.
Um 64 Bits (BefehlMOVQ
) von einer bestimmten Speicheradresse in das Register SI
zu kopieren, müssen wir auf die Daten zugreifen, die sich normalerweise im Hauptspeicher befinden. Da das Lesen aus dem Arbeitsspeicher langsam ist, werden die Daten zuerst im L-Cache gesucht. Die CPU fragt beim ersten Versuch den L1-Cache nach diesen Bytes. Wenn die Daten dort nicht vorhanden sind (Cache-Miss), besucht sie einen größeren L2-Cache, dann den größten L3-Cache und schließlich den Hauptspeicher (RAM). Bei jedem dieser Fehlversuche versucht die CPU, die komplette "Cache-Zeile" (in der Regel 64 Byte, also achtmal so groß wie das Register) zu holen, sie in allen Caches zu speichern und nur diese speziellen Bytes zu verwenden.
Das Lesen mehrerer Bytes auf einmal (Cache-Zeile) ist sinnvoll, da es die gleiche Latenzzeit benötigt wie das Lesen eines einzelnen Bytes (erklärt in "Physischer Speicher"). Statistisch gesehen ist es außerdem wahrscheinlich, dass die nächste Operation Bytes neben dem Bereich benötigt, auf den zuvor zugegriffen wurde. L-Caches entschärfen das Problem der Speicherlatenz teilweise und reduzieren die zu übertragende Gesamtdatenmenge, sodass die Speicherbandbreite erhalten bleibt.
Die erste direkte Folge der L-Caches in unseren CPUs ist, dass die Effizienz umso besser ist, je kleiner und ausgerichteter die Datenstruktur ist, die wir definieren. Eine solche Struktur hat mehr Chancen, vollständig in die Caches der unteren Ebenen zu passen und teure Cache-Misses zu vermeiden. Die zweite Folge ist, dass Anweisungen für sequentielle Daten schneller sind, da in den Cache-Zeilen in der Regel mehrere Elemente nebeneinander gespeichert sind.
Pipelining und Out-of-Order-Ausführung
Wenn die Daten auf magische Weise in Nullzeit zugänglich wären, hätten wir eine perfekte Situation, in der jeder CPU-Kernzyklus einen sinnvollen Befehl ausführt, und zwar so schnell, wie es die Geschwindigkeit des CPU-Kerns erlaubt. Da dies nicht der Fall ist, versuchen moderne CPUs, jeden Teil des CPU-Kerns durch kaskadierendes Pipelining zu beschäftigen. Im Prinzip kann der CPU-Kern viele Schritte, die für die Befehlsausführung erforderlich sind, gleichzeitig in einem Zyklus ausführen. Das bedeutet, dass wir die Parallelität auf Anweisungsebene (Instruction-Level Parallelism, ILP) ausnutzen können, um z. B. fünf unabhängige Anweisungen in fünf CPU-Zyklen auszuführen, wodurch wir den süßen Durchschnitt von einer Anweisung pro Zyklus (IPC) erhalten.16 In einem anfänglichen 5-stufigen Pipeline-System (moderne CPUs haben 14-24 Stufen!) rechnet ein einzelner CPU-Kern beispielsweise 5 Anweisungen gleichzeitig in einem Zyklus, wie in Abbildung 4-4 dargestellt.
Die klassische fünfstufige Pipeline besteht aus fünf Vorgängen:
IF
-
Hole die auszuführende Anweisung.
ID
-
Dekodiere die Anweisung.
EX
-
Starte die Ausführung der Anweisung.
MEM
-
Hole die Operanden für die Ausführung.
WB
-
Schreibe das Ergebnis der Operation zurück (falls vorhanden).
Um die Sache noch komplizierter zu machen, wie wir im Abschnitt über die L-Caches besprochen haben, ist es selten der Fall, dass sogar das Abrufen der Daten (z. B. die Phase MEM
) nur einen Zyklus dauert. Um dies abzumildern, verwendet der CPU-Kern eine Technik, die Out-of-Order-Execution genannt wird. Bei dieser Methode versucht die CPU, die Anweisungen in einer Reihenfolge auszuführen, die sich nach der Verfügbarkeit der Eingabedaten und der Ausführungseinheit (wenn möglich) richtet und nicht nach ihrer ursprünglichen Reihenfolge im Programm. Für unsere Zwecke reicht es aus, sie als eine komplexe, dynamischere Pipeline zu betrachten, die interne Warteschlangen für eine effizientere CPU-Ausführung nutzt.
Die daraus resultierende Pipeline- und Out-of-Order-CPU-Ausführung ist komplex, aber die vorangegangene vereinfachte Erklärung sollte ausreichen, um zwei wichtige Konsequenzen für uns als Entwickler zu verstehen. Die erste, triviale Folge ist, dass jeder Wechsel des Befehlsstroms enorme Kosten verursacht (z. B. in Form von Latenzzeiten),17 denn die Pipeline muss zurückgesetzt werden und von vorne beginnen, zusätzlich zu den offensichtlichen Cache-Löschungen.18 Wir haben noch nicht den Overhead des Betriebssystems erwähnt, der noch dazu kommt. Wir bezeichnen dies oft als Kontextwechsel, der bei modernen Computern unvermeidlich ist, da die typischen Betriebssysteme eine präemptive Aufgabenplanung verwenden. In diesen Systemen kann der Ausführungsfluss eines einzelnen CPU-Kerns viele Male pro Sekunde unterbrochen werden, was in extremen Fällen von Bedeutung sein kann. Wie du dieses Verhalten beeinflussen kannst, besprechen wir im Zeitplanungsprogramm des Betriebssystems.
Die zweite Folge ist: Je vorausschauender unser Code ist, desto besser. Das liegt daran, dass die CPU-Kerne beim Pipelining komplexe Verzweigungsvorhersagen machen müssen, um Anweisungen zu finden, die nach der aktuellen Anweisung ausgeführt werden. Wenn unser Code voller Verzweigungen wie if
, switch
oder Sprunganweisungen wie continue
ist, kann es unmöglich sein, auch nur zwei Anweisungen zu finden, die gleichzeitig ausgeführt werden, weil eine Anweisung darüber entscheiden kann, welche Anweisung als nächstes ausgeführt wird. Dies wird als Datenabhängigkeit bezeichnet. Moderne CPU-Kernimplementierungen gehen sogar noch weiter, indem sie eine spekulative Ausführung durchführen. Da sie nicht weiß, welche Anweisung die nächste ist, wählt sie die wahrscheinlichste aus und geht davon aus, dass eine solche Verzweigung gewählt wird. Unnötige Ausführungen bei falschen Verzweigungen sind besser als verschwendete CPU-Zyklen beim Nichtstun. Deshalb gibt es viele verzweigungsfreie Codierungstechniken, die der CPU helfen, Verzweigungen vorherzusagen, und die zu schnellerem Code führen können. Einige Methoden werden vom Go-Compiler automatisch angewendet, aber manchmal müssen manuelle Verbesserungen hinzugefügt werden.
Generell gilt: Je einfacher der Code ist, mit weniger verschachtelten Bedingungen und Schleifen, desto besser für den Branch Predictor. Deshalb hören wir oft, dass der Code, der "nach links geneigt" ist, schneller ist.
Nach meiner Erfahrung [habe ich] immer wieder gesehen, dass Code, der schnell sein will, auf die linke Seite geht. Wenn du also eine Schleife, ein if, ein for und einen switch schreibst, wird er nicht schnell sein. Übrigens, der Linux-Kernel, weißt du, was der Codierungsstandard ist? Acht Zeichen Tabulator, 80 Zeichen Zeilenbreite. Du kannst im Linux-Kernel keinen schlechten Code schreiben. Du kannst dort keinen langsamen Code schreiben. ... In dem Moment, in dem du zu viele ifs und Entscheidungspunkte ... in deinem Code hast, ist die Effizienz weg vom Fenster.
Andrei Alexandrescu, "Geschwindigkeit findet sich in den Köpfen der Menschen"
Die Existenz von Verzweigungsvorhersagen und spekulativen Ansätzen in der CPU hat eine weitere Folge. Sie führt dazu, dass Datenstrukturen mit zusammenhängendem Speicher in Pipeline-CPU-Architekturen mit L-Caches viel besser funktionieren.
Zusammenhängende Speicherstruktur ist wichtig
In der Praxis sollten Entwickler auf modernen CPUs in den meisten Fällen zusammenhängende Datenstrukturen wie Arrays anstelle von verknüpften Listen in ihren Programmen bevorzugen. Das liegt daran, dass eine typische Linked-List-Implementierung (z. B. ein Baum) Speicherzeiger auf die nächsten, vergangenen, untergeordneten oder übergeordneten Elemente verwendet. Das bedeutet, dass der CPU-Kern bei der Iteration über eine solche Struktur nicht sagen kann, welche Daten und welche Anweisung wir als Nächstes ausführen werden, bis wir den Knoten besuchen und den Zeiger überprüfen. Dadurch werden die Spekulationsmöglichkeiten eingeschränkt, was zu einer ineffizienten CPU-Nutzung führt.
Hyper-Threading
Hyper-Threading ist Intels geschützter Name für die CPU-Optimierungstechnik namens Simultanes Multithreading (SMT).19 Auch andere CPU-Hersteller implementieren SMT. Mit dieser Methode kann ein einzelner CPU-Kern in einem Modus betrieben werden, der für Programme und Betriebssysteme als zwei logische CPU-Kerne sichtbar ist.20 SMT fordert das Betriebssystem auf, zwei Threads auf demselben physischen CPU-Kern zu planen. Ein einzelner physischer Kern wird zwar nie mehr als einen Befehl gleichzeitig ausführen, aber mehr Befehle in der Warteschlange sorgen dafür, dass der CPU-Kern während der Leerlaufzeiten beschäftigt ist. Angesichts der Wartezeiten für den Speicherzugriff kann dies einen einzelnen CPU-Kern besser auslasten, ohne die Latenzzeit der Prozessausführung zu beeinträchtigen. Außerdem ermöglichen die zusätzlichen Register im SMT den CPUs schnellere Kontextwechsel zwischen mehreren Threads, die auf einem einzigen physischen Kern laufen.
SMT muss unterstützt und in das Betriebssystem integriert werden. Wenn SMT aktiviert ist, solltest du doppelt so viele Kerne wie physische Kerne in deinem Rechner sehen. Um herauszufinden, ob deine CPU Hyper-Threading unterstützt, überprüfe die Angaben zu "Thread(s) pro Kern" in den Spezifikationen. Wenn du zum Beispiel den Befehl lscpu
Linux in Beispiel 4-4 verwendest, hat meine CPU zwei Threads, d.h. Hyper-Threading ist verfügbar.
Beispiel 4-4. Ausgabe des Befehls lscpu
auf meinem Linux-Laptop
Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian Address sizes: 39 bits physical, 48 bits virtual CPU(s): 12 On-line CPU(s) list: 0-11 Thread(s) per core: 2
Core(s) per socket: 6 Socket(s): 1 NUMA node(s): 1 Vendor ID: GenuineIntel CPU family: 6 Model: 158 Model name: Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz CPU MHz: 2600.000 CPU max MHz: 4600.0000 CPU min MHz: 800.0000
Die SMT ist normalerweise standardmäßig aktiviert, kann aber auf neueren Kerneln bei Bedarf eingeschaltet werden. Das hat eine Konsequenz für die Ausführung unserer Go-Programme. Normalerweise können wir wählen, ob wir diesen Mechanismus für unsere Prozesse aktivieren oder deaktivieren wollen. Aber sollten wir das? In den meisten Fällen ist es besser, diesen Mechanismus für unsere Go-Programme zu aktivieren, denn so können wir die physischen Kerne voll ausnutzen, wenn wir mehrere verschiedene Aufgaben auf einem einzigen Computer ausführen. In einigen extremen Fällen kann es sich jedoch lohnen, einem einzelnen Prozess einen ganzen physischen Kern zu widmen, um die höchste Servicequalität zu gewährleisten. Generell sollte uns ein Benchmark auf der jeweiligen Hardware Aufschluss geben.
Zusammenfassend lässt sich sagen, dass alle oben genannten CPU-Optimierungen und die entsprechenden Programmiertechniken, die dieses Wissen nutzen, in der Regel erst ganz am Ende des Optimierungszyklus zum Einsatz kommen und auch nur dann, wenn wir das letzte Dutzend Nanosekunden auf dem kritischen Pfad herausquetschen wollen.
Drei Prinzipien zum Schreiben von CPU-effizientem Code auf dem kritischen Pfad
Die drei Grundregeln, die zu einem CPU-freundlichen Code führen, lautenwie folgt:
-
Verwende Algorithmen, die weniger Arbeit machen.
-
Konzentriere dich darauf, Code mit geringer Komplexität zu schreiben, der leichter für den Compiler und die CPU-Verzweigungsvorhersage zu optimieren ist. Idealerweise trennst du "heißen" von "kaltem" Code.
-
Bevorzuge Datenstrukturen mit zusammenhängendem Speicher, wenn du vorhast, sie häufig zu iterieren oder zu durchlaufen .
Nach diesem kurzen Verständnis der CPU-Hardware-Dynamik wollen wir uns nun mit den wesentlichen Softwaretypen befassen, die es uns ermöglichen, Tausende von Programmen gleichzeitig auf gemeinsam genutzten Hardware-Schedulern laufen zu lassen.
Zeitplannungsprogramme
Zeitplanung bedeutet in der Regel die Zuweisung der notwendigen, meist begrenzten Ressourcen, damit ein bestimmter Prozess abgeschlossen werden kann. Zum Beispiel muss die Montage von Autoteilen an einem bestimmten Ort und zu einer bestimmten Zeit in einer Autofabrik genau geplant werden, um Ausfallzeiten zu vermeiden. Vielleicht müssen wir auch ein Meeting mit bestimmten Teilnehmern planen, bei dem nur bestimmte Zeitfenster des Tages frei sind.
In modernen Computern oder Server-Clustern gibt es Tausende von Programmen, die auf gemeinsamen Ressourcen wie CPU, Speicher, Netzwerk, Festplatten usw. laufen müssen. Deshalb hat die Industrie viele Arten von Zeitplanungsprogrammen (allgemein als Scheduler bezeichnet) entwickelt, die sich darauf konzentrieren, diese Programme auf vielen Ebenen auf freie Ressourcen zu verteilen.
In diesem Abschnitt befassen wir uns mit dem Zeitplanungsprogramm für die CPU. Von der untersten Ebene ausgehend, haben wir ein Betriebssystem, das beliebige Programme auf einer begrenzten Anzahl von physischen CPUs plant. Die Mechanismen des Betriebssystems sollen uns zeigen, wie sich mehrere gleichzeitig laufende Programme auf unsere CPU-Ressourcen und damit auf die Ausführungszeit unseres Go-Programms auswirken können. Es wird uns auch helfen zu verstehen, wie ein Entwickler mehrere CPU-Kerne gleichzeitig, parallel oder gleichzeitig nutzen kann, um eine schnellere Ausführung zu erreichen.
Zeitplanungsprogramm des Betriebssystems
Wie bei den Compilern gibt es auch bei den Betriebssystemen (OS) viele verschiedene Logiken zur Aufgabenplanung und Ressourcenverwaltung. Während die meisten Systeme mit ähnlichen Abstraktionen arbeiten (z. B. Threads, Prozesse mit Prioritäten), konzentrieren wir uns in diesem Buch auf das Linux-Betriebssystem. Sein Kern, der so genannte Kernel, hat viele wichtige Funktionen, wie die Verwaltung von Speicher, Geräten, Netzwerkzugriff, Sicherheit und mehr. Außerdem sorgt er für die Ausführung von Programmen mithilfe einer konfigurierbaren Komponente, dem Zeitplannungsprogramm.
Als zentraler Bestandteil des Ressourcenmanagements muss das Zeitplannungsprogramm des Betriebssystems die folgende, einfache Invariante einhalten: Es muss sicherstellen, dass fertige Threads auf verfügbaren Kernen eingeplant werden.
J.P. Lozi et al., "Das Linux Zeitplannungsprogramm: Ein Jahrzehnt verschwendeter Kerne"
Die kleinste Zeitplanungseinheit des Zeitplannungsprogramms von Linux wird als OS-Thread bezeichnet. Der Thread (manchmal auch als Task oder leichtgewichtiger Prozess bezeichnet) enthält einen unabhängigen Satz von Maschinencode in Form von CPU-Befehlen, die sequentiell ausgeführt werden sollen. Während Threads ihren Ausführungsstatus, ihren Stack und ihren Registersatz beibehalten können, können sie nicht aus dem Kontext heraus laufen.
Jeder Thread läuft als Teil des Prozesses. Der Prozess stellt ein Programm in Ausführung dar und kann durch seine Prozessidentifikationsnummer (PID) identifiziert werden. Wenn wir das Linux-Betriebssystem anweisen, unser kompiliertes Programm auszuführen, wird ein neuer Prozess erstellt (zum Beispiel, wenn ein fork
Systemaufruf verwendet wird).
Die Prozesserstellung umfasst die Zuweisung einer neuen PID, die Erstellung des anfänglichen Threads mit seinem Maschinencode (unser func main()
im Go-Code) und Stack, Dateien für Standardausgaben und -eingaben und jede Menge anderer Daten (z. B. Liste der offenen Dateideskriptoren, Statistiken, Limits, Attribute, gemountete Elemente, Gruppen usw.). Darüber hinaus wird ein neuer Speicheradressraum erstellt, der vor anderen Prozessen geschützt werden muss. All diese Informationen werden für die Dauer der Programmausführung in dem speziellen Verzeichnis /proc/<PID>
gespeichert.
Threads können neue Threads erstellen (z. B. mit dem clone
syscall), die unabhängige Maschinencode-Sequenzen haben, sich aberdenselben Speicheradressraum teilen. Threads können auch neue Prozesse erstellen (z. B. mit fork
), die isoliert laufen und das gewünschte Programm ausführen. Threads behalten ihren Ausführungsstatus bei: Laufend, Bereit und Blockiert. Die möglichen Umwandlungen dieser Zustände sind in Abbildung 4-5 dargestellt.
Der Threadstatus teilt dem Zeitplannungsprogramm mit, was der Thread gerade macht:
- Laufen
-
Der Thread ist dem CPU-Kern zugewiesen und erledigt seine Arbeit.
- Blockiert
-
Der Thread wartet auf ein Ereignis, das möglicherweise länger dauert als ein Kontextwechsel. Zum Beispiel liest ein Thread von einer Netzwerkverbindung und wartet auf ein Paket oder darauf, dass er an der Reihe ist, die Mutex zu sperren. Dies ist eine Gelegenheit für das Zeitplannungsprogramm, einzugreifen und anderen Threads die Ausführung zu ermöglichen.
- Bereit
-
Der Thread ist bereit zur Ausführung, wartet aber darauf, dass er an der Reihe ist.
Wie du vielleicht schon bemerkt hast, arbeitet das Zeitplannungsprogramm von Linux mit einer präemptiven Art der Thread-Planung. Präemptiv bedeutet, dass das Zeitplannungsprogramm die Ausführung eines Threads jederzeit stoppen kann. In modernen Betriebssystemen gibt es immer mehr Threads, die ausgeführt werden müssen, als CPU-Kerne zur Verfügung stehen, sodass das Zeitplannungsprogramm mehrere "fertige" Threads auf einem einzigen CPU-Kern ausführen muss. Der Thread wird jedes Mal vorzeitig beendet, wenn er auf eine E/A-Anforderung oder andere Ereignisse wartet. Der Thread kann dem Betriebssystem auch mitteilen, dass er sich selbst ausliefern soll (z. B. mit dem sched_yield
syscall). Wenn er unterbrochen wird, geht er in einen "blockierten" Zustand über, und ein anderer Thread kann in der Zwischenzeit seinen Platz einnehmen.
Der naive Zeitplanungsalgorithmus könnte darauf warten, dass der Thread sich selbst vorzeitig beendet. Das würde gut für I/O-gebundene Threads funktionieren, die sich oft im Zustand "Blocked" befinden - zum Beispiel interaktive Systeme mit grafischen Oberflächen oder leichtgewichtige Webserver, die mit Netzwerkaufrufen arbeiten. Was aber, wenn der Thread an die CPU gebunden ist, d.h. die meiste Zeit nur die CPU und den Speicher nutzt, z.B. bei rechenintensiven Aufträgen wie der linearen Suche, der Multiplikation von Matrizen oder dem Brute-Forcing eines Hash-Passworts? In solchen Fällen kann der CPU-Kern minutenlang mit einer Aufgabe beschäftigt sein, die alle anderen Threads im System aushungert. Stell dir zum Beispiel vor, du könntest eine Minute lang nicht in deinem Browser tippen oder die Größe eines Fensters ändern - das würde wie ein langer Systemstillstand aussehen!
Dieses primäre Zeitplannungsprogramm für Linux löst dieses Problem. Das Zeitplannungsprogramm heißt Completely Fair Scheduler (CFS) und weist die Threads in kurzen Abständen zu. Jedem Thread wird ein bestimmter Anteil der CPU-Zeit zugewiesen, in der Regel zwischen 1 ms und 20 ms, was den Eindruck erweckt, dass die Threads gleichzeitig laufen. Das hilft vor allem Desktop-Systemen, die auf menschliche Interaktionen reagieren müssen. Es gibt noch ein paar andere wichtige Konsequenzen dieses Designs:
-
Je mehr Threads ausgeführt werden wollen, desto weniger Zeit haben sie in jeder Runde. Dies kann jedoch zu einer geringeren produktiven Auslastung des CPU-Kerns führen, der anfängt, mehr Zeit mit teuren Kontextwechseln zu verbringen.
-
Auf dem überlasteten Rechner hat jeder Thread kürzere Umläufe auf dem CPU-Kern und kann auch weniger Umläufe pro Sekunde haben. Obwohl keiner der Threads vollständig ausgehungert (blockiert) ist, kann sich ihre Ausführung deutlich verlangsamen.
CPU-Überlastung
CPU-effizienten Code zu schreiben bedeutet, dass unser Programm deutlich weniger CPU-Zyklen verschwendet. Das ist natürlich immer toll, aber die effiziente Implementierung kann trotzdem sehr langsam arbeiten, wenn die CPU überlastet ist.
Eine überlastete CPU oder ein überlastetes System bedeutet, dass zu viele Threads um die verfügbaren CPU-Kerne konkurrieren. Das kann dazu führen, dass die Maschine überlastet ist oder dass ein oder zwei Prozesse zu viele Threads erzeugen, um eine schwere Aufgabe zu erledigen (wir nennen diese Situation einen lärmenden Nachbarn). Wenn die CPU überlastet ist, sollte die Überprüfung der CPU-Auslastungsmetrik der Maschine zeigen, dass die CPU-Kerne zu 100 % ausgelastet sind. In einem solchen Fall wird jeder Thread langsamer ausgeführt, was zu einem eingefrorenen System, Timeouts und mangelnder Reaktionsfähigkeit führt.
-
Es ist schwierig, sich auf die reine Programmausführungslatenz (manchmal auch als Wall-Time oder Wall-Clock-Time bezeichnet) zu verlassen, um die CPU-Effizienz unseres Programms abzuschätzen. Das liegt daran, dass moderne Zeitplannungsprogramme präemptiv sind und das Programm oft auf andere E/A oder Synchronisierungen wartet. Daher ist es ziemlich schwierig, zuverlässig zu prüfen, ob unser Programm nach einer Korrektur die CPU besser ausnutzt als die vorherige Implementierung. Aus diesem Grund hat die Industrie eine wichtige Kennzahl definiert, die erfasst, wie lange unser Programmprozess (alle Threads) auf allenCPU-Kernen im Zustand "Running" war. Wir nennen das üblicherweise CPU-Zeit und werden es unter "CPU-Auslastung" besprechen .
CPU-Zeit auf einer überlasteten Maschine
Die Messung der CPU-Zeit ist eine gute Methode, um die CPU-Effizienz unseres Programms zu überprüfen. Sei jedoch vorsichtig, wenn du die CPU-Zeit in einem engen Fenster der Prozessausführungszeit betrachtest. Eine niedrigere CPU-Zeit kann zum Beispiel bedeuten, dass unser Prozess zu diesem Zeitpunkt nicht viel CPU-Leistung in Anspruch genommen hat, sie kann aber auch auf eine überlastete CPU hindeuten.
Die gemeinsame Nutzung von Prozessen auf demselben System hat ihre Tücken. Deshalb neigen wir in virtualisierten Umgebungen dazu, diese Ressourcen zu reservieren. Wir können zum Beispiel die CPU-Nutzung eines Prozesses auf 200 Millisekunden CPU-Zeit pro Sekunde beschränken, also 20% eines CPU-Kerns.
-
Die letzte Konsequenz des CFS-Designs ist, dass es zu fair ist, um einem einzelnen Thread dedizierte CPU-Zeit zu garantieren. Das Zeitplannungsprogramm von Linux verfügt über Prioritäten, ein vom Benutzer konfigurierbares "niceness"-Flag und verschiedene Zeitplanungsrichtlinien. Moderne Linux-Betriebssysteme verfügen sogar über eine Zeitplanungsrichtlinie, die ein spezielles Zeitplannungsprogramm anstelle des CFS für Threads verwendet, die in der ersten Reihenfolge ausgeführt werden müssen.21
Leider kann ein Linux-System auch mit einem Zeitplannungsprogramm nicht sicherstellen, dass Threads mit höherer Priorität die gesamte benötigte CPU-Zeit erhalten, da es immer noch versuchen wird, sicherzustellen, dass Threads mit niedriger Priorität nicht ausgehungert werden. Da sowohl das CFS als auch die Echtzeit-Pendants präemptiv sind, sind sie außerdem nicht deterministisch und vorausschauend. Das hat zur Folge, dass für Aufgaben mit harten Echtzeitanforderungen (z. B. Handels- oder Flugzeugsoftware im Millisekundenbereich) nicht genügend Ausführungszeit vor der Deadline garantiert werden kann. Deshalb entwickeln einige Unternehmen eigene Zeitplanungsprogramme oder Systeme für strenge Echtzeitprogramme wie Zephyr OS.
Trotz der etwas komplexen Eigenschaften des Zeitplannungsprogramms CFS ist es nach wie vor das beliebteste Thread-Orchestrierungssystem in modernen Linux-Systemen. Im Jahr 2016 wurde das CFS auch für Multicore-Maschinen und NUMA-Architekturen überarbeitet, basierend auf den Erkenntnissen aus einer berühmten Forschungsarbeit. Das Ergebnis ist, dass Threads jetzt intelligent auf ungenutzte Kerne verteilt werden, während gleichzeitig sichergestellt wird, dass Migrationen nicht zu oft und nicht zwischen Threads, die sich dieselben Ressourcen teilen, durchgeführt werden.
Mit einem grundlegenden Verständnis des Zeitplannungsprogramms des Betriebssystems wollen wir uns nun ansehen, warum es das Zeitplannungsprogramm von Go gibt und wie es Entwicklern ermöglicht, mehrere Tasks zu programmieren, die gleichzeitig auf einem oder mehreren CPU-Kernen laufen.
Go Zeitplannungsprogramm
Das Go-Gleichzeitigkeitsframework basiert auf der Prämisse, dass es für einen einzelnen Fluss von CPU-Befehlen (z. B. eine Funktion) schwierig ist, alle CPU-Zyklen zu nutzen, da der typische Arbeitsablauf I/O-gebunden ist. Während die Thread-Abstraktion des Betriebssystems dieses Problem durch das Multiplexen von Threads auf eine Reihe von CPU-Kernen entschärft, bringt die Sprache Go eine weitere Ebene ein - eine Goroutine, dieFunktionen über eine Reihe von Threads multiplexiert. Die Idee der Goroutine ist ähnlich wie die der Coroutine, aber da sie nicht dieselbe ist (Goroutine können vorzeitig beendet werden) und in der Sprache Go geschrieben ist, hat sie das Präfix Go. Ähnlich wie beim Betriebssystem-Thread kann das Zeitplannungsprogramm von Go (nicht das Betriebssystem!) schnell zu einer anderen Goroutine wechseln, die im selben Thread (oder bei Bedarf in einem anderen) weiterläuft, wenn die Goroutine durch einen Systemaufruf oder eine E/A blockiert wird.
Im Wesentlichen hat Go I/O-gebundene Arbeit [auf Anwendungsebene] in CPU-gebundene Arbeit auf Betriebssystemebene umgewandelt. Da alle Kontextwechsel auf der Anwendungsebene stattfinden, verlieren wir pro Kontextwechsel nicht dieselben 12K Anweisungen (im Durchschnitt), die wir bei der Verwendung von Threads verloren haben. In Go kosten dich diese Kontextwechsel 200 Nanosekunden oder 2,4K Anweisungen. Das Zeitplannungsprogramm hilft auch bei der Effizienzsteigerung von Cache-Zeilen und NUMA. Deshalb brauchen wir nicht mehr Threads, als wir virtuelle Kerne haben.
William Kennedy, "Zeitplanungsprogramm in Go: Teil II - Go Zeitplannungsprogramm"
Als Ergebnis haben wir in Go sehr billige Ausführungs-"Threads" im Userspace (eine neue Goroutine weist nur ein paar Kilobyte für den anfänglichen, lokalen Stack zu), die die Anzahl der konkurrierenden Threads in unserer Maschine reduzieren und Hunderte von Goroutinen in unserem Programm ohne großen Overhead ermöglichen. Ein einziger OS-Thread pro CPU-Kern sollte ausreichen, um die gesamte Arbeit in unseren Goroutinen zu erledigen.22 Dies ermöglicht viele Lesbarkeitsmuster wie Ereignisschleifen, Map-Reduce, Pipes, Iteratoren und mehr, ohne dass teures Kernel-Multithreading erforderlich ist.
Tipp
Die Verwendung von Go-Gleichzeitigkeit in Form von Goroutinen ist eine hervorragende Möglichkeit, um:
-
Komplexe asynchrone Abstraktionen darstellen (z. B. Ereignisse)
-
Unsere CPU für I/O-gebundene Aufgaben voll ausnutzen
-
Eine Multithreading-Anwendung erstellen, die mehrere CPUs nutzen kann, um schneller zu arbeiten
Das Starten einer weiteren Goroutine ist in Go sehr einfach. Es ist in der Sprache über eine go <func>()
Syntax eingebaut. Beispiel 4-5 zeigt eine Funktion, die zwei goroutines startet und ihre Arbeit beendet.
Beispiel 4-5. Eine Funktion, die zwei Goroutinen startet
func
anotherFunction
(
arg1
string
)
{
/*...*/
}
func
function
(
)
{
// ...
go
func
(
)
{
// ...
}
(
)
go
anotherFunction
(
"argument1"
)
return
}
Es ist wichtig, sich daran zu erinnern, dass alle Goroutinen untereinander eine flache Hierarchie haben. Technisch gesehen gibt es keinen Unterschied, ob die Goroutine A
die B
oder die B
die A
gestartet hat.
In beiden Fällen sind die beiden Goroutinen A
und B
gleichberechtigt und sie wissen nichts voneinander.23 Sie können sich auch nicht gegenseitig stoppen, es sei denn, wir implementieren eine explizite Kommunikation oder Synchronisation und "bitten" die Goroutine, sich zu beenden. Die einzige Ausnahme ist die Haupt-Goroutine, die mit der Funktion main()
beginnt. Wenn die Haupt-Goroutine beendet wird, wird das gesamte Programm beendet und alle anderen Goroutinen werden zwangsweise beendet.
Was die Kommunikation angeht, so haben Goroutinen, ähnlich wie OS-Threads, Zugriff auf denselben Speicherbereich innerhalb des Prozesses. Das bedeutet, dass wir Daten zwischen den Goroutinen über gemeinsamen Speicher weitergeben können. Das ist jedoch nicht so trivial, denn fast keine Operation in Go ist atomar. Gleichzeitiges Schreiben (oder Schreiben und Lesen) aus demselben Speicher kann zu Data Races führen, die ein unbestimmtes Verhalten oder sogar Datenbeschädigungen zur Folge haben. Um dieses Problem zu lösen, müssen wir Synchronisierungstechniken wie explizite atomare Funktionen (wie in Beispiel 4-6) oder Mutex (wie in Beispiel 4-7), also eine Sperre, verwenden.
Beispiel 4-6. Sichere Multigoroutine-Kommunikation durch dedizierte atomare Addition
func
sharingWithAtomic
(
)
(
sum
int64
)
{
var
wg
sync
.
WaitGroup
concurrentFn
:=
func
(
)
{
atomic
.
AddInt64
(
&
sum
,
randInt64
(
)
)
wg
.
Done
(
)
}
wg
.
Add
(
3
)
go
concurrentFn
(
)
go
concurrentFn
(
)
go
concurrentFn
(
)
wg
.
Wait
(
)
return
sum
}
Beachte, dass wir zwar die Additionen zwischen
concurrentFn
goroutines atomar synchronisieren, aber zusätzlichsync.WaitGroup
(eine andere Form des Sperrens) verwenden, um zu warten, bis alle diese goroutines fertig sind. Das Gleiche machen wir in Beispiel 4-7.
Beispiel 4-7. Sichere Multigoroutine-Kommunikation durch Mutex (Sperre)
func
sharingWithMutex
()
(
sum
int64
)
{
var
wg
sync
.
WaitGroup
var
mu
sync
.
Mutex
concurrentFn
:=
func
()
{
mu
.
Lock
()
sum
+=
randInt64
()
mu
.
Unlock
()
wg
.
Done
()
}
wg
.
Add
(
3
)
go
concurrentFn
()
go
concurrentFn
()
go
concurrentFn
()
wg
.
Wait
()
return
sum
}
Die Wahl zwischen atomar und sperren hängt von der Lesbarkeit, den Effizienzanforderungen und der Operation ab, die du synchronisieren willst. Wenn du zum Beispiel eine einfache Operation an einer Zahl wie das Schreiben oder Lesen eines Wertes, Addition, Substitution oder Vergleichen und Tauschen gleichzeitig durchführen willst, kannst du das atomare Paket in Betracht ziehen. Atomic ist oft effizienter als Mutexe (Lock), da der Compiler sie in spezielle atomare CPU-Operationen übersetzt, die Daten unter einer einzigen Speicheradresse auf eine thread-sichere Weise ändern können.24
Wenn die Verwendung von Atomic jedoch die Lesbarkeit unseres Codes beeinträchtigt, der Code nicht auf einem kritischen Pfad liegt oder wir eine komplexere Operation synchronisieren müssen, können wir eine Sperre verwenden. Go bietet sync.Mutex
, das einfache Sperren erlaubt, und sync.RWMutex
, das Sperren für Lese- (RLock()
) und Schreibvorgänge (Lock()
) erlaubt. Wenn du viele Goroutinen hast, die den gemeinsamen Speicher nicht verändern, sperre sie mit RLock()
, damit es keine Sperrkonkurrenz zwischen ihnen gibt, da das gleichzeitige Lesen des gemeinsamen Speichers sicher ist. Nur wenn eine Goroutine den Speicher verändern will, kann sie mit Lock()
eine vollständige Sperre erhalten, die alle Leser blockiert.
Andererseits sind Lock und Atomic nicht die einzigen Möglichkeiten. Die Sprache Go hat bei diesem Thema noch ein weiteres Ass im Ärmel. Neben dem Coroutine-Konzept nutzt Go auch das C. A. R. Hoare das Paradigma der Communicating Sequential Processes (CSP), das auch als typsichere Verallgemeinerung der Unix-Pipes angesehen werden kann.
Kommuniziere nicht, indem du den Speicher teilst; teile den Speicher stattdessen, indem du kommunizierst.
Dieses Modell fördert die gemeinsame Nutzung von Daten durch die Implementierung einer Kommunikationspipeline zwischen den Goroutinen unter Verwendung eines Kanalkonzepts. Die gemeinsame Nutzung der gleichen Speicheradresse zur Weitergabe von Daten erfordert eine zusätzliche Synchronisierung. Nehmen wir jedoch an, eine Goroutine sendet diese Daten an einen Kanal und eine andere empfängt sie. In diesem Fall synchronisiert sich der gesamte Ablauf natürlich selbst, und auf die gemeinsam genutzten Daten wird nie von zwei Goroutinen gleichzeitig zugegriffen, was die Thread-Sicherheit gewährleistet.25 Ein Beispiel für die Kanalkommunikation ist in Beispiel 4-8 dargestellt.
Beispiel 4-8. Ein Beispiel für speichersichere Multigoroutine-Kommunikation über den Kanal
func
sharingWithChannel
(
)
(
sum
int64
)
{
result
:=
make
(
chan
int64
)
concurrentFn
:=
func
(
)
{
// ...
result
<-
randInt64
(
)
}
go
concurrentFn
(
)
go
concurrentFn
(
)
go
concurrentFn
(
)
for
i
:=
0
;
i
<
3
;
i
++
{
sum
+=
<-
result
}
close
(
result
)
return
sum
}
Kanäle können in Go mit der
ch := make(chan <type>, <buffer size>)
Syntax erstellt werden.Wir können Werte eines bestimmten Typs an unseren Kanal senden.
Beachte, dass wir in diesem Beispiel
sync.WaitGroup
nicht brauchen, da wir das Wissen darüber missbrauchen, wie viele Nachrichten wir genau erwarten. Hätten wir diese Information nicht, bräuchten wir eine Wartegruppe oder einen anderen Mechanismus.Wir können Werte eines bestimmten Typs aus unserem Kanal lesen.
Kanäle sollten auch geschlossen werden, wenn wir nicht mehr vorhaben, etwas über sie zu senden. Dadurch werden Ressourcen freigesetzt und bestimmte Empfangs- und Sendeflüsse entsperrt (mehr dazu später).
Der wichtige Aspekt von Kanälen ist, dass sie gepuffert werden können. In einem solchen Fall verhält er sich wie eine Warteschlange. Wenn wir einen Kanal mit z. B. einem Puffer von drei Elementen erstellen, kann eine sendende Goroutine genau drei Elemente senden, bevor sie blockiert wird, bis jemand aus diesem Kanal liest. Wenn wir drei Elemente senden und den Kanal schließen, kann die empfangende Goroutine noch drei Elemente lesen, bevor sie merkt, dass der Kanal geschlossen wurde. Ein Kanal kann sich in drei Zuständen befinden. Es ist wichtig, sich daran zu erinnern, wie sich die Goroutine, die diesen Kanal sendet oder empfängt, beim Wechsel zwischen diesen Zuständen verhält:
- Zugewiesener, offener Kanal
-
Wenn wir einen Kanal mit
make(chan <type>)
erstellen, wird er von Anfang an zugewiesen und geöffnet. Wenn wir davon ausgehen, dass es keinen Puffer gibt, blockiert ein solcher Kanal den Versuch, einen Wert zu senden, bis eine andere Goroutine ihn empfängt oder wenn wir die Anweisungselect
mit mehreren Fällen verwenden. Ähnlich verhält es sich mit dem Empfangskanal, der blockiert wird, bis jemand an diesen Kanal sendet, es sei denn, wir empfangen in einerselect
Anweisung mit mehreren Fällen oder der Kanal wurde geschlossen. - Geschlossener Kanal
-
Wenn wir
close(ch)
den zugewiesenen Kanal schließen, führt ein Senden an diesen Kanal zu einer Panik und die empfangenen Werte werden sofort mit Null zurückgegeben. Deshalb ist es empfehlenswert, die Verantwortung für den schließenden Kanal in der Goroutine zu behalten, die die Daten sendet (Sender). - Nil Kanal
-
Wenn du den Kanaltyp (
var ch chan <type>
) definierst, ohne ihn mitmake(chan <type>)
zuzuweisen, ist unser Kanal gleich null. Wir können einen zugewiesenen Kanal auch "nil" machen, indem wir nil (ch = nil
) zuweisen. In diesem Zustand wird das Senden und Empfangen für immer blockiert. In der Praxis ist es selten sinnvoll, Kanäle mit Null zu belegen.
Go-Kanäle sind ein erstaunliches und elegantes Paradigma, mit dem sich sehr lesbare, ereignisbasierte Gleichzeitigkeitsmuster erstellen lassen. In Bezug auf die CPU-Effizienz sind sie jedoch im Vergleich zum atomic
Paket und den Mutexen am wenigsten effizient. Lass dich davon nicht entmutigen! Für die meisten praktischen Anwendungen (wenn sie nicht überstrapaziert werden!) können Kanäle unsere Anwendung zu einer robusten und effizienten gleichzeitigen Implementierung strukturieren. Wir werden einige praktische Muster für die Verwendung von Kanälen in "Optimizing Latency Using Concurrency" untersuchen .
Bevor wir diesen Abschnitt beenden, ist es wichtig zu verstehen, wie wir die Gleichzeitigkeitseffizienz in einem Go-Programm einstellen können. Die Gleichzeitigkeitslogik wird vom Zeitplannungsprogramm im Go-Laufzeitpaket implementiert, das auch für andere Dinge wie Speicherbereinigung (siehe "Garbage Collection"),Profile oder Stack Framing zuständig ist. Das Zeitplannungsprogramm von Go arbeitet ziemlich automatisch. Es gibt nicht viele Konfigurationsflags. Zum jetzigen Zeitpunkt gibt es zwei praktische Möglichkeiten, wie Entwickler die Gleichzeitigkeit in ihrem Code kontrollieren können:26
- Eine Reihe von Goroutinen
-
Als Entwickler kontrollieren wir normalerweise, wie viele Goroutinen wir in unserem Programm erstellen. Sie für jedes kleine Werkstück zu erzeugen, ist in der Regel nicht die beste Idee, also solltest du sie nicht übermäßig nutzen. Es ist auch erwähnenswert, dass viele Abstraktionen aus Standardbibliotheken oder Bibliotheken von Drittanbietern Goroutinen erzeugen können, insbesondere solche, die
Close
oder Stornierungen erfordern. Vor allem gängige Operationen wiehttp.Do
,context.WithCancel
undtime.After
erzeugen Goroutinen. Bei unsachgemäßer Verwendung können die Goroutinen leicht auslaufen (und verwaiste Goroutinen hinterlassen), was in der Regel Speicher und CPU-Leistung verschwendet. In "Goroutine" werden wir untersuchen, wie man Zahlen und Schnappschüsse von Goroutinen debuggen kann .
Erste Regel des effizienten Codes
Schließe oder gib die Ressourcen, die du benutzt, immer wieder frei. Manchmal können einfache Strukturen eine kolossale und unbegrenzte Verschwendung von Speicher und Goroutinen verursachen, wenn wir vergessen, sie zu schließen. Wir werden häufige Beispiele in "Don't Leak Resources" untersuchen .
GOMAXPROCS
-
Diese wichtige Umgebungsvariable kann gesetzt werden, um die Anzahl der virtuellen CPUs zu bestimmen, die du in deinem Go-Programm nutzen möchtest. Derselbe Konfigurationswert kann über die Funktion
runtime.GOMAXPROCS(n)
angewendet werden. Die zugrunde liegende Logik, wie das Zeitplannungsprogramm von Go diese Variable verwendet, ist ziemlich komplex,27 aber im Allgemeinen steuert sie, wie viele parallele OS-Thread-Ausführungen Go erwarten kann (intern als "proc"-Zahl bezeichnet). Das Zeitplannungsprogramm von Go unterhält dannGOMAXPROCS/proc
Anzahl von Warteschlangen und versucht, die Goroutinen auf diese zu verteilen. Der Standardwert vonGOMAXPROCS
ist immer die Anzahl der virtuellen CPU-Kerne, die dein Betriebssystem zur Verfügung stellt, und das ist in der Regel das, was dir die beste Leistung bringt. Verringere den Wert vonGOMAXPROCS
, wenn du möchtest, dass dein Go-Programm weniger CPU-Kerne nutzt (weniger Parallelität) und dafür eine höhere Latenzzeit in Kauf nehmen musst.
Empfohlene GOMAXPROCS Konfiguration
Setze GOMAXPROCS
auf die Anzahl der virtuellen Kerne, die dein Go-Programm auf einmal nutzen soll. Normalerweise wollen wir den gesamten Rechner nutzen; daher sollte der Standardwert funktionieren.
Für virtualisierte Umgebungen, insbesondere bei Verwendung von leichtgewichtigen Virtualisierungsmechanismen wie Containern, kannst du die automaxprocs
Bibliothek von Uber verwenden, die GOMAXPROCS
basierend auf den Linux-CPU-Limits, die der Container verwenden darf, anpasst.
Multitasking ist immer ein schwieriges Konzept, wenn man es in eine Sprache einführen will. Ich glaube, dass dieGoroutinen mit Kanälen in Go eine recht elegante Lösung für dieses Problem sind, die viele lesbare Programmiermuster ermöglicht, ohne die Effizienz zu beeinträchtigen. Wir werden praktische Gleichzeitigkeitsmuster in "Optimierung der Latenz durch Gleichzeitigkeit" untersuchen , indem wir die Latenz des in diesem Kapitel vorgestellten Beispiels 4-1 verbessern .
Schauen wir uns nun an, wann Gleichzeitigkeit in unseren Go-Programmen nützlich sein könnte.
Wann sollte man Gleichzeitigkeit nutzen?
Wie bei jeder Effizienzoptimierung gelten auch bei der Umwandlung eines einzelnen Goroutine-Codes in einen nebenläufigen Code die gleichen klassischen Regeln. Hier gibt es keine Ausnahmen. Wir müssen uns auf das Ziel konzentrieren, die TFBO-Schleife anwenden, früh Benchmarks durchführen und nach dem größten Engpass suchen. Wie bei allem gibt es auch bei der Gleichzeitigkeit Kompromisse, und es gibt Fälle, in denen wir sie vermeiden sollten. Fassen wir die praktischen Vor- und Nachteile von parallelem Code gegenüber sequentiellem Code zusammen:
- Vorteile
-
-
Gleichzeitigkeit ermöglicht es uns, die Arbeit zu beschleunigen, indem wir sie in Teile aufteilen und jeden Teil gleichzeitig ausführen. Solange die Synchronisierung und die gemeinsam genutzten Ressourcen keinen bedeutenden Engpass darstellen, sollten wir eine verbesserte Latenzzeit erwarten.
-
Da das Zeitplannungsprogramm von Go einen effizienten präemptiven Mechanismus implementiert, verbessert die Gleichzeitigkeit die Auslastung der CPU-Kerne für I/O-gebundene Aufgaben, was sich in einer geringeren Latenzzeit niederschlagen sollte, selbst bei einem
GOMAXPROCS=1
(einem einzelnen CPU-Kern). -
Besonders in virtuellen Umgebungen reservieren wir oft eine bestimmte CPU-Zeit für unsere Programme. Die Gleichzeitigkeit ermöglicht es uns, die Arbeit gleichmäßiger auf die verfügbare CPU-Zeit zu verteilen.
-
In einigen Fällen, wie der asynchronen Programmierung und der Ereignisbehandlung, stellt die Gleichzeitigkeit eine Problemdomäne dar, die trotz einiger Komplexität zu einer besseren Lesbarkeit führt. Ein weiteres Beispiel ist der HTTP-Server. Jede eingehende HTTP-Anfrage als eigene Goroutine zu behandeln, ermöglicht nicht nur eine effiziente Nutzung der CPU-Kerne, sondern passt auch ganz natürlich dazu, wie der Code gelesen undverstanden werden sollte.
-
- Benachteiligungen
-
-
Nebenläufigkeit erhöht die Komplexität des Codes erheblich, vor allem wenn wir bestehenden Code in Nebenläufigkeit umwandeln (anstatt die API von Anfang an um Kanäle herum aufzubauen). Das geht zu Lasten der Lesbarkeit, da der Ausführungsfluss fast immer verschleiert wird, aber noch schlimmer ist, dass der Entwickler nicht in der Lage ist, alle Kanten und potenziellen Fehler vorherzusehen. Das ist einer der Hauptgründe, warum ich empfehle, die Einführung von Gleichzeitigkeit so lange wie möglich aufzuschieben. Und wenn du die Gleichzeitigkeit einführen musst, verwende so wenige Kanäle wie möglich für das jeweilige Problem.
-
Bei Gleichzeitigkeit besteht die Gefahr, dass die Ressourcen durch unbegrenzte Gleichzeitigkeit (unkontrollierte Anzahl von Goroutinen in einem einzigen Moment) oder durch undichte Goroutinen (verwaiste Goroutinen) gesättigt werden. Auch darauf müssen wir achten und dagegen testen (mehr dazu in "Keine Ressourcen verschenken").
-
Obwohl Go über ein sehr effizientes Gleichzeitigkeitsframework verfügt, sind Goroutinen und Channels nicht frei von Overhead. Wenn sie falsch eingesetzt werden, können sie die Effizienz unseres Codes beeinträchtigen. Konzentriere dich darauf, jeder Goroutine so viel Arbeit zu geben, dass ihre Kosten gerechtfertigt sind. Benchmarks sind ein Muss.
-
Wenn wir die Gleichzeitigkeit nutzen, fügen wir plötzlich drei weitere, nicht triviale Tuning-Parameter zu unserem Programm hinzu. Wir haben eine
GOMAXPROCS
Einstellung, und je nachdem, wie wir die Dinge implementieren, können wir die Anzahl der gespaarten Goroutinen steuern und wie groß der Puffer des Kanals sein soll. Die richtigen Zahlen zu finden, erfordert stundenlanges Benchmarking und ist immer noch anfällig für Fehler. -
Gleichzeitiger Code ist schwer zu vergleichen, weil er noch stärker von der Umgebung, möglichen lauten Nachbarn, Multicore-Einstellungen, der Betriebssystemversion usw. abhängt. Sequentieller Single-Core-Code ist dagegen viel deterministischer und portabler und lässt sich daher leichter nachweisen und vergleichen.
-
Wie wir sehen, ist die Gleichzeitigkeit nicht das Heilmittel für alle Leistungsprobleme. Sie ist nur ein weiteres Werkzeug, das wir nutzen können, um unsere Effizienzziele zu erreichen.
Das Hinzufügen von Gleichzeitigkeit sollte eine der letzten bewussten Optimierungen sein, die wir versuchen
Wie in unserem TFBO-Zyklus beschrieben, solltest du, wenn du deine RAERs, z. B. in Bezug auf die Geschwindigkeit, immer noch nicht erreichst, zunächst einfachere Optimierungstechniken ausprobieren, bevor du Nebenläufigkeit einbaust. Als Faustregel gilt, dass du über Nebenläufigkeit nachdenken solltest, wenn unser CPU-Profiler (erklärt in Kapitel 9) zeigt, dass unser Programm CPU-Zeit nur für Dinge aufwendet, die für unsere Funktionalität entscheidend sind. Im Idealfall ist das der effizienteste Weg, den wir kennen, bevor wir an die Grenze der Lesbarkeit stoßen.
Die erwähnte Liste der Nachteile ist ein Grund, aber der zweite ist, dass sich die Eigenschaften unseres Programms nach grundlegenden Optimierungen (ohne Gleichzeitigkeit) ändern können. Wir dachten zum Beispiel, dass unsere Aufgabe an die CPU gebunden ist, aber nach den Verbesserungen stellen wir vielleicht fest, dass die meiste Zeit damit verbracht wird, auf E/A zu warten. Oder wir stellen fest, dass wir die umfangreichen Gleichzeitigkeitsoptimierungen gar nicht brauchen.
Zusammenfassung
Die moderne CPU-Hardware ist eine nicht triviale Komponente, die es uns ermöglicht, unsere Software effizient auszuführen. Mit der Weiterentwicklung der Betriebssysteme, der Go-Sprache und der Hardware werden nur noch mehr Optimierungstechniken und Komplexitäten entstehen, um die Betriebskosten zu senken und die Rechenleistung zu erhöhen.
In diesem Kapitel habe ich dir hoffentlich Grundlagen vermittelt, die dir dabei helfen, die Nutzung der CPU-Ressourcen und generell die Ausführungsgeschwindigkeit deiner Software zu optimieren. Zuerst haben wir die Assemblersprache besprochen und wie sie bei der Entwicklung von Go nützlich sein kann. Dann haben wir uns mit den Funktionen des Go-Compilers, den Optimierungen und den Möglichkeiten zur Fehlerbehebung beschäftigt.
Danach sind wir auf die größte Herausforderung für die CPU-Ausführung eingegangen: die Speicherzugriffslatenz in modernen Systemen. Schließlich haben wir die verschiedenen Low-Level-Optimierungen wie L-Caches, Pipelining, CPU-Zweigvorhersage und Hyper-Threading besprochen.
Zuletzt haben wir uns mit den praktischen Problemen bei der Ausführung unserer Programme in Produktionssystemen beschäftigt. Leider ist das Programm auf unserer Maschine selten der einzige Prozess, sodass eine effiziente Ausführung wichtig ist. Abschließend haben wir die Vor- und Nachteile des Gleichzeitigkeitsframeworks von Go zusammengefasst.
In der Praxis ist es wichtig, die CPU-Ressourcen in modernen Infrastrukturen zu optimieren, um eine schnellere Ausführung zu erreichen und weniger für unsere Workloads zu bezahlen. Leider ist die CPU-Ressource nur ein Aspekt. So kann es zum Beispiel sein, dass wir bei der Optimierung mehr Arbeitsspeicher verwenden, um die CPU-Nutzung zu reduzieren, oder andersherum.
Daher verbrauchen unsere Programme in der Regel eine Menge Speicherressourcen (plus E/A-Verkehr auf der Festplatte oder im Netzwerk). Die Ausführung ist zwar an CPU-Ressourcen wie Speicher und E/A gebunden, aber je nachdem, was wir wollen (z. B. billigere Ausführung, schnellere Ausführung oder beides), steht sie an erster Stelle auf unserer Optimierungsliste. Wir werden die Speicherressourcen im nächsten Kapitel besprechen.
1 Um technisch genau zu sein, haben moderne Computer heutzutage getrennte Caches für Programmanweisungen und Daten, während beide gleich im Hauptspeicher gespeichert werden. Dies ist die sogenannte modifizierte Harvard-Architektur. Bei den Optimierungsgraden, die wir in diesem Buch anstreben, können wir diese Details getrost auslassen.
2 Bei Skriptsprachen (interpretierten Sprachen) wird der Code nicht vollständig kompiliert. Stattdessen gibt es einen Interpreter, der den Code Anweisung für Anweisung kompiliert. Ein weiterer einzigartiger Sprachtyp ist die Familie der Sprachen, die die Java Virtual Machine (JVM) verwenden. Eine solche Maschine kann dynamisch von der Interpretation zur Just-in-Time-Kompilierung (JIT) für Laufzeitoptimierungen wechseln.
3 Eine ähnliche Ausgabe wie in Beispiel 4-2 erhält man, wenn man den Quellcode mit dem folgenden Befehl nach Assembly kompiliert go build -gcflags -S <source>
.
4 Beachte, dass die Namen im Go Assembly Register aus Gründen der Portabilität abstrahiert sind. Da wir für die 64-Bit-Architektur kompilieren werden, bedeuten SP
und SI
RSP- und RSI-Register. Außerdem kann sich die Reihenfolge der Operanden in verschiedenen Assembler-Standards unterscheiden.
5 Es kann zu Inkompatibilitäten kommen, vor allem bei Spezialbefehlen wie kryptografischen oder SIMD-Befehlen, die zur Laufzeit überprüft werden können, wenn sie vor der Ausführung verfügbar sind.
6 Beachte, dass die Strukturmethoden aus Sicht des Compilers nur Funktionen sind, deren erstes Argument die Struktur ist.
7 Ein Funktionsaufruf benötigt mehr CPU-Anweisungen, da das Programm Argumentvariablen und Rückgabeparameter über den Stack übergeben, den Status der aktuellen Funktion beibehalten, den Stack nach dem Funktionsaufruf zurückspulen, den neuen Frame-Stack hinzufügen usw. muss.
8 Mit den Go-Werkzeugen können wir dank der Umgebungsvariablen GOSSAFUNC
den Zustand unseres Programms bei jeder Optimierung im SSA-Formular überprüfen. Es ist so einfach, wie unser Programm mit GOSSAFUNC=<function to see> go build
zu erstellen und die daraus resultierende Datei ssa.html zu öffnen. Du kannst hier mehr darüber lesen.
9 Du kannst es mit dem Befehl tar <archive>
oder go tool pack e <archive>
entpacken. Das Go-Archiv enthält normalerweise die Objektdatei und die Paketmetadaten in der Datei __.PKGDEF.
10 Es gibt jedoch Diskussionen darüber, sie aus dem Standardbauprozess zu entfernen.
11 Die Eliminierung von gebundenen Schecks wird in diesem Buch nicht erklärt, da sie eine seltene Optimierungsidee ist.
12 Dies wird sehr oft in Standardbibliotheken für kritischen Code verwendet.
13 Zusätzlich zu SISD und SIMD gibt es in Flynns Taxonomie auch MISD, das die Ausführung mehrerer Befehle auf dieselben Daten beschreibt, und MIMD, das die vollständige Parallelität beschreibt. MISD ist selten und kommt nur vor, wenn Zuverlässigkeit wichtig ist. Zum Beispiel führen vier Flugsteuerungscomputer in jedem NASA Space Shuttle genau die gleichen Berechnungen für die vierfache Fehlerprüfung durch. MIMD hingegen ist dank Multicore- oder sogar Multi-CPU-Designs häufiger anzutreffen.
14 Aus diesem Grund gibt es spezialisierte Chips (sogenannte Neural Processing Units oder NPUs), die in Standardgeräten eingesetzt werden - zum Beispiel die Tensor Processing Unit (TPU) in Google-Handys, der A14 Bionic-Chip in iPhones und die dedizierte NPU im M1-Chip in Apple-Laptops.
15 Die Größe der Caches kann variieren. Die Beispielgrößen stammen von meinem Laptop. Du kannst die Größe deines CPU-Caches unter Linux mit dem Befehl sudo dmidecode -t cache
überprüfen.
16 Wenn eine CPU insgesamt bis zu einer Anweisung pro Zyklus ausführen kann (IPC ⇐ 1), nennen wir sie eine skalare CPU. Die meisten modernen CPU-Kerne haben IPC ⇐ 1, aber eine CPU hat mehr als einen Kern, wodurch IPC > 1 ist. Das macht diese CPUs superskalar. IPC hat sich schnell zu einer Leistungskennzahl für CPUs entwickelt.
17 Enorme Kosten sind keine Übertreibung. Die Latenz des Kontextwechsels hängt von vielen Faktoren ab, aber es wurde gemessen, dass die direkte Latenz (einschließlich der Latenz des Betriebssystemwechsels) im besten Fall etwa 1.350 Nanosekunden beträgt - 2.200 Nanosekunden, wenn auf einen anderen Kern gewechselt werden muss. Das ist nur die direkte Latenzzeit vom Ende eines Threads bis zum Beginn eines anderen. Die Gesamtlatenzzeit, die die indirekten Kosten in Form von Cache- und Pipeline-Aufwärmzeiten einschließt, könnte bis zu 10.000 Nanosekunden betragen (und genau das sehen wir in Tabelle A-1). In dieser Zeit könnten wir etwa 40.000 Anweisungen berechnen.
18 Das Wort "Trashing" bezeichnet in der Informatik eine Situation, in der sich der Inhalt eines begrenzten Speicherplatzes (L-Cache, RAM, Festplatte usw.), der als temporärer Cache genutzt wird, aufgrund von CPU- oder Speichersättigung ständig ändert. Trashing führt dazu, dass ein solcher Cache sehr ineffektiv ist - eine große Anzahl von Cache-Fehlern verursacht einen Overhead, der den Sinn eines Caches zunichte macht und zu weiteren Verlangsamungen führt (Kaskadeneffekt).
19 In einigen Quellen wird diese Technik auch als CPU-Threading (auch Hardware-Threads genannt) bezeichnet. In diesem Buch vermeide ich diese Terminologie, da es zu Verwechslungen mit Betriebssystem-Threads kommen kann.
20 Verwechsle die logischen Hyper-Threading-Kerne nicht mit den virtuellen CPUs (vCPUs), die bei der Verwendung von Virtualisierungen wie virtuellen Maschinen verwendet werden. Gastbetriebssysteme verwenden je nach Wahl des Hosts die physischen oder logischen CPUs der Maschine, aber in beiden Fällen werden sie vCPUs genannt.
21 Es gibt viele gute Materialien zum Tuning des Betriebssystems. Viele Virtualisierungsmechanismen, wie z. B. Container mit Orchestrierungssystemen wie Kubernetes, haben auch ihre eigenen Vorstellungen von Prioritäten und Affinitäten (das Festlegen von Prozessen auf bestimmte Kerne oder Maschinen). In diesem Buch konzentrieren wir uns auf das Schreiben von effizientem Code, aber wir müssen uns darüber im Klaren sein, dass das Tuning der Ausführungsumgebung eine wichtige Rolle dabei spielt, schnelle und zuverlässige Programmausführungen zu gewährleisten .
22 Die Details der Go-Laufzeit, die das Go-Scheduling implementiert , sind ziemlich beeindruckend. Im Wesentlichen tut Go alles, um den OS-Thread zu beschäftigen (den OS-Thread zu spinnen), damit er so lange wie möglich nicht in einen blockierenden Zustand übergeht. Bei Bedarf kann es Goroutinen von anderen Threads stehlen, Netzwerke abfragen usw., um sicherzustellen, dass wir die CPU beschäftigt halten, damit das Betriebssystem dem Go-Prozess nicht zuvorkommt.
23 In der Praxis gibt es Möglichkeiten, diese Informationen durch Debug-Tracing zu erhalten. Wir sollten uns jedoch nicht darauf verlassen, dass das Programm weiß, welche Goroutine eine Eltern-Goroutine für den normalen Ausführungsablauf ist.
24 Lustigerweise erfordern auch atomare Operationen auf der CPU eine Art von Sperre. Der Unterschied besteht darin, dass atomare Befehle statt spezieller Sperrmechanismen wie Spinlock die schnellere Speicherbussperre verwenden können.
25 Vorausgesetzt, der Programmierer hält sich an diese Regel. Es gibt eine Möglichkeit, eine Zeigervariable (z. B. *string
) zu senden, die auf gemeinsamen Speicher zeigt, was gegen die Regel des Informationsaustauschs durch Kommunikation verstößt.
26 Ich habe absichtlich zwei zusätzliche Mechanismen ausgelassen. Zum einen gibt es runtime.Gosched()
, mit dem man die aktuelle Goroutine aufgeben kann, damit andere in der Zwischenzeit etwas arbeiten können. Dieser Befehl ist heutzutage weniger nützlich, da das aktuelle Zeitplannungsprogramm von Go präemptiv ist und das manuelle Yielding unpraktisch geworden ist. Die zweite interessante Operation, runtime.LockOSThread()
, hört sich zwar nützlich an, ist aber nicht auf Effizienz ausgelegt, sondern bindet die Goroutine an den OS-Thread, damit wir bestimmte OS-Thread-Zustände aus ihr herauslesen können.
27 Ich empfehle, den Vortrag von Chris Hines auf der GopherCon 2019 anzuschauen, um mehr über die Details des Zeitplannungsprogramms von Go zu erfahren.
Get Efficient Go now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.