Strukturierte Nebenläufigkeit
Strukturierte Nebenläufigkeit ordnet Coroutines nach Lebensdauer. Du lernst, wie Parent-Jobs, Scopes und Cancellation zusammenarbeiten.
Asynchrone Arbeit ist in Android normal: Daten laden, Netzwerk anfragen, Datenbank lesen, Bilder vorbereiten oder UI-Zustand aktualisieren. Structured Concurrency hilft dir dabei, diese Arbeit nicht als lose Hintergrundaufgaben zu behandeln, sondern als geordnete Aufgaben mit klarer Lebensdauer, klaren Eltern-Kind-Beziehungen und vorhersehbarer Cancellation.
Was ist das?
Structured Concurrency bedeutet: Jede Coroutine gehört zu einem passenden CoroutineScope, und dieser Scope bestimmt, wie lange die Coroutine leben darf. Eine gestartete Coroutine ist also nicht einfach irgendwo im Hintergrund unterwegs. Sie hängt an einem Parent-Job, der ihre Lebensdauer und ihr Fehlerverhalten mitverwaltet.
Das löst ein typisches Problem in Android-Apps. Wenn ein Screen geschlossen wird, darf seine laufende Ladeoperation meist nicht weiter die UI aktualisieren. Wenn ein ViewModel nicht mehr gebraucht wird, sollen seine Jobs beendet werden. Wenn eine parallele Teilaufgabe fehlschlägt, muss klar sein, ob die anderen Teilaufgaben ebenfalls abgebrochen werden. Ohne Struktur entstehen Speicherlecks, doppelte Requests, flackernde UI-Zustände oder schwer erklärbare Fehler in Tests.
Das mentale Modell ist ein Aufgabenbaum. Oben steht ein Scope, etwa viewModelScope im ViewModel oder ein Scope, den eine Architekturkomponente kontrolliert. Darunter hängen Coroutines als Kind-Aufgaben. Startest du innerhalb einer Coroutine weitere Coroutines mit coroutineScope, async oder launch, entstehen weitere Knoten in diesem Baum. Wird der Parent abgebrochen, werden seine Kinder ebenfalls abgebrochen. Schlägt ein Kind fehl, kann dieser Fehler nach oben wandern und den Parent beeinflussen. Genau dadurch wird das Verhalten berechenbar.
Im modernen Android-Alltag begegnet dir das ständig. In Jetpack Compose beobachtest du State aus einem ViewModel. Das ViewModel startet Arbeit in viewModelScope. Repositories stellen suspend-Funktionen oder Flow bereit. Die Data Layer entscheidet, ob Daten aus Netzwerk, Datenbank oder Cache kommen. Structured Concurrency sorgt dabei dafür, dass diese Ebenen nicht willkürlich eigene langlebige Hintergrundarbeit starten, sondern ihre Arbeit an den Aufrufer und dessen Lebensdauer koppeln.
Wie funktioniert es?
Der zentrale Baustein ist der CoroutineScope. Ein Scope enthält einen CoroutineContext, typischerweise mit einem Job und einem Dispatcher. Der Job ist wichtig für Structured Concurrency: Er ist der Parent für Coroutines, die in diesem Scope gestartet werden. Wenn du viewModelScope.launch { ... } schreibst, startet der Block als Kind dieses ViewModel-Scopes. Wird das ViewModel zerstört, wird der Scope abgebrochen, und die laufende Arbeit bekommt Cancellation.
Cancellation ist kooperativ. Eine Coroutine wird nicht an jeder beliebigen Stelle hart beendet. Viele suspendierende Funktionen aus Kotlin und Android prüfen Cancellation automatisch, zum Beispiel delay, viele Flow-Operatoren oder suspendierende Datenbank- und Netzwerkaufrufe. Wenn du lange CPU-Schleifen schreibst, musst du selbst darauf achten, regelmäßig auf Abbruch zu reagieren, etwa durch ensureActive() oder indem du die Arbeit in kleinere suspendierende Schritte aufteilst.
Ein weiterer Baustein ist der Unterschied zwischen launch und async. launch ist geeignet für Arbeit, die keinen direkten Rückgabewert liefert, etwa das Aktualisieren eines Mutable State im ViewModel. async liefert ein Deferred, dessen Ergebnis du mit await() abholst. In strukturierter Form verwendest du async innerhalb eines Scopes und wartest die Ergebnisse ab. So bleibt klar, dass die gestarteten Teilaufgaben zur aktuellen Operation gehören.
Wichtig ist auch coroutineScope. Diese suspendierende Funktion erstellt einen inneren Scope und wartet, bis alle darin gestarteten Kind-Coroutines fertig sind. Dadurch kannst du mehrere parallele Operationen als eine einzige strukturierte Operation behandeln. Wenn eine Teilaufgabe fehlschlägt, wird die Gesamtoperation fehlschlagen, und die übrigen Kinder werden abgebrochen. Dieses Verhalten ist oft genau das, was du willst, wenn mehrere Ergebnisse gemeinsam einen UI-Zustand bilden.
In Android-Architektur passt das besonders gut zur Aufteilung zwischen UI, ViewModel und Data Layer. Die UI startet keine langlebigen Jobs auf eigene Faust. Das ViewModel koordiniert Screen-Arbeit mit viewModelScope. Repositories führen Datenoperationen aus und bieten klare APIs an: suspend für einzelne Ergebnisse, Flow für Datenströme. Ein Repository sollte nicht heimlich GlobalScope.launch verwenden, weil der Aufrufer dann nicht mehr steuern kann, wann die Arbeit endet. Das wäre das Gegenteil von strukturierter Nebenläufigkeit.
Bei Flow ist das gleiche Prinzip sichtbar. Ein Flow ist kalt, solange er nicht gesammelt wird. Die Arbeit startet erst beim Sammeln, etwa im ViewModel oder in Compose über lifecycle-bewusste APIs. Wenn das Sammeln abgebrochen wird, wird auch die Flow-Pipeline abgebrochen. In Offline-First-Architekturen ist das nützlich: Ein Repository kann Datenbankänderungen als Flow liefern, während Synchronisation und Netzwerkzugriffe trotzdem an passende Scopes gebunden bleiben.
In der Praxis
Stell dir einen Screen vor, der ein Profil und die letzten Beiträge laden muss. Beide Requests können parallel laufen. Das ViewModel soll den UI-State aktualisieren. Wenn der Screen verlassen wird, soll die Arbeit abbrechen. Wenn ein Request fehlschlägt, soll der Fehler kontrolliert im State landen.
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: UserProfile? = null,
val posts: List<Post> = emptyList(),
val errorMessage: String? = null
)
class ProfileViewModel(
private val repository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile(userId: String) {
viewModelScope.launch {
_uiState.value = ProfileUiState(isLoading = true)
try {
val result = coroutineScope {
val profile = async { repository.loadProfile(userId) }
val posts = async { repository.loadPosts(userId) }
profile.await() to posts.await()
}
_uiState.value = ProfileUiState(
profile = result.first,
posts = result.second
)
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
_uiState.value = ProfileUiState(
errorMessage = "Profil konnte nicht geladen werden."
)
}
}
}
}
An diesem Beispiel siehst du mehrere Regeln. Die Arbeit startet im viewModelScope, also lebt sie nur so lange wie das ViewModel. Die beiden parallelen Requests gehören zu einem inneren coroutineScope. Der Code wartet auf beide Ergebnisse, bevor er den fertigen State setzt. Wenn loadProfile fehlschlägt, wird loadPosts abgebrochen, sofern es noch läuft. Das verhindert, dass eine halbe Operation weiterläuft, obwohl das Gesamtergebnis nicht mehr brauchbar ist.
Achte besonders auf den Umgang mit CancellationException. Du solltest sie nicht wie einen normalen Fehler verschlucken. Cancellation ist ein Kontrollsignal der Coroutine-Infrastruktur. Wenn du breit Exception fängst, fängst du auch CancellationException, weil sie davon erbt. Deshalb wird sie im Beispiel erneut geworfen. So kann der Parent-Job korrekt abbrechen. Eine typische Stolperfalle ist ein allgemeiner catch (e: Exception), der Cancellation in einen UI-Fehlertext verwandelt. Dann wirkt ein normaler Screen-Wechsel wie ein Ladefehler.
Eine zweite Stolperfalle ist GlobalScope. Es wirkt bequem, weil du damit jederzeit eine Coroutine starten kannst. Für Android-Code ist das fast immer ein Warnzeichen. Die Coroutine hängt dann nicht am Screen, nicht am ViewModel und nicht am aktuellen Use Case. Sie kann weiterlaufen, obwohl niemand das Ergebnis noch braucht. Für echte App-weite Arbeit brauchst du bewusst verwaltete Scopes, zum Beispiel in einer klaren Infrastrukturkomponente oder über WorkManager, wenn Arbeit garantiert später ausgeführt werden soll. Für normale Screen- und Datenladevorgänge ist GlobalScope die falsche Richtung.
Eine praktische Entscheidungsregel lautet: Frage bei jeder Coroutine, wer ihr Parent ist und wer sie abbrechen darf. Wenn du darauf keine klare Antwort hast, ist der Code wahrscheinlich unstrukturiert. Im ViewModel ist die Antwort häufig viewModelScope. In einer suspendierenden Repository-Funktion ist der Parent meist der Aufrufer, weil das Repository keine eigene Coroutine starten muss. In Tests ist der Parent ein Test-Scope, damit du Zeit, Ausführung und Fehler kontrollieren kannst.
Auch Code-Reviews profitieren von dieser Sichtweise. Prüfe nicht nur, ob der Code kompiliert. Prüfe, ob alle gestarteten Jobs ein klares Ende haben. Suche nach launch in Repositories, nach versteckten Scopes in Klassen, nach breiten catch-Blöcken und nach async, dessen Ergebnis nie mit await() gelesen wird. Prüfe außerdem, ob Flow an der richtigen Stelle gesammelt wird. Ein Flow, der direkt in einer tiefen Datenschicht dauerhaft gesammelt wird, kann ein Hinweis sein, dass Lebensdauer und Verantwortung vermischt wurden.
Zum Üben kannst du bewusst Logging einbauen: Logge den Start und das Ende einer Coroutine sowie den Fall finally. Öffne einen Screen, starte das Laden und verlasse den Screen sofort wieder. Wenn Structured Concurrency korrekt genutzt wird, solltest du sehen, dass die laufende Arbeit abgebrochen wird. In Unit-Tests kannst du mit Coroutine-Testwerkzeugen prüfen, dass ein ViewModel bei Fehlern den erwarteten State setzt und dass Cancellation nicht als normaler Fehler behandelt wird. Solche Tests sind besonders wertvoll, weil Nebenläufigkeit sonst schnell nur durch Zufall korrekt wirkt.
Fazit
Structured Concurrency ist keine Zusatzregel für schönen Code, sondern ein Grundprinzip für robuste Android-Apps mit Coroutines, Flow und klarer Architektur. Du ordnest asynchrone Arbeit einem Scope zu, verstehst Parent-Jobs als Aufgabenbaum und behandelst Cancellation als normalen Teil des Lebenszyklus. Prüfe beim nächsten Coroutine-Code aktiv drei Fragen: Wer besitzt diese Arbeit, wann endet sie, und was passiert bei Fehler oder Abbruch? Wenn du diese Antworten im Debugger, in Tests und im Code-Review nachvollziehen kannst, bist du deutlich näher an professionellem Coroutine-Code.