Saltar al contenido
K
Corrutinas y FlowPaso a paso30 min de lectura · 5 min de máquina Intermedio

Corrutinas y suspend

Al terminar: Entender qué es una corrutina y qué significa suspend, e instalar la librería de corrutinas.

Al terminar esta lección sabrás qué es una corrutina, en qué se diferencia de un hilo, qué significa la palabra suspend y por qué esperar con delay es distinto de esperar con Thread.sleep. Lo habrás medido tú mismo, y :torneo tendrá instalada la librería de corrutinas, lista para usarla en la lección siguiente.

Es la base de todo lo que sigue en el curso: cada pantalla que pide datos a la red o a una base de datos espera algo, y esperar mal es la primera causa de apps que se congelan. Temas: corrutina frente a hilo y qué significa suspend; instalar una librería con versión propia y sin plugin.

Los ejemplos mínimos de las secciones de teoría los puedes probar en el Kotlin Playground, que ya trae la librería de corrutinas. En tu proyecto, la librería no está todavía: se instala en el paso de esta lección. Los ejemplos empiezan con líneas import, que ya conoces de Bucles y tu primer test: dicen de dónde viene cada nombre que usa el código.

Un hilo espera; una corrutina se aparta

Un hilo (thread) es el trabajador que ejecuta tu código línea a línea. Hasta ahora tu programa tenía uno solo: el hilo main que viste en la pila de llamadas de Nulabilidad. Thread es la clase de Java para los hilos. Puedes ver el nombre del tuyo:

ejemplo mínimo: el hilo en el que corre tu código (con comentarios)
fun main() {
    // Thread.currentThread() es el hilo que ejecuta esta línea; .name es su nombre
    println("Este código corre en el hilo: ${Thread.currentThread().name}")
}

Al ejecutarlo, imprime:

Este código corre en el hilo: main

Un ordenador puede tener muchos hilos a la vez, pero cada uno reserva memoria propia y el sistema tiene que repartirse el procesador entre ellos. Y si un hilo llama a Thread.sleep(1000) (dormir 1000 milisegundos), se queda bloqueado: no hace nada, pero nadie más puede usarlo.

Una corrutina (coroutine) es un trabajo que puede pausarse sin bloquear el hilo donde corre. Mientras espera, el hilo queda libre para ejecutar otra corrutina, y cuando la espera acaba, la corrutina continúa (quizá en otro hilo).

Un grupo de hilos es un equipo de hilos que la librería reutiliza para muchas corrutinas, en lugar de crear un hilo nuevo por cada una.

suspend es la palabra que marca una función como pausable. No significa «corre en otro hilo»: una función suspend corre donde la llamen. Solo significa que dentro puede haber puntos donde se pause, como delay(…), la versión que no bloquea de Thread.sleep(…). Y por eso solo se puede llamar desde otra función suspend o desde una corrutina (el bloque de runBlocking { }, que verás enseguida, es una corrutina).

ejemplo mínimo: suspend y delay (con comentarios)
import kotlinx.coroutines.delay          // delay viene de la librería de corrutinas
import kotlinx.coroutines.runBlocking    // runBlocking también
 
// `suspend`: esta función puede pausarse. No dice en qué hilo corre.
suspend fun esperarUnSegundo() {
    delay(1000)           // se pausa 1000 ms; el hilo queda libre
    // Thread.sleep(1000) // esto bloquearía el hilo entero
}
 
fun main() {
    // `main` normal no es suspend, así que no puede llamar a `esperarUnSegundo`.
    // `runBlocking` es el puente: abre un bloque donde sí se puede, y bloquea
    // el hilo actual hasta que ese bloque termina.
    runBlocking {
        esperarUnSegundo()
        println("pasó un segundo")
    }
}

Al ejecutarlo, imprime lo siguiente una vez, al cabo de 1 s:

pasó un segundo

Un dato que necesitarás enseguida: runBlocking trabaja con un solo hilo, el que lo llamó. launch { } arranca una corrutina nueva dentro de él y sigue con la línea siguiente sin esperarla (se ve entero en launch y async). Todas las corrutinas que se lancen así comparten ese hilo y se turnan en él. (Por eso mismo, medir con Thread.sleep dentro de él sale peor: lo verás justo debajo.)

Ese hilo main de la consola no es el «hilo principal» de una app con pantalla, que se llama Dispatchers.Main y se verá en Dispatchers y withContext.

Error típico: bloquear el hilo dentro de una corrutina

Dentro de runBlocking, dos launch son dos corrutinas que comparten su único hilo.

Quieres que dos partidos avancen a la vez y escribes la espera con Thread.sleep, porque es el nombre que suena a «esperar». Este ejemplo prueba las dos versiones, una detrás de otra, y mide cada una con measureTimeMillis, que ejecuta un bloque y devuelve cuántos milisegundos tardó. Pruébalo en el Kotlin Playground:

ejemplo mínimo: Thread.sleep frente a delay (con comentarios)
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
 
