Planen in Go: Teil II - Der Go-Planer

Hallo Habr! Dies ist der zweite Beitrag in einer dreiteiligen Reihe, die einen Eindruck von der Mechanik und Semantik der Arbeit des Schedulers in Go vermittelt. In diesem Beitrag geht es um den Go-Planer.

Im ersten Teil dieser Serie habe ich Aspekte des Betriebssystem-Schedulers erläutert, die meiner Meinung nach wichtig sind, um die Semantik des Go-Schedulers zu verstehen und zu bewerten. In diesem Beitrag werde ich auf semantischer Ebene erklären, wie der Go-Scheduler funktioniert. Der Go Scheduler ist ein komplexes System und kleine mechanische Details sind nicht wichtig. Es ist wichtig, ein gutes Modell dafür zu haben, wie alles funktioniert und sich verhält. Auf diese Weise können Sie die besten technischen Entscheidungen treffen.

Ihr Programm startet


Wenn Ihr Go-Programm gestartet wird, wird ihm für jeden auf dem Hostcomputer definierten virtuellen Kern ein logischer Prozessor (P) zugewiesen. Wenn Sie einen Prozessor mit mehreren Hardwarethreads pro physischem Kern haben (Hyper-Threading), wird jeder Hardwarethread Ihrem Programm als virtueller Kern angezeigt. Schauen Sie sich zum besseren Verständnis den Systembericht für mein MacBook Pro an.

Bild

Sie können sehen, dass ich einen Prozessor mit 4 physischen Kernen habe. In diesem Bericht wird die Anzahl der Hardwarethreads pro physischem Kern nicht angegeben. Der Intel Core i7-Prozessor verfügt über Hyper-Threading-Technologie, was bedeutet, dass der physische Core über 2 Hardware-Threads verfügt. Dies teilt Go mit, dass 8 virtuelle Kerne verfügbar sind, um Betriebssystem-Threads parallel auszuführen. Betrachten Sie das folgende Programm, um dies zu überprüfen:

package main

