wandres.dev
CORRUTINAS I · qué es suspender

Una corrutina no es un hilo

Cerrado el mecanismo, queda situarlo. Esta lección separa la concurrencia, que es una propiedad de la estructura del programa, del paralelismo, que es una propiedad del hardware que lo ejecuta; compara el coste real de un hilo del sistema operativo con el de un objeto de estado en el montículo; explica por qué suspenderse y bloquear son operaciones incomparables aunque desde el código fuente se parezcan; y establece qué es lo que sigue costando dinero cuando se cambian los hilos por corrutinas.

⏱ 19 min

Después de leer la transformación completa se puede responder con precisión a la frase que da título a esta lección, y conviene hacerlo porque circula en dos versiones y ambas son inexactas. La primera dice que una corrutina es un hilo ligero, lo cual sugiere que se trata del mismo concepto con menos peso y lleva a esperar de ella cosas que no hace. La segunda dice que una corrutina es simplemente una función, lo cual ignora que tiene identidad, ciclo de vida y capacidad de sobrevivir a la pila que la creó. La formulación correcta se deduce de las cuatro lecciones anteriores sin añadir nada: una corrutina es una computación cuyo estado de avance vive en el montículo en vez de en la pila de un hilo, y que por tanto puede abandonar el hilo que la ejecuta y volver a otro más tarde. Todo lo demás, incluidas las cifras espectaculares que suelen citarse, es consecuencia aritmética de esa definición.

🎯 Al terminar esta lección sabrás
  • Distinguir concurrencia de paralelismo y localizar cuál de las dos aporta el mecanismo de suspensión.
  • Comparar el coste de creación y de mantenimiento de un hilo del sistema operativo con el de una corrutina.
  • Explicar por qué bloquear dentro de una corrutina anula el modelo entero y cómo se reconoce ese fallo.
  • Enunciar qué recursos siguen siendo escasos cuando se sustituyen hilos por corrutinas.

Concurrencia es estructura, paralelismo es hardware

Concurrencia es la propiedad de un programa cuyas tareas están escritas de forma que su ejecución puede entrelazarse: cada una avanza por tramos y ninguna necesita que las demás terminen para poder progresar. Paralelismo es la propiedad de una ejecución en la que dos instrucciones ocurren literalmente en el mismo instante, y para eso hace falta más de un núcleo. Son dimensiones independientes: hay programas concurrentes que se ejecutan en un solo núcleo sin ningún paralelismo, y hay programas rigurosamente secuenciales que el hardware paraleliza por dentro sin que nadie lo pida.

La primera es una propiedad del texto del programa y por tanto una decisión de diseño; la segunda es una propiedad de una ejecución concreta sobre una máquina concreta, y el mismo programa puede tenerla en un equipo y no tenerla en otro. Mantener separadas ambas ideas evita las dos confusiones más frecuentes del terreno: creer que expresar concurrencia acelera algo por sí mismo, y creer que ejecutar en un solo hilo elimina la necesidad de pensar en el entrelazado.

Lo que la transformación de las cuatro lecciones anteriores aporta es exclusivamente concurrencia. Un punto de suspensión es un lugar donde una computación puede detenerse y dejar sitio a otra; nada en la máquina de estados hace que dos corrutinas se ejecuten a la vez. El paralelismo, cuando aparece, lo aporta el elemento del contexto que decide en qué hilo se reanuda cada continuación, y ese es asunto del nivel siguiente. Un despachador de un solo hilo ejecuta miles de corrutinas de forma perfectamente concurrente y absolutamente no paralela, y esa combinación no es una carencia sino la configuración que hace innecesarios los cerrojos.

// Concurrencia sin paralelismo: un solo hilo, dos tareas entrelazadas
val unSoloHilo = newSingleThreadContext("uno")

// Las dos avanzan por tramos, ninguna espera a que la otra termine,
// y no hay ni un instante en que dos instrucciones ocurran a la vez.

Que esa distinción importe tanto se aprecia al preguntarse dónde hacen falta los cerrojos. Dos corrutinas que se entrelazan en un mismo hilo solo pueden cambiar de turno en los puntos de suspensión, que son lugares visibles en el código fuente; entre dos de esos puntos la ejecución es indivisible desde el punto de vista de las demás corrutinas de ese hilo. Dos hilos verdaderamente paralelos pueden entrelazarse en cualquier instrucción, incluida la mitad de una escritura. No es lo mismo, y confundir ambas situaciones lleva a poner sincronización donde no hace falta o, mucho peor, a omitirla donde sí.

