GoSuda

Funktionsweise von Go-Goroutinen: 2-KB-Stacks mit variabler Größe und der M:N-GMP-Scheduler

By hamori views ...

Goroutine

Wenn man Gophers bittet, die Vorteile von Golang zu nennen, taucht häufig ein Beitrag zum Thema Nebenläufigkeit (Concurrency) auf. Dessen Grundlage ist die Goroutine, die leichtgewichtig und einfach zu handhaben ist. Dazu wurde hier eine kurze Darstellung verfasst.

Nebenläufigkeit (Concurrency) vs. Parallelität (Parallelism)

Bevor wir auf Goroutinen eingehen, möchten wir zunächst zwei Konzepte klären, die häufig verwechselt werden.

  • Nebenläufigkeit: Nebenläufigkeit betrifft die gleichzeitige Bearbeitung vieler Aufgaben. Sie bedeutet nicht zwingend, dass diese tatsächlich gleichzeitig ausgeführt werden, sondern ist ein strukturelles, logisches Konzept, bei dem mehrere Aufgaben in kleine Einheiten zerlegt und abwechselnd ausgeführt werden, sodass es für die Nutzer so erscheint, als würden mehrere Aufgaben gleichzeitig verarbeitet. Nebenläufigkeit ist auch auf einem einzelnen Kern möglich.
  • Parallelität: Parallelität bedeutet „die gleichzeitige Verarbeitung mehrerer Aufgaben auf mehreren Kernen“. Wortwörtlich werden Aufgaben parallel vorangetrieben, wobei andere Arbeiten gleichzeitig ausgeführt werden.

Goroutinen ermöglichen es über den Go-Laufzeit-Scheduler, Nebenläufigkeit einfach zu realisieren, und durch die Einstellung GOMAXPROCS wird darüber hinaus auch Parallelität selbstverständlich genutzt.

Der häufig genutzte Multithreading-Ansatz von Java ist ein repräsentatives Konzept der Parallelität.

Warum sind Goroutinen so vorteilhaft?

Leichtgewichtig (lightweight)

Der Erstellungsaufwand ist im Vergleich zu anderen Sprachen sehr gering. Hier stellt sich die Frage, warum Golang weniger Ressourcen benötigt. Der Grund liegt darin, dass der Ort der Erstellung innerhalb der Go-Laufzeit verwaltet wird. Denn sie sind leichtgewichtige logische Threads, die kleiner sind als OS-Threads, benötigen einen initialen Stack von etwa 2 KB und wachsen dynamisch, indem bei Bedarf Stack hinzugefügt wird.

Da die Verwaltung auf Stack-Ebene erfolgt, sind Erstellung und Entfernung sehr schnell und kostengünstig, sodass selbst der Betrieb von mehreren Millionen Goroutinen unproblematisch möglich ist. Dadurch kann Goroutine dank des Laufzeit-Schedulers den Eingriff des OS-Kernels weitgehend minimieren.

Leistungsfähigkeit (performance)

Zunächst gilt: Da Goroutinen, wie oben erläutert, wenig Eingriffe des OS-Kernels erfordern, ist ein Kontextwechsel auf Benutzerebene (User-Level) kostengünstiger als auf Ebene von OS-Threads, sodass Aufgaben schnell gewechselt werden können.

Darüber hinaus werden sie mittels des M:N-Modells auf OS-Threads abgebildet und verwaltet. Durch einen OS-Thread-Pool werden keine vielen Threads benötigt, sondern die Verarbeitung ist auch mit wenigen Threads möglich. Gerät beispielsweise ein Thread in einen Wartezustand wie einen Systemaufruf, führt die Go-Laufzeit auf dem OS-Thread eine andere Goroutine aus, sodass der OS-Thread ohne Pause und effizient die CPU nutzt und somit eine schnelle Verarbeitung ermöglicht.

Dadurch kann Golang insbesondere bei I/O-Operationen eine im Vergleich zu anderen Sprachen höhere Leistung erzielen.

Prägnant (concise)

Ein großer Vorteil ist auch, dass für Nebenläufigkeit eine Funktion mit nur einem Schlüsselwort go einfach ausgeführt werden kann.

Komplexe Sperrmechanismen wie Mutex und Semaphore müssen verwendet werden, und bei Verwendung von Sperren muss zwangsläufig der Deadlock-Zustand berücksichtigt werden, wodurch bereits in der Entwurfsphase vor der Entwicklung ein aufwendiger Schritt notwendig wird.

Goroutinen folgen dem Grundsatz „Kommuniziere nicht durch gemeinsamen Speicher, sondern teile Speicher durch Kommunikation“ und empfehlen daher die Datenübergabe über Channel. Außerdem unterstützt SELECT in Kombination mit Channels die Verarbeitung des Channels, dessen Daten zuerst bereitstehen. Zudem ermöglicht sync.WaitGroup, auf den Abschluss mehrerer Goroutinen einfach zu warten, wodurch Arbeitsabläufe leicht verwaltet werden können. Dank dieser Werkzeuge lassen sich Datenwettlaufprobleme zwischen Threads vermeiden und die Nebenläufigkeit sicherer umsetzen.

