1 Einführung in Goroutines

1.1 Grundkonzepte von Nebenläufigkeit und Parallelität

Nebenläufigkeit und Parallelität sind zwei gängige Konzepte in der Mehrfadenprogrammierung. Sie werden verwendet, um Ereignisse oder Programmabläufe zu beschreiben, die gleichzeitig auftreten können.

  • Nebenläufigkeit bezieht sich auf die gleichzeitige Verarbeitung mehrerer Aufgaben im selben Zeitrahmen, wobei jedoch jeweils nur eine Aufgabe zu einem gegebenen Zeitpunkt ausgeführt wird. Die Aufgaben wechseln schnell zwischen einander, was dem Benutzer eine Illusion von simultaner Ausführung vermittelt. Nebenläufigkeit eignet sich für Prozessoren mit nur einem Kern.
  • Parallelität bezieht sich auf die tatsächlich gleichzeitige Ausführung mehrerer Aufgaben zur gleichen Zeit, was eine Unterstützung durch Mehrkernprozessoren erfordert.

Die Go-Sprache zielt darauf ab, Nebenläufigkeit als eines ihrer Hauptziele effizient umzusetzen. Sie erreicht effiziente Nebenläufigkeits-Programmiermodelle durch Goroutinen und Kanäle. Die Laufzeitumgebung von Go verwaltet Goroutinen und kann diese auf mehreren Systemthreads planen, um eine parallele Verarbeitung zu erreichen.

1.2 Goroutinen in der Go-Sprache

Goroutinen sind das Kernkonzept für die Umsetzung von Nebenläufigkeit in der Go-Sprache. Sie sind leichte Threads, die von der Laufzeitumgebung von Go verwaltet werden. Aus Benutzersicht ähneln sie Threads, verbrauchen jedoch weniger Ressourcen und starten schneller.

Die Merkmale von Goroutinen sind:

  • Leichtgewichtig: Goroutinen beanspruchen weniger Stapelspeicher im Vergleich zu herkömmlichen Threads, und ihre Stapelgröße kann sich dynamisch vergrößern oder verkleinern, je nach Bedarf.
  • Geringer Overhead: Der Overhead für das Erstellen und Zerstören von Goroutinen ist wesentlich niedriger als für herkömmliche Threads.
  • Einfacher Kommunikationsmechanismus: Kanäle bieten einen einfachen und effektiven Kommunikationsmechanismus zwischen Goroutinen.
  • Nicht-blockierendes Design: Goroutinen blockieren andere Goroutinen nicht bei bestimmten Operationen. Beispielsweise können andere Goroutinen weiterhin ausgeführt werden, während eine Goroutine auf E/A-Operationen wartet.

2 Erstellung und Verwaltung von Goroutinen

2.1 Erstellung einer Goroutine

In der Go-Sprache können Sie ganz einfach eine Goroutine durch Verwendung des Schlüsselworts go erstellen. Wenn Sie einen Funktionsaufruf mit dem Schlüsselwort go versehen, wird die Funktion asynchron in einer neuen Goroutine ausgeführt.

Werfen wir einen Blick auf ein einfaches Beispiel:

package main

import (
	"fmt"
	"time"
)

// Definieren einer Funktion, die 'Hallo' ausgibt
func sayHello() {
	fmt.Println("Hallo")
}

func main() {
	// Starten einer neuen Goroutine mit dem go-Schlüsselwort
	go sayHello()

	// Die Haupt-Goroutine wartet eine Weile, um 'sayHello' die Ausführung zu ermöglichen
	time.Sleep(1 * time.Second)
	fmt.Println("Hauptfunktion")
}

In dem obigen Code wird die Funktion sayHello() asynchron in einer neuen Goroutine ausgeführt. Das bedeutet, dass die main()-Funktion nicht auf das Ende von sayHello() warten wird, bevor sie fortsetzt. Daher verwenden wir time.Sleep, um die Haupt-Goroutine zu pausieren und die Ausgabe in sayHello ausführen zu lassen. Dies dient nur zu Demonstrationszwecken. In der tatsächlichen Entwicklung verwenden wir in der Regel Kanäle oder andere Synchronisationsmethoden, um die Ausführung verschiedener Goroutinen zu koordinieren.

Hinweis: In praktischen Anwendungen sollte time.Sleep() nicht zur Wartezeit für das Beenden einer Goroutine verwendet werden, da dies kein zuverlässiger Synchronisationsmechanismus ist.

2.2 Goroutine-Planungsmechanismus

In Go wird die Planung von Goroutinen vom Scheduler der Go-Laufzeitumgebung gehandhabt, der für die Zuweisung von Ausführungszeit auf verfügbaren logischen Prozessoren verantwortlich ist. Der Go-Scheduler verwendet die M:N-Planungstechnologie (mehrere Goroutinen, die mehreren Betriebssystemthreads zugeordnet sind), um bessere Leistung auf Mehrkernprozessoren zu erreichen.

GOMAXPROCS und Logische Prozessoren

GOMAXPROCS ist eine Umgebungsvariable, die die maximale Anzahl von CPUs definiert, die dem Laufzeitscheduler zur Verfügung stehen, wobei der Standardwert der Anzahl der CPU-Kerne auf dem Rechner entspricht. Die Go-Laufzeitumgebung weist für jeden logischen Prozessor einen Betriebssystemthread zu. Durch Festlegen von GOMAXPROCS können wir die Anzahl der vom Scheduler verwendeten Kerne begrenzen.