💡
La prueba de una frase

Si dos tareas pueden entrelazarse, el programa es concurrente. Si ocurren a la vez, la ejecución es paralela. La primera es una decisión de quien escribe; la segunda, del hardware y del despachador. Cuando alguien pregunte si las corrutinas hacen más rápido un cálculo intensivo, la respuesta se deduce de aquí: no, salvo que además se reparta entre núcleos, y eso no lo hace la palabra suspend.

El presupuesto de un hilo y el de una corrutina

Un hilo es un objeto del sistema operativo. Crearlo requiere una llamada al núcleo, planificarlo lo hace el planificador del sistema, y mantenerlo vivo obliga a reservarle una región de memoria para su pila cuyo tamaño se decide al nacer, no según lo que vaya a usar. En una JVM de sesenta y cuatro bits ese reservado ronda el orden de un megabyte por hilo, y ha de existir aunque el hilo esté dormido esperando una respuesta de red que tardará medio segundo.

Una corrutina suspendida es un puñado de objetos enlazados en el montículo: uno por cada función suspendida activa en su cadena de llamadas, cada uno con sus campos promocionados. Su tamaño no se decide de antemano sino que resulta de lo que hay guardado, y suele medirse en decenas o centenares de bytes. Crearla no habla con el sistema operativo. Reanudarla es una llamada virtual y un salto dentro de una selección múltiple.

Hay además una diferencia cualitativa que las cifras no capturan y que conviene nombrar. El planificador que reparte los hilos es el del sistema operativo, y puede apartar a un hilo en cualquier instrucción, incluso en mitad de una escritura compuesta. El reparto entre corrutinas es cooperativo y ocurre únicamente en los puntos que el compilador marcó, que son lugares identificables leyendo el código fuente. Esa previsibilidad no hace las corrutinas más rápidas, pero sí hace mucho más fácil razonar sobre ellas, porque la pregunta de dónde puede interrumpirse esta secuencia tiene una respuesta escrita en el propio texto del programa.

🧵

Hilo del sistema

Objeto del núcleo con pila reservada por adelantado. Su creación es una llamada al sistema y su cambio de contexto lo arbitra el planificador del sistema operativo, con coste del orden de microsegundos.

🌀

Corrutina suspendida

Cadena de objetos de estado en el montículo, dimensionada por las variables que de verdad cruzan una espera. Crearla es asignar memoria; reanudarla, una llamada virtual del orden de nanosegundos.

📏

La consecuencia de escala

Miles de hilos ya son un problema de memoria y de planificación. Cientos de miles de corrutinas en espera son un problema del recolector de basura, que es un problema de otra categoría.

De ahí sale la comparación que suele citarse y que ahora se puede razonar en lugar de creer. La razón de que quepan tantas corrutinas no es que sean una versión optimizada del hilo: es que representan una cosa distinta. Un hilo reserva capacidad para cualquier profundidad de pila que pueda llegar a necesitar; una corrutina guarda exactamente lo que su análisis de variables vivas demostró que hacía falta. Es la diferencia entre alquilar un almacén por si acaso y pagar por los metros ocupados.

// Esto termina en un abrir y cerrar de ojos y cabe en memoria
repeat(100_000) {
    lanzar { delay(1_000); contador.incrementAndGet() }
}

// Esto agota la memoria mucho antes de llegar al final
repeat(100_000) {
    Thread { Thread.sleep(1_000) }.start()
}

El experimento anterior conviene ejecutarlo una vez en la propia máquina, porque la intuición cambia al ver los dos comportamientos seguidos. Y conviene también entender por qué la comparación es justa a pesar de parecer tramposa: ambas versiones expresan lo mismo, cien mil tareas que esperan un segundo, y la única diferencia está en dónde se guarda el estado de cada una durante esa espera. En un caso, en cien mil pilas del sistema operativo dimensionadas por adelantado; en el otro, en cien mil cadenas de objetos con un campo de etiqueta y poco más. La aritmética hace el resto sin necesidad de ningún truco de planificación.

flowchart TD
A[Mil tareas que esperan a la red] --> B[Con hilos]
A --> C[Con corrutinas]
B --> D[Mil pilas reservadas en memoria]
B --> E[Mil entradas para el planificador del sistema]
C --> F[Mil cadenas de objetos en el monticulo]
C --> G[Unos pocos hilos reutilizados por el despachador]

Suspender no es bloquear

