Coroutines in Kotlin: launch, async und scope
1. Übersicht
Coroutines sind Kotlins Weg, asynchronen Code zu schreiben, ohne in Callback Hell zu verfallen oder Threads zu blockieren. Dieser Artikel erklärt, wie launch, async, suspend fun und CoroutineScope funktionieren und wann du sie einsetzt. Danach kannst du sauberen, nebenläufigen Code schreiben, die Lebenszyklen von Coroutines kontrollieren und alles mit JUnit 5 testen.
2. Erforderliche Abhängigkeiten
Coroutines sind nicht in der Kotlin-Standardbibliothek enthalten – du brauchst kotlinx-coroutines-core.
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-test</artifactId>
<version>1.8.1</version>
<scope>test</scope>
</dependency>
Mit Spring Boot bringt spring-boot-starter-webflux oder spring-boot-starter bereits Kotlin-Extensions mit, die suspend fun in Controllern unterstützen.
3. suspend fun: die Grundeinheit der Coroutine
suspend ist ein Schlüsselwort, das markiert, dass eine Funktion unterbrochen (suspend) werden kann, ohne einen Thread zu blockieren.
suspend fun fetchOrder(orderId: Long): Order {
delay(200) // simuliert einen I/O-Aufruf, blockiert aber keinen echten Thread
return Order(orderId, "PENDING")
}
data class Order(val id: Long, val status: String)
delay() ist eine eingebaute Suspend-Funktion – anders als Thread.sleep() blockiert sie keinen Thread während des Wartens. suspend fun kann nur aus einer anderen suspend fun heraus oder von einem Coroutine-Builder (launch, async, runBlocking) aus aufgerufen werden.
Hinweis: Eine
suspend fundirekt aus einer normalen Funktion (funohne suspend) aufzurufen führt zu einem Compilefehler. Kotlin zwingt dich damit, den asynchronen Kontext explizit zu deklarieren.
4. launch: fire-and-forget Coroutine
launch erstellt eine neue Coroutine und gibt einen Job zurück – ohne Ergebnis. Nutze es, wenn du einfach nur «starten und vergessen» willst.
fun main() = runBlocking {
val job: Job = launch {
println("Beginne mit Verarbeitung der Bestellung auf Thread: ${Thread.currentThread().name}")
val order = fetchOrder(1L)
println("Order-Status: ${order.status}")
}
println("launch hat zurückgegeben, die Coroutine läuft parallel")
job.join() // warte auf die Coroutine
}
launch hat zurückgegeben, die Coroutine läuft parallel
Beginne mit Verarbeitung der Bestellung auf Thread: main
Order-Status: PENDING
runBlocking erstellt einen Scope zum Ausführen von Coroutines in main – nur für Demo/Tests, nicht für echten Production-Code. launch blockiert den aktuellen Thread nicht – es plant die Coroutine ein und gibt sofort zurück. job.join() ist der Punkt, an dem du auf die Coroutine wartest.
5. async: Coroutine mit Rückgabewert
async ähnelt launch, gibt aber Deferred<T> zurück – ein «Versprechen», auf das du mit .await() wartest, um das Ergebnis zu erhalten.
suspend fun fetchCustomer(customerId: Long): String {
delay(150)
return "Customer-$customerId"
}
suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
val orderDeferred = async { fetchOrder(orderId) }
val customerDeferred = async { fetchCustomer(customerId) }
val order = orderDeferred.await()
val customer = customerDeferred.await()
"Order ${order.id} (${order.status}) von $customer"
}
Die beiden async-Aufrufe laufen parallel (nebenläufig), nicht nacheinander. Die gesamte Wartezeit liegt bei ~200ms (Maximum von 200ms und 150ms), nicht 350ms. .await() ist der Punkt, an dem die Coroutine unterbrochen wird, um das Ergebnis zu erhalten – wenn du await() unmittelbar nach jedem async aufgerufen, anstatt sie zu trennen, läuft der Code sequenziell und verliert den Parallelitätsvorteil.
// Falsch: läuft sequenziell, verschenkt den Sinn von async
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()
Hinweis: Der Code oben ist syntaktisch korrekt, aber zerstört den Sinn von
async– jedesawait()blockiert sofort danach, sodass die beiden Coroutines niemals gleichzeitig laufen.
6. CoroutineScope: wer verwaltet den Lebenszyklus einer Coroutine
Jede Coroutine muss zu einem CoroutineScope gehören. Der Scope entscheidet: wie lange die Coroutine lebt, wann sie abgebrochen wird, wie Exceptions sich ausbreiten.
class OrderService {
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun processOrderAsync(orderId: Long) {
serviceScope.launch {
val order = fetchOrder(orderId)
println("Bestellung ${order.id} verarbeitet")
}
}
fun shutdown() {
serviceScope.cancel() // bricht alle laufenden Child-Coroutines in diesem Scope ab
}
}
serviceScope als Feld der Klasse zu halten ist der Weg, zu verhindern, dass verwaiste Coroutines ewig laufen, nachdem das Objekt nicht mehr genutzt wird. SupervisorJob() sorgt dafür, dass der Fehler einer Child-Coroutine die anderen nicht abbricht (anders als bei Job(), wo ein Fehler alle Geschwister abbricht).
6.1. coroutineScope vs CoroutineScope
Verwechsle coroutineScope { ... } (Suspend-Funktion, Kleinbuchstaben) nicht mit CoroutineScope(...) (Konstruktor für einen neuen Scope, Großbuchstaben).
suspend fun loadDashboard(): String = coroutineScope {
// coroutineScope wartet, bis ALLE Child-Coroutines fertig sind
// bevor es zurückgibt, und nutzt den Kontext der Parent-Coroutine
val a = async { fetchOrder(1L) }
val b = async { fetchCustomer(2L) }
"${a.await()} - ${b.await()}"
}
coroutineScope ist ein Suspend-Funktions-Builder zum Gruppieren mehrerer Child-Coroutines und stellt sicher, dass nur «structured concurrency» verwendet wird: die Funktion gibt nur zurück, wenn alle async/launch-Aufrufe darin fertig sind (oder einer fehlschlägt und alle werden abgebrochen). Es erstellt keinen neuen unabhängigen Scope, sondern erbt den aktuellen Kontext – anders als CoroutineScope(...) aus Abschnitt 6, das einen unabhängigen Scope erstellt, der nicht automatisch abgebrochen wird, wenn die Funktion endet.
7. Dispatcher: auf welchem Thread läuft die Coroutine
Dispatcher entscheidet, wo die Coroutine läuft – ähnlich wie die Wahl eines Executor in Java.
suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
// Dispatchers.IO wird für blockierendes I/O verwendet (Dateilesevorgänge, JDBC-Aufrufe...)
File(path).readText()
}
suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
// Dispatchers.Default wird für CPU-intensive Arbeiten verwendet
data.sumOf { it.toLong() * it }
}
withContext wechselt die aktuelle Coroutine zu einem anderen Dispatcher und kehrt automatisch zum alten zurück, wenn sie fertig ist – sehr nützlich, wenn du blockierenden Code (JDBC, File-I/O) aufrufen musst, ohne den Haupt-Thread von Dispatchers.Default oder Dispatchers.Main (auf Android) zu blockieren.
| Dispatcher | Verwendung |
|---|---|
Dispatchers.Default |
CPU-intensive Berechnungen (Parse, Sort, Compute) |
Dispatchers.IO |
Blockierendes I/O (Datei, JDBC, blockiertes Netzwerk) |
Dispatchers.Main |
UI-Aktualisierungen (nur auf Android/JavaFX) |
Dispatchers.Unconfined |
Selten verwendet, läuft auf dem ursprünglichen Aufruf-Thread |
8. Exception-Behandlung in Coroutines
Exceptions in launch und async verbreiten sich unterschiedlich – das ist die häufigste Quelle für Fehler.
fun main() = runBlocking {
// launch: Exception wird sofort geworfen, kann den Scope crashen, wenn nicht abgefangen
val job = launch {
try {
throw IllegalStateException("Bestellung existiert nicht")
} catch (e: IllegalStateException) {
println("Fehler in launch abgefangen: ${e.message}")
}
}
job.join()
// async: Exception wird in Deferred behalten, nur wenn await() aufgerufen wird, wird sie geworfen
val deferred = async {
throw IllegalStateException("Kunde existiert nicht")
}
try {
deferred.await()
} catch (e: IllegalStateException) {
println("Fehler in async abgefangen: ${e.message}")
}
}
Fehler in launch abgefangen: Bestellung existiert nicht
Fehler in async abgefangen: Kunde existiert nicht
Mit async wird die Exception still «geschluckt», wenn du .await() vergisst, bis Deferred garbage-collected wird oder du await() irgendwo aufrufst – das führt leicht zu schwer zu debuggenden Fehlern in der Produktion.
9. Tests für Coroutines schreiben
Nutze kotlinx-coroutines-test mit runTest, um Suspend-Funktionen zu testen, ohne auf echte delay-Aufrufe zu warten.
class OrderServiceTest {
@Test
fun `fetchOrder gibt Status PENDING zurück`() = runTest {
val order = fetchOrder(1L)
assertThat(order.id).isEqualTo(1L)
assertThat(order.status).isEqualTo("PENDING")
}
@Test
fun `loadOrderDetail führt zwei Coroutines parallel aus`() = runTest {
val result = loadOrderDetail(orderId = 1L, customerId = 2L)
assertThat(result).contains("Order 1")
assertThat(result).contains("Customer-2")
}
}
runTest nutzt automatisch virtuelle Zeit – delay(200) innerhalb von fetchOrder verursacht keine echten 200ms Verzögerung im Test, es springt direkt zur Resume-Logik. Das ist der Grund, warum du Coroutines in Unit-Tests nicht mit runBlocking testen solltest: es wartet echte Zeit, und deine Test-Suite wird immer langsamer, je mehr Tests du hast.
10. Häufige Fehler
GlobalScopeverwenden: Coroutines leben für die gesamte Anwendungslaufzeit, werden nach keinem Lebenszyklus abgebrochen – verursacht ein Leak. Erstelle immer einen Scope, der mit einer bestimmten Klasse/Komponente gebunden ist, wie in Abschnitt 6.runBlockingin Production-Code aufrufen: es blockiert den aktuellen Thread wirklich und zerstört den Zweck von Coroutines. Nutze es nur inmain(), Tests oder dort, wo du blockierenden und suspendierten Code verbinden musst..await()aufasyncvergessen: Exception wird geschluckt, die Coroutine läuft, aber niemand wartet auf das Ergebnis – Verschwendung und schwer zu debuggen.Dispatchers.IOfür CPU-intensive Arbeiten verwenden:Dispatchers.IOhat einen viel größeren Thread-Pool (optimiert für I/O-Wartevorgänge), bei schweren Berechnungen sinkt die Scheduling-Effizienz.
Hinweis: Wenn dein Service keine echte Nebenläufigkeit braucht (nur wenige I/O-Aufrufe nacheinander), könnten Coroutines Over-Engineering sein. Manchmal ist eine einfache, sequenzielle
suspend funohneasync/launchviel klarer und wartbarer.
11. Fazit
suspend fun ist die Grundeinheit, launch wird verwendet, wenn kein Ergebnis nötig ist, async wenn du parallel ausführen und das Ergebnis mit await() abrufen musst. CoroutineScope entscheidet über den Lebenszyklus – binde den Scope immer an eine bestimmte Komponente, vermeide GlobalScope und nutze coroutineScope/SupervisorJob für strukturierte Nebenläufigkeit. Diese drei Konzepte zu beherrschen genügt, um den meisten asynchronen Code in Kotlin sicher und leicht testbar zu schreiben.