Goroutines
Goroutines sind ein zentrales Konzept in Go zur Realisierung von Nebenläufigkeit. Sie ermöglichen die gleichzeitige Ausführung mehrerer Funktionen innerhalb eines Programms und sind besonders ressourcenschonend. Durch die einfache Syntax und das effiziente Scheduling von Goroutines lassen sich parallele Abläufe und reaktive Anwendungen in Go unkompliziert umsetzen. Dieser Artikel erläutert die Funktionsweise von Goroutines, zeigt typische Anwendungsfälle und stellt praxisnahe Beispiele für den Einsatz vor.
Inhaltsverzeichnis
Einführung
Goroutines ist eines der mächtigsten Features der Programmiersprache Go. Sie ermöglichen es, nebenläufigen Code (concurrent) auf eine elegante und effiziente Weise zu schreiben. Im Gegensatz zu traditionellen Thread-Modellen in anderen Programmiersprachen bietet Go mit Goroutines ein leichtgewichtiges Konzept, das es ermöglicht, Tausende oder sogar Millionen von gleichzeitig laufenden Aufgaben zu verwalten, ohne dass das System unter der Last zusammenbricht.
Die Philosophie hinter Goroutines ist eng mit dem Go-Motto “Do not communicate by sharing memory; instead share memory by communicating” verbunden. Dieser Ansatz fördert ein Programmiermodell, bei dem die Kommunikation zwischen nebenläufigen Prozessen über Channels erfolgt, anstatt über gemeinsam genutzten Speicher und Locks.
Eine Goroutine ist somit eine leichtgewichtige Einheit der Nebenläufigkeit in Go. Man kann sich eine Goroutine als eine Funktion oder Methode vorstellen, die gleichzeitig (concurrent) mit anderen Goroutines im selben Adressraum ausgeführt wird. Das Schlüsselwort hierbei ist “leichtgewichtig” - Goroutines sind wesentlich weniger ressourcenintensiv als traditionelle Betriebssystem-Threads.
Grundlegende Eigenschaften von Goroutines
Leichtgewichtigkeit
Eine Goroutine benötigt initial nur etwa 2-8 KB Stack-Speicher, der bei Bedarf dynamisch wachsen kann. Im Vergleich dazu benötigt ein Betriebssystem-Thread typischerweise 1-2 MB Stack-Speicher von Anfang an. Diese Eigenschaft ermöglicht es, Hunderttausende von Goroutines gleichzeitig auf einem System laufen zu lassen.
Multiplexing
Goroutines werden vom Go-Runtime-Scheduler auf eine kleinere Anzahl von Betriebssystem-Threads gemultiplext. Das bedeutet, dass viele Goroutines auf wenigen OS-Threads laufen können. Der Go-Scheduler kümmert sich intelligent um die Verteilung der Goroutines auf die verfügbaren Threads, ohne dass der Entwickler sich um diese Details kümmern muss.
Einfache Syntax
Das Starten einer Goroutine ist syntaktisch extrem einfach - man muss lediglich das Schlüsselwort go vor einem Funktionsaufruf setzen. Diese Einfachhheit fördert die Verwendung von Nebenläufigkeit und ein Programmiermodell, bei dem sie ein natürlicher Teil der Softwareentwicklung ist.
Kommunikation über Channels
Goroutines sind darauf ausgelegt, über Channels zu kommunizieren, was ein typsicheres und weniger fehleranfälliges Modell als das traditionelle Shared-Memory-Modell mit Mutexes darstellt. Channels ermöglichen es, Daten zwischen Goroutines zu senden und zu empfangen, wobei die Synchronisation implizit durch die Channel-Operationen erfolgt.
Kein direkter Zugriff
Im Gegensatz zu Threads in vielen anderen Sprachen haben Goroutines keine ID, auf die man direkt zugreifen kann und es gibt keine direkte Möglichkeit, eine spezifische Goroutine von außen zu stoppen oder zu kontrollieren. Dies fördert ein Programmiermodell, bei dem Goroutines kooperativ arbeiten und selbst entscheiden, wann sie ihre Arbeit beenden.
Goroutines erstellen und starten
Das Erstellen und Starten einer Goroutine ist in Go außerordentlich einfach. Man verwendet das Schlüsselwort go gefolgt von einem Funktionsaufruf. Diese Funktion wird dann als separate Goroutine ausgeführt und läuft nebenläufig zum aufrufenden Code weiter.
Goroutine - einfachstes Beispiel
Das folgende, einfachste Beispiel zeigt, wie man eine Goroutine startet, die eine simple Aufgabe ausführt.
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from Goroutine")
}
func main() {
// Starte Goroutine
go sayHello()
// Hauptprogramm wartet kurz
// damit die Goroutine Zeit hat zum Laufen
time.Sleep(100 * time.Millisecond)
fmt.Println("Programm Ende")
}Hello from Goroutine
Programm EndeIn diesem Beispiel wird die Funktion sayHello() als Goroutine gestartet. Das go Schlüsselwort bewirkt, dass diese Funktion asynchron ausgeführt wird. Wichtig ist hier die time.Sleep() Anweisung - ohne diese würde das Hauptprogramm beenden, bevor die Goroutine ihre Ausgabe machen kann, da das Programm endet, sobald die main() Funktion terminiert, unabhängig davon, ob noch Goroutines laufen.
Goroutine mit anonymer Funktion
Goroutines können auch mit anonymen Funktionen (Closures) gestartet werden, was besonders praktisch ist, wenn man schnell eine kleine nebenläufige Aufgabe ausführen möchte.
package main
import (
"fmt"
"time"
)
func main() {
message := "Hallo von anonymen Goroutine"
// Starte die Goroutine mit anonymer Funktion
go func() {
fmt.Println(message)
}()
// Warten, damit Goroutine ausgeführt werden kann
time.Sleep(100 * time.Millisecond)
fmt.Println("Programm Ende")
}Hallo von anonymen Goroutine
Programm EndeHier wird eine anonyme Funktion direkt als Goroutine gestartet. Die anonyme Funktion hat Zugriff auf die Variable message aus dem umschließenden Scope (Closure). Dies ist ein mächtiges Feature, da man so leicht Kontext an Goroutines übergeben kann.
Goroutine mit Parametern
Goroutines können auch Parameter erhalten, genau wie normale Funktionen.
package main
import (
"fmt"
"time"
)
func printMessage(msg string, repeats int) {
for i := 0; i < repeats; i++ {
fmt.Printf("%s (Iteration: %d)\n", msg, i+1)
time.Sleep(50 * time.Millisecond)
}
}
func main() {
// Starte Goroutine mit Parametern
go printMessage("Goroutine A", 3)
// Starte eine weitere Goroutine
go printMessage("Goroutine B", 3)
// Zeit für die Ausführung der Goroutines
time.Sleep(500 * time.Millisecond)
fmt.Println("Programm Ende")
}Goroutine B (Iteration: 1)
Goroutine A (Iteration: 1)
Goroutine A (Iteration: 2)
Goroutine B (Iteration: 2)
Goroutine B (Iteration: 3)
Goroutine A (Iteration: 3)
Programm EndeIn diesem Beispiel werden zwei Goroutines gestartet, die beide die gleiche Funktion mit unterschiedlichen Parametern ausführen. Man kann sehen, dass die Ausgaben der beiden Goroutines sich vermischen, da beide nebenläufig laufen. Die genaue Reihenfolge der Ausgaben ist nicht deterministisch und kann bei jedem Programmlauf unterschiedlich sein.
Mehrere Goroutines in Schleife
Ein häufiges Pattern ist das Starten mehrere Goroutines in einer Schleife.
package main
import (
"fmt"
"time"
)
func worker(id int) {
fmt.Printf("Worker %d gestartet\n", id)
time.Sleep(time.Duration(id*100) * time.Millisecond)
fmt.Printf("Worker %d beendet\n", id)
}
func main() {
// Starte 5 Worker-Goroutines
for i := 1; i <= 5; i++ {
go worker(i)
}
// Genug Zeit für alle Worker warten
time.Sleep(1 * time.Second)
fmt.Println("Programm Ende - Alle Worker sollten fertig sein")
}Worker 1 gestartet
Worker 2 gestartet
Worker 5 gestartet
Worker 4 gestartet
Worker 3 gestartet
Worker 1 beendet
Worker 2 beendet
Worker 3 beendet
Worker 4 beendet
Worker 5 beendet
Programm Ende - Alle Worker sollten fertig seinDieses Beispiel zeigt ein typisches Worker-Pattern, bei dem mehrere Goroutines parallel Aufgaben ausführen können. Jeder Worker hat ein eindeutige ID und simuliert eine unterschiedlich lange Arbeitsdauer. Man beachte, dass die Worker nicht notwendigerweise in der Reihenfolge fertig werden, in der sie gestartet wurden.
Goroutines vs. Betriebssystem-Threads
Um die Vorteile von Goroutines wirklich zu schätzen, ist es wichtig, den Unterschied zu traditionellen Betriebssystem-Threads zu verstehen.
Speicherverbrauch
Ein Betriebssystem-Thread benötigt typischerweise einen festen Stack von 1-2 MB Größe. Dieser Stack muss von Anfang an alloziert werden, auch wenn er nicht vollständig genutzt wird. Bei 10.000 Threads würde allein der Stack-Speicher 10-20 GB RAM beanspruchen. Goroutines hingegen starten mit einem sehr kleinen Stack von etwas 2-8 KB, der bei Bedarf dynamisch wachsen kann. Dies ermöglicht es, Hunderttausende von Goroutines zu erstellen, ohne dass der Speicher zum Problem wird.
Der Go-Runtime verwaltet den Stack jeder Goroutine intelligent. Wenn eine Goroutine mehr Stack-Speicher benötigt (z.B. durch tiefe Rekursion oder viele lokale Variablen), wird der Stack automatisch vergrößert. Umgekehrt kann der Stack auch wieder verkleinert werden, wenn der Speicher nicht mehr benötigt wird.
Scheduling-Overhead
Betriebssystem-Threads werden vom Betriebssystem-Scheduler verwaltet. Ein Context-Switch zwischen Threads ist eine relativ teure Operation, die typischerweise 1-2 Mikrosekunden dauert und das Speichern und Wiederherstellen von CPU-Registern, Caches und anderen Zustandsinformationen erfordert. Bei vielen Threads kann der Scheduler-Overhead erheblich werden.
Goroutines werden hingegen vom Go-Runtime-Scheduler verwaltet, der im User-Space läuft. Der Scheduler ist speziell für Goroutines optimiert und kann Context-Switches viel schneller durchführen. Ein Goroutine-Context-Switch kostet nur etwa 200 Nanosekunden - das ist etwa 10x schneller als ein Thread-Context-Switch. Außerdem kann der Go-Scheduler intelligentere Entscheidungen treffen, da er mehr Informationen über den Programmzustand hat als der OS-Scheduler.
Um den Unterschied in der Skalierbarkeit zu demonstrieren, hier ein Beispiel, das viele Goroutines erstellt.
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func lightWork(id int, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}
func main() {
amount := 50000
var wg sync.WaitGroup
wg.Add(amount)
start := time.Now()
// Starte viele Goroutines
for i := 0; i < amount; i++ {
go lightWork(i, &wg)
}
wg.Wait()
duration := time.Since(start)
fmt.Printf("Anzahl Goroutines: %d\n", amount)
fmt.Printf("Anzahl OS-Threads: %d\n", runtime.NumGoroutines())
fmt.Printf("Gesamtdauer: %v\n", duration)
}Anzahl Goroutines: 50000
Anzahl OS-Threads: 1
Gesamtdauer: 72.666042msDieses Beispiel erstellt 50.000 Goroutines, was auf einem modernen System problemlos möglich ist. Würde man versuchen, 50.000 OS-Threads zu erstellen, würde dies auf den meisten Systemen entweder sehr lange dauern oder schlicht scheitern, da die Ressourcen nicht ausreichen würden. Mit Goroutines ist dies hingegen eine Routinenaufgabe.
Beispiel: CPU-intensive vs I/O-intensive Aufgaben
Goroutines zeigen ihre Stärken besonders bei I/O-intensiven Aufgaben, wo viel Wartezeit anfällt.
package main
import (
"fmt"
"sync"
"time"
)
func simulateNetworkRequest(id int, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(100 * time.Millisecond)
fmt.Printf("Anfrage %d abgeschlossen\n", id)
}
func main() {
requestAmount := 100
var wg sync.WaitGroup
wg.Add(requestAmount)
start := time.Now()
// Starte viele parallele "Netzwerkanfragen"
for i := 0; i < requestAmount; i++ {
go simulateNetworkRequest(i, &wg)
}
wg.Wait()
duration := time.Since(start)
fmt.Printf("\nAlle %d Anfragen in %v abgeschlossen\n", requestAmount, duration)
fmt.Printf("Ohne Parallelität hätte es ~%v gedauert\n", time.Duration(requestAmount)*100*time.Millisecond)
}Anfrage 98 abgeschlossen
Anfrage 96 abgeschlossen
Anfrage 2 abgeschlossen
Anfrage 74 abgeschlossen
Anfrage 4 abgeschlossen
Anfrage 48 abgeschlossen
Anfrage 28 abgeschlossen
Anfrage 39 abgeschlossen
Anfrage 40 abgeschlossen
Anfrage 78 abgeschlossen
Anfrage 72 abgeschlossen
Anfrage 0 abgeschlossen
Anfrage 73 abgeschlossen
Anfrage 22 abgeschlossen
Anfrage 27 abgeschlossen
Anfrage 5 abgeschlossen
Anfrage 6 abgeschlossen
Anfrage 7 abgeschlossen
Anfrage 8 abgeschlossen
Anfrage 9 abgeschlossen
Anfrage 10 abgeschlossen
Anfrage 11 abgeschlossen
Anfrage 12 abgeschlossen
Anfrage 13 abgeschlossen
Anfrage 14 abgeschlossen
Anfrage 15 abgeschlossen
Anfrage 18 abgeschlossen
Anfrage 19 abgeschlossen
Anfrage 20 abgeschlossen
Anfrage 17 abgeschlossen
Anfrage 16 abgeschlossen
Anfrage 52 abgeschlossen
Anfrage 23 abgeschlossen
Anfrage 24 abgeschlossen
Anfrage 21 abgeschlossen
Anfrage 25 abgeschlossen
Anfrage 1 abgeschlossen
Anfrage 58 abgeschlossen
Anfrage 49 abgeschlossen
Anfrage 50 abgeschlossen
Anfrage 51 abgeschlossen
Anfrage 26 abgeschlossen
Anfrage 86 abgeschlossen
Anfrage 89 abgeschlossen
Anfrage 87 abgeschlossen
Anfrage 64 abgeschlossen
Anfrage 88 abgeschlossen
Anfrage 34 abgeschlossen
Anfrage 35 abgeschlossen
Anfrage 36 abgeschlossen
Anfrage 33 abgeschlossen
Anfrage 41 abgeschlossen
Anfrage 42 abgeschlossen
Anfrage 37 abgeschlossen
Anfrage 44 abgeschlossen
Anfrage 43 abgeschlossen
Anfrage 47 abgeschlossen
Anfrage 45 abgeschlossen
Anfrage 46 abgeschlossen
Anfrage 59 abgeschlossen
Anfrage 60 abgeschlossen
Anfrage 71 abgeschlossen
Anfrage 61 abgeschlossen
Anfrage 63 abgeschlossen
Anfrage 3 abgeschlossen
Anfrage 65 abgeschlossen
Anfrage 62 abgeschlossen
Anfrage 66 abgeschlossen
Anfrage 90 abgeschlossen
Anfrage 67 abgeschlossen
Anfrage 32 abgeschlossen
Anfrage 68 abgeschlossen
Anfrage 93 abgeschlossen
Anfrage 69 abgeschlossen
Anfrage 53 abgeschlossen
Anfrage 70 abgeschlossen
Anfrage 54 abgeschlossen
Anfrage 81 abgeschlossen
Anfrage 82 abgeschlossen
Anfrage 55 abgeschlossen
Anfrage 76 abgeschlossen
Anfrage 83 abgeschlossen
Anfrage 75 abgeschlossen
Anfrage 56 abgeschlossen
Anfrage 84 abgeschlossen
Anfrage 57 abgeschlossen
Anfrage 77 abgeschlossen
Anfrage 95 abgeschlossen
Anfrage 38 abgeschlossen
Anfrage 85 abgeschlossen
Anfrage 94 abgeschlossen
Anfrage 29 abgeschlossen
Anfrage 79 abgeschlossen
Anfrage 97 abgeschlossen
Anfrage 30 abgeschlossen
Anfrage 80 abgeschlossen
Anfrage 31 abgeschlossen
Anfrage 92 abgeschlossen
Anfrage 91 abgeschlossen
Anfrage 99 abgeschlossen
Alle 100 Anfragen in 101.776125ms abgeschlossen
Ohne Parallelität hätte es ~10s gedauertIn diesem Beispiel werden 100 simulierte Netzwerkanfragen parallel ausgeführt. Ohne Parallelität würde dies etwa 10 Sekunden dauern (100 * 100ms), mit Goroutines nur etwas über 100 Millisekunden.