Aquí está el error que más daño hace en la práctica, y su origen es que las dos operaciones se escriben igual de corto. Suspenderse es devolver el control y liberar el hilo para que atienda a otra corrutina. Bloquear es quedarse dentro del hilo impidiendo que haga nada más. Una corrutina que bloquea no está esperando de forma barata: está inmovilizando exactamente el mismo recurso caro que el modelo pretendía dejar de inmovilizar, y además lo hace sobre un conjunto de hilos que suele ser deliberadamente pequeño.

suspend fun mal() {
    Thread.sleep(1_000)          // el hilo queda inutilizado un segundo
}

suspend fun bien() {
    delay(1_000)                 // punto de suspension: el hilo se va
}

La diferencia no está en la duración sino en quién ocupa el hilo durante ella. En la primera versión el hilo permanece dentro del método; en la segunda la función escribe su etiqueta, guarda sus variables, devuelve el centinela y programa una reanudación futura, de modo que el hilo vuelve a la reserva y atiende a otra corrutina. La forma general del error es cualquier llamada bloqueante escrita dentro de una función suspendida: entrada y salida síncrona, un cerrojo que espera, un cálculo largo sin puntos de cesión.

El diagnóstico es sencillo de aplicar y merece convertirse en costumbre. Ante cualquier línea escrita dentro de una función suspendida, pregúntate si esa línea es una llamada a otra función suspendida. Si lo es, hay un punto de cesión y el hilo puede marcharse. Si no lo es, ese código retiene el hilo hasta que termine, y la única pregunta pendiente es cuánto va a tardar. Las llamadas bloqueantes no desaparecen del mundo por adoptar corrutinas, pero sí dejan de poder escribirse en cualquier sitio: hay que trasladarlas a un conjunto de hilos dedicado a esperar, de modo que quien quede inmovilizado sea un hilo destinado a eso y no uno de los pocos que hacen avanzar al resto del programa.

suspend fun leerFichero(ruta: String): String =
    cambiarDeContexto(hilosDeEsperaBloqueante) {
        File(ruta).readText()      // bloquea, pero a un hilo que puede permitirselo
    }
⚠️
Una corrutina que no se suspende nunca cede

El modelo es cooperativo, y eso significa que el reparto del hilo depende por completo de que el código pase por puntos de suspensión. Un bucle de cálculo intensivo sin ninguna llamada suspendida retiene el hilo hasta terminar, y si el despachador tiene un solo hilo, deja sin ejecutar a todo lo demás. La misma propiedad explica por qué la cancelación de un bucle así no surte efecto: no hay ningún punto donde se compruebe, porque la comprobación vive precisamente en los puntos de suspensión.

Lo que sigue siendo escaso

Convertir hilos en corrutinas no elimina la escasez, la desplaza. Los hilos siguen existiendo y siguen siendo el recurso que ejecuta, solo que ahora se comparten entre muchas más tareas, y un hilo mal ocupado perjudica a muchas más corrutinas que antes. La memoria sigue siendo finita, y una corrutina suspendida que retiene una estructura grande en una variable viva la mantiene alcanzable para el recolector durante toda la espera.

Esa última observación se deduce directamente de la lección anterior y tiene consecuencias prácticas inmediatas. Si una variable local cruza un punto de suspensión, deja de vivir en la pila y pasa a ser un campo de un objeto que sobrevive a la espera; por tanto, sostener una lista de cien mil elementos a través de una llamada que tarda medio segundo significa exactamente que esos elementos permanecen retenidos medio segundo más. El remedio no es misterioso: reducir el alcance de la variable para que muera antes de la espera, o consumir lo grande y guardar solo lo pequeño.

suspend fun mejor(datos: List<Fila>): Int {
    val resumen = datos.sumOf { it.peso }   // lo grande muere aqui
    return enviar(resumen)                  // solo cruza la espera un entero
}

Y hay un recurso nuevo que aparece con el modelo: la capacidad de las conexiones, los descriptores, los cupos de peticiones concurrentes o cualquier otro límite del sistema al que estemos llamando. Cuando lanzar cien mil tareas deja de ser imposible, el límite ya no lo pone la máquina que las lanza sino la que las recibe, y el trabajo de diseño se traslada a decidir cuántas deben estar en vuelo a la vez. Ese desplazamiento del cuello de botella es, en la práctica, el cambio más importante que produce adoptar corrutinas, y es también la razón de que los niveles siguientes se ocupen de estructura, alcance y cancelación en lugar de seguir hablando de rendimiento.

Conviene añadir un apunte sobre el vecindario tecnológico, porque en los últimos años ha aparecido una alternativa que responde a la misma necesidad desde el lado opuesto. Los hilos virtuales de la propia plataforma Java atacan el problema haciendo baratos los hilos en lugar de evitarlos, con un mecanismo distinto: el entorno de ejecución copia y restaura la pila real, lo que permite que cualquier código bloqueante existente pase a ser barato sin recompilar y sin marcar nada. Es una solución elegante para el coste, y deliberadamente no toca lo demás. Las corrutinas de Kotlin, al vivir en el compilador y no en la máquina, resuelven además cuestiones que un hilo barato deja intactas: qué se cancela junto con qué, quién es el padre de quién, dónde va a parar una excepción y qué garantiza una firma. Por eso comparar ambas propuestas por su número máximo de tareas es fijarse en la coincidencia y no en la diferencia.

📝
El coste de crear, medido

Crear una corrutina que se ejecuta y termina es aproximadamente asignar un objeto de estado y hacer unas cuantas llamadas virtuales; crear un hilo es una llamada al sistema operativo que reserva memoria virtual y registra una entidad planificable. La distancia es de varios órdenes de magnitud, pero la conclusión operativa no es lanzar corrutinas sin medida: cada una tiene identidad, se registra en la jerarquía de su alcance y ocupa memoria mientras espera. La regla razonable es que crearlas debe dejar de ser una decisión de rendimiento para volver a ser una decisión de diseño.

La verdadera aportación no es la escala, es haber recuperado la función como unidad de composición

Es tentador cerrar este nivel con la cifra, porque la cifra impresiona: donde cabían miles de hilos caben millones de corrutinas, y esa aritmética se deduce limpiamente de todo lo que hemos leído. Pero quedarse ahí sería medir el mecanismo por su efecto menos interesante. Piénsese en qué se había perdido realmente en la primera lección. No se había perdido rendimiento: los callbacks son rápidos, no asignan casi nada y funcionan perfectamente. Lo que se había perdido era la función como unidad de composición. En un programa de estilo directo, una función es una caja que recibe argumentos, devuelve un resultado, propaga sus errores hacia arriba y puede ser leída, probada y compuesta sin saber nada de su interior; en cuanto una operación se vuelve asíncrona, esa caja se rompe, el resultado sale por un parámetro, el error sale por otro, el retorno deja de significar terminación, y la composición pasa a ser un asunto de disciplina manual que nadie verifica. Todo el aparato que hemos estudiado, el parámetro añadido, la máquina de estados, los campos promocionados y el valor centinela, existe para reconstruir esa caja: para que una función que espera medio segundo se declare, se llame, se lea, se pruebe y se componga exactamente igual que una que no espera nada, con su resultado por retorno y sus errores por excepción. La escala enorme es un subproducto agradable de haber elegido representar la espera con objetos pequeños en lugar de con pilas reservadas, pero la conquista es otra: es que el sistema de tipos vuelva a poder hablar de operaciones que tardan, que un try vuelva a significar lo que dice, que un bucle siga siendo un bucle y que una firma vuelva a contar la verdad completa sobre lo que una función hace y sobre cómo puede fallar. Por eso suspend es una palabra clave del lenguaje y no una clase de una librería, y por eso el estudio correcto de las corrutinas empieza por el bytecode y no por el catálogo de constructores. Quien ha visto la transformación no necesita memorizar reglas sobre qué se puede llamar desde dónde ni por qué unas esperas son baratas y otras catastróficas: las deduce, cada vez, de saber qué función existe realmente detrás de la que escribió.

⚔️ Sitúa el modelo
  1. Escribe un ejemplo de programa concurrente sin nada de paralelismo y otro paralelo sin apenas concurrencia expresada por el programador. Justifica la clasificación de cada uno.
  2. Lanza un número muy alto de corrutinas que solo esperen y razona, en términos de objetos y campos, de dónde sale la memoria que consumen.
  3. Sustituye en un ejemplo tuyo una espera suspendida por su equivalente bloqueante y explica qué le ocurre al hilo en cada versión y a quién perjudica.
  4. Coge un bucle de cálculo intensivo dentro de una corrutina y explica por qué no cede el control ni responde a la cancelación, y qué habría que introducir para que lo hiciera.
  5. Argumenta por qué la aportación central del modelo no es la escala sino la recuperación de la firma como descripción completa de lo que una función hace.