import "runtime"

func init() {
    runtime.GOMAXPROCS(2)
}

Der obige Code legt maximal zwei Kerne fest, um Goroutinen zu planen, selbst wenn das Programm auf einem Rechner mit mehr Kernen läuft.

Scheduler-Betrieb

Der Scheduler operiert mithilfe von drei wichtigen Entitäten: M (Maschine), P (Prozessor) und G (Goroutine). M repräsentiert eine Maschine oder einen Thread und dient als Abstraktion von OS-Kernel-Threads. P repräsentiert den Kontext der Planung und G repräsentiert eine spezifische Goroutine.

  1. M: Stellt eine Maschine oder einen Thread dar und dient als Abstraktion von OS-Kernel-Threads.
  2. P: Stellt die Ressourcen dar, die zur Ausführung einer Goroutine erforderlich sind. Jedes P hat eine lokale Goroutine-Warteschlange.
  3. G: Stellt eine Goroutine dar, einschließlich ihres Ausführungsstapels, Befehlssatzes und anderer Informationen.

Die Arbeitsprinzipien des Go-Schedulers sind:

  • M muss ein P haben, um G auszuführen. Wenn kein P vorhanden ist, wird M in den Thread-Cache zurückgegeben.
  • Wenn G nicht durch andere G blockiert ist (z. B. bei Systemaufrufen), läuft es so weit wie möglich auf demselben M, um dazu beizutragen, dass die lokalen Daten von G für eine effizientere CPU-Cache-Nutzung "warm" bleiben.
  • Wenn ein G blockiert ist, trennen sich M und P, und P sucht nach einem neuen M oder weckt ein neues M auf, um andere G zu bedienen.
go func() {
    fmt.Println("Hallo von Goroutine")
}()

Der obige Code zeigt das Starten einer neuen Goroutine, was den Scheduler dazu veranlasst, diese neue G zur Ausführungswarteschlange hinzuzufügen.

Unterbrechende Planung von Goroutinen

In den frühen Stadien verwendete Go kooperative Planung, was bedeutet, dass Goroutinen andere Goroutinen aushungern konnten, wenn sie lange Zeit ohne freiwillige Aufgabe der Kontrolle ausgeführt wurden. Jetzt implementiert der Go-Scheduler unterbrechende Planung, was es ermöglicht, dass lang laufende Gs pausiert werden, um anderen Gs die Möglichkeit zur Ausführung zu geben.

2.3 Verwaltung des Lebenszyklus von Goroutinen

Um die Robustheit und Leistung Ihrer Go-Anwendung zu gewährleisten, ist das Verständnis und die ordnungsgemäße Verwaltung des Lebenszyklus von Goroutinen entscheidend. Das Starten von Goroutinen ist einfach, kann aber ohne ordnungsgemäße Verwaltung zu Problemen wie Speicherlecks und Wettlaufbedingungen führen.

Sicheres Starten von Goroutinen

Bevor Sie eine Goroutine starten, stellen Sie sicher, dass Sie ihre Arbeitslast und Laufzeitmerkmale verstehen. Eine Goroutine sollte einen klaren Start und ein Ende haben, um die Erstellung von "Goroutine-Waisen" ohne Beendigungsbedingungen zu vermeiden.

func worker(done chan bool) {
    fmt.Println("Arbeiten...")
    time.Sleep(time.Second) // teure Aufgabe simulieren
    fmt.Println("Arbeit abgeschlossen.")
    done <- true
}

func main() {
    // Hier wird der Kanalmechanismus in Go verwendet. Sie können den Kanal einfach als eine einfache Nachrichtenwarteschlange betrachten und den "<-" Operator verwenden, um Daten in die Warteschlange zu schreiben und daraus zu lesen.
    done := make(chan bool, 1)
    go worker(done)
    
    // Warten, bis die Goroutine beendet ist
    <-done
}

Der obige Code zeigt eine Möglichkeit, auf eine Goroutine zu warten, bis sie über den done Kanal beendet ist.

Hinweis: Dieses Beispiel verwendet den Kanalmechanismus in Go, der in späteren Kapiteln ausführlicher erläutert wird.

Beenden von Goroutinen

Im Allgemeinen wird das Ende des gesamten Programms alle Goroutinen implizit beenden. In lang laufenden Diensten müssen wir jedoch möglicherweise Goroutinen aktiv stoppen.

  1. Verwenden von Kanälen zum Senden von Stopp-Signalen: Goroutinen können Kanäle abfragen, um nach Stopp-Signalen zu suchen.
stop := make(chan struct{})

go func() {
    for {
        select {
        case <-stop:
            fmt.Println("Stopp-Signal erhalten. Herunterfahren...")
            return
        default:
            // normale Operation ausführen
        }
    }
}()

// Stopp-Signal senden
stop <- struct{}{}
  1. Verwenden des context-Pakets zum Verwalten des Lebenszyklus:
ctx, cancel := context.WithCancel(context.Background())

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Stopp-Signal erhalten. Herunterfahren...")
            return
        default:
            // normale Operation ausführen
        }
    }
}(ctx)

// Wenn Sie die Goroutine beenden möchten
cancel()

Die Verwendung des context-Pakets ermöglicht eine flexiblere Steuerung von Goroutinen und bietet Timeout- und Abbruchfunktionen. In großen Anwendungen oder Mikroservices ist context der empfohlene Weg zur Steuerung der Lebenszyklen von Goroutinen.