navigation Navigation


Inhaltsverzeichnis

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.

    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")
    }
    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.

    Beispiel - Anonyme Fuktion
    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 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.

    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")
    }
    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 mehrere Goroutines in einer Schleife.

    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")
    }
    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 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.

    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("Anzahl OS-Threads: %d\n", runtime.NumGoroutines())
        fmt.Printf("Gesamtdauer: %v\n", duration)
    }
    Anzahl Goroutines: 50000
    Anzahl OS-Threads: 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.

    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)
    }
    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.