fun main() {
    // Primera versión: la espera bloquea el hilo
    val conSleep = measureTimeMillis {
        runBlocking {
            // `;` separa dos instrucciones escritas en la misma línea
            launch { Thread.sleep(1000); println("partido 1 (Thread.sleep)") }
            launch { Thread.sleep(1000); println("partido 2 (Thread.sleep)") }
        }
    }
    // Segunda versión: la espera pausa la corrutina sin bloquear
    val conDelay = measureTimeMillis {
        runBlocking {
            launch { delay(1000); println("partido 1 (delay)") }
            launch { delay(1000); println("partido 2 (delay)") }
        }
    }
    println("Thread.sleep, 2000 ms o más: ${conSleep >= 2000}")
    println("delay, menos de 1500 ms: ${conDelay < 1500}")
}

Al ejecutarlo, imprime:

partido 1 (Thread.sleep)
partido 2 (Thread.sleep)
partido 1 (delay)
partido 2 (delay)
Thread.sleep, 2000 ms o más: true
delay, menos de 1500 ms: true

Las dos versiones compilan y funcionan, y ese es el problema: con Thread.sleep no hay ningún error, solo tarda el doble. Y en una app real, ese hilo suele ser el que dibuja la pantalla.

Lo medido: Thread.sleep frente a delay

Si cambias los dos println finales para que muestren conSleep y conDelay tal cual, verás las cifras reales. Las de la tabla salen de esa prueba (dos esperas de 1000 ms dentro de runBlocking), en una Mac de 11 núcleos con JDK 17 y coroutines 1.11.0. Un núcleo es cada unidad del procesador capaz de calcular por su cuenta. En tu máquina saldrán otras cifras, porque dependen del procesador y de lo que tenga abierto. Lo que se mantiene es la proporción.

PruebaThread.sleepdelay
Dos partidos de 1000 ms en runBlocking (un solo hilo)2030 ms1013 ms

Con Thread.sleep el segundo partido tiene que esperar a que el primero suelte el hilo: 2030 ms, la suma. Con delay, el primero se aparta al esperar y el hilo pasa al segundo, así que las dos esperas transcurren juntas: 1013 ms.

Lo mismo a lo grande, en el Kotlin Playground: 10.000 esperas de 100 ms sobre el único hilo de runBlocking. Con un hilo del sistema por espera serían 10.000 hilos; con corrutinas, solo una por espera. Dos palabras nuevas: repeat(n) { … } ejecuta su bloque n veces, y coroutineScope { … } no termina hasta que acaban todas las corrutinas lanzadas dentro. Hace falta porque el cronómetro tiene que rodear algo que espere: runBlocking solo espera al final, fuera del cronómetro. Y un detalle del número: el guion bajo de 10_000 solo separa los miles para leerlo mejor; para Kotlin es 10000.

ejemplo mínimo: 10.000 corrutinas esperando a la vez (con comentarios)
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
 
fun main() = runBlocking {
    val ms = measureTimeMillis {          // ejecuta el bloque y devuelve cuántos ms tardó
        coroutineScope {                  // no termina hasta que acaban TODAS las corrutinas de dentro
            repeat(10_000) {              // el bloque se ejecuta 10.000 veces
                launch { delay(100) }     // una corrutina nueva por vuelta: espera 100 ms y acaba
            }
        }
    }
    println("10.000 corrutinas de 100 ms, menos de 1000 ms: ${ms < 1000}")
}

Al ejecutarlo, imprime:

10.000 corrutinas de 100 ms, menos de 1000 ms: true

El tiempo real ronda los 150 ms. El mínimo teórico de 100 ms es el de una sola espera, porque las 10.000 esperan juntas; los 50 ms de más son lo que cuesta crear y arrancar 10.000 corrutinas. Un detalle de la medida: si el cronómetro solo rodea el repeat, sin el coroutineScope, mides lo que cuesta lanzar las corrutinas (unos 20 ms) y no lo que tardan en terminar.

Cancelar una corrutina en plena espera también cambia con delay y con Thread.sleep; eso se mide en Alcance, Job y cancelación.

Swift tiene su propia versión de las dos ideas, con otras palabras (async y await): la verás en async/await y Task.

Reto · Ejercicio: tres esperas en fila y tres a la vez

En el Kotlin Playground, que ya trae la librería de corrutinas, escribe una función que se pause 300 ms sin bloquear el hilo y después imprima el nombre que recibe. En main, llámala tres veces seguidas (a, b, c) y mide cuánto tarda; después, lanza las tres a la vez y vuelve a medir. La consola debe mostrar:

a
b
c
En fila, 900 ms o más: true
a
b
c
A la vez, menos de 600 ms: true

Segunda parte: cambia la espera de la función por una que bloquee el hilo en lugar de pausarlo, y vuelve a probar la versión «a la vez». ¿Cuánto tarda y por qué?

