Introduzione a Kotlin Coroutines

1. Panoramica

In questo articolo, esamineremo le coroutine dal linguaggio Kotlin. In poche parole, le coroutine ci consentono di creare programmi asincroni in modo molto scorrevole e si basano sul concetto di programmazione in stile di passaggio di continuazione .

Il linguaggio Kotlin ci fornisce costrutti di base ma può ottenere l'accesso a coroutine più utili con la libreria kotlinx-coroutines-core . Esamineremo questa libreria una volta compresi gli elementi costitutivi di base del linguaggio Kotlin.

2. Creazione di una coroutine con BuildSequence

Creiamo una prima coroutine usando la funzione buildSequence .

E implementiamo un generatore di sequenze di Fibonacci usando questa funzione:

val fibonacciSeq = buildSequence { var a = 0 var b = 1 yield(1) while (true) { yield(a + b) val tmp = a + b a = b b = tmp } }

La firma di una funzione di rendimento è:

public abstract suspend fun yield(value: T)

La parola chiave suspend significa che questa funzione può essere bloccante. Tale funzione può sospendere una coroutine buildSequence .

Le funzioni di sospensione possono essere create come funzioni Kotlin standard, ma dobbiamo essere consapevoli che possiamo chiamarle solo dall'interno di una coroutine. Altrimenti, otterremo un errore del compilatore.

Se abbiamo sospeso la chiamata all'interno di buildSequence, quella chiamata verrà trasformata nello stato dedicato nella macchina a stati. Una coroutine può essere passata e assegnata a una variabile come qualsiasi altra funzione.

Nella coroutine fibonacciSeq , abbiamo due punti di sospensione. Primo, quando chiamiamo yield (1) e secondo quando chiamiamo yield (a + b).

Se quella funzione yield risulta in qualche chiamata di blocco, il thread corrente non si bloccherà su di essa. Sarà in grado di eseguire un altro codice. Una volta che la funzione sospesa termina la sua esecuzione, il thread può riprendere l'esecuzione della coroutine fibonacciSeq .

Possiamo testare il nostro codice prendendo alcuni elementi dalla sequenza di Fibonacci:

val res = fibonacciSeq .take(5) .toList() assertEquals(res, listOf(1, 1, 2, 3, 5))

3. Aggiunta della dipendenza Maven per kotlinx-coroutines

Diamo un'occhiata alla libreria kotlinx-coroutines che ha costrutti utili compilati sopra le coroutine di base.

Aggiungiamo la dipendenza alla libreria kotlinx-coroutines-core . Nota che dobbiamo anche aggiungere il repository jcenter :

 org.jetbrains.kotlinx kotlinx-coroutines-core 0.16    central //jcenter.bintray.com  

4. Programmazione asincrona utilizzando la routine launch ()

La libreria kotlinx-coroutines aggiunge molti costrutti utili che ci consentono di creare programmi asincroni. Supponiamo di avere una funzione di calcolo costosa che aggiunge una stringa all'elenco di input:

suspend fun expensiveComputation(res: MutableList) { delay(1000L) res.add("word!") }

Possiamo usare una coroutine di lancio che eseguirà quella funzione di sospensione in modo non bloccante: dobbiamo passare un pool di thread come argomento ad essa.

La funzione di avvio restituisce un'istanza di lavoro su cui possiamo chiamare un metodo join () per attendere i risultati:

@Test fun givenAsyncCoroutine_whenStartIt_thenShouldExecuteItInTheAsyncWay() { // given val res = mutableListOf() // when runBlocking { val promise = launch(CommonPool) { expensiveComputation(res) } res.add("Hello,") promise.join() } // then assertEquals(res, listOf("Hello,", "word!")) }

Per essere in grado di testare il nostro codice, passiamo tutta la logica nella coroutine runBlocking , che è una chiamata di blocco. Pertanto il nostro assertEquals () può essere eseguito in modo sincrono dopo il codice all'interno del metodo runBlocking () .

Notare che in questo esempio, sebbene il metodo launch () venga attivato per primo, si tratta di un calcolo ritardato. Il thread principale procederà aggiungendo la stringa "Hello" all'elenco dei risultati.

Dopo il ritardo di un secondo introdotto nella funzione costosaComputation () , la "parola!" La stringa verrà aggiunta al risultato.

5. Le coroutine sono molto leggere

Immaginiamo una situazione in cui vogliamo eseguire 100000 operazioni in modo asincrono. La generazione di un numero così elevato di thread sarà molto costosa e probabilmente produrrà un'eccezione OutOfMemoryException.

Fortunatamente, quando si usano le coroutine, questo non è un caso. Possiamo eseguire tutte le operazioni di blocco che vogliamo. Dietro le quinte, quelle operazioni saranno gestite da un numero fisso di thread senza creare thread eccessivi:

@Test fun givenHugeAmountOfCoroutines_whenStartIt_thenShouldExecuteItWithoutOutOfMemory() { runBlocking { // given val counter = AtomicInteger(0) val numberOfCoroutines = 100_000 // when val jobs = List(numberOfCoroutines) { launch(CommonPool) { delay(1000L) counter.incrementAndGet() } } jobs.forEach { it.join() } // then assertEquals(counter.get(), numberOfCoroutines) } }

Nota che stiamo eseguendo 100.000 coroutine e ogni esecuzione aggiunge un notevole ritardo. Tuttavia, non è necessario creare troppi thread perché tali operazioni vengono eseguite in modo asincrono utilizzando il thread dal CommonPool.

6. Cancellazione e timeout

A volte, dopo aver attivato un calcolo asincrono di lunga durata, desideriamo annullarlo perché non siamo più interessati al risultato.

Quando iniziamo la nostra azione asincrona con la coroutine launch () , possiamo esaminare il flag isActive . Questo flag è impostato su false ogni volta che il thread principale richiama il metodo cancel () sull'istanza del lavoro:

@Test fun givenCancellableJob_whenRequestForCancel_thenShouldQuit() { runBlocking { // given val job = launch(CommonPool) { while (isActive) { println("is working") } } delay(1300L) // when job.cancel() // then cancel successfully } }

Questo è un modo molto elegante e semplice per utilizzare il meccanismo di cancellazione . Nell'azione asincrona, dobbiamo solo controllare se il flag isActive è uguale a false e annullare la nostra elaborazione.

Quando richiediamo un'elaborazione e non siamo sicuri di quanto tempo richiederà il calcolo, è consigliabile impostare il timeout su tale azione. Se l'elaborazione non termina entro il timeout specificato, otterremo un'eccezione e possiamo reagire in modo appropriato.

Ad esempio, possiamo ritentare l'azione:

@Test(expected = CancellationException::class) fun givenAsyncAction_whenDeclareTimeout_thenShouldFinishWhenTimedOut() { runBlocking { withTimeout(1300L) { repeat(1000) { i -> println("Some expensive computation $i ...") delay(500L) } } } }

Se non definiamo un timeout, è possibile che il nostro thread venga bloccato per sempre perché il calcolo si bloccherà. Non possiamo gestire quel caso nel nostro codice se il timeout non è definito.

7. Esecuzione simultanea di azioni asincrone

Let's say that we need to start two asynchronous actions concurrently and wait for their results afterward. If our processing takes one second and we need to execute that processing twice, the runtime of synchronous blocking execution will be two seconds.

It would be better if we could run both those actions in separate threads and wait for those results in the main thread.

We can leverage the async() coroutine to achieve this by starting processing in two separate threads concurrently:

@Test fun givenHaveTwoExpensiveAction_whenExecuteThemAsync_thenTheyShouldRunConcurrently() { runBlocking { val delay = 1000L val time = measureTimeMillis { // given val one = async(CommonPool) { someExpensiveComputation(delay) } val two = async(CommonPool) { someExpensiveComputation(delay) } // when runBlocking { one.await() two.await() } } // then assertTrue(time < delay * 2) } }

After we submit the two expensive computations, we suspend the coroutine by executing the runBlocking() call. Once results one and two are available, the coroutine will resume, and the results are returned. Executing two tasks in this way should take around one second.

We can pass CoroutineStart.LAZY as the second argument to the async() method, but this will mean the asynchronous computation will not be started until requested. Because we are requesting computation in the runBlocking coroutine, it means the call to two.await() will be made only once the one.await() has finished:

@Test fun givenTwoExpensiveAction_whenExecuteThemLazy_thenTheyShouldNotConcurrently() { runBlocking { val delay = 1000L val time = measureTimeMillis { // given val one = async(CommonPool, CoroutineStart.LAZY) { someExpensiveComputation(delay) } val two = async(CommonPool, CoroutineStart.LAZY) { someExpensiveComputation(delay) } // when runBlocking { one.await() two.await() } } // then assertTrue(time > delay * 2) } }

The laziness of the execution in this particular example causes our code to run synchronously. That happens because when we call await(), the main thread is blocked and only after task one finishes task two will be triggered.

We need to be aware of performing asynchronous actions in a lazy way as they may run in a blocking way.

8. Conclusion

In this article, we looked at basics of Kotlin coroutines.

We saw that buildSequence is the main building block of every coroutine. We described how the flow of execution in this Continuation-passing programming style looks.

Finally, we looked at the kotlinx-coroutines library that ships a lot of very useful constructs for creating asynchronous programs.

L'implementazione di tutti questi esempi e frammenti di codice può essere trovata nel progetto GitHub.