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.Sleepzur Synchronisierung von Goroutines in echtem Code ist ein Anti-Pattern. Abschnitt 4 stelltsync.WaitGroupals 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 Typfloat64.chan<- float64im 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 + taxsendet einen Wert in den Channel.<-resultinmainwird blockiert, bis Daten verfügbar sind, und fungiert als natürliche Synchronisierung — keineSleeperforderlich.
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 Panicsend on closed channel; Lesen aus einem geschlossenen Channel ist erlaubt und gibt den Null-Wert undok == falsezurü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 vorgoaufgerufen 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.WithTimeouterstellt einen Context, der sich nach 1200ms automatischDone()setzt.- In
monitorPaymentwartetselectsowohl aufctx.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 (überdefer), auch wenn SieWithTimeoutoderWithCancelverwenden. 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.