Ver solución
solución del reto
import kotlinx.coroutines.*   // el asterisco importa todos los nombres de ese paquete de una vez
import kotlin.system.measureTimeMillis
 
suspend fun preparar(nombre: String) {
    delay(300)
    println(nombre)
}
 
fun main() = runBlocking {
    val enFila = measureTimeMillis {
        preparar("a"); preparar("b"); preparar("c")
    }
    println("En fila, 900 ms o más: ${enFila >= 900}")
 
    val aLaVez = measureTimeMillis {
        coroutineScope {
            for (n in listOf("a", "b", "c")) launch { preparar(n) }
        }
    }
    println("A la vez, menos de 600 ms: ${aLaVez < 600}")
}

Con Thread.sleep(300) en lugar de delay(300), la versión «a la vez» vuelve a tardar unos 900 ms: runBlocking tiene un solo hilo (ya lo viste arriba) y cada Thread.sleep lo ocupa entero, así que las corrutinas no se pueden turnar.

Paso · Instala la librería de corrutinas

Objetivo: que :torneo tenga kotlinx-coroutines-core y que import kotlinx.coroutines.delay se reconozca.

Gradle Scripts
libs.versions.toml (Version Catalog "libs")se modifica: [versions] y [libraries]
build.gradle.kts (Module :torneo)se modifica: dependencies
Los dos ficheros de esta instalación, tal como los ves en la vista Android.

1 · Qué instalas. El lenguaje solo trae la palabra suspend; delay, launch, async, runBlocking, coroutineScope y, luego, Flow viven en la librería org.jetbrains.kotlinx:kotlinx-coroutines-core. El -core es el núcleo; otras piezas, como kotlinx-coroutines-test (Tests de corrutinas: tiempo virtual), se añaden aparte.

2 · [versions]. En libs.versions.toml (Version Catalog "libs"), al final de [versions]:

catálogo: línea nueva en [versions]
kotlinxCoroutinesCore = "1.11.0"   # su número propio, no el de Kotlin

3 · [libraries]. Al final de la sección:

catálogo: línea nueva en [libraries]
# version.ref apunta a la línea de [versions]
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }

4 · Sin plugin. El compilador ya transforma por sí solo las funciones suspend; no tocas [plugins] ni la raíz.

5 · La dependencia. En build.gradle.kts (Module :torneo), debajo de la de serialización:

build.gradle.kts (Module :torneo): el bloque dependencies, antes
dependencies {
    implementation(libs.kotlinx.serialization.json)
    testImplementation(libs.kotlin.test)
}
build.gradle.kts (Module :torneo): el bloque dependencies, después (con comentarios)
dependencies {
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.kotlinx.coroutines.core)   // los tests ven todo lo que ve el programa: no hace falta testImplementation
    testImplementation(libs.kotlin.test)
}

6 · Sync Now. Sin errores rojos; la primera vez necesita internet.

7 · Comprueba. En la Terminal de Android Studio (View → Tool Windows → Terminal):

./gradlew :torneo:dependencies --configuration runtimeClasspath

Debe aparecer org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0 y, debajo, kotlinx-coroutines-core-jvm:1.11.0 (la versión para la JVM, que Gradle elige sola). La opción --configuration runtimeClasspath pide la lista de librerías que usa el programa al ejecutarse. La prueba definitiva llega en la lección siguiente, con import kotlinx.coroutines.delay.

Y los tests deben seguir como estaban. Ejecútalos y abre el informe, como aprendiste en Bucles y tu primer test:

./gradlew :torneo:test
open torneo/build/reports/tests/test/index.html

Punto de control

  • En Gradle Scripts, el catálogo tiene kotlinxCoroutinesCore = "1.11.0" en [versions] y kotlinx-coroutines-core en [libraries], y build.gradle.kts (Module :torneo) tiene implementation(libs.kotlinx.coroutines.core).
  • ./gradlew :torneo:dependencies --configuration runtimeClasspath muestra kotlinx-coroutines-core:1.11.0.
  • ./gradlew :torneo:test termina en BUILD SUCCESSFUL y el informe pone 82 tests, los mismos que antes: esta lección no añade ninguno ni cambia ningún fichero de código.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Qué significa marcar una función con suspend?

  2. 2.Dos partidos de 1000 ms en runBlocking tardaron 2030 ms con Thread.sleep y 1013 ms con delay. ¿Por qué?

  3. 3.¿Para qué sirve runBlocking en un main normal?

  4. 4.Quieres medir cuánto tardan en terminar 10.000 corrutinas que esperan 100 ms. Pones el cronómetro alrededor del repeat que las lanza, sin envolver el coroutineScope, y sale una cifra minúscula (unos 20 ms). ¿Por qué?

  5. 5.Un compañero dice: «las corrutinas son hilos más ligeros». ¿Cuál es la respuesta más precisa?

  6. 6.Vas a instalar kotlinx-coroutines-core en :torneo. ¿Qué cambios hacen falta?

Faltan 6 de 6