Außerdem kann mittels context auf Benutzerebene (User-Level) der Lebenszyklus, Abbruch, Timeouts, Deadlines und der Anforderungsbereich gesteuert werden, wodurch ein gewisses Maß an Stabilität gewährleistet werden kann.

Parallele Arbeit von Goroutinen (GOMAXPROCS)

Nachdem die Vorteile der Nebenläufigkeit von Goroutinen dargelegt wurden, stellt sich sicherlich die Frage, ob Parallelität nicht unterstützt wird. Die Anzahl der CPU-Kerne liegt inzwischen anders als früher im zweistelligen Bereich, und auch Heim-PCs verfügen über eine beachtliche Anzahl an Kernen.

Goroutinen führen jedoch auch parallele Arbeit aus, und dafür ist GOMAXPROCS zuständig.

Wird GOMAXPROCS nicht eingestellt, wird es je nach Version unterschiedlich gesetzt.

  1. Vor 1.5: Standardwert 1; falls mehr als 1 benötigt wird, ist die Einstellung zwingend erforderlich, etwa mit runtime.GOMAXPOCS(runtime.NumCPU())

  2. 1.5 bis 1.24: Der Wert wurde auf die Anzahl aller verfügbaren logischen Kerne geändert. Ab diesem Zeitpunkt ist es für Entwickler nicht nötig, eine Einstellung vorzunehmen, sofern keine besonderen Einschränkungen erforderlich sind.

  3. 1.25: Passend zu einer Sprache, die in Container-Umgebungen verbreitet ist, prüft sie die im Linux-cGroup für den Container festgelegte CPU-Begrenzung.

    Beträgt die Anzahl der logischen Kerne beispielsweise 10 und das CPU-Limit 5, wird GOMAXPROCS auf den niedrigeren Wert 5 gesetzt.

Die Änderung in 1.25 ist eine sehr bedeutende Neuerung, da sich die Nutzbarkeit der Sprache in Container-Umgebungen verbessert hat. Dadurch können unnötige Thread-Erstellungen und Kontextwechsel reduziert und CPU-Throttling verhindert werden.

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: 시작\n", name) // Start der Goroutine
14	time.Sleep(10 * time.Millisecond) // Verzögerung zur Simulation der Arbeit
15	fmt.Printf("Goroutine %d: 시작\n", name) // Start der Goroutine
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Nur 2 CPU-Kerne verwenden
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("모든 goroutine이 끝날 때까지 대기합니다...") // Warten, bis alle Goroutinen beendet sind
29	wg.Wait()
30	fmt.Println("모든 작업이 완료되었습니다.") // Alle Aufgaben wurden abgeschlossen
31
32}
33

Scheduler der Goroutinen (M:N-Modell)

Der zuvor erwähnte Punkt, dass Goroutinen mittels des M:N-Modells auf OS-Threads abgebildet und verwaltet werden, lässt sich genauer betrachten: Dort existiert das GMP-Modell der Goroutinen.

  • G (Goroutine): Die kleinste Arbeitseinheit, die in Go ausgeführt wird
  • M (Machine): OS-Thread (tatsächlicher Ausführungsort)
  • P (Processor): Ein logischer Prozessor, der von der Go-Laufzeit verwaltet wird

P verfügt zusätzlich über eine lokale Ausführungswarteschlange (Local Run Queue) und übernimmt die Rolle eines Schedulers, der die zugewiesene G an eine M vergibt. Vereinfacht ausgedrückt läuft der Ablauf von GMP wie folgt ab:

  1. Wird eine G (Goroutine) erstellt, wird sie der lokalen Ausführungswarteschlange eines P (Processor) zugewiesen.
  2. P (Processor) weist die G (Goroutine) in der lokalen Ausführungswarteschlange einer M (Machine) zu.
  3. M (Machine) liefert den Zustand der G (Goroutine) zurück, nämlich block, complete oder preempted.
  4. Work-Stealing (Arbeitsdiebstahl): Wenn die lokale Ausführungswarteschlange eines P leer wird, prüft ein anderer P die globale Warteschlange. Befindet sich dort ebenfalls keine G (Goroutine), stiehlt er die Arbeit eines anderen lokalen P (Processor), sodass alle M ohne Unterbrechung arbeiten.
  5. Behandlung von Systemaufrufen (Blocking): Tritt während der Ausführung einer G (Goroutine) eine Blockierung auf, wird die M (Machine) wartend. In diesem Fall trennt der P (Processor) die blockierte M (Machine) ab, verbindet sich mit einer anderen M (Machine) und führt die nächste G (Goroutine) aus. Dadurch entsteht auch während der Wartezeit bei I/O-Operationen keine Verschwendung der CPU.
  6. Wenn eine G (Goroutine) über längere Zeit die Ausführung belegt (preempted), erhalten andere G (Goroutinen) die Gelegenheit zur Ausführung.

Auch der Garbage Collector (GC) von Golang läuft auf den Goroutinen, sodass der Speicher parallel bereinigt werden kann, während die Ausführung der Anwendung nur minimal unterbrochen wird (STW), wodurch Systemressourcen effizient genutzt werden.

Abschließend sei gesagt, dass Golang eine seiner starken Stärken ist und darüber hinaus noch viele weitere bietet. Ich wünsche mir, dass viele Entwicklerinnen und Entwickler Freude an Go haben werden.

Vielen Dank.