Goroutine und Channel: Grundlagen der Concurrency in Go

1. Übersicht

Go wurde entwickelt, um nebenläufige Programme einfach zu schreiben, ohne Callback-Hell oder komplexe Thread-Pools. Die beiden Kernbausteine sind Goroutine (leichte Ausführungseinheiten) und Channel (Kommunikationskanal zwischen Goroutines). Nach dieser Lektüre werden Sie wissen, wie man Goroutines starten, sie mit sync.WaitGroup synchronisiert, sicher über Channels kommuniziert, select zur Verarbeitung mehrerer Channels gleichzeitig nutzt und häufige Fehler wie Goroutine-Leaks oder Deadlocks vermeidet.

Alle Beispiele verwenden Go 1.22+, ohne externe Bibliotheken — Sie müssen nur go run main.go ausführen.

2. Was ist eine Goroutine?

Eine Goroutine ist eine Funktion, die unabhängig läuft und von der Go Runtime statt vom Betriebssystem verwaltet wird. Die Kosten für die Erstellung einer Goroutine sind sehr gering (wenige KB Stack, automatisch skalierbar), sodass Sie Zehntausende von Goroutines erstellen können, ohne sich Sorgen um Ressourcenerschöpfung wie bei Systemthreads zu machen.

package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	fmt.Println("Hallo,", name)
}

func main() {
	go sayHello("Order Service")
	go sayHello("Payment Service")

	time.Sleep(100 * time.Millisecond) // warten, bis Goroutines fertig sind
	fmt.Println("Main beendet")
}

Das Schlüsselwort go vor einem Funktionsaufruf führt diese Funktion in einer neuen Goroutine aus, ohne main zu blockieren. Da main beenden könnte, bevor die beiden Goroutines ausgeben, müssen wir time.Sleep verwenden, um zu warten — dies ist eine unzuverlässige Methode, nur zur Veranschaulichung. Die Ausgabe könnte sein:

Hallo, Payment Service
Hallo, Order Service
Main beendet

Die Reihenfolge der Ausgabe ist nicht festgelegt, da der Scheduler entscheidet, welche Goroutine zuerst läuft. Das ist das Wesen der Concurrency: mehrere Aufgaben laufen “gleichzeitig”, aber die Reihenfolge ist nicht garantiert.

Hinweis: time.Sleep zur Synchronisierung von Goroutines in echtem Code ist ein Anti-Pattern. Abschnitt 4 stellt sync.WaitGroup als Ersatz vor.

3. Channel: Kommunikationskanal zwischen Goroutines

Ein Channel ist ein typsicheres Rohr für Goroutines zum Senden und Empfangen von Daten. Die Philosophie von Go lautet: “Don’t communicate by sharing memory; share memory by communicating.”

package main

import "fmt"

func calculateTotal(amount float64, result chan<- float64) {
	tax := amount * 0.1
	result <- amount + tax
}

func main() {
	result := make(chan float64)

	go calculateTotal(1_000_000, result)

	total := <-result
	fmt.Printf("Gesamtbestellungsbetrag: %.0f\n", total)
}
  • make(chan float64) erstellt einen unbuffered Channel vom Typ float64.
  • chan<- float64 im Funktionsparameter bedeutet, dass der Channel nur zum Senden (send-only) verwendet wird, sodass der Compiler einen Fehler abfängt, wenn Sie versehentlich davon lesen.
  • result <- amount + tax sendet einen Wert in den Channel.
  • <-result in main wird blockiert, bis Daten verfügbar sind, und fungiert als natürliche Synchronisierung — keine Sleep erforderlich.
Gesamtbestellungsbetrag: 1100000

3.1. Unbuffered vs. Buffered Channel

Ein unbuffered Channel (make(chan T)) erfordert, dass Sender und Empfänger “sich treffen” — das Senden wird blockiert, bis jemand empfängt. Ein buffered Channel (make(chan T, n)) ermöglicht das Senden von bis zu n Werten, ohne dass der Empfänger verfügbar sein muss.

package main

import "fmt"

func main() {
	orders := make(chan string, 3)

	orders <- "Order-1"
	orders <- "Order-2"
	orders <- "Order-3"
	close(orders)

	for order := range orders {
		fmt.Println("Verarbeite:", order)
	}
}

