Introducción a las corrutinas

Una IU receptiva es un elemento esencial para lograr una app excelente. Aunque quizás lo diste por hecho en las apps que compilaste hasta ahora, cuando comiences a agregar funciones más avanzadas, como las capacidades de red o de base de datos, puede resultarte cada vez más difícil escribir código funcional y de buen rendimiento. En el ejemplo siguiente, se ilustra lo que puede suceder si las tareas prolongadas, como descargar imágenes de Internet, no se manejan de forma correcta. Si bien la funcionalidad relativa a la imagen es satisfactoria, el desplazamiento es irregular y hace que la IU parezca no responsiva (y poco profesional).

9f8c54ba29f548cd.gif

A fin de evitar los problemas de la app anterior, deberás aprender sobre algo llamado subprocesos. Un subproceso es un concepto algo abstracto, pero lo puedes considerar como una única ruta de ejecución para el código de tu app. Cada línea de código que escribes es una instrucción que se ejecuta en ese orden dentro del mismo subproceso.

Ya trabajaste con subprocesos en Android. Cada app para Android tiene un subproceso "principal" predeterminado. Este es (en general) el subproceso de IU. Todo el código que escribiste hasta ahora está en el subproceso principal. Para cada instrucción (es decir, cada línea de código), se espera a que finalice la anterior antes de que se ejecute la siguiente línea.

Sin embargo, en una app en ejecución, existen otros subprocesos además del principal. En segundo plano, el procesador no funciona con subprocesos independientes, sino que alterna entre todas las diferentes instrucciones a fin de aparentar que se están realizando varias tareas a la vez. Un subproceso es una abstracción que puedes usar cuando escribes código a efectos de determinar la ruta de ejecución que debe seguir cada instrucción. Trabajar con subprocesos que no sean el principal permite que tu app realice en segundo plano tareas complejas, como la descarga de imágenes, mientras la interfaz de usuario de la app sigue siendo responsiva. Esto se denomina código simultáneo o, simplemente, simultaneidad.

En este codelab, obtendrás más información sobre subprocesos y la forma de usar una función de Kotlin llamada corrutinas a efectos de escribir código simultáneo, claro y que no provoque bloqueos.

Requisitos previos

Qué aprenderás

  • Qué es la simultaneidad y por qué es importante
  • Cómo usar corrutinas y subprocesos a fin de escribir código simultáneo que no provoque bloqueos
  • Cómo acceder al subproceso principal para realizar actualizaciones de la IU de manera segura cuando se realizan tareas en segundo plano
  • Cómo y cuándo usar diferentes patrones de simultaneidad (Scope/Dispatchers/Deferred)
  • Cómo escribir un código que interactúe con los recursos de red

Qué compilarás

  • En este codelab, escribirás algunos programas pequeños a efectos de explorar el trabajo con subprocesos y corrutinas en Kotlin.

Requisitos

  • Una computadora con un navegador web moderno, como la versión más reciente de Chrome
  • Acceso a Internet en la computadora

Subprocesos múltiples y simultaneidad

Hasta ahora, tratamos una app para Android como un programa con una única ruta de ejecución. Puedes hacer mucho con esa única ruta, pero, a medida que tu app crezca, deberás tener en cuenta la simultaneidad.

La simultaneidad permite que varias unidades de código se ejecuten de forma desordenada o que aparenten hacerlo en paralelo, lo que permite un uso más eficiente de los recursos. El sistema operativo puede usar las características del sistema, el lenguaje de programación y la unidad de simultaneidad para administrar varias tareas al mismo tiempo.

fe71122b40bdb5e3.png

¿Por qué necesitas usar la simultaneidad? A medida que tu app se vuelva más compleja, es importante que tu código no provoque bloqueos. Esto significa que realizar una tarea prolongada, como una solicitud de red, no detenga la ejecución de otras acciones de tu app. Si no implementas la simultaneidad de forma correcta, es posible que tu app les parezca no responsiva a los usuarios.

Observarás varios ejemplos que demuestran la programación simultánea en Kotlin. Todos los ejemplos se pueden ejecutar en el Playground de Kotlin:

https://developer.android.com/training/kotlinplayground

Un subproceso es la unidad de código más pequeña que puede programarse y ejecutarse en los límites de un programa. A continuación, se incluye un pequeño ejemplo en el que podemos ejecutar código simultáneo.

Puedes crear un subproceso simple proporcionando una lambda. Haz lo siguiente en el Playground.

fun main() {
    val thread = Thread {
        println("${Thread.currentThread()} has run.")
    }
    thread.start()
}

