Gestión de la ejecución de las corrutinas

TL; DR

  • Lo que se ejecuta en una corrutina se ejecuta en un hilo.
  • Android decide si lo pone todo en un hilo, o en varios, o si crea o reutiliza hilos.
  • Las corrutinas son concurrentes entre sí porque son hilos y los hilos son concurrentes entre sí.
  • Cuando dos hilos se ejecutan a la par, si imprimimos logs desde cada uno de ellos, estos saldrán desordenados porque no se pueden imprimir solapados (duh!).

"¿Cuán concurrentes son las corrutinas?" o "¿por qué sale desordenado el código?" son las preguntas que te han traído aquí.

Asíncrono y concurrente son cosas distintas (podéis leer esto mejor en este artículo de Víctor Gómez de Juan).

Asincronía: el código en un hilo es síncrono, es decir, una línea se ejecuta después de la otra, pero con las corrutinas (al igual que con los callbacks) el código de la corrutina no se ejecuta inmediatamente después de lanzarla. Lanzar una corrutina no significa ejecutarla inmediatamente, significa que le decimos a Android que gestione un nuevo proceso y él se encargará de ejecutarlo cuando pueda (por supuesto que lo hará tan pronto como cree o quede libre el hilo solicitado, pero esto no es inmediato). Esto es la asincronía: lo que va dentro de una corrutina no se ejecuta después de la línea anterior, sino en el futuro.

Concurrencia: por lo que respecta a la concurrencia, Android se encargará de decidir en qué hilo se ejecuta cada corrutina, pudiendo crear uno nuevo o reutilizar uno existente. Un hilo es la unidad mínima de ejecución. Un hilo solo puede ejecutar un código al mismo tiempo. Por lo tanto, si Android decide crear un hilo para una corrutina y crear otro hilo para una segunda corrutina, pueden llegar a ejecutarse concurrentemente, porque los hilos se ejecutan a la par. Por ejemplo:

viewModelScope.launch(Dispatchers.IO) {  
    repeat(3) {  
        async {  
            printThreadName("La $it")
        }  
    }
}

Este código puede producir este resultado:

La 0 se está ejecutando en DEFAULTDISPATCHER-WORKER-1
La 2 se está ejecutando en DEFAULTDISPATCHER-WORKER-3
La 1 se está ejecutando en DEFAULTDISPATCHER-WORKER-2

Esto quiere decir que las tres corrutinas se han ejecutado practicamente a la par, porque cada una iba en un hilo. No podemos garantizar si han sido completamente simultáneas porque el log es síncrono, no puede imprimir unas líneas solapándose con otras.

Sin embargo, igual que Android ha creado un hilo para cada corrutina, puede suceder que Android decida reutilizar el hilo de una corrutina para ejecutar otra después, por lo que en ese caso estas serán síncronas entre sí (una sucederá después de la otra), pero seguirán siendo concurrentes a otras corrutinas que estén en otros hilos). Por ejemplo, unos de los posibles resultados del código anterior podría haber sido este:

La 0 se está ejecutando en DEFAULTDISPATCHER-WORKER-1
La 2 se está ejecutando en DEFAULTDISPATCHER-WORKER-2
La 1 se está ejecutando en DEFAULTDISPATCHER-WORKER-1

Esto quiere decir que las corrutinas 0 y 1 han sido síncronas, es decir, hasta que no ha terminado la 0 no ha podido empezar la 1, porque sucedían en el mismo hilo. Eran síncronas entre sí. Sin embargo, todo lo que sucedía en ese hilo ha sucedido a la par que lo que había en el hilo 2, porque el resultado sale mezclado, es decir, que la corrutina 2 era concurrente respecto a las corrutinas 0 y 1.

En resumen, no debes pensar en qué corrutina va antes que otra, porque es impredecible. Lo mismo lanzas 20 corrutinas y las ponen en 20 hilos diferentes y son todas concurrentes entre sí, que lo mismo las ejecuta todas en un mismo hilo y cada una espera a la anterior. Lo único que puedes hacer si de verdad necesitas algo de contro es utilizar async() solo cuando tengas que forzar que dos corrutinas sean síncronas (es decir, cuando tengas hacer que una espere a tener el resultado de otra).

Por supuesto, esto es una exageración. Si lanzas 20 corrutinas, Android no va a crear 20 hilos. No sería óptimo. Lo que hará será programar unas cuantas en un hilo, otras cuantas en otro y así creará tal vez cuatro hilos. Según el caso, Android decidirá cuántos hilos tiene que usar.

Solo tienes que recordar que lo que va en un hilo es síncrono en sí, pero concurrente a lo que sucede en otros hilos. Y por supuesto, todo esto pasa solo si usas dispatchers de hilos secundarios. Si usas el dispatcher Main todo va a ser síncrono sí o sí, porque todo va a ir en el mismo hilo.

Corrutinas anidadas

Dentro de una corrutina puedes lanzar otras corrutinas porque dentro de la lambda del constructor de corrutinas this es un scope de corrutinas, por lo que puedes llamar a los constructores sin tener que poner el scope de nuevo.