import (
	"fmt"
	"runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

Wenn ich dieses Programm auf meinem Computer ausführe, ist das Ergebnis des Aufrufs der Funktion NumCPU () 8. Jedes Go-Programm, das ich auf meinem Computer ausführe, erhält 8 (P).
Jedem P ist ein Betriebssystemstrom ( M ) zugeordnet. Dieser Thread wird weiterhin vom Betriebssystem verwaltet, und das Betriebssystem ist weiterhin dafür verantwortlich, den Thread zur Ausführung im Kernel zu platzieren. Das heißt, wenn ich Go auf meinem Computer ausführe, stehen mir 8 Threads zur Verfügung, die jeweils einzeln mit P verknüpft sind.

Jedes Go-Programm erhält außerdem eine erste Goroutine ( G.) Goroutine ist im Wesentlichen Coroutine, aber es ist Go, also ersetzen wir den Buchstaben C durch G und erhalten das Wort Goroutine. Sie können sich Goroutines als Threads auf Anwendungsebene vorstellen, die OS-Threads sehr ähnlich sind. So wie Betriebssystem-Threads vom Kernel ein- und ausgeschaltet werden, werden Kontextprogramme vom Kontext ein- und ausgeschaltet.

Das letzte Rätsel sind die Ausführungswarteschlangen. Der Go-Scheduler enthält zwei verschiedene Ausführungswarteschlangen: die globale Ausführungswarteschlange (GRQ) und die lokale Ausführungswarteschlange (LRQ). Jedem P wird ein LRQ zugewiesen, der die Goroutins steuert, die im Kontext von P ausgeführt werden sollen. Diese Goroutinen werden in dem diesem P zugewiesenen Kontext M ein- und ausgeschaltet. GRQ ist für Goroutinen vorgesehen, die nicht P zugewiesen wurden. Es gibt einen Prozess zum Verschieben der Goroutinen von GRQ zu LRQ, die wir später diskutieren werden.

Das Bild zeigt alle diese Komponenten zusammen.

Bild

Genossenschaftsplaner


Wie bereits im ersten Beitrag erwähnt, ist der OS-Scheduler ein präventiver Scheduler. Dies bedeutet im Wesentlichen, dass Sie nicht vorhersagen können, was der Planer zu einem bestimmten Zeitpunkt tun wird. Der Kernel trifft Entscheidungen und alles ist nicht deterministisch. Anwendungen, die auf dem Betriebssystem ausgeführt werden, steuern nicht, was im Kernel mit der Zeitplanung geschieht, es sei denn, sie verwenden Synchronisationsprimitive wie atomare Anweisungen und Mutex-Aufrufe.

Der Go Scheduler ist Teil der Go Runtime, und die Go Runtime ist in Ihre Anwendung integriert. Dies bedeutet, dass der Go-Scheduler im Benutzerbereich des Kernels arbeitet. Die aktuelle Go-Scheduler-Implementierung ist kein präventiver, sondern ein interaktiver Scheduler. Ein kooperativer Planer zu sein bedeutet, dass der Planer klar definierte Ereignisse im Benutzerbereich benötigt, die an sicheren Stellen im Code auftreten, um Planungsentscheidungen zu treffen.

Das Gute an Go's kollaborativem Planer ist, dass er proaktiv aussieht und sich proaktiv anfühlt. Sie können nicht vorhersagen, was der Go-Scheduler tun wird. Dies liegt an der Tatsache, dass die Entscheidungsfindung für diesen Scheduler nicht von den Entwicklern abhängt, sondern von der Ausführungszeit von Go. Es ist wichtig, sich den Go-Scheduler als proaktiven Scheduler vorzustellen, und da der Scheduler nicht deterministisch ist, ist er nicht allzu schwierig.

Gorutin Staaten


Goroutinen haben genau wie Bäche die gleichen drei Zustände auf hoher Ebene. Sie bestimmen die Rolle, die der Go-Planer bei jeder Goroutine spielt. Goroutin kann sich in einem von drei Zuständen befinden: Warten, Bereit oder Erfüllen.

Warten : Dies bedeutet, dass die Goroutine gestoppt wird und darauf wartet, dass etwas fortgesetzt wird. Dies kann beispielsweise aus Gründen des Wartens auf das Betriebssystem (Systemaufrufe) oder der Synchronisierung von Aufrufen (Atom- und Mutex-Operationen) geschehen. Diese Arten von Verzögerungen sind die Hauptursache für schlechte Leistung.

Bereitschaft: Dies bedeutet, dass Goroutine Zeit möchte, um den zugewiesenen Anweisungen zu folgen. Wenn Sie viele Goroutinen haben, die Zeit brauchen, müssen Goroutinen länger warten, um Zeit zu bekommen. Darüber hinaus wird die individuelle Zeit, die eine Goroutine erhält, reduziert, wenn mehr Goroutinen um die Zeit konkurrieren. Diese Art der Planungsverzögerung kann auch zu einer schlechten Leistung führen.

Erfüllung : Dies bedeutet, dass Goroutine in M ​​platziert wurde und seinen Anweisungen folgt. Die mit dem Antrag verbundenen Arbeiten wurden abgeschlossen. Das will jeder.

Kontextwechsel


Der Go Scheduler erfordert genau definierte User-Space-Ereignisse, die an sicheren Punkten im Code auftreten, um den Kontext zu wechseln. Diese Ereignisse und sicheren Punkte werden in Funktionsaufrufen angezeigt. Funktionsaufrufe sind für die Leistung des Go Schedulers von entscheidender Bedeutung. Wenn Sie enge Schleifen ausführen, die keine Funktionsaufrufe ausführen, kommt es zu Verzögerungen im Scheduler und in der Garbage Collection. Funktionsaufrufe müssen unbedingt innerhalb eines angemessenen Zeitraums erfolgen.

In Ihren Go-Programmen treten vier Ereignisklassen auf, mit denen der Planer Planungsentscheidungen treffen kann. Dies bedeutet nicht, dass dies bei einem dieser Ereignisse immer der Fall sein wird. Dies bedeutet, dass der Scheduler die Möglichkeit erhält.

  • Verwenden Sie das Schlüsselwort go
  • Müllsammler
  • Systemaufrufe
  • Synchronisation

Verwenden des
Schlüsselworts go Mit dem Schlüsselwort go erstellen Sie eine Goroutine. Sobald eine neue Goroutine erstellt wird, hat der Planer die Möglichkeit, eine Planungsentscheidung zu treffen.

Garbage Collector (GC)
Da der GC mit einem eigenen Satz von Goroutinen arbeitet, benötigen diese Gorutine Zeit auf M, um ausgeführt zu werden. Dies zwingt den GC zu viel Chaos bei der Planung. Der Planer ist jedoch sehr klug in dem, was Goroutine tut, und er wird es verwenden, um Entscheidungen zu treffen. Eine vernünftige Lösung besteht darin, den Kontext auf Goroutine umzustellen, die auf die Systemressource zugreifen möchte, und niemand anderes als sie während der Speicherbereinigung. Wenn der GC funktioniert, werden viele Planungsentscheidungen getroffen.

Systemaufrufe
Wenn Goroutine einen Systemaufruf ausführt, der M blockiert, kann der Scheduler den Kontext auf eine andere Goroutine umschalten, auf dieselbe M.

Synchronisation
Wenn ein Aufruf einer atomaren Operation, eines Mutex oder eines Kanals dazu führt, dass Goroutine blockiert wird, kann der Scheduler den Kontext wechseln, um eine neue Goroutine zu starten. Sobald Goroutine wieder funktionieren kann, kann es in die Warteschlange gestellt werden und schließlich wieder zu M wechseln.

Asynchrone Systemaufrufe


Wenn das Betriebssystem, an dem Sie arbeiten, einen Systemaufruf asynchron verarbeiten kann, kann ein sogenannter Netzwerk-Poller verwendet werden, um den Systemaufruf effizienter zu verarbeiten. Dies wird mit kqueue (MacOS), epoll (Linux) oder iocp (Windows) in diesen jeweiligen Betriebssystemen erreicht.

Netzwerksystemaufrufe können von vielen der heute verwendeten Betriebssysteme asynchron verarbeitet werden. Hier zeigt sich der Netzwerk-Poller, da sein Hauptzweck darin besteht, Netzwerkoperationen zu verarbeiten. Mit dem Netzwerk-Poller für Netzwerksystemaufrufe kann der Scheduler verhindern, dass Goroutinen M während dieser Systemaufrufe blockieren. Dies hilft, M verfügbar zu halten, um andere Goroutinen in LRQ P auszuführen, ohne dass neues M erstellt werden muss. Dies hilft, den Planungsaufwand im Betriebssystem zu verringern.

Der beste Weg, um zu sehen, wie dies funktioniert, ist ein Beispiel. Die Abbildung zeigt unser grundlegendes Planungsschema. Gorutin-1 wird auf M ausgeführt, und 3 weitere Gorutins warten in LRQ darauf, ihre Zeit auf M zu bekommen. Der Netzwerk-Poller ist untätig und hat nichts zu tun.

Bild

In der folgenden Abbildung möchte Gorutin-1 (G1) einen Netzwerksystemaufruf ausführen, daher wechselt G1 zum Netzwerk-Poller und wird als asynchroner Netzwerksystemaufruf behandelt. Sobald G1 in den Netzwerk-Poller verschoben wurde, steht M nun zur Verfügung, um eine weitere Goroutine von LRQ auszuführen. In diesem Fall wechselt Gorutin-2 zu M.

Bild

In der folgenden Abbildung endet der Systemnetzwerkaufruf mit einem asynchronen Netzwerkaufruf, und G1 kehrt für P zu LRQ zurück. Nachdem G1 zu M zurückgeschaltet werden kann, wird der mit Go verknüpfte Code verwendet Die Antworten können erneut ausgeführt werden. Der große Gewinn ist, dass keine zusätzliche Frau erforderlich ist, um Netzwerksystemanrufe zu tätigen. Der Netzwerk-Poller verfügt über einen Betriebssystem-Thread und wird über eine Ereignisschleife verarbeitet.

Synchrone Systemaufrufe


Was passiert, wenn Goroutine einen Systemaufruf ausführen möchte, der nicht asynchron ausgeführt werden kann? In diesem Fall kann der Netzwerk-Poller nicht verwendet werden, und die Goroutine, die den Systemaufruf ausführt, blockiert M. Dies ist schlecht, aber es gibt keine Möglichkeit, dies zu verhindern. Ein Beispiel für einen Systemaufruf, der nicht asynchron ausgeführt werden kann, sind dateibasierte Systemaufrufe. Wenn Sie CGO verwenden, kann es andere Situationen geben, in denen das Aufrufen von C-Funktionen auch M blockiert.
Das Windows-Betriebssystem kann dateibasierte asynchrone Systemaufrufe ausführen. Technisch gesehen können Sie unter Windows den Netzwerk-Poller verwenden.
Mal sehen, was mit einem synchronen Systemaufruf (z. B. Datei-E / A) passiert, der M blockiert. Die Abbildung zeigt unser grundlegendes Planungsdiagramm, aber dieses Mal wird G1 einen synchronen Systemaufruf ausführen, der M1 blockiert.

Bild

In der folgenden Abbildung kann der Scheduler feststellen, dass G1 eine M-Sperre verursacht hat. Zu diesem Zeitpunkt trennt der Scheduler M1 von P, wobei ein blockierendes G1 noch angeschlossen ist. Der Scheduler führt dann einen neuen M2 ein, um P zu bedienen. Zu diesem Zeitpunkt kann G2 aus LRQ ausgewählt und in den M2-Kontext aufgenommen werden. Wenn M aufgrund eines vorherigen Austauschs bereits vorhanden ist, ist dieser Übergang schneller als die Notwendigkeit, ein neues M zu erstellen.

Bild

Der nächste Schritt schließt den Aufruf des Sperrsystems von G1 ab. Zu diesem Zeitpunkt kann G1 zu LRQ zurückkehren und erneut von P. M1 bedient werden. M1 wird dann für die zukünftige Verwendung beiseite gelegt, falls dieses Szenario wiederholt werden sollte.

Bild

Arbeit stehlen


Ein weiterer Aspekt des Schedulers ist, dass es sich um einen Goroutine-Diebstahlplaner handelt. Dies hilft in mehreren Bereichen, eine effektive Planung zu unterstützen. Erstens müssen Sie als letztes M in den Standby-Zustand versetzen, da das Betriebssystem M in diesem Fall mithilfe des Kontexts vom Kernel wechselt. Dies bedeutet, dass P keine Arbeit leisten kann, selbst wenn sich eine Goroutine in einem gesunden Zustand befindet, bis M zurück zum Kernel wechselt. Gorutin-Diebstahl hilft auch dabei, Zeitintervalle zwischen allen Ps auszugleichen, damit die Arbeit besser verteilt und effizienter ausgeführt wird.

In der Abbildung haben wir ein Multithread-Go-Programm mit zwei Ps, die jeweils vier Gs und ein G in GRQ bedienen. Was passiert, wenn einer von P schnell sein gesamtes G bedient?

Bild

Außerdem muss P1 keine Goroutinen mehr ausführen. Es gibt jedoch Goroutinen im Arbeitszustand, sowohl in LRQ für P2 als auch in GRQ. Dies ist der Moment, in dem P1 Goroutine stehlen muss.

Bild

Die Regeln für den Diebstahl von Goroutinen lauten wie folgt. Der gesamte Code kann in den Laufzeitquellen angezeigt werden.

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

Basierend auf diesen Regeln sollte P1 P2 auf das Vorhandensein von Goroutinen in seinem LRQ prüfen und die Hälfte von dem nehmen, was es findet.

Bild

Was passiert, wenn P2 alle seine Programme beendet und P1 nichts mehr in LRQ hat?

P2 hat alle seine Arbeiten abgeschlossen und muss nun die Goroutinen stehlen. Zuerst wird er sich den LRQ P1 ansehen, aber keine Goroutinen finden. Als nächstes wird er sich GRQ ansehen. Dort findet er die G9.

Bild

P2 stiehlt G9 von GRQ und beginnt mit der Arbeit. Das Gute an all diesem Diebstahl ist, dass M damit beschäftigt bleiben und nicht inaktiv sein kann.

Bild

Praktisches Beispiel


Mit Mechanik und Semantik möchte ich Ihnen zeigen, wie dies alles zusammenkommt, damit der Go-Scheduler im Laufe der Zeit mehr Arbeit leisten kann. Stellen Sie sich eine in C geschriebene Multithread-Anwendung vor, in der das Programm zwei Betriebssystem-Threads verwaltet, die Nachrichten aneinander senden. Das Bild enthält zwei Threads, die die Nachricht hin und her senden. Thread 1 empfängt den kontextvermittelten Kern 1 und wird jetzt ausgeführt, wodurch Thread 1 seine Nachricht an Thread 2 senden kann.

Bild

Wenn Thread 1 das Senden der Nachricht beendet hat, muss er auf eine Antwort warten. Dies führt dazu, dass Thread 1 vom Kontext von Kernel 1 getrennt und in einen Wartezustand versetzt wird. Sobald Thread 2 eine Nachrichtenbenachrichtigung erhält, wird er in einen fehlerfreien Zustand versetzt. Jetzt kann das Betriebssystem einen Kontextwechsel durchführen und Thread 2 auf dem Kernel ausführen, der sich als Kernel 2 herausstellt. Dann verarbeitet Thread 2 die Nachricht und sendet eine neue Nachricht zurück an Thread 1.

Bild

Als nächstes wechselt der Stream zurück zum Kontext, wenn die Nachricht von Stream 2 von Stream 1 empfangen wird. Nun wechselt Stream 2 vom Ausführungsstatus in den Standby-Status, und Stream 1 wechselt vom Standby-Status in den Bereitschaftsstatus und kehrt schließlich in den Ausführungsstatus zurück, wodurch er verarbeitet werden kann und senden Sie eine neue Nachricht zurück. Alle diese Kontextwechsel und Statusänderungen benötigen Zeit, um abgeschlossen zu werden, was die Geschwindigkeit der Arbeit begrenzt. Da jeder Kontextwechsel eine Verzögerung von ~ 1000 Nanosekunden mit sich bringt und wir hoffen, dass die Hardware 12 Befehle pro Nanosekunde ausführt, sehen Sie sich 12.000 Befehle an, die während dieser Kontextwechsel mehr oder weniger nicht ausgeführt werden. Da sich diese Strömungen auch zwischen verschiedenen Kernen schneiden,Die Wahrscheinlichkeit einer zusätzlichen Verzögerung bei Cache-Zeilenfehlern ist ebenfalls hoch.

Bild

In der Abbildung sind zwei Gorutine zu sehen, die in Harmonie miteinander sind und die Botschaft hin und her weitergeben. G1 erhält den Kontextschalter M1, der auf Core 1 ausgeführt wird und es G1 ermöglicht, seine Arbeit zu erledigen.

Bild

Wenn G1 das Senden der Nachricht beendet hat, muss er auf eine Antwort warten. Dies führt dazu, dass G1 vom M1-Kontext getrennt und in den Ruhezustand versetzt wird. Sobald G2 über die Nachricht informiert wird, geht sie in einen fehlerfreien Zustand über. Jetzt kann der Go-Scheduler eine Kontextumschaltung durchführen und G2 auf M1 ausführen, das noch auf Core 1 ausgeführt wird. Dann verarbeitet G2 die Nachricht und sendet eine neue Nachricht zurück an G1.

Bild

Im nächsten Schritt schaltet alles wieder um, wenn die von G2 gesendete Nachricht von G1 empfangen wird. Nun wechselt der Kontext G2 vom Ausführungsstatus in den Wartezustand, und der Kontext G1 wechselt vom Wartezustand in den Ausführungsstatus und schließlich zurück in den Ausführungsstatus, wodurch er eine neue Nachricht verarbeiten und zurücksenden kann.

Bild

Die Dinge an der Oberfläche scheinen nicht anders zu sein. Unabhängig davon, ob Sie Streams oder Goroutinen verwenden, treten dieselben Kontext- und Statusänderungen auf. Es gibt jedoch einen großen Unterschied zwischen der Verwendung von Streams und Gorutin, der auf den ersten Blick nicht offensichtlich ist.

Wenn Goroutine verwendet wird, werden für die gesamte Verarbeitung dieselben Betriebssystem-Threads und derselbe Kernel verwendet. Dies bedeutet, dass OS Flow aus Betriebssystemsicht niemals in einen Wartezustand wechselt. nicht einmal. Infolgedessen gehen alle Anweisungen, die wir beim Wechseln von Kontexten bei der Verwendung von Streams verloren haben, bei der Verwendung von Goroutin nicht verloren.

Im Wesentlichen verwandelte Go IO / Blocking-Arbeit in einen prozessorgebundenen Job auf Betriebssystemebene. Da alle Kontextwechsel auf Anwendungsebene stattfinden, verlieren wir beim Kontextwechsel nicht die gleichen ~ 12.000 Anweisungen (im Durchschnitt), die wir bei der Verwendung von Streams verloren haben. In Go kosten dieselben Kontextwechsel ~ 200 Nanosekunden oder ~ 2,4 Tausend Befehle. Der Scheduler hilft auch dabei, die Leistung von Caching-Strings und NUMA zu verbessern. Deshalb benötigen wir nicht mehr Threads als virtuelle Kerne. Go kann im Laufe der Zeit mehr Arbeit leisten, da der Go-Scheduler versucht, weniger Threads zu verwenden und mehr für jeden Thread zu tun, wodurch die Belastung des Betriebssystems und der Hardware verringert wird.

Fazit


Der Go Scheduler ist wirklich erstaunlich, wie er die Feinheiten des Betriebssystems und der Hardware berücksichtigt. Durch die Möglichkeit, E / A / Lock-Vorgänge auf Betriebssystemebene in prozessorgebundene Vorgänge umzuwandeln, können wir mit der Zeit mehr Prozessorleistung erzielen. Aus diesem Grund benötigen Sie nicht mehr Betriebssystem-Threads als virtuelle Kernel. Sie können davon ausgehen, dass Ihre gesamte Arbeit (mit CPU-Bindung und E / A / Sperren) mit einem Betriebssystem-Thread pro virtuellem Kernel ausgeführt wird. Dies ist für Netzwerkanwendungen und andere Anwendungen möglich, die keine Systemaufrufe benötigen, die Betriebssystem-Threads blockieren.

Als Entwickler sollten Sie immer noch verstehen, was Ihre Anwendung in Bezug auf die Art der Arbeit tut. Sie können nicht eine unbegrenzte Anzahl von Goroutinen erstellen und eine erstaunliche Leistung erwarten. Weniger ist immer mehr, aber mit einem Verständnis dieser Semantik des Go-Schedulers können Sie bessere technische Entscheidungen treffen.

All Articles