Weil der Buffer Größe 3 hat, werden die drei Sendeoperationen nicht blockiert. close(orders) signalisiert, dass keine weiteren Daten kommen, sodass die range-Schleife beendet wird, anstatt unendlich auf den nächsten Wert zu warten.

Verarbeite: Order-1
Verarbeite: Order-2
Verarbeite: Order-3

Hinweis: Nur die Sender-Seite darf einen Channel close. Senden in einen geschlossenen Channel verursacht Panic send on closed channel; Lesen aus einem geschlossenen Channel ist erlaubt und gibt den Null-Wert und ok == false zurück.

4. Mehrere Goroutines mit WaitGroup synchronisieren

Wenn Sie mehrere Goroutines warten möchten, bis sie fertig sind, und sich nicht um Rückgabewerte kümmern, ist sync.WaitGroup eleganter als Channel.

package main

import (
	"fmt"
	"sync"
)

func processOrder(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Order-%d verarbeitet\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go processOrder(i, &wg)
	}

	wg.Wait()
	fmt.Println("Alle Bestellungen verarbeitet")
}
  • wg.Add(1) inkrementiert den Zähler, bevor die Goroutine läuft — muss vor go aufgerufen werden, nicht innerhalb der Goroutine, um Race Conditions zu vermeiden.
  • defer wg.Done() dekrementiert den Zähler, wenn die Goroutine beendet wird.
  • wg.Wait() blockiert main, bis der Zähler 0 erreicht.
Order-2 verarbeitet
Order-1 verarbeitet
Order-3 verarbeitet
Alle Bestellungen verarbeitet

Die Reihenfolge der ersten drei Zeilen ist nicht festgelegt, aber die letzte Zeile wird immer zuletzt gedruckt — das garantiert WaitGroup.

4.1. Testen von nebenläufigem Verhalten

Sie können Tests schreiben, um zu bestätigen, dass alle Goroutines fertig werden, indem Sie Ergebnisse über einen Zählkanal verfolgen statt auf Ausgabereihenfolge zu verlassen:

package orderpkg

import (
	"sync"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestProcessOrders_AllCompleted(t *testing.T) {
	var wg sync.WaitGroup
	var processed int32

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			atomic.AddInt32(&processed, 1)
		}(i)
	}

	wg.Wait()

	assert.Equal(t, int32(5), processed)
}

atomic.AddInt32 vermeidet Race Conditions, wenn mehrere Goroutines dieselbe Zählvariable inkrementieren — ohne atomic würde go test -race einen Data Race melden.

Abhängigkeit für Tests (fügen Sie go.mod hinzu):

go get github.com/stretchr/testify

5. Select: mehrere Channels gleichzeitig verarbeiten

select ist wie switch, aber für Channels — es wählt den ersten verfügbaren Case aus. Sehr nützlich, wenn Sie auf mehrere Datenquellen warten oder einen Timeout setzen möchten.

package main

import (
	"fmt"
	"time"
)

func fetchFromCache(ch chan<- string) {
	time.Sleep(50 * time.Millisecond)
	ch <- "Daten aus Cache"
}

func fetchFromDB(ch chan<- string) {
	time.Sleep(200 * time.Millisecond)
	ch <- "Daten aus Datenbank"
}

func main() {
	cacheCh := make(chan string)
	dbCh := make(chan string)

	go fetchFromCache(cacheCh)
	go fetchFromDB(dbCh)

	select {
	case res := <-cacheCh:
		fmt.Println("Erhalten:", res)
	case res := <-dbCh:
		fmt.Println("Erhalten:", res)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("Timeout, verwende Standarddaten")
	}
}

select blockiert, bis einer der Cases Daten hat. Da der Cache nach 50ms antwortet, aber die DB nach 200ms, gewinnt der cacheCh-Case:

Erhalten: Daten aus Cache

time.After erstellt einen Channel, der nach der angegebenen Zeit einen Wert sendet — wird verwendet, um einen Timeout für den gesamten select zu implementieren.

6. Goroutines richtig mit context abbrechen