Cuando lanzas una corrutina dentro de otra, esta corrutina es una corrutina hija (puedes leer sobre ello en Concurrencia estructurada > Hijas) y funciona exactamente igual. Es decir, que se va a lanzar síncronamente, pero la ejecución va a seguir siendo asíncrona (priorizada por Android).

Mira este otro código similar en el que lanzamos varias corrutinas dentro de otra:

GlobalScope.launch {
   printThreadName("launch 1")
   launch { printThreadName("launch 2") }
   launch { printThreadName("launch 3") }
   launch { printThreadName("launch 4") }
   launch { printThreadName("launch 5") }
   launch { printThreadName("launch 6") }
 }

Obtendrás diferentes resultados, pero uno de ellos puede ser este:

launch 1 se está ejecutando en DefaultDispatcher-worker-1
launch 4 se está ejecutando en DefaultDispatcher-worker-3
launch 3 se está ejecutando en DefaultDispatcher-worker-4
launch 2 se está ejecutando en DefaultDispatcher-worker-2
launch 6 se está ejecutando en DefaultDispatcher-worker-1
launch 5 se está ejecutando en DefaultDispatcher-worker-2

El código de una corrutina se ejecuta secuencialmente, ¿no? Entonces ¿por qué no salen los printThreadName() en orden?

Porque cada launch() es una corrutina nueva. Sí que han sido lanzadas secuencialmente, pero serán ejecutadas asíncronamente en el orden que determine Android.

  1. Primero hemos lanzado una corrutina (el primer launch()).
  2. Cuando Android lo decide ejecutarla, lo que hay dentro se ejecuta sencuencialmente: primero se imprime "launch 1" y luego se lanzan todas los demás launch() uno tras otro.
  3. Y volvemos al punto 2. Todos estos nuevos subprocesos van a ser priorizados por Android a su manera. Android los pondrá en los hilos que le parezca y terminará de crear unos antes que otros. Por ello, aunque han sido lanzados unos detrás de otros, la ejecución es aleatoria y los printThreadName() salen mezclados.

Además, sabes que la ejecución sucede en hilos, por lo que todo lo que vaya en un hilo es secuencial, pero es a su vez concurrente a lo que suceda en otros, por lo que esto quiere decir que:

  • "launch 1" y "launch 6" han sucedido de manera secuencial (es decir, hasta que no ha terminado "launch 1", no ha empezado "launch 6", porque van en el mismo hilo.
  • Lo mismo pasa con "launch 2" y "launch 5".
  • "launch 1", 4, 3 y 2 han sucedido al mismo tiempo (concurrentemente o en paralelo), ya van en hilos distintos y un hilo no tiene que esperar a otro. Sin embargo, en el log vemos las frases una después de otra. ¿Seguro que no se están esperando? ¡Claro que no! Lo que pasa es que no se pueden imprimir las cosas al mismo tiempo (no podemos pintar una línea pisando a la otra).

Prueba ahora con este código más complicado:

GlobalScope.launch {
    println("launch 1")
    
    launch {
        println("launch 2.1")
        println("launch 2.2")
        println("launch 2.3")
        println("launch 2.4")
        println("launch 2.5")
        println("launch 2.6")
        println("launch 2.7")
        println("launch 2.8")
    }
    
    launch {
        println("launch 3.1")
        println("launch 3.2")
        println("launch 3.3")
        println("launch 3.4")
        println("launch 3.5")
        println("launch 3.6")
        println("launch 3.7")
        println("launch 3.8")
    }
}

println("Resto del código")

Piensa qué resultado esperarías obtener y compáralo con el siguiente, que es uno de los posibles:

Resto del código
launch 1
launch 2.1
launch 2.2
launch 2.3
launch 3.1
launch 3.2
launch 3.3
launch 3.4
launch 3.5
launch 3.6
launch 2.4
launch 3.7
launch 2.5
launch 3.8
launch 2.6
launch 2.7
launch 2.8

Hemos lanzado dos corrutinas dentro de otra y estas se ejecutan asíncronamente y casi al mismo tiempo.

  1. Lanzamos el launch().
  2. El código es secuencial, por lo que, lanzada la corrutina, sigue adelante y ejecuta la siguiente línea, imprimiendo "Resto del código".
  3. En algún momento, Android decide ejecutar la corrutina y:
    1. Primero imprime "launch 1".
    2. Luego lanza la corrutina 2.
    3. Y por último lanza la corrutina 3.
    4. En algún momento decide ejecutarlas y las prioriza a su antojo: parece que en este caso comenzó a ejecutar primero launch 2 y cuando iba por "launch 2.3" la ejecución comenzó a ser en paralelo.

Fíjate en que la ejecución de ambas corrutinas se intercala (porque no se pueden imprimir las líneas que suceden al mismo tiempo solapándose), lo que quiere decir que están sucediendo a la vez y en hilos diferentes. Pero dentro de cada hilo, el código se ejecuta de forma secuencial; no encontrarás jamás 2.3 y luego 2.1. por ejemplo.

Comentarios

Entradas populares de este blog

Funciones de suspensión

Tratamiento de excepciones en las corrutinas

Venga, explícame cómo se usan