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:
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: mainUn 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).
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 segundoUn 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:
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: trueLas 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.
| Prueba | Thread.sleep | delay |
|---|---|---|
Dos partidos de 1000 ms en runBlocking (un solo hilo) | 2030 ms | 1013 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.
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: trueEl 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: trueSegunda 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
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.
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]:
kotlinxCoroutinesCore = "1.11.0" # su número propio, no el de Kotlin3 · [libraries]. Al final de la sección:
# 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:
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlin.test)
}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 runtimeClasspathDebe 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.htmlPunto de control
- En
Gradle Scripts, el catálogo tienekotlinxCoroutinesCore = "1.11.0"en[versions]ykotlinx-coroutines-coreen[libraries], ybuild.gradle.kts (Module :torneo)tieneimplementation(libs.kotlinx.coroutines.core). ./gradlew :torneo:dependencies --configuration runtimeClasspathmuestrakotlinx-coroutines-core:1.11.0../gradlew :torneo:testtermina enBUILD SUCCESSFULy 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.¿Qué significa marcar una función con
suspend?2.Dos partidos de 1000 ms en
runBlockingtardaron 2030 ms conThread.sleepy 1013 ms condelay. ¿Por qué?3.¿Para qué sirve
runBlockingen unmainnormal?4.Quieres medir cuánto tardan en terminar 10.000 corrutinas que esperan 100 ms. Pones el cronómetro alrededor del
repeatque las lanza, sin envolver elcoroutineScope, y sale una cifra minúscula (unos 20 ms). ¿Por qué?5.Un compañero dice: «las corrutinas son hilos más ligeros». ¿Cuál es la respuesta más precisa?
6.Vas a instalar
kotlinx-coroutines-coreen:torneo. ¿Qué cambios hacen falta?