El subproceso no se ejecuta hasta que la función alcanza la llamada a función start(). El resultado debería ser similar a este:

Thread[Thread-0,5,main] has run.

Ten en cuenta que currentThread() muestra una instancia de Thread, que se convierte en su representación de string y muestra el nombre, la prioridad y el grupo del subproceso. El resultado anterior puede ser un poco diferente.

Crea y ejecuta varios subprocesos

A fin de demostrar la simultaneidad simple, crearemos algunos subprocesos para ejecutar. El código creará 3 subprocesos que imprimen la línea de información del ejemplo anterior.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

Resultado en el Playground:

Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting

Resultado en AS(console):

Thread[Thread-0,5,main] has started
Thread[Thread-1,5,main] has started
Thread[Thread-2,5,main] has started
Thread[Thread-1,5,main] - Starting
Thread[Thread-0,5,main] - Starting
Thread[Thread-2,5,main] - Starting
Thread[Thread-1,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 1
Thread[Thread-2,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 2
Thread[Thread-1,5,main] - Doing Task 2
Thread[Thread-2,5,main] - Doing Task 2
Thread[Thread-0,5,main] - Ending
Thread[Thread-2,5,main] - Ending
Thread[Thread-1,5,main] - Ending

Ejecuta el código varias veces. Verás distintos resultados. A veces, parecerá que los subprocesos se ejecutan en secuencia; otras veces, el contenido se intercalará.

El uso de subprocesos es una manera sencilla de comenzar a trabajar con varias tareas y con simultaneidad, pero no es algo libre de problemas. Pueden surgir varios de ellos cuando usas Thread directamente en tu código.

Los subprocesos requieren muchos recursos.

La creación, el cambio y la administración de subprocesos consumen tiempo y recursos del sistema, y limitan la cantidad de subprocesos sin procesar que se pueden administrar al mismo tiempo. Los costos de creación pueden ser realmente elevados.

Si bien una app en ejecución tendrá varios subprocesos, cada uno tendrá un subproceso dedicado, específicamente responsable de la IU de tu app. Este subproceso se suele conocer como subproceso principal o de IU.

Debido a que este subproceso se encarga de ejecutar la IU de tu app, es importante que el subproceso principal tenga un rendimiento óptimo de modo que la app se ejecute sin problemas. Cualquier tarea prolongada se bloqueará hasta que se complete y hará que tu app no responda.

El sistema operativo hace mucho para intentar que las cosas resulten responsivas para el usuario. Los teléfonos actuales intentan actualizar la IU de 60 a 120 veces por segundo (60 como mínimo). Hay un tiempo limitado y corto para preparar y dibujar la IU (a 60 fotogramas por segundo, cada actualización de pantalla debería tardar 16 ms o menos). Android descartará fotogramas o anulará la acción de completar un solo ciclo de actualización a fin de intentar actualizarse. Es normal que algunos fotogramas desaparezcan o fluctúen; pero, si esto ocurre demasiado, tu app parecerá no responsiva.

Condiciones de carrera y comportamiento impredecible

Como se explicó antes, un subproceso es una abstracción de la manera en que un procesador parece manejar varias tareas a la vez. A medida que el procesador cambia entre conjuntos de instrucciones en diferentes subprocesos, el tiempo exacto en el que se ejecuta un subproceso y el momento en el que este se detiene están fuera de tu control. No siempre puedes esperar un resultado predecible cuando trabajas con subprocesos directamente.

Por ejemplo, el siguiente código usa un bucle simple para contar de 1 a 50, pero en este caso se crea un subproceso nuevo cada vez que se incrementa el recuento. Piensa en cómo esperas que se vea el resultado y, luego, ejecuta el código varias veces.

fun main() {
   var count = 0
   for (i in 1..50) {
       Thread {
           count += 1
           println("Thread: $i count: $count")
       }.start()
   }
}

¿El resultado fue lo que esperabas? ¿Fue lo mismo siempre? A continuación, te mostramos un resultado de ejemplo.

Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 12
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 19 count: 19
Thread: 20 count: 20
Thread: 21 count: 21
Thread: 23 count: 22
Thread: 22 count: 23
Thread: 24 count: 24
Thread: 25 count: 25
Thread: 26 count: 26
Thread: 27 count: 27
Thread: 30 count: 28
Thread: 28 count: 29
Thread: 29 count: 41
Thread: 40 count: 41
Thread: 39 count: 41
Thread: 41 count: 41
Thread: 38 count: 41
Thread: 37 count: 41
Thread: 35 count: 41
Thread: 33 count: 41
Thread: 36 count: 41
Thread: 34 count: 41
Thread: 31 count: 41
Thread: 32 count: 41
Thread: 44 count: 42
Thread: 46 count: 43
Thread: 45 count: 44
Thread: 47 count: 45
Thread: 48 count: 46
Thread: 42 count: 47
Thread: 49 count: 48

Al contrario de lo que dice el código, parece que el último subproceso se ejecutó primero y que algunos de los otros subprocesos se ejecutaron de forma desordenada. Si observas el "recuento" de algunas de las iteraciones, notarás que no se modifican después de varios subprocesos. Aún más extraño: el recuento llega a 50 en el subproceso 43, aunque en el resultado se sugiere que este es solo el segundo subproceso que se ejecutará. A juzgar solo por el resultado, es imposible conocer el valor final de count.

Esta es solo una forma en que los subprocesos pueden provocar un comportamiento impredecible. Cuando trabajes con varios subprocesos, es posible que te encuentres con lo que se denomina una condición de carrera. Esta se da cuando varios subprocesos intentan acceder al mismo valor en memoria al mismo tiempo. Las condiciones de carrera pueden generar errores difíciles de reproducir y en apariencia aleatorios, lo que puede hacer que tu app falle, a menudo de forma impredecible.

Algunos de los motivos por los que no recomendamos trabajar directamente con subprocesos son los problemas de rendimiento, las condiciones de carrera y los errores difíciles de reproducir. En su lugar, obtendrás información sobre una función de Kotlin llamada Corrutinas que te ayudará a escribir código simultáneo.

La creación y el uso de subprocesos para tareas en segundo plano directamente tiene su lugar en Android, pero Kotlin también ofrece corrutinas que proporcionan una forma más flexible y fácil de administrar la simultaneidad.

Las corrutinas permiten realizar varias tareas a la vez, pero ofrecen otro nivel de abstracción por sobre trabajar solamente con subprocesos. Una función clave de las corrutinas es la capacidad de almacenar el estado, de modo que pueden detenerse y reanudarse. Una corrutina puede ejecutarse o no hacerlo.

El estado, representado por las Continuations, permite que partes del código indiquen cuándo necesitan otorgar el control o esperar a que otra corrutina complete su trabajo antes de reanudar. Este flujo se denomina realización de varias tareas a la vez de forma cooperativa. La implementación de corrutinas de Kotlin agrega varias funciones para ayudar a realizar varias tareas a la vez. Además de las Continuations, la creación de una corrutina integra ese trabajo en un Job, una unidad de trabajo cancelable con un ciclo de vida, dentro de un CoroutineScope. Un CoroutineScope es un contexto que aplica la cancelación y otras reglas a sus elementos secundarios y a los secundarios de estos de forma recurrente. Un Dispatcher administra el subproceso de copia de seguridad que la corrutina usará para su ejecución, lo que le quitará al desarrollador la responsabilidad de determinar cuándo y dónde usar un subproceso nuevo.

Job

Es una unidad de trabajo cancelable, como una creada con la función launch().

CoroutineScope

Las funciones que se usan para crear corrutinas nuevas como launch() y async() extienden CoroutineScope.

Dispatcher

Determina el subproceso que usará la corrutina. El despachador Main siempre ejecutará corrutinas en el subproceso principal, mientras que aquellos como Default, IO o Unconfined usarán otros subprocesos.

Obtendrás más información sobre esto más adelante, pero los Dispatchers son una de las maneras en que las corrutinas pueden tener un rendimiento óptimo. Uno evita el costo de rendimiento que conlleva inicializar subprocesos nuevos.

Adaptemos nuestros ejemplos anteriores para usar corrutinas.

import kotlinx.coroutines.*

fun main() {
    repeat(3) {
        GlobalScope.launch {
            println("Hi from ${Thread.currentThread()}")
        }
    }
}
Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#1,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#3`,5,main]

El fragmento anterior crea tres corrutinas en el Alcance Global mediante el despachador predeterminado. GlobalScope permite que se ejecute cualquier corrutina en él mientras se ejecute la app. Por los motivos que revisamos en relación con el subproceso principal, no se recomienda esta acción fuera de códigos de ejemplo. Cuando uses corrutinas en tus apps, usaremos otros alcances.

La función launch() crea una corrutina a partir del código encerrado unido a un objeto Job cancelable. launch() se usa cuando no se necesita que se muestre un valor fuera de los límites de la corrutina.

Veamos la firma completa de launch() a fin de comprender el otro concepto importante sobre las corrutinas.

fun CoroutineScope.launch {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
}

En segundo plano, el bloque de código que pasaste a launch está marcado con la palabra clave suspend. Suspend indica que un bloque de código o una función se pueden pausar o reanudar.

Aclaraciones sobre runBlocking

En los siguientes ejemplos, se usará runBlocking(), que, como su nombre indica, inicia una corrutina nueva y bloquea el subproceso actual hasta que esta se completa. En especial, se usa para conectar los códigos que provocan bloqueos con los que no lo hacen en funciones y pruebas principales. No lo utilizarás con frecuencia en el código típico de Android.

import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

val formatter = DateTimeFormatter.ISO_LOCAL_TIME
val time = { formatter.format(LocalDateTime.now()) }

suspend fun getValue(): Double {
    println("entering getValue() at ${time()}")
    delay(3000)
    println("leaving getValue() at ${time()}")
    return Math.random()
}

fun main() {
    runBlocking {
        val num1 = getValue()
        val num2 = getValue()
        println("result of num1 + num2 is ${num1 + num2}")
    }
}

getValue() muestra un número al azar después de un tiempo de retraso establecido. Usa un DateTimeFormatter a fin de mostrar las horas de entrada y salida apropiadas. La función principal llama a getValue() dos veces y muestra la suma.

entering getValue() at 17:44:52.311
leaving getValue() at 17:44:55.319
entering getValue() at 17:44:55.32
leaving getValue() at 17:44:58.32
result of num1 + num2 is 1.4320332550421415

Para ver cómo funciona, reemplaza la función main() (mantén sin cambios el resto del código) por lo siguiente:

fun main() {
    runBlocking {
        val num1 = async { getValue() }
        val num2 = async { getValue() }
        println("result of num1 + num2 is ${num1.await() + num2.await()}")
    }
}

Las dos llamadas a getValue() son independientes y no necesariamente precisan que se suspenda la corrutina. Kotlin tiene una función asíncrona similar a launch. La función async() se define de la siguiente manera:

Fun CoroutineScope.async() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
}: Deferred<T>

La función async() muestra un valor de tipo Deferred. Un Deferred es un Job cancelable que puede contener una referencia a un valor futuro. Mediante Deferred, aún puedes llamar a una función como si mostrara de inmediato un valor: un Deferred solo sirve como marcador de posición, dado que no puedes saber cuándo se mostrará el resultado de una tarea asincrónica. Un Deferred (también llamado Promise o Future en otros lenguajes) garantiza que se mostrará un valor a este objeto más adelante. Por otro lado, una tarea asíncrona no bloqueará ni esperará la ejecución de manera predeterminada. A fin de iniciar que la línea de código actual espere el resultado de un Deferred, puedes llamar a await() en él. Se mostrará el valor sin procesar.

entering getValue() at 22:52:25.025
entering getValue() at 22:52:25.03
leaving getValue() at 22:52:28.03
leaving getValue() at 22:52:28.032
result of num1 + num2 is 0.8416379026501276

Cuándo marcar funciones como suspend

En el ejemplo anterior, tal vez hayas notado que la función getValue() también está definida con la palabra clave suspend. El motivo es que esta llama a delay(), que también es una función suspend. Cuando una función llama a otra función suspend, también debe ser una función suspend.

Si este es el caso, ¿por qué la función main() de nuestro ejemplo no se marcaría con suspend? Después de todo, llama a getValue().

No necesariamente. En realidad, se llama a getValue() en la función que se pasa a runBlocking(), que es una función suspend, similar a las que se pasan a launch() y async(). Sin embargo, no se llama a getValue() en main(), y runBlocking() no es una función suspend, por lo que main() no se marca con suspend. Si una función no llama a una función suspend, no es necesario que sea una función suspend en sí misma.

Al comienzo de este codelab, viste el ejemplo que se muestra a continuación, en el que se usaron varios subprocesos. Con tu conocimiento de corrutinas, vuelve a escribir el código para usar estas en lugar de Thread.

Nota: No es necesario que edites las sentencias println(), aunque hagan referencia a Thread.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}
import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               delay(5000)
           }
       }
   }
}

Aprendiste lo siguiente:

  • Por qué la simultaneidad es necesaria
  • Qué son los subprocesos y por qué son importantes para la simultaneidad
  • Cómo escribir código simultáneo en Kotlin mediante corrutinas
  • Cuándo marcar una función como "suspend" y cuándo no hacerlo
  • Cuáles son las funciones de CoroutineScope, Job y Dispatcher
  • Cuál es la diferencia entre Deferred y Await