Ein häufiger Fehler ist, Goroutines zu starten, aber keine Möglichkeit zu haben, sie zu stoppen, was zu Goroutine-Leaks führt. Das Package context löst dies, indem es ein Abbruchsignal über die gesamte Call-Chain versendet.

package main

import (
	"context"
	"fmt"
	"time"
)

func monitorPayment(ctx context.Context, orderID string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Stoppe Überwachung der Bestellung:", orderID, "-", ctx.Err())
			return
		case <-time.After(500 * time.Millisecond):
			fmt.Println("Überprüfe Status der Bestellung:", orderID)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
	defer cancel()

	go monitorPayment(ctx, "Order-99")

	<-ctx.Done()
	time.Sleep(50 * time.Millisecond) // warten, bis Goroutine letzte Zeile druckt
}
  • context.WithTimeout erstellt einen Context, der sich nach 1200ms automatisch Done() setzt.
  • In monitorPayment wartet select sowohl auf ctx.Done() als auch auf regelmäßige Ticks. Wenn der Context abläuft, beendet sich die Goroutine sauber, statt für immer zu laufen.
  • defer cancel() stellt sicher, dass Context-Ressourcen auch dann freigegeben werden, wenn die Funktion früh beendet wird.
Überprüfe Status der Bestellung: Order-99
Überprüfe Status der Bestellung: Order-99
Stoppe Überwachung der Bestellung: Order-99 - context deadline exceeded

Hinweis: Rufen Sie immer cancel() auf (über defer), auch wenn Sie WithTimeout oder WithCancel verwenden. Diese Schritte zu überspringen verursacht keinen sofortigen Fehler, führt aber zu Context-Lecks in langen Schleifen.

7. Häufige Fehler

Deadlock, weil Channel keinen Empfänger hat. Senden in einen unbuffered Channel ohne eine Goroutine, die liest, blockiert das Programm, und die Go Runtime erkennt dies:

func main() {
	ch := make(chan int)
	ch <- 1 // Niemand liest → Deadlock
}
fatal error: all goroutines are asleep - deadlock!

Goroutine-Leak. Eine Goroutine wartet für immer auf Daten aus einem Channel, weil niemand sendet und es keinen Abbruchmechanismus gibt — Speicher wird bis zum Ende des Programms gehalten. Verwenden Sie immer context oder einen “done”-Channel, wenn Sie längerlaufende Goroutines starten.

Loop-Variable falsch in Closure erfasst (vor Go 1.22). Wenn Sie Go < 1.22 verwenden, wird die Loop-Variable i in for über alle Iterationen geteilt, sodass alle Goroutines denselben letzten Wert sehen könnten. Ab Go 1.22 erstellt jede Schleifeniteration eine neue i-Variable, daher ist dieses Problem gelöst — aber wenn Ihr Projekt eine ältere Version verwendet, übergeben Sie die Variable als Parameter wie im Beispiel in Abschnitt 4.1.

Race Condition beim Variablensharing. Zwei Goroutines, die dieselbe Variable lesen/schreiben, ohne Channels oder sync/atomic zu verwenden, ist eine klassische Race Condition. Führen Sie immer go test -race oder go run -race während der Entwicklung aus, um früh Probleme zu erkennen.

Wann sollten Sie Goroutines NICHT verwenden: Bei kurzen Aufgaben können die Kosten für die Erstellung einer Goroutine und Synchronisierung größer sein als der Nutzen. Wenn eine Aufgabe nur wenige Mikrosekunden dauert, ist eine sequenzielle Ausführung oft schneller und lesbarer.

8. Fazit

Goroutines geben Ihnen eine Möglichkeit, leichte Ausführungsthreads mit extrem niedrigen Kosten zu erstellen, während Channels einen sicheren Kommunikationsmechanismus bieten, anstatt Speicher direkt zu teilen. Kombiniert mit sync.WaitGroup für Synchronisierung, select für Multi-Source-Verarbeitung und context für ordnungsgemäße Beendigung haben Sie alle Werkzeuge, die Sie benötigen, um gängige Concurrency-Patterns in Go aufzubauen — vom Worker Pool über Pipeline bis zu Fan-In/Fan-Out. Der nächste Schritt sollte das Üben mit go test -race sein, um sich früh an die Erkennung von Race Conditions zu gewöhnen.

golanggoroutinechannelconcurrency