Concurrency
Nach diesem Kapitel kannst du Goroutines, Channels, select, WaitGroup und Mutex gezielt einsetzen, einen Worker-Pool mit sauberem Shutdown bauen und die zwei typischen Fehler – Leaks und Data Races – erkennen und vermeiden.
upwatch in diesem Teil: der Worker-Pool in scheduler.go und seine Tests in scheduler_test.go Voraussetzungen: Teil 03 bis 05
go doSomething()
Nebenläufigkeit beginnt in Go mit einem Schlüsselwort:
go check(target)
check läuft jetzt in einer eigenen Goroutine, und die aufrufende Funktion macht sofort mit der nächsten Zeile weiter. Eine Goroutine ist kein Betriebssystem-Thread. Die Go-Laufzeit verteilt beliebig viele Goroutines auf wenige Threads (in der Regel so viele, wie GOMAXPROCS angibt), und jede Goroutine startet mit einem Stack von wenigen Kilobyte, der bei Bedarf wächst. Zehntausende gleichzeitige Goroutines sind deshalb in einem Server kein Problem; in net/http bekommt jede eingehende Verbindung ihre eigene.
Drei Eigenschaften überraschen Umsteiger:
- Eine Goroutine hat keinen Rückgabewert und kein Handle. Es gibt kein
Task<T>und keinPromise, das man später abfragen könnte. Ergebnisse und Fehler musst du selbst zurückmelden, meist über einen Channel. - Man kann eine Goroutine nicht von außen beenden. Sie endet, wenn ihre Funktion zurückkehrt. Wer sie stoppen will, muss ihr das mitteilen – über einen Channel oder einen Context (Teil 07).
- Wenn
mainzurückkehrt, endet das Programm, egal wie viele Goroutines noch laufen. Es wird auf niemanden gewartet.
Das folgende Programm gibt deshalb wahrscheinlich gar nichts aus:
func main() {
go fmt.Println("checking https://example.com")
} // main returns, the process exits
Alles Weitere in diesem Kapitel dreht sich um zwei Fragen: Wie kommen Daten zwischen Goroutines hin und her, und woher weiß man, wann eine Goroutine fertig ist?
Goroutines blockieren, Funktionen sind nicht gefärbt
Bevor es um Channels geht, lohnt ein Blick auf den grundlegenden Unterschied zu C# und TypeScript. Dort sieht ein HTTP-Check so aus:
async Task<int> CheckAsync(string url, CancellationToken ct)
{
var resp = await client.GetAsync(url, ct);
return (int)resp.StatusCode;
}
await gibt den Thread frei, solange auf das Netzwerk gewartet wird. Der Preis: Die Funktion wird async, ihr Rückgabetyp wird Task<int>, und jeder Aufrufer, der nicht blockieren will, muss selbst async werden. Die Eigenschaft „asynchron“ breitet sich durch die Aufrufkette aus. Dafür hat sich der Begriff „Function Coloring“ eingebürgert: Es gibt rote (async) und blaue (sync) Funktionen, und sie lassen sich nicht frei mischen.
In Go gibt es diese Unterscheidung nicht. client.Do(req) blockiert die aufrufende Goroutine, bis die Antwort da ist. Die Laufzeit parkt die Goroutine, registriert den Socket beim Netpoller des Betriebssystems (epoll, kqueue, IOCP) und lässt den Thread in der Zwischenzeit andere Goroutines ausführen. Was C# mit await im Code markiert, erledigt Go unsichtbar im Scheduler.
Die naheliegendste Analogie kommt aus Java: Virtual Threads (seit Java 21) funktionieren genauso. Code blockiert ganz normal, und die JVM parkt den virtuellen Thread, solange er auf I/O wartet. Der Unterschied liegt in der Verbreitung: In Java sind Virtual Threads eine Option neben Plattform-Threads und reaktiven Bibliotheken, in Go ist die Goroutine das einzige Modell, und jede Bibliothek ist dafür geschrieben.
| Aufgabe | C# | Java | TypeScript | Go |
|---|---|---|---|---|
| leichtgewichtige Einheit | Task mit async/await |
Virtual Thread | Promise auf einer Event-Loop |
Goroutine |
| starten | Task.Run(...) |
Thread.startVirtualThread(...) |
async-Funktion aufrufen |
go f() |
| auf alle warten | Task.WhenAll |
CountDownLatch, ExecutorService.invokeAll |
Promise.all |
sync.WaitGroup |
| Werte weiterreichen | Channel<T> (System.Threading.Channels) |
BlockingQueue |
– | chan T |
| Ergebnis abholen | await task |
future.get() |
await promise |
kein Handle, meist über einen Channel |
Für den Code heißt das: Jede Funktion ist synchron geschrieben. check.Checker.Check in check.go ist eine gewöhnliche Funktion, die ein Ergebnis zurückgibt. Ob sie parallel läuft, entscheidet nicht sie selbst, sondern der Aufrufer – der Scheduler, der sie in mehreren Goroutines startet. Bibliotheken bieten deshalb in Go fast immer blockierende APIs an, und das ist richtig so: Nebenläufigkeit ist eine Entscheidung der Anwendung, nicht der Bibliothek.
Der Trade-off: In C# siehst du an jedem await, wo eine Funktion unterbrochen werden kann. In Go kann jede Goroutine zu jeder Zeit unterbrochen werden, und mehrere laufen echt parallel auf mehreren Kernen. Geteilte Daten brauchen deshalb immer Synchronisation – auch dort, wo in einer Single-Thread-Event-Loop wie Node.js nichts passieren könnte.
Channels
Ein Channel ist eine typisierte Leitung zwischen Goroutines:
results := make(chan monitor.Result) // unbuffered
results <- res // send
res := <-results // receive
Ein ungepufferter Channel hat keine Kapazität. Ein Send blockiert, bis eine andere Goroutine empfängt, und umgekehrt. Damit ist jede Übergabe gleichzeitig ein Synchronisationspunkt: Wenn results <- res zurückkehrt, weißt du, dass jemand das Ergebnis angenommen hat.
Ein gepufferter Channel hat eine feste Kapazität:
serveErr := make(chan error, 1)
Sends blockieren erst, wenn der Puffer voll ist. Das entkoppelt Sender und Empfänger, kann aber auch Probleme verdecken: Ein Puffer von 1000 verschiebt einen Stau nur nach hinten. Gepufferte Channels mit Kapazität 1 sind ein häufiges Muster für „eine Goroutine meldet genau ein Ergebnis, und es ist nicht sicher, ob jemand es abholt“. Genau so nutzt main.go serveErr: Die Server-Goroutine kann ihren Fehler immer ablegen und sich beenden, auch wenn main gerade mit dem Shutdown beschäftigt ist und nicht mehr liest.
Richtung im Typ
In Funktionssignaturen kannst du einschränken, was mit einem Channel erlaubt ist:
func (s *Scheduler) dispatch(ctx context.Context, jobs chan<- monitor.Target)
func (s *Scheduler) work(ctx context.Context, jobs <-chan monitor.Target, results chan<- monitor.Result)
chan<- T darf nur senden, <-chan T nur empfangen. Ein bidirektionaler Channel wird beim Aufruf automatisch umgewandelt. Das ist Dokumentation, die der Compiler prüft: dispatch kann jobs nicht versehentlich lesen, work kann jobs nicht schließen.
close und range
close(ch) signalisiert: Es kommen keine Werte mehr. Empfänger bekommen danach die restlichen gepufferten Werte und dann sofort den Nullwert; mit der Zwei-Werte-Form erkennst du das Ende:
t, ok := <-jobs // ok == false: closed and drained
Üblicher ist range, das genau bis zum Schließen läuft:
for t := range jobs {
// handle t
}
// jobs was closed
Senden auf einen geschlossenen Channel panikt, doppeltes Schließen auch. Schließen ist also keine Aufräumarbeit wie bei einer Datei, sondern eine Nachricht an die Empfänger. Ein Channel, den niemand schließt, wird trotzdem vom Garbage Collector eingesammelt, sobald keiner ihn mehr referenziert. Schließen musst du nur, wenn ein Empfänger auf das Ende wartet.
select
select wartet auf mehrere Channel-Operationen gleichzeitig und führt die erste aus, die bereit ist. Sind mehrere bereit, wird zufällig gewählt. Das wichtigste Muster ist „sende, aber gib auf, wenn abgebrochen wird“, hier aus dem Dispatcher des Schedulers:
for _, t := range due {
select {
case jobs <- t:
case <-ctx.Done():
return
}
}
select {
case <-ticker.C:
case <-ctx.Done():
return
}
Zwei Bausteine darin sind neu. ctx.Done() ist ein Channel, der beim Abbruch geschlossen wird – Details in Teil 07; bis dahin reicht: Ein Receive darauf kehrt zurück, sobald upwatch herunterfahren soll. ticker stammt aus time.NewTicker(s.tick). Ein Ticker liefert über seinen Channel ticker.C in festen Abständen einen Wert; der Dispatcher wartet also bis zum nächsten Takt oder bis zum Abbruch, je nachdem, was zuerst kommt. defer ticker.Stop() hält ihn an, wenn der Dispatcher endet.
Ohne das select würde jobs <- t beim Shutdown ewig blockieren, wenn kein Worker mehr annimmt. Mit default wird select nicht-blockierend: Ist kein Fall bereit, läuft sofort der default-Zweig. Und ein nil-Channel blockiert für immer, wodurch man einzelne Fälle in einer select-Schleife gezielt abschalten kann, indem man die Variable auf nil setzt.
sync.WaitGroup
Eine WaitGroup zählt laufende Goroutines und lässt eine andere warten, bis alle fertig sind. Die klassische Form mit Add und Done:
var wg sync.WaitGroup
for _, t := range targets {
wg.Add(1)
go func() {
defer wg.Done()
check(t)
}()
}
wg.Wait()
Seit Go 1.25 gibt es WaitGroup.Go, das beides zusammenfasst und den häufigsten Fehler – Add vergessen oder in der Goroutine statt davor aufrufen – unmöglich macht:
var wg sync.WaitGroup
for _, t := range targets {
wg.Go(func() { check(t) })
}
wg.Wait()
upwatch verwendet ausschließlich diese Form. Dass die Closure t direkt benutzen darf, liegt an einer Änderung in Go 1.22: Seitdem bekommt jede Schleifeniteration eine eigene Variable. Vorher teilten sich alle Goroutines dieselbe, und man sah überall t := t als Workaround. In älterem Code findest du das noch.
Die Dokumentation von WaitGroup.Go enthält einen Satz, der leicht überlesen wird: Die Funktion darf nicht paniken. Eine Panik in einer Goroutine beendet ohnehin das ganze Programm (Teil 05), aber Go ist nicht dafür gebaut, sie aufzufangen.
sync.Mutex: wenn geteilter Zustand einfacher ist
Aus der Go-Welt stammt der Satz „Don’t communicate by sharing memory; share memory by communicating.“ Er wird gern so verstanden, als seien Mutexe unidiomatisch. Das ist falsch. Channels sind gut, um Besitz weiterzugeben – ein Ziel geht vom Dispatcher an einen Worker, das Ergebnis vom Worker an den Writer – und um Abläufe zu koordinieren. Wenn mehrere Goroutines einen gemeinsamen Zustand lesen und ändern, ist ein Mutex meist klarer:
// Stats counts results per status code. It is safe for concurrent use.
type Stats struct {
mu sync.Mutex
counts map[int]int
}
func (s *Stats) Record(status int) {
s.mu.Lock()
defer s.mu.Unlock()
s.counts[status]++
}
Konventionen: Der Mutex steht direkt über den Feldern, die er schützt, ist ein Wert (kein Zeiger) und wird nie kopiert – deshalb haben solche Typen Pointer-Receiver, und go vet warnt, wenn du einen Mutex kopierst. Lock und defer Unlock gehören zusammen. sync.RWMutex erlaubt beliebig viele gleichzeitige Leser oder einen Schreiber; das lohnt sich, wenn deutlich mehr gelesen als geschrieben wird, wie im In-Memory-Store in Teil 08. Für einzelne Zähler gibt es sync/atomic mit Typen wie atomic.Int64.
Im Test des Schedulers schützt ein Mutex die Fake-Datenbank, weil Dispatcher und Writer gleichzeitig darauf zugreifen (scheduler_test.go). Der echte SQLite-Store braucht keinen eigenen, denn *sql.DB ist bereits für gleichzeitige Nutzung gebaut.
Der Worker-Pool im Scheduler
Jetzt kommt alles zusammen. Der Scheduler in scheduler.go prüft alle fälligen Ziele mit einer festen Zahl von Workern. Sein Paketkommentar zeigt den Datenfluss:
ticker ─► ClaimDue ─► jobs ─┬─► worker ─┐
├─► worker ─┼─► results ─► writer ─► SaveResult
└─► worker ─┘
Links holt ein Dispatcher in jedem Takt die fälligen Ziele aus der Datenbank und schiebt sie in jobs. Mehrere Worker lesen aus demselben Channel – das ist Fan-out: Arbeit wird auf mehrere Goroutines verteilt, und wer gerade frei ist, nimmt sich das nächste Ziel. Alle Worker schreiben in denselben Channel results, aus dem genau ein Writer liest – das ist Fan-in.
Erst ohne Datenbank: das reduzierte Beispiel
pool/main.go hat dieselbe Struktur, aber statt Datenbank und HTTP nur einen simulierten Check, der zwischen 50 und 150 ms dauert. Starte es zuerst:
go run ./beispiele/06-concurrency/pool
https://site00.example 200 75ms
https://site03.example 200 88ms
https://site02.example 200 106ms
https://site01.example 200 115ms
...
https://site11.example 200 100ms
https://site10.example 200 129ms
12 checks with 4 workers in 360ms
Zwei Dinge fallen auf. Die Reihenfolge ist nicht die der Eingabe: site03 ist vor site02 fertig, weil sein Check kürzer war. Und zwölf Checks von durchschnittlich 100 ms brauchen mit vier Workern nicht 1,2 Sekunden, sondern etwa ein Drittel davon. Starte das Programm mehrmals; Reihenfolge und Laufzeit ändern sich jedes Mal, weil die Dauer zufällig ist.
Der Kern ist die Funktion Run:
func Run(ctx context.Context, urls []string, workers int, check CheckFunc) []Result {
jobs := make(chan string)
results := make(chan Result)
// Fan-out: several workers receive from the same channel.
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for u := range jobs {
results <- check(ctx, u)
}
})
}
// Fan-in: one goroutine collects from the shared results channel.
var out []Result
collected := make(chan struct{})
go func() {
defer close(collected)
for r := range results {
out = append(out, r)
}
}()
dispatch(ctx, urls, jobs)
close(jobs) // 1. no more jobs: workers leave their range loops
wg.Wait() // 2. all workers gone: nobody sends on results
close(results) // 3. collector leaves its range loop
<-collected // 4. out is complete and no longer written to
return out
}
dispatch schickt jede URL mit dem select von oben in jobs. Die vier nummerierten Zeilen am Ende sind das Herunterfahren; dazu gleich mehr.
Jetzt mit Datenbank und Writer
Der echte Scheduler hat dieselbe Form. Statt einer festen URL-Liste holt der Dispatcher in jedem Takt fällige Ziele aus der Datenbank, und statt einer Goroutine, die Ergebnisse in ein Slice sammelt, speichert ein Writer jedes Ergebnis:
func (s *Scheduler) Run(ctx context.Context) {
jobs := make(chan monitor.Target)
results := make(chan monitor.Result)
var workers sync.WaitGroup
for range s.workers {
workers.Go(func() { s.work(ctx, jobs, results) })
}
writerDone := make(chan struct{})
go func() {
defer close(writerDone)
s.write(ctx, results)
}()
s.dispatch(ctx, jobs)
// Shutdown order matters: the dispatcher has returned, so nobody sends
// on jobs any more. Closing it ends the workers' range loops. Once all
// workers are gone nobody sends on results, so it can be closed too.
close(jobs)
workers.Wait()
close(results)
<-writerDone
}
Der Worker ist kurz:
func (s *Scheduler) work(ctx context.Context, jobs <-chan monitor.Target, results chan<- monitor.Result) {
for t := range jobs {
res, err := s.checker.Check(ctx, t)
if err != nil {
// Only happens when ctx was canceled: the check was cut short
// by shutdown and its result would be misleading.
continue
}
results <- res
}
}
Einige Entscheidungen darin sind nicht offensichtlich.
Warum ungepufferte Channels? Sie erzeugen Gegendruck. Sind alle Worker beschäftigt, blockiert der Dispatcher beim Senden und holt keine neuen Ziele aus der Datenbank. ClaimDue fragt höchstens s.workers*4 Ziele auf einmal ab, sodass nie mehr Arbeit in Bearbeitung ist, als in absehbarer Zeit erledigt werden kann. Ein großer Puffer würde Ziele aus der Datenbank „beanspruchen“, die dann im Speicher warten – und beim Shutdown verloren gingen.
Warum ein einzelner Writer? SQLite erlaubt immer nur einen Schreiber. Acht Worker, die gleichzeitig SaveResult aufrufen, würden sich um die Schreibsperre streiten. Der Writer serialisiert die Schreibzugriffe, und weil ein INSERT im Vergleich zu einem HTTP-Check sehr schnell ist, wird er nicht zum Engpass. Nebenbei bündelt er Logging und Metriken an einer Stelle.
Warum struct{} für writerDone? Der Channel transportiert keine Daten, nur das Ereignis „fertig“. struct{} belegt keinen Speicher und macht das im Typ deutlich. close ist dabei das Signal – es weckt jeden Empfänger auf, auch mehrere.
Die Shutdown-Reihenfolge
Die fünf Schritte am Ende von Run sind der Kern des sauberen Herunterfahrens. Die Reihenfolge ergibt sich aus einer einzigen Regel: Ein Channel darf erst geschlossen werden, wenn garantiert niemand mehr darauf sendet.
s.dispatch(ctx, jobs)kehrt zurück, wennctxabgebrochen wird. Ab jetzt sendet niemand mehr aufjobs.close(jobs)– die Worker beenden ihrerange-Schleife, sobald sie ihren aktuellen Check abgeschlossen haben.workers.Wait()– wartet, bis alle Worker zurückgekehrt sind. Danach sendet niemand mehr aufresults.close(results)– der Writer beendet seinerange-Schleife, nachdem er alle bereits gelieferten Ergebnisse gespeichert hat.<-writerDone– wartet, bis der Writer wirklich fertig ist. Erst dann kehrtRunzurück, undmaindarf die Datenbank schließen.
Vertauschst du zwei Schritte, gibt es Fehler. close(results) vor workers.Wait() führt zur Panik, wenn ein Worker noch ein Ergebnis senden will. Fehlt <-writerDone, schließt main womöglich die Datenbank, während der Writer noch schreibt. Fehlt close(jobs), warten die Worker ewig und workers.Wait() kehrt nie zurück.
Dass der Writer während des Shutdowns überhaupt noch speichern kann, obwohl ctx bereits abgebrochen ist, liegt an context.WithoutCancel – das ist Thema in Teil 07.
Testen mit virtueller Zeit
Der Test des Pools (pool/main_test.go) prüft die Laufzeit exakt, obwohl die Checks Zeit brauchen. Dafür sorgt testing/synctest; die Übungen dieses Kapitels brauchen es, ausführlich erklärt wird es in Teil 11.
synctest in fünf Sätzen:
synctest.Test(t, func(t *testing.T) { … })(seit Go 1.25) führt den Test in einer Bubble aus, zu der jede darin gestartete Goroutine gehört. In der Bubble läuft eine Fake-Uhr:time.Now,time.Sleep, Timer und Ticker benutzen virtuelle Zeit. Diese Zeit läuft nur weiter, wenn alle Goroutines der Bubble blockiert sind, und springt dann direkt zum nächsten Zeitpunkt, an dem etwas aufwacht.synctest.Wait()wartet, bis alle anderen Goroutines der Bubble blockiert sind, damit der Test einen stabilen Zustand prüft. Bleibt am Ende eine Goroutine dauerhaft blockiert, schlägt der Test fehl, statt zu hängen – Details in Teil 11.
Zehn Checks à 100 ms auf drei Workern brauchen damit genau vier Runden, und der Test kann auf die Millisekunde prüfen:
synctest.Test(t, func(t *testing.T) {
start := time.Now()
got := Run(t.Context(), urls(10), 3, slowCheck)
if len(got) != 10 {
t.Fatalf("got %d results, want 10", len(got))
}
// 10 checks on 3 workers: 4 rounds of 100ms.
if d := time.Since(start); d != 400*time.Millisecond {
t.Errorf("took %s, want 400ms", d)
}
})
t.Context() ist ein Context, der mit dem Test endet; mehr dazu in Teil 07.
Jetzt du: ein Worker oder acht
Am echten upwatch siehst du den Pool, wenn die Checks langsam sind. langsam/main.go ist eine absichtlich langsame Website, die jede Anfrage nach einer Sekunde beantwortet. Starte sie in einem Terminal:
go run ./beispiele/06-concurrency/langsam
In einem zweiten Terminal upwatch mit einem einzigen Worker, einer frischen Datenbank und einem Takt von einer Sekunde. UPWATCH_ALLOW_PRIVATE_TARGETS=true erlaubt Checks gegen 127.0.0.1, die upwatch sonst ablehnt (Teil 13):
export UPWATCH_DB="$(mktemp -d)/upwatch.db"
UPWATCH_WORKERS=1 UPWATCH_TICK=1s UPWATCH_ALLOW_PRIVATE_TARGETS=true \
UPWATCH_ADDR=localhost:8090 go run ./cmd/upwatch
Im dritten Terminal vier Ziele anlegen und nach ein paar Sekunden die Ergebnisse abfragen:
for p in a b c d; do
curl -s -X POST localhost:8090/targets -d "{\"url\":\"http://127.0.0.1:18101/$p\"}" > /dev/null
done
sleep 6
for id in 1 2 3 4; do curl -s localhost:8090/targets/$id/results; echo; done
Achte auf checked_at. Mit einem Worker liegen die Checks genau eine Sekunde auseinander, weil jeder auf den vorigen wartet:
[{"id":1,"status_code":200,"duration_ms":1008,"up":true,"checked_at":"2026-10-10T21:18:21.613Z"}]
[{"id":2,"status_code":200,"duration_ms":1001,"up":true,"checked_at":"2026-10-10T21:18:22.621Z"}]
[{"id":3,"status_code":200,"duration_ms":1001,"up":true,"checked_at":"2026-10-10T21:18:23.623Z"}]
[{"id":4,"status_code":200,"duration_ms":1001,"up":true,"checked_at":"2026-10-10T21:18:24.624Z"}]
Beende upwatch mit Ctrl+C, setze mit einem neuen export UPWATCH_DB=… eine frische Datenbank und wiederhole das Ganze mit UPWATCH_WORKERS=8. Jetzt starten alle vier Checks in derselben Millisekunde:
[{"id":2,"status_code":200,"duration_ms":1008,"up":true,"checked_at":"2026-10-10T21:18:29.822Z"}]
[{"id":3,"status_code":200,"duration_ms":1008,"up":true,"checked_at":"2026-10-10T21:18:29.822Z"}]
[{"id":1,"status_code":200,"duration_ms":1008,"up":true,"checked_at":"2026-10-10T21:18:29.823Z"}]
[{"id":4,"status_code":200,"duration_ms":1008,"up":true,"checked_at":"2026-10-10T21:18:29.822Z"}]
Auch das Log der langsamen Website zeigt den Unterschied: einmal vier Zeilen im Sekundenabstand, einmal vier Zeilen mit demselben Zeitstempel. Beende danach beide Programme mit Ctrl+C.
Wer sendet, schließt
Aus dem Worker-Pool lässt sich eine Faustregel ableiten, die fast alle Channel-Probleme verhindert: Nur der Sender schließt einen Channel, nie der Empfänger. Der Empfänger weiß nicht, ob noch etwas kommt; schließt er, panikt der nächste Send.
Bei einem einzelnen Sender ist das einfach: Er schließt, wenn er fertig ist, typischerweise mit defer close(ch). Bei mehreren Sendern – den Workern, die alle auf results schreiben – darf keiner von ihnen schließen, denn keiner weiß, ob die anderen fertig sind. Dann schließt ein Koordinator, der auf alle Sender wartet. In Run ist das die Funktion selbst mit workers.Wait() gefolgt von close(results).
Die zweite Hälfte der Regel: Jede Goroutine, die du startest, braucht ein klar definiertes Ende. Wenn du nicht in einem Satz sagen kannst, wann und warum eine Goroutine zurückkehrt, hast du vermutlich einen Leak.
Goroutine-Leaks
Ein Goroutine-Leak entsteht, wenn eine Goroutine für immer blockiert – meist auf einem Send oder Receive, auf den nie jemand antwortet. Der Garbage Collector räumt blockierte Goroutines nicht auf. Sie belegen Speicher, halten Referenzen auf alles, was ihre Closure kennt, und in einem Server, der tagelang läuft, summiert sich das.
Ein klassischer Fall: mehrere Spiegelserver anfragen und die erste Antwort nehmen (leak/main.go).
// FirstLeaky returns the first answer. The other goroutines block forever
// on the unbuffered channel, because nobody receives their result.
func FirstLeaky(urls []string, fetch FetchFunc) string {
ch := make(chan string)
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch
}
Die Funktion liefert das richtige Ergebnis, und jeder Unit-Test, der nur den Rückgabewert prüft, ist grün. Aber nach dem ersten Empfang liest niemand mehr aus ch, und die übrigen Goroutines hängen für immer in ch <- .... Der Fix ist hier ein Puffer, der für jede Antwort Platz hat:
ch := make(chan string, len(urls))
Jetzt kann jede Goroutine ihr Ergebnis ablegen und sich beenden, auch wenn es niemand abholt. Alternativ kann jede Goroutine mit select auf einen Context warten, den der Aufrufer nach der ersten Antwort abbricht – das hat den Vorteil, dass auch die laufenden Requests abgebrochen werden.
go run ./beispiele/06-concurrency/leak
FirstLeaky: goroutines left over: 200
First: goroutines left over: 0
100 Aufrufe mit drei Spiegeln, zwei verwaiste Goroutines pro Aufruf.
Leaks findest du in Tests am zuverlässigsten mit synctest: synctest.Test verlangt, dass alle in der Blase gestarteten Goroutines enden. Bleibt eine dauerhaft blockiert, schlägt der Test fehl, statt zu hängen. Ersetzt man im Test aus leak/main_test.go First durch FirstLeaky, sieht die Ausgabe so aus (gekürzt):
--- FAIL: TestFirstDoesNotLeak (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
...
goroutine 37 [chan send (durable), synctest bubble 1]:
github.com/casoon/go-tutorial/beispiele/06-concurrency/leak.FirstLeaky.func1()
.../beispiele/06-concurrency/leak/main.go:19 +0x3c
Der Stacktrace zeigt genau die Zeile mit dem hängenden Send.
Race Conditions und go test -race
Ein Data Race liegt vor, wenn zwei Goroutines gleichzeitig auf dieselbe Speicherstelle zugreifen, mindestens eine davon schreibt und keine Synchronisation dazwischen liegt. Das Ergebnis ist nicht nur „manchmal falsch“: Bei Strings, Slices und Interfaces kann ein Race inkonsistente Werte erzeugen, und gleichzeitige Schreibzugriffe auf eine Map lassen die Laufzeit oft mit „concurrent map writes“ abbrechen. racy.go zählt Checks ohne Schutz:
var checks int
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() {
checks++ // read, add, write: not atomic, not synchronized
})
}
wg.Wait()
fmt.Println("checks:", checks)
Die Datei trägt //go:build ignore, damit sie nicht zum Paket gehört und go test ./beispiele/... grün bleibt. Gestartet wird sie direkt mit dem Race-Detector:
cd beispiele/06-concurrency/race
go run -race racy.go
==================
WARNING: DATA RACE
Read at 0x00c0001ae018 by goroutine 8:
main.main.func1()
.../beispiele/06-concurrency/race/racy.go:19 +0x2c
Previous write at 0x00c0001ae018 by goroutine 7:
main.main.func1()
.../beispiele/06-concurrency/race/racy.go:19 +0x3c
...
==================
checks: 835
Found 2 data race(s)
exit status 66
Zwei Dinge sind bemerkenswert. Der Zähler steht bei 835 statt 1000: Inkremente sind verloren gegangen, weil zwei Goroutines denselben alten Wert gelesen und beide +1 zurückgeschrieben haben. Und der Detector nennt beide beteiligten Zugriffe mit Zeile – bei einem Race, das in Produktion einmal pro Woche auftritt, ist das Gold wert.
Der Race-Detector instrumentiert jeden Speicherzugriff und macht das Programm dadurch deutlich langsamer und speicherhungriger. Er gehört deshalb in Tests und CI, nicht in den Produktions-Build. upwatch führt alle Tests mit -race aus (make test im Makefile). Wichtig: Er findet nur Races, die während des Laufs tatsächlich auftreten. Ein Test, der den nebenläufigen Pfad nicht ausführt, beweist nichts. Deshalb startet der Test der korrekten Variante (race/main_test.go) bewusst 100 Goroutines:
go test -race ./beispiele/06-concurrency/race/
Entfernst du dort den Mutex aus Stats, schlägt der Test fehl.
Nicht alles parallelisieren
Goroutines sind billig, aber nicht kostenlos – und vor allem ist nebenläufiger Code schwerer zu lesen, zu testen und zu debuggen als sequenzieller. Ein paar Überlegungen, bevor du go schreibst:
Wartet die Arbeit auf etwas? Parallelität lohnt sich, wenn Goroutines die meiste Zeit auf I/O warten, wie HTTP-Checks. Drei Datenbankabfragen, die je eine Millisekunde dauern, parallel auszuführen, bringt dagegen kaum etwas und kostet Lesbarkeit. Bei CPU-Arbeit ist mehr als GOMAXPROCS parallel laufende Goroutines sinnlos.
Verträgt die Gegenseite es? Die Anzahl gleichzeitiger Zugriffe sollte immer begrenzt sein. upwatch begrenzt Checks über die Zahl der Worker (UPWATCH_WORKERS) und Schreibzugriffe über den einzelnen Writer. Für einmalige Fan-outs ohne festen Pool reicht ein gepufferter Channel als Semaphor:
var wg sync.WaitGroup
sem := make(chan struct{}, 4) // at most 4 at a time
for _, t := range targets {
wg.Go(func() {
sem <- struct{}{} // blocks while 4 checks are running
defer func() { <-sem }() // frees the slot
check(t)
})
}
wg.Wait()
Wie kommen Fehler zurück? Sequenziell ist das ein return err. Parallel brauchst du einen Weg, Fehler zu sammeln und bei Bedarf die übrigen Goroutines abzubrechen. Das Modul golang.org/x/sync/errgroup (nicht Teil der Standardbibliothek, aber vom Go-Team gepflegt) bündelt WaitGroup, ersten Fehler und Context-Abbruch; upwatch braucht es nicht, weil Checks ihre Fehler im Ergebnis tragen.
Spielt die Reihenfolge eine Rolle? Ergebnisse paralleler Arbeit kommen in beliebiger Reihenfolge an. Wenn du sie geordnet brauchst, musst du sie nachträglich sortieren oder per Index in ein vorab angelegtes Slice schreiben.
Die Server-Grundstruktur ist in Go ohnehin schon nebenläufig: net/http startet für jede Verbindung eine Goroutine. Innerhalb eines Handlers sequenziell zu arbeiten, ist der Normalfall und meist richtig.
Im Projekt
- scheduler.go: Worker-Pool mit Fan-out und Fan-in, Channel-Richtungen in den Signaturen,
selectmitctx.Done(),WaitGroup.Go, die Shutdown-Reihenfolge. - scheduler_test.go:
synctestmit virtueller Zeit, ein Fake-Store mit Mutex. - main.go:
wg.Gofür den Scheduler, der gepufferteserveErr-Channel,selectzwischen Serverfehler und Signal. - store.go:
ClaimDuebeansprucht fällige Ziele in einer Transaktion, damit ein langsamer Check nicht in zwei aufeinanderfolgenden Takten doppelt gestartet wird – Nebenläufigkeit über Prozessgrenzen hinweg, gelöst in der Datenbank. - Makefile:
go test -race ./....
Übungen
Musterlösungen mit Begründung stehen im Branch loesungen – versuch es zuerst selbst.
- Aufwärmen: Ändere in
beispiele/06-concurrency/pool/main.godie Zahl der Worker auf 1, dann auf 12, und starte das Programm jeweils dreimal. Erkläre die Laufzeiten. Warum bringen 24 Worker bei zwölf URLs nichts mehr? - Kern: Ersetze in
beispiele/06-concurrency/raceden Mutex inStatsdurch zwei Felder vom Typatomic.Int64, eines für Erfolge und eines für Fehler. Prüfe mitgo test -race. Was gewinnst du, was verlierst du gegenüber der Map mit Mutex? - Kern: Schreibe
Firstaus dem Leak-Beispiel so um, dass es nach der ersten Antwort einen Channeldone chan struct{}schließt und die anderen Goroutines überselectzwischen ihrem Send und<-doneaussteigen lassen – ganz ohne Puffer. Prüfe mit demsynctest-Test ausleak/main_test.go(siehe Kasten oben), dass nichts leckt. - Profi: Vertausche in
Scheduler.Rundie Zeilenworkers.Wait()undclose(results). Die bestehenden Tests bleiben grün – warum? Schreibe einen Fake-Checker, derctxignoriert und nach dem Abbruch noch ein Ergebnis liefert, und einensynctest-Test, der die Panik „send on closed channel“ auslöst. Die Grundlagen stehen im Kasten oben, die Details zu Bubbles undsynctest.Waitin Teil 11. Stelle danach die Reihenfolge wieder her.