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.

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 Einfachheit 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.

Go Beispiel - Einfachste Goroutine
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")
}
Output
Hello from Goroutine
Programm Ende

In 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.

Go Beispiel - Anonyme Funktion
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")
}
Output
Hallo von anonymen Goroutine
Programm Ende

Hier 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.

Go Beispiel - Mit Parametern
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")
}
Output
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 Ende

In 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 mehrerer Goroutines in einer Schleife.

Go Beispiel - Goroutines in 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")
}
Output
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 sein

Dieses Beispiel zeigt ein typisches Worker-Pattern, bei dem mehrere Goroutines parallel Aufgaben ausführen können. Jeder Worker hat eine 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 etwa 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.

Go Beispiel
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("Aktive Goroutines (runtime): %d\n", runtime.NumGoroutine())
    fmt.Printf("Gesamtdauer: %v\n", duration)
}
Output
Anzahl Goroutines: 50000
Aktive Goroutines (runtime): 1
Gesamtdauer: 72.666042ms

Dieses 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.

Go Beispiel
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)
}
Output
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 gedauert

In 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.

Häufige Stolperfallen

main beendet → alle Goroutines sterben mitten im Lauf.

Sobald die main-Funktion zurückkehrt, terminiert das gesamte Programm — unabhängig davon, ob noch Goroutines arbeiten. time.Sleep als Wartemechanismus ist eine Krücke und race-anfällig. Korrekt: sync.WaitGroup mit Add vor dem go, defer wg.Done() in der Goroutine und wg.Wait() in main.

Goroutine-Leak durch ewig wartenden Channel.

Eine Goroutine, die auf einem Channel-Empfang blockiert, der nie befüllt oder geschlossen wird, lebt bis zum Programmende weiter — inklusive Stack und Closure-Variablen. Bei langlebigen Servern summiert sich das zu echten Speicherleaks. Sicherstellen, dass Sender den Channel schließen oder ein context.Context den Abbruch signalisiert.

Race Condition durch geteilten Speicher ohne Lock.

Mehrere Goroutines, die dieselbe Variable schreiben, sind ein Klassiker für undefiniertes Verhalten. Entweder sync.Mutex um den kritischen Abschnitt, ein sync/atomic-Wert oder — idiomatischer — die Kommunikation über einen Channel. go run -race bzw. go test -race deckt solche Fehler zuverlässig auf und sollte in jeder CI laufen.

Loop-Variable in Goroutine — Falle vor Go 1.22.

In älteren Versionen teilten alle Goroutines einer for-Schleife dieselbe Variable, sodass am Ende oft nur der letzte Wert sichtbar war. Lösung damals: i := i als lokale Kopie oder Übergabe als Parameter. Seit Go 1.22 hat jede Iteration ihre eigene Variable — beim Wechsel der Go-Version den go-Direktive-Eintrag in go.mod prüfen.

Eine Panic in einer Goroutine reißt das ganze Programm runter.

Panics propagieren nicht in die aufrufende Goroutine — sie eskalieren bis zum Top-Frame der eigenen Goroutine und beenden dann das gesamte Programm. Wer robusten Code schreibt, packt langlebige Worker in einen defer recover()-Block oder eine Wrapper-Funktion und loggt Fehler kontrolliert.

Goroutines sind günstig, aber nicht gratis.

Stack-Initialisierung, Scheduler-Buchführung und Garbage-Collector-Druck kosten messbar Zeit, sobald Millionen von Goroutines im Spiel sind. Für CPU-gebundene Arbeit ist eine feste Worker-Pool-Größe (typisch runtime.GOMAXPROCS(0)) oft schneller als „eine Goroutine pro Aufgabe”.

runtime.Gosched ist meist überflüssig.

Der Go-Scheduler ist seit Go 1.14 präemptiv und unterbricht auch CPU-lastige Goroutines selbstständig. Manuelles Gosched ist nur in Spezialfällen sinnvoll und meist ein Code-Smell, der auf eine zu enge Schleife oder eine fehlende Channel-Operation hinweist.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Goroutines & Channels

Zur Übersicht