Goroutines, die nie enden: Leaks in Go finden und vermeiden
Eine Goroutine wird nicht eingesammelt, nur weil niemand mehr auf sie wartet. Sie bleibt, solange sie blockiert, und hält alles fest, worauf sie zeigt. Genau das macht den Speicherbedarf über Tage sichtbar.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der Verbrauch steigt gleichmäßig, und im Code ist nichts falsch
Das Muster ist unverwechselbar. Der Dienst läuft nach dem Start unauffällig, nach einigen Stunden liegt der Speicherbedarf höher, nach zwei Tagen deutlich höher, und irgendwann räumt die Umgebung den Prozess ab. Ein Neustart setzt alles zurück, weshalb die Sache lange als Betriebsthema gilt. Auffällig ist dabei, dass der Verbrauch gleichmäßig wächst und nicht in Sprüngen, und dass er mit der Zahl der bearbeiteten Anfragen zusammenhängt.
Anders als in Sprachen mit manueller Speicherverwaltung gibt es dabei keinen verlorenen Zeiger. Der Speicher ist nicht unerreichbar, er wird noch benutzt, jedenfalls formal: Eine Goroutine wartet auf einen Kanal. Für die Speicherbereinigung ist eine laufende Goroutine ein Ausgangspunkt der Erreichbarkeit, also bleibt sowohl ihr eigener Stapelspeicher bestehen als auch alles, was sie in ihren Variablen festhält. Wenn das ein Anfrageobjekt mit Nutzdaten ist, summiert sich das schnell.
Der Grund, warum solche Stellen so lange unentdeckt bleiben, liegt in den Tests. Fast alle prüfen den erfolgreichen Ablauf, und auf diesem Weg beenden sich die Goroutines ordentlich. Die Blockade entsteht auf dem Fehlerpfad: Eine Funktion kehrt früher zurück, weil ein Aufruf fehlgeschlagen ist, und die vier Goroutines, die ihr Ergebnis in einen ungepufferten Kanal schreiben wollten, warten von diesem Moment an bis zum Ende des Prozesses.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Die Muster, die im Alltag wirklich auflaufen
Sortiert nach dem, was ihr beobachtet. Zu jedem Fall gehört die Eigenschaft der Sprache, aus der er folgt, und der Umbau, der ihn dauerhaft erledigt, statt den Dienst nachts neu zu starten.
Symptom
Nach jeder fehlgeschlagenen Anfrage bleiben mehrere Goroutines zurück, und die Zahl steigt genau mit der Fehlerrate.
Ursache
Das ist das häufigste Muster überhaupt: Mehrere Goroutines schreiben ihr Ergebnis in einen ungepufferten Kanal, die aufrufende Funktion liest die Ergebnisse in einer Schleife und kehrt beim ersten Fehler vorzeitig zurück. Ab diesem Moment gibt es keinen Empfänger mehr, und jeder noch ausstehende Sendevorgang blockiert für immer.
Lösung
Gib dem Kanal eine Kapazität in Höhe der Zahl der Sender, dann kann jede Goroutine ihr Ergebnis ablegen und sich beenden, auch wenn niemand mehr liest. Alternativ leitest du einen Kontext ab, brichst ihn beim vorzeitigen Rücksprung ab und schreibst in jeder Goroutine in eine Auswahl aus Sendevorgang und Abbruchsignal. Beides ist richtig, die erste Variante ist kürzer, die zweite trägt auch, wenn die Zahl der Sender unbekannt ist.
Symptom
Ein Worker, der Aufgaben aus einem Kanal liest, beendet sich beim Herunterfahren nicht, und der Dienst hängt beim Stoppen.
Ursache
Ein Empfang aus einem Kanal blockiert, bis ein Wert kommt oder der Kanal geschlossen wird. Wenn beim Herunterfahren niemand den Kanal schließt, warten die Arbeitsprozesse weiter. Wartet daneben noch eine WaitGroup auf ihr Ende, hängt der gesamte Ablauf, und das sieht im Betrieb aus wie ein Hänger und nicht wie ein Leck.
Lösung
Der Sender schließt den Kanal, nie der Empfänger, denn nur der Sender weiß, wann nichts mehr kommt. Beim Herunterfahren gehört ein ausdrückliches Signal dazu: Kanal schließen oder Kontext abbrechen, dann auf die Zählgruppe warten, und zwar mit einer Obergrenze für die Wartezeit. Ein Empfang in einer Schleife über den Kanal endet beim Schließen von selbst und ist deshalb die einfachere Bauform.
Symptom
Der Verbrauch wächst langsam, aber stetig, auch wenn kaum Anfragen ankommen, und im Profil stehen viele Goroutines im Zusammenhang mit Zeitgebern.
Ursache
Ein Ticker, der nicht gestoppt wird, läuft weiter und hält die zugehörigen Strukturen. Ähnlich wirkt ein Zeitgeber, der innerhalb einer Schleife in einer Auswahl angelegt wird: Bei jedem Durchlauf entsteht ein neuer, und jeder davon bleibt bis zu seinem Ablauf bestehen, auch wenn die Auswahl längst über einen anderen Zweig verlassen wurde. Bei kurzen Schleifen und langen Zeitspannen summiert sich das erheblich.
Lösung
Jeder Ticker bekommt unmittelbar nach dem Anlegen einen aufgeschobenen Aufruf zum Stoppen. In Schleifen legst du den Zeitgeber einmal außerhalb an und setzt ihn zurück, statt in jedem Durchlauf einen neuen zu erzeugen. Wenn die Wartezeit ohnehin an einem Abbruchkontext hängt, ist ein Kontext mit Frist die klarere Formulierung, weil er sich beim Verlassen selbst aufräumt.
Symptom
Ein Aufruf an ein anderes System hängt gelegentlich sehr lange, und die Zahl der Goroutines steigt genau in diesen Zeiträumen.
Ursache
Ein Netzaufruf ohne Frist wartet, solange die Gegenstelle die Verbindung offen hält, und das kann sehr lange sein. Dazu kommt ein zweiter, leiser Fall: Wenn der Response Body nicht vollständig gelesen und anschließend geschlossen wird, bleibt die zugrunde liegende Verbindung samt zugehöriger Goroutine belegt, statt in den Connection Pool zurückzugehen.
Lösung
Gib jedem ausgehenden Aufruf einen Kontext mit Frist, und zwar auf der Ebene, die weiß, wie lange das Warten fachlich vertretbar ist. Setz zusätzlich eine Obergrenze im verwendeten Client, damit ein vergessener Kontext nicht sofort zum Problem wird. Und schließ den Antwortkörper immer mit einem aufgeschobenen Aufruf, auch bei Fehlerantworten, bei denen der Inhalt dich nicht interessiert.
Symptom
Ein abgeleiteter Kontext wird angelegt, aber nie aufgeräumt, und statische Prüfungen melden das.
Ursache
Ein Kontext mit Abbruchmöglichkeit oder mit Frist erzeugt intern Zustand, der erst beim Aufräumen freigegeben wird. Wer die zurückgegebene Aufräumfunktion nicht aufruft, hinterlässt bei jedem Durchlauf einen Eintrag, der am Elternkontext hängen bleibt. Bei einem langlebigen Elternkontext, etwa dem des Dienstes selbst, wächst das über die gesamte Laufzeit.
Lösung
Auf jede Ableitung folgt unmittelbar der aufgeschobene Aufruf zum Aufräumen, ohne Ausnahme, auch wenn der Kontext ohnehin gleich abläuft. Das eingebaute Prüfwerkzeug der Werkzeugkette findet genau diese Stelle und gehört in die CI-Umgebung. Für Gruppen von Goroutines mit gemeinsamem Abbruch nimmst du die entsprechende Hilfsbibliothek, die den Kontext beim ersten Fehler von selbst abbricht.
Symptom
Die Tests sind grün, der Dienst leckt trotzdem, und in der Vorabumgebung ist nie etwas aufgefallen.
Ursache
Getestet wird der erfolgreiche Ablauf, und auf diesem Weg beenden sich alle Goroutines ordentlich. Die Blockade sitzt im Fehlerfall, beim vorzeitigen Abbruch und beim Herunterfahren, und genau diese drei Pfade nimmt selten ein Test. In der Vorabumgebung fällt es nicht auf, weil dort nach jeder Auslieferung neu gestartet wird und der Verbrauch nie lange genug wachsen kann.
Lösung
Nimm eine Bibliothek zur Prüfung auf übrig gebliebene Goroutines und verankere sie einmal je Testpaket. Ergänz gezielt Tests für den Fehlerpfad und für das Herunterfahren. Seit Go 1.25 gibt es dafür zusätzlich ein Paket in der Standardbibliothek, das Tests für nebenläufigen Code in einer abgeschlossenen Blase mit virtueller Zeit ausführt und ausdrücklich abwarten kann, bis alle Goroutines darin blockieren. Genau dieser Zustand ist die Stelle, an der ein Leck sichtbar wird.
Fünf Fragen führen zur blockierten Goroutine
- 01 Bleibt die Zahl der Goroutines nach einer Lastspitze dauerhaft höher?
- 02 Welche Stapelverläufe kommen in der zweiten Aufnahme häufiger vor als in der ersten?
- 03 Wie lange blockiert die häufigste Gruppe schon, und worauf?
- 04 Wer soll diese Goroutine beenden, wenn der Empfänger vorzeitig zurückkehrt?
- 05 Hört jeder blockierende Zugriff dort auch auf das Abbruchsignal?
Was du danach an einem Nachmittag erledigst
Die Suche ist erstaunlich mechanisch, wenn man die Reihenfolge einhält. Sechs Schritte führen vom Verdacht zur konkreten Codestelle und danach zu einem Test, der den Fall festhält.
Die Zahl der Goroutines als Messwert führen
Der einfachste verlässliche Indikator ist die laufende Anzahl. Gib sie als Messwert aus und leg sie neben den Speicherverbrauch. Eine Kurve, die nach jeder Lastspitze auf einem höheren Niveau bleibt, statt zurückzugehen, ist der Beleg, und sie unterscheidet ein Leck von einem einfach zu klein bemessenen Dienst.
Zwei Aufnahmen vergleichen statt eine deuten
Nimm das Goroutine-Profil zweimal auf, mit einigen Minuten Abstand und gleicher Last dazwischen. Interessant ist nur, was in der zweiten Aufnahme häufiger vorkommt. Eine einzelne Aufnahme zeigt vor allem die vielen Goroutines, die gerade völlig regulär arbeiten.
Nach Stacktrace gruppieren und die häufigste Stelle lesen
Die ausführliche Fassung des Profils zeigt zu jeder Goroutine den vollständigen Stapel und wie lange sie schon blockiert. Gruppiert nach Stapelverlauf steht die Ursache meist in der obersten Gruppe, mit einer Wartezeit, die deutlich über allem anderen liegt.
Prüfen, wer die Goroutine beenden soll
Für jede gestartete Goroutine muss eine Antwort auf zwei Fragen existieren: Wer beendet sie, und was passiert, wenn der Empfänger vorzeitig verschwindet. Wo diese Antwort fehlt, liegt das Leck, und zwar unabhängig davon, ob es sich bisher gezeigt hat.
Den Abbruch bis nach unten durchreichen
Ein Abbruchkontext ist nur wirksam, wenn er tatsächlich in jedem blockierenden Vorgang berücksichtigt wird. Jeder Kanalzugriff, der länger warten kann, gehört in ein select, das auch auf das Abbruchsignal hört, und jeder abgeleitete Kontext braucht ein defer auf die Aufräumfunktion.
Den Fall in einem Test festhalten
Wenn die Ursache gefunden ist, gehört ein Test dazu, der den Fehlerpfad nimmt und danach prüft, dass keine Goroutine übrig geblieben ist. Ohne diesen Schritt kommt dieselbe Klasse in einem halben Jahr an anderer Stelle zurück, weil der Fehlerpfad wieder niemandem aufgefallen ist.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Warum eine blockierte Goroutine nicht eingesammelt wird
Eine Goroutine ist billig, aber nicht kostenlos. Sie beginnt mit einem kleinen eigenen Stapelspeicher, der bei Bedarf wächst, und sie ist für die Speicherbereinigung ein Ausgangspunkt der Erreichbarkeit. Solange sie existiert, gilt alles, was sie in ihren Variablen hält, als benutzt. Deshalb kostet ein Leck nicht nur den Stapel selbst, sondern häufig ein Vielfaches davon an Daten, die daran hängen: ein Anfrageobjekt, ein gelesener Nutzinhalt, ein Puffer.
Zum Vergleich lohnt der Blick auf den Normalfall: Eine Goroutine endet, wenn ihre Funktion zurückkehrt, und danach ist alles frei. Es gibt keine Möglichkeit, sie von außen zu beenden, keinen Abbruch und keinen Aufruf, der sie einsammelt. Das ist eine bewusste Entscheidung der Sprache und die eigentliche Ursache dafür, dass es diese Fehlerklasse gibt: Wer eine Goroutine startet, übernimmt damit die Verantwortung dafür, dass sie auch wieder zurückkehrt.
Blockieren kann sie an drei Stellen: an einem Kanal, an dem niemand mehr liest oder schreibt, an einer Sperre, die nie freigegeben wird, und an einer Bedingungsvariablen, die niemand mehr benachrichtigt. Die Sprache erkennt einen vollständigen Stillstand aller Goroutines und meldet ihn, aber das hilft nur, wenn wirklich alles steht. Ein Dienst, in dem hunderttausend Goroutines warten und der Rest normal weiterläuft, fällt in diese Erkennung nicht hinein.
Wie ihr sie im laufenden Dienst findet
Der erste Messwert ist die Zahl der Goroutines, direkt aus der Laufzeitumgebung abfragbar und in jeder Überwachung darstellbar. Sie ist deshalb so nützlich, weil sie das Problem eindeutig von einem zu klein bemessenen Dienst unterscheidet: Nach einer Lastspitze muss die Kurve wieder auf das Ausgangsniveau zurückgehen. Tut sie das nicht, ist die Diagnose gestellt, und die Fehlersuche kann gezielt beginnen.
Der zweite Schritt ist das Goroutine-Profil. Es gehört zu den fest eingebauten Profilen der Laufzeitumgebung und lässt sich im Betrieb über eine eigene Schnittstelle abrufen, die man in einem internen Dienst ohne großen Aufwand freischalten kann. In der ausführlichen Fassung zeigt es zu jeder Goroutine den vollständigen Stapel und die bisherige Wartezeit. Entscheidend ist, zwei Aufnahmen im Abstand zu vergleichen: Was dazugekommen ist, ist der Verdächtige, und der Stapelverlauf zeigt direkt auf die Zeile.
Seit Go 1.26 gibt es dafür zusätzlich ein eigenes Profil, das ausdrücklich geleckte Goroutines meldet. Es ist noch als Experiment gekennzeichnet und muss beim Bauen eingeschaltet werden, danach steht es als weiteres Profil und als eigener Endpunkt zur Verfügung. Die Erkennung nutzt die Erreichbarkeitsanalyse der Speicherbereinigung und meldet nur Goroutines, die tatsächlich nicht mehr geweckt werden können. Das ist ein starkes Versprechen, hat aber eine Kehrseite: Wenn der Kanal, auf dem gewartet wird, noch von irgendwoher erreichbar ist, etwa als globale Variable, bleibt der Fall unentdeckt.
Die Regeln, die Leaks von vornherein verhindern
Die erste und wichtigste Regel: Wer eine Goroutine startet, muss sagen können, wann sie zurückkehrt. Wenn diese Antwort im Kopf des Autors nicht existiert, existiert sie auch im Code nicht. Praktisch heißt das, für jede gestartete Goroutine einen Beendigungsweg zu benennen, sei es das Ende eines Kanals, ein Abbruchsignal oder eine natürliche Fertigstellung.
Die zweite Regel betrifft blockierende Zugriffe. Jeder Sendevorgang und jeder Empfang, der länger warten kann als eine Anfrage dauern darf, gehört in eine Auswahl, die zusätzlich auf ein Abbruchsignal hört. Ein Kontext, der nur bis zur Hälfte des Aufrufwegs durchgereicht wird, ist dabei ungefähr so nützlich wie gar keiner, denn genau die letzte, blockierende Stelle ist die, auf die es ankommt.
Dazu kommen drei kleinere Gewohnheiten, die viel abfangen. Kanäle werden vom Sender geschlossen, weil nur er weiß, wann nichts mehr kommt. Die Kapazität eines Kanals wird bewusst gewählt und nicht zufällig, denn sie entscheidet darüber, ob ein Sender ohne Empfänger blockiert. Und jeder abgeleitete Kontext bekommt unmittelbar seinen aufgeschobenen Aufruf zum Aufräumen, auch wenn er ohnehin gleich abläuft. Das eingebaute Prüfwerkzeug der Werkzeugkette findet den letzten Punkt zuverlässig, wenn ihr es in der CI-Umgebung laufen lasst.
Im Test finden statt im Betrieb
Der wirksamste einzelne Handgriff ist eine Prüfung auf übrig gebliebene Goroutines am Ende eines Testlaufs. Die verbreitete Bibliothek dafür lässt sich einmal je Testpaket über die zentrale Einstiegsfunktion einhängen und meldet dann jede Goroutine, die nach dem Ende aller Tests noch läuft. Alternativ lässt sie sich für einzelne Tests aktivieren, was allerdings nicht mit parallel ausgeführten Tests zusammengeht.
Seit Go 1.25 gibt es dafür zusätzlich ein Paket in der Standardbibliothek, das aus einem früheren Experiment hervorgegangen ist. Es führt eine Testfunktion in einer abgeschlossenen Blase aus, in der die Zeit virtualisiert ist: Zeitgeber laufen sofort ab, sobald alle Goroutines in der Blase blockieren. Dazu kommt eine Funktion, die genau darauf wartet, dass alle Goroutines der Blase blockiert sind. Damit lassen sich Abläufe mit Wartezeiten in Millisekunden statt in Sekunden testen, und der Zustand, in dem etwas hängt, wird zu einem beobachtbaren Ereignis statt zu einer Zufallsfrage.
Was in beiden Fällen dazugehört, sind Tests für die Pfade, die im Betrieb das Problem machen: der vorzeitige Rücksprung wegen eines Fehlers, der abgebrochene Kontext und das geordnete Herunterfahren. Diese drei Fälle decken den überwiegenden Teil aller Lecks ab, und sie sind schnell geschrieben, sobald die Prüfung auf übrig gebliebene Goroutines einmal eingerichtet ist.
Dazu passende Kurse
Weil sich diese Muster an fremden Beispielen schlecht üben lassen, sind Go-Kurse mit Schwerpunkt Nebenläufigkeit der direktere Weg zu Diensten, die auch nach Tagen noch unauffällig laufen.
Weil fast jedes Leck auf einem Pfad sitzt, den kein Test nimmt, gehören Kurse, in denen Fehlerpfade systematisch geprüft werden zur selben Baustelle.
Wie sicher bist du beim Thema wirklich?
Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.
Wo du genau das übst
Sehr umfangreicher Überblick über Zusammenhänge und Anwendungsbereiche. Sehr zu empfehlen.
Effektives, gemeinsames Erarbeiten der Aufgaben mit vielen praktischen Tipps.
Sehr guter Trainer und gute Aufbereitung der Inhalte und auch eingehen auf Fragen der Beteiligten.
Häufige Fragen
Wie viele Goroutines sind eigentlich zu viele?
Kann ich eine hängende Goroutine von außen beenden?
Reicht es, den Kanal einfach zu puffern?
Findet die eingebaute Deadlock-Erkennung solche Fälle?
Lohnt sich das neue Profil für geleckte Goroutines schon?
Deine Ansprechpartner
Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.
Yves Hoppe
Weiterbildung & Beratung
Hilft dir, aus dem Programmierung-Programm den passenden Kurs für deinen Stand zu finden.
Norbert Jansen
Beratung & Inhouse
Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.
Nebenläufigkeit in Go an eigenen Diensten üben
Im Go-Kurs zur Nebenläufigkeit bei cmt baust du Kanäle, Kontexte und Arbeitsgruppen von Grund auf und siehst dabei genau die Stellen, an denen eine Goroutine später hängen bleibt.