Einführung in Kotlin Coroutines

Keine Kommentare

Es gibt kaum noch Anwendungen, die ohne Nebenläufigkeit auskommen. In Java löst man Probleme mit Asynchronität und Nebenläufigkeit in der Regel mit Threads. Leider bringen Threads einen relativ großen Overhead mit sich, dazu kommen Callbacks (oder noch schlimmer: verschachtelte Callbacks), die es schwerer machen, den Code zu lesen und zu verstehen.
Kotlin bietet als Lösungsansatz sogenannte Coroutines an, mit denen man nebenläufige Programme ganz natürlich – das heißt sequenziell und ohne Callbacks – schreiben kann.

Setup

Um Coroutines nutzen zu können, müssen sie erst im Build File aktiviert und zu den Dependencies hinzugefügt werden:

Für Gradle:

dependencies {
    ...
    compile 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.22.5'
}
...
kotlin.experimental.coroutines = 'enable' //necessary to avoid compiler warnings

Oder Maven:

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>0.21.2</version>
</dependency>

Von der niedrigen Versionsnummer darf man sich genauso wenig abschrecken lassen wie von der Tatsache, dass Coroutines als “experimental” gekennzeichnet sind: Coroutines sollen explizit auch in Produktion eingesetzt werden. Es kann lediglich sein, dass sich die API noch etwas häufiger ändert als andere Schnittstellen der Standard Library.

Sequenziell und trotzdem nebenläufig

Coroutines ermöglichen nebenläufige Programmierung im sequenziellen Stil. Ganz ohne Callbacks, Promises oder Threads.
Anstatt einen Thread zu starten und über ein Callback auf das Ergebnis dessen Berechnung zu warten, kann ich in der nächsten Zeile mit diesem Ergebnis einfach weiterarbeiten.

In Code gegossen sieht das dann so aus:

suspend fun showMails() {
   val emails = retrieveMails() 

   emails.forEach {
       println(it)
   }
}

suspend fun retrieveMails(): List {
   ... //get mails from the internet
   return ...
   }
}

In diesem Snippet sind zwei Dinge interessant:
Das Keyword suspend kennzeichnet eine suspending function. Suspend ist ein Hinweis darauf, dass diese Funktion asynchron ist und nicht sofort ein Ergebnis liefern muss.
Die Funktion retrieveMails() stellt hier eine potenziell blockierende Operation, also zum Beispiel einen Network call, dar. Ohne das suspend Keyword würde diese Funktion den aufrufenden Thread so lange blockieren, bis retrieveMails() (und das anschließende Ausgeben der Mails) abgeschlossen ist. Man müsste sie also z. B. in einem eigenen Thread ausführen.

Suspending functions können jedoch nicht aus normalen Funktionen aufgerufen werden, sondern müssen über einen sogenannten Coroutine Builder gestartet werden.

fun main(args: Array) {
   launch { showMails() }
}

Coroutine Builder

Die Funktion launch{} ist ein solcher Coroutine Builder. Startet man jetzt die Anwendung, würde man die erwartete Ausgabe aus showMails() jedoch nicht sehen. Der Aufruf von launch{} startet die suspending function, interessiert sich jedoch nicht für das Ergebnis. Coroutines verhalten sich wie Daemon-Threads. Sie halten den Prozess, aus dem sie gestartet wurden, nicht am Leben. Die main-Funktion würde also terminieren, bevor alle Mails abgeholt worden sind.
Um das zu verhindern, muss ich irgendwo auf das Ergebnis warten. Das kann ich z. B. mit join() und dem Coroutine Builder runBlocking{} erreichen.

fun main(args: Array)  {
   val job = launch { showMails() }
   println("You've got mail:")

   runBlocking {
       job.join()
   }
}

Die join()-Funktion wartet darauf, dass der Job beendet wird. Sie ist selbst eine suspending function und blockiert daher auch nicht den aktuellen Thread. Damit das funktioniert, muss sie über den Coroutine Builder runBlocking{} ausgeführt werden.
Um die Verbindung zwischen der asynchronen und synchronen Welt herzustellen, bietet es sich daher an, die main-Funktion als Einstiegspunkt in die gesamte Anwendung selbst als blocking zu definieren:

fun main(args: Array) = runBlocking {
   val job = launch { showMails() }
   println("You've got mail:")
  
   job.join()
}

Neben launch{} und runBlocking{} gibt es noch weitere Coroutine Builder.
Der async{} Builder lässt mich Coroutines auf ähnliche Art benutzen wie async/await in JavaScript oder C#.

fun main(args: Array) = runBlocking {
   val numbers = async {
       List(10) { i ->
           // create a list with 10 elements, waiting 1000 millis in every iteration
           delay(1000)
           i
       }
   }

   println("Your numbers:")

   numbers.await().forEach {
       println(it)
   }
}

Der Coroutine Builder async{} gibt ein Objekt des Typs Deferred zurück. Auf diesem Objekt kann dann mittels await() auf das Ergebnis des async-Blocks (also die Liste mit 10 Ints) gewartet werden. Auch diese Operation blockiert den Thread.

Coroutines als Alternative zu Threads

Aber was soll das? Wo ist der Unterschied zu Threads?

Coroutines sind viel leichtgewichtiger als Threads. Das bedeutet, dass ich 100000 Coroutines gleichzeitig starten kann, ohne in einen OutOfMemoryError zu laufen:

fun main(args: Array) = runBlocking {
    val jobs = List(100_000) { i -> 
        launch {
            delay(1000L)
            println(i)
        }
    }
    jobs.forEach { it.join() } // wait for all jobs to complete
}

Um dasselbe mit Threads zu machen, muss ich lediglich den Coroutine Builder launch{} durch Kotlins thread{}-Funktion austauschen und das delay() durch ein Thread.sleep() ersetzen:

 fun main(args: Array) = runBlocking {
   val jobs = List(100_000) { i->
       thread {
           sleep(1000L)
           println(i)
       }
   }
   jobs.forEach { it.join() } // wait for all jobs to complete
}

Diese Variante führt fast sicher zu einem OutOfMemoryError (“unable to create new native thread”), da jeder Thread (je nach Plattform) zwischen 256KB und 1024KB benötigt.

Coroutines sind also wie leichtgewichtige Daemon-Threads und werden über sogenannte Coroutine Builder gestartet.

Continuation-passing style

Im Grunde funktionieren Coroutines über Callbacks. Jede mit suspend definierte Funktion erhält einen unsichtbaren Parameter des Typs Continuation.
Diese Art der Kontrollflusssteuerung wird daher auch Continuation-passing style genannt.

Am Beispiel vom Anfang des Artikels kann man sich das in etwa so vorstellen:

fun showMails() {
   val emails = retrieveMails { emails ->

      emails.forEach {
          println(it)
      }
  }
}

fun retrieveMails(callback: Continuation>) {
   val emails = …  
   callback(emails)
   }
}

Das Komfortable daran ist aber, dass diese Callbacks vom Compiler generiert werden.

Mit nur einem neuen Keyword, suspend, und einigen Compiler-Tricks ermöglichen es Kotlins Coroutines, asynchronen Code zu schreiben, ohne dabei vom natürlichen sequenziellen Programmierstil abzuweichen.

Lovis Möller

Lovis Möller ist ein echter „Hamburger Jung“ und immer auf der Suche nach neuen Möglichkeiten, Denkweisen und Perspektiven in der Softwareentwicklung.

Artikel von Lovis Möller

Mock? What, When, How?

Weitere Inhalte zu Kotlin

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.