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 fun direkt aus einer normalen Funktion (fun ohne 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 – jedes await() 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

  • GlobalScope verwenden: 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.
  • runBlocking in Production-Code aufrufen: es blockiert den aktuellen Thread wirklich und zerstört den Zweck von Coroutines. Nutze es nur in main(), Tests oder dort, wo du blockierenden und suspendierten Code verbinden musst.
  • .await() auf async vergessen: Exception wird geschluckt, die Coroutine läuft, aber niemand wartet auf das Ergebnis – Verschwendung und schwer zu debuggen.
  • Dispatchers.IO für CPU-intensive Arbeiten verwenden: Dispatchers.IO hat 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 fun ohne async/launch viel 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.

kotlincoroutinesconcurrencyandroidspring-boot