Introducción a las corrutinas de Kotlin

1. Información general

En este artículo, veremos corrutinas del lenguaje Kotlin. En pocas palabras, las corrutinas nos permiten crear programas asincrónicos de una manera muy fluida y se basan en el concepto de programación de estilo de paso de continuación .

El lenguaje Kotlin nos brinda construcciones básicas pero puede obtener acceso a corrutinas más útiles con la biblioteca kotlinx-coroutines-core . Examinaremos esta biblioteca una vez que entendamos los componentes básicos del lenguaje Kotlin.

2. Creación de una corrutina con BuildSequence

Creemos una primera corrutina usando la función buildSequence .

E implementemos un generador de secuencias de Fibonacci usando esta función:

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 de una función de rendimiento es:

public abstract suspend fun yield(value: T)

La palabra clave suspend significa que esta función puede estar bloqueando. Dicha función puede suspender una corrutina buildSequence.

Las funciones de suspensión se pueden crear como funciones estándar de Kotlin, pero debemos tener en cuenta que solo podemos llamarlas desde una corrutina. De lo contrario, obtendremos un error del compilador.

Si hemos suspendido la llamada dentro de buildSequence, esa llamada se transformará al estado dedicado en la máquina de estado. Una corrutina se puede pasar y asignar a una variable como cualquier otra función.

En la corrutina fibonacciSeq , tenemos dos puntos de suspensión. Primero, cuando llamamos rendimiento (1) y segundo cuando llamamos rendimiento (a + b).

Si esa función de rendimiento da como resultado alguna llamada de bloqueo, el hilo actual no se bloqueará en ella. Podrá ejecutar algún otro código. Una vez que la función suspendida finaliza su ejecución, el hilo puede reanudar la ejecución de la corrutina fibonacciSeq .

Podemos probar nuestro código tomando algunos elementos de la secuencia de Fibonacci:

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

3. Agregar la dependencia de Maven para kotlinx-coroutines

Echemos un vistazo a la biblioteca kotlinx-coroutines , que tiene construcciones útiles construidas sobre las corrutinas básicas.

Agreguemos la dependencia a la biblioteca kotlinx-coroutines-core . Tenga en cuenta que también necesitamos agregar el repositorio jcenter :

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

4. Programación asincrónica mediante la rutina launch () C

La biblioteca kotlinx-coroutines agrega muchas construcciones útiles que nos permiten crear programas asincrónicos. Digamos que tenemos una función de cálculo costosa que agrega una Cadena a la lista de entrada:

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

Podemos usar una corrutina de lanzamiento que ejecutará esa función de suspensión de una manera no bloqueante; necesitamos pasar un grupo de subprocesos como argumento.

La función de lanzamiento devuelve una instancia de trabajo en la que podemos llamar a un método join () para esperar los resultados:

@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!")) }

Para poder probar nuestro código, pasamos toda la lógica a la corrutina runBlocking, que es una llamada de bloqueo. Por lo tanto, nuestro assertEquals () se puede ejecutar sincrónicamente después del código dentro del método runBlocking () .

Tenga en cuenta que en este ejemplo, aunque el método launch () se activa primero, es un cálculo retrasado. El hilo principal continuará agregando la cadena "Hola" a la lista de resultados.

Después del retraso de un segundo que se introduce en la función costosaComputación () , la "palabra!" La cadena se agregará al resultado.

5. Las corrutinas son muy ligeras

Imaginemos una situación en la que queremos realizar 100000 operaciones de forma asincrónica. Generar un número tan alto de subprocesos será muy costoso y posiblemente producirá una excepción OutOfMemoryException.

Afortunadamente, cuando se utilizan las corrutinas, este no es un caso. Podemos ejecutar tantas operaciones de bloqueo como queramos. Bajo el capó, esas operaciones serán manejadas por un número fijo de subprocesos sin una creación excesiva de subprocesos:

@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) } }

Tenga en cuenta que estamos ejecutando 100,000 corrutinas y cada ejecución agrega un retraso sustancial. Sin embargo, no es necesario crear demasiados subprocesos porque esas operaciones se ejecutan de forma asincrónica utilizando subprocesos de CommonPool.

6. Cancelación y tiempos de espera

A veces, después de haber desencadenado algún cálculo asincrónico de larga duración, queremos cancelarlo porque ya no estamos interesados ​​en el resultado.

Cuando iniciamos nuestra acción asincrónica con la corrutina launch () , podemos examinar la bandera isActive . Este indicador se establece en falso siempre que el hilo principal invoca el método cancel () en la instancia del trabajo:

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

Esta es una forma muy elegante y fácil de utilizar el mecanismo de cancelación . En la acción asincrónica, solo necesitamos verificar si el indicador isActive es igual a falso y cancelar nuestro procesamiento.

Cuando solicitamos algún procesamiento y no estamos seguros de cuánto tiempo llevará ese cálculo, es aconsejable establecer el tiempo de espera para dicha acción. Si el procesamiento no finaliza dentro del tiempo de espera dado, obtendremos una excepción y podremos reaccionar de manera apropiada.

Por ejemplo, podemos volver a intentar la acción:

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

Si no definimos un tiempo de espera, es posible que nuestro hilo se bloquee para siempre porque ese cálculo se bloqueará. No podemos manejar ese caso en nuestro código si el tiempo de espera no está definido.

7. Ejecución de acciones asincrónicas al mismo tiempo

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.

La implementación de todos estos ejemplos y fragmentos de código se puede encontrar en el proyecto GitHub.