Funciones de suspensión

Funciones de suspensión

TL; DR

  • Suspender significa detener la ejecución del código de una corrutina en una línea hasta que termina una operación asíncrona y la reanuda cuando termine.
  • Detener la ejecución del código de una corrutina en ningún caso significa detener el hilo.
  • Esto se hace a nuestras espaldas mediante un callback llamado Continuation. La continuation encapsula todo el código que se ha escrito después de la función de suspensión y se le pasa a esta para ser llamado cuando termine.
  • Una función de suspensión se declara con el modificador suspend.
  • Querremos suspender una corrutina únicamente cuando queramos cambiar de contexto (es decir, cambiar de hilo). Si no cambiamos de contexto, la suspensión es absurda porque un mismo hilo ya es síncrono, por lo que siempre la siguiente línea va a esperar a la anterior.
  • Las funciones de suspensión del framework suspenden hasta que se completa la corrutina que ejecuten.
  • Una función de suspensión debe garantizar la seguridad del hilo principal, cambiando de contexto para ejecutar las operaciones costosas.


El código en un mismo hilo se ejecuta siempre una línea después de otra, ¿no?

viewModelScope.launch {
    hacerAlgo()
    hacerAlgoMas()
    hacerAlgoMasTodavia()
}

Para que se ejecute hacerAlgoMasTodavia() tiene que ejecutarse primero hacerAlgoMas(), ¿no? Esto es código síncrono y la ejecución en un hilo es síncrona.

¿Cuándo no se cumple esto? Cuando utilizas código asíncrono. Es decir, cuando haces una llamada a una corrutina, porque ya sabes que eso se ejecuta de forma asíncrona.

viewModelScope.launch {
    hacerAlgo()

    launch(Dispatchers.Default) {
        hacerAlgoMas()
    }

    hacerAlgoMasTodavia()
}

hacerAlgoMas() se va a ejecutar en algun momento después de hacerAlgo(), pero no sabemos si ocurrirá antes o después de hacerAlgoMasTodavia() porque eso queda a merced de Android.

Bueno, pues cuando dentro de una corrutina quieres lanzar otra pero quieres forzar a que se haga de forma síncrona, es decir, que no se ejecute la siguiente línea hasta que no termine la corrutina primero, tienes que llamar a una función de suspensión que lo haga. En el caso de async(), esta función de suspensión es await(); es quien se encarga de "resincronizar" ese código asíncrono, deteniendo la ejecución de la corrutina principal hasta que termina el código de async().

viewModelScope.launch {
    hacerAlgo()

    async(Dispatchers.Default) {
        hacerAlgoMas()
    }.await()

    hacerAlgoMasTodavia()
}

De esta manera, hacerAlgoMasTodavia() no se va a ejecutar hasta que no termine el async() porque await() es una función de suspensión que detendrá la ejecución del launch() hasta que termine async().

Es decir, una función de suspensión fuerza la resincronización de un código asíncrono porque suspende el código de la corrutina en la línea que es llamada hasta que no termina de ejecutarse su cuerpo (o dicho de otra forma, hasta que retorne). Así la ejecución de este launch() se detiene en la línea del await() hasta que termina de ejecutarse lo que hay dentro del await() (en este caso digamos que el cuerpo del await() es lo que hay dentro de async()).

Y eso es la suspensión: el código de una corrutina se para en una línea hasta que termina de ejecutarse la función de suspensión llamada. Suspensión significa "espera".

¿Cuándo voy a querer crear funciones de suspensión?

Una función de suspensión se crea poniendo suspend fun en lugar de solo fun y solo puede ser llamada desde dentro de una corrutina (porque solo se pueden suspender corrutinas) o desde dentro de otra función de suspensión (porque se entiende que esta solo puede haber sido llamada desde una corrutina).

Como programador, querrás hacer métodos atómicos, por lo que a veces querrás extraer métodos de una corrutina que dentro tengan una llamada a una función de suspensión.

Por ejemplo:

fun llamarAMama() {
    launch(Dispatchers.Default) {
        
        val numero = async(Dispatchers.Default) {
            buscarNumero("Mamá")
        }.await()
        
        marcarNumeroLiveData.value = numero
}

fun buscarNumero(nombre: String): String =
    getContactos().getNumero("Mamá")
}

No podemos cambiar el valor del LiveData hasta que no tengamos el numero, pero queremos que la busqueda del numero suceda en segundo plano porque a saber cuántos contactos puede haber en la agenda y cuánto puede tardar en encontrar el que queremos y por eso la hemos metido en un async().

Entonces habrás pillado que, mientras que todo sucede en el hilo principal, el bloque de async() estará sucediendo en un hilo secundario pero de forma síncrona respecto al hilo principal porque await() se encarga de hacer que nos esperemos. Es decir, a pesar de ser dos hilos distintos, le estás diciendo que uno se espere al otro. Y como await() es una función de suspensión, no es que el hilo principal esté bloqueado esperando a que termine el otro, solo se detiene el código; el hilo principal sigue haciendo sus cosas (dedicándose a pintar), pero el código de launch() se ha detenido hasta que termine el del async().

Esto es suspender. No es bloquear hilos, sino parar y resincronizar código asíncrono. Es decir "espera a que termine esto otro".

Ahora por simplicidad (porque si no sería mucho lío para este ejemplo) vamos a convertir este async().await() en withContext(), que es una función de suspensión equivalente a llamar a async() para cambiar de contexto e inmediatamente a await() sin que haya otras líneas de por medio. Fíjate además en que, si escribieras este código, el IDE te estaría resaltando la llamada a async() para indicarte que puedes reemplazarla con withContext(), por lo que en este caso nos viene de lujo (puedes saber más a continuación en ¿Es async() intercambiable con withContext()). La cosa nos quedará tal que así:

val numero = withContext(Dispatchers.Default) { buscarNumero("Mamá") }

withContext(), igual que await(), es una función de suspensión, por lo que tiene que estar dentro de una corrutina o dentro de otra función de suspensión. Al llamarla, se ejecutará una corrutina nueva con el dispatcher que le hemos indicado y el launch() se esperará en esa línea hasta que termine antes de seguir (igualito que llamar a async() e inmediatamente después a await()).

A continuación caes en la cuenta de que, siempre que quieras obtener un número, tendrás que hacerlo en segundo plano, por lo que decides que, para utilizar este método en otros casos de uso, vas a mover el withContext() a dentro de la función buscarNumero() para no tener que duplicarla. Y aquí viene el tema: como withContext() es una función de suspensión, tenemos que declarar nuestra función también como función de suspensión para poder llamarlo. Como withContext() te hace esperar, tienes que tu función también tiene que hacer esperar a quien la llame.

Y el código queda así:

fun llamarAMama() {
    viewModelScope.launch {
        val numero = buscarNumero("Mamá")
        marcarNumeroLiveData.value = numero
    }
}

suspend fun buscarNumero(nombre: String) = withContext(Dispatchers.Default) {
    getContactos().getNumero(nombre)
}

Ahora esto es lo que pasará cuando se ejecute el launch():

  1. La ejecución de launch() se parará en la llamada buscarNumero() hasta que esta función retorne porque es de suspensión.
  2. Dentro de esta función, la ejecución se parará en la llamada a withContext() hasta que dicha función retorne, porque es una función de suspensión.
  3. Cuando se obtenga el número, withContext() retornará y también retornará buscarNumero() y así se reanudará el código de launch().

Dicho de otra forma más sencilla: launch() llama a buscarNumero() y este le dice "espera" porque va a llamar a withContext(), quien también le dice "espera". Por lo tanto, si te van a poner en espera, tienes que poner en espera a quien estás atendiendo.

Y con esto, habrás conseguido encapsular una llamada en segundo plano en una función de suspensión que puede ser llamada desde cualquier corrutina.

Recuerda:

  • Desde una corrutina puedes llamar a funciones de suspensión para cambiar de contexto y ejecuten cosas en otros hilos, pero al volver, sigues estando en el mismo.
  • No vas a necesitar hacer una función de suspensión si dentro no vas a acabar llamando a una función de suspensión del framework.
  • Si no vas a cambiar de contexto, la función de suspensión no tiene sentido.

¿Podríamos haber extraído directamente async() y await() sin convertirlo en withContext()? Es decir, ¿podemos usar un coroutine builder desde una función de suspensión? Podemos, pero para eso necesitamos tener un CoroutineScope. Eso lo podemos hacer:

  • Pasándole un CoroutineScope a nuestra función de suspensión, lo cual no es muy limpio porque este no tiene nada que ver con la lógica de negocio
  • Declarando nuestra función de suspensión como función de extensión de CoroutineScope.
  • Usando una función de suspensión constructora de ámbito que vamos a ver en el siguiente apartado.

¿Y podríamos haber hecho viewModelScope.async(Dispatchers.Default) en lugar de usar withContext() en nuestra función de suspensión? Por supuesto que no, ya que eso destruiría la concurrencia estructurada. Utilizar el mismo scope para lanzar una nueva corrutina crea dos corrutinas diferentes y una no guardaría relación con la otra aunque la lances desde dentro. Siempre hay que utilizar el scope nuevo en el que nos pone el coroutine builder.

Funciones de suspensión constructoras de ámbito

Puede que hayas llegado a intentar probarlo, pero no, no se pueden lanzar nuevas corrutinas desde una función de suspensión.

suspend fun hacerTrabajoPesado() {
  launch { } // No se puede
  async { } // Tampoco se puede
}

Recuerda que launch() y async() están definidas como funciones de extensión de un CoroutineScope, y dentro de una función de suspensión sabemos que estamos siendo llamados desde una corrutina, pero no tenemos el scope de esa corrutina, y es crucial tener el scope de la corrutina para respetar la concurrencia estructurada.

No obstante, a veces vamos a querer lanzar corrutinas desde una función de suspensión, y para eso existen tres funciones de suspensión que permiten "obtener" el ámbito de corrutinas del que venimos y usarlo. Estas son withContext(), coroutineScope() y supervisorScope().

Las tres te permiten ejecutar una lambda que es una función de extensión de un CoroutineScope, por lo que nos darán la capacidad de lanzar corrutinas.

withContext()

Ya conoces withContext(). Es una función de suspensión que cambia de contexto (nos fuerza a cambiar de contexto) y suspende la ejecución de la corrutina en la que se encuentra hasta que finalice la ejecución de su bloque. Si te fijas en el último parámetro de la función withContext() comprobarás que es una lambda que se ejecutará como función de extensión de un CoroutineScope. Eso quiere decir que withContext() crea un ámbito de corrutinas en base a la corrutina desde la que es llamada y dentro de este podemos lanzar otras.

fun obtenerInfo(usuario: User) {
    viewModelScope.launch {
        val infoUsuario = obtenerInfo(usuario)
        liveData.value = infoUsuario
    }
}

suspend fun obtenerInfo(): InfoUsuario = withContext(Dispatchers.IO) {
    val amigosDeferred = async { facebookApi.getAmigos(usuario) }
    val datosPersonalesDeferred = async { facebookApi.getDatosPersonales(usuario) }
    val gruposDeferred = async { facebookApi.getGrupos(usuario) }

    return InfoUsuario(
        amigosDeferred.await(),
        datosPersonalesDeferred.await(),
        gruposDeferred.await()
    )
}

withContext() crea un CoroutineScope con el dispatcher que le indicamos y ejecuta ahí el código, y no regresa (es decir, suspende la corrutina en la que ha sido llamado) hasta que termina, por lo que puede devolver un resultado. En este caso, ejecuta tres peticiones simultáneamente en un hilo secundario, espera hasta que las tres hayan terminado y devuelve InfoUsuario a la corrutina desde la que partió en el hilo principal.

Esto es una función de suspensión constructora de ámbito: una función que crea un ámbito de corrutinas estando dentro de una función de suspensión. ¿Cómo lo hace? (Lee primero ¿Qué es realmente suspender si quieres para saber lo que es la Continuation).

withContext() recupera el contexto del objeto Continuation en el que está metido (por estar dentro de una corrutina o dentro de una función de suspensión) y combina ese contexto con el nuevo que le pasamos y lo usa para crear una nueva corrutina que será hija de la anterior. De esta forma mantiene la concurrencia estructurada.

Como habrás visto en ejemplos anteriores, si dentro de withContext() queremos hacer una sola tarea en otro hilo, es perfecto porque nos cambia de contexto automáticamente. Sin embargo, si queremos lanzar más de una corrutina y que sucedan en paralelo, withContext() el cambio de contexto forzoso al que nos obliga sobra, porque cada corrutina que lancemos ya tendrá el cambio de dispatcher en su coroutine builder. Así que para esto nos convienen las otras dos funciones de suspensión constructoras de ámbito.

coroutineScope() y supervisorScope()

Ahora bien, existen dos funciones de suspensión constructoras de ámbitos de corrutinas que, al contrario que withContext(), nos permiten ejecutar corrutinas desde una función de suspensión sin tener que cambiar de contexto, que son coroutineScope() (no la confundas con la función CoroutineScope()) y supervisorScope().

Estas funciones hacen lo mismo que withContext() pero sin obligarte a pasar un contexto nuevo, de forma que se les puede pasar una lambda que se ejecutará como función de extensión de un ámbito de corrutina.

fun obtenerInfo(usuario: User) {
    viewModelScope.launch {
        val infoUsuario = obtenerInfo(usuario)
        liveData.value = infoUsuario
    }
}

suspend fun obtenerInfo(): InfoUsuario = coroutineScope {
    val amigosDeferred = async(Dispatcher.IO) {
        facebookApi.getAmigos(usuario)
    }
    val datosPersonalesDeferred = async(Dispatcher.IO) {
        facebookApi.getDatosPersonales(usuario)
    }
    val gruposDeferred = async(Dispatcher.IO) {
        facebookApi.getGrupos(usuario)
    }

    InfoUsuario(
        amigosDeferred.await(),
        datosPersonalesDeferred.await(),
        gruposDeferred.await()
    )
}

Como puedes ver en este ejemplo, coroutineScope() está confinado en el contexto en el que es llamado, por lo que nos encontramos en el hilo principal, y dentro estamos lanzando tres corrutinas que se ejecutarán concurrentemente y solo cambiamos de contexto para lanzarlas, ahorrándonos el cambio de contexto que nos obligaba withContext().

La diferencia entre supervisorScope() y coroutineScope() es que supervisorScope() hace exactamente lo mismo pero en lugar de usar un Job, usará un SupervisorJob.

delay()

Esta es una función de suspensión del framework de las corrutinas. Se utiliza para simular un trabajo costoso que dura un determinado periodo de tiempo. Es muy parecida a Thread.sleep(), pero no tiene nada que ver.

Mientras que Thread.sleep() detiene el hilo en el que se encuentra, delay() suspende la corrutina por un periodo de tiempo especificado, es decir, no ejecuta las líneas posteriores hasta que no transcurre ese tiempo, pero el hilo sigue vivo.

Así, en este código se imprime "Hola" al transcurrir 4 segundos pero no se bloquea el hilo principal, que es el que se está utilizando en estos ejemplos:

viewModelScope.launch {  
  delay(4000L)
  println("Hola")
}

Sin embargo, en este sí se bloquea:

viewModelScope.launch {  
  Thread.sleep(4000L)
  println("Hola")
}

Por ejemplo, si hubiera un cargando en pantalla, en este segundo caso el cargando se quedaría bloqueado durante esos 4 segundos hasta que el hilo principal volviera a despertar.

¿Qué está pasando realmente? Thread.sleep() detiene el hilo, mientras que delay() suspende, programa la reanudación de la corrutina para más adelante. Es decir, es una espera no bloqueante, no impide que el hilo siga pudiendo trabajar por otro lado.

Suspensión y concurrencia son conceptos opuestos

¡Cuidado! ¡Muy importante! Las funciones de suspensión suspenden las corrutinas en las que son llamadas hasta que retornan. Y ¿cuándo retornan las funciones de suspensión?

  • Las funciones de suspensión del framework digamos que siempre van a ejecutar un código asíncrono y no retornan hasta que este se completa:

    • await() retorna cuando se complete async().
    • delay() retorna cuando transcurra un tiempo.
    • withContext(), coroutineScope() y supervisorScope() van a crear un ámbito de corrutina y no retornan hasta que se completen todas sus corrutinas.
  • Si hacemos una función de suspensión nosotros, será para acabar llamando en última instancia a una función de suspensión del framework, por lo que estas también van a esperar a lo mismo (obviamente, si en nuestra función de suspensión no llamamos a una función de suspensión del framework, no sirve de nada que sea de suspensión; la suspensión no sería efectiva porque no estaríamos haciendo ningún trabajo asíncrono que haga que suspender sirva para algo).

Por lo tanto, siempre que creemos o usemos una función de suspensión, vamos a suspender la corrutina en la que nos encontremos hasta que la función termine. Y aquí es donde está la clave. ¿Qué se entiende por "terminar"? "Terminar" significa que se ejecute todo su código y TODAS SUS CORRUTINAS HIJAS SE COMPLETEN. Una corrutina no se completa hasta que no se completan todas sus hijas.

¿Dónde está el peligro de esto? Pues imagina por ejemplo que envolvemos todo el código de la función de suspensión en un único async() para devolver un Deferred<InfoUsuario> y que obtenerInfo() se ejecute concurrentemente al código de launch():

fun obtenerInfo(usuario: User) {
    viewModelScope.launch {
        val infoUsuario = obtenerInfo(usuario)
        otrasFunciones()
        liveData.value = infoUsuario.await()
    }
}

suspend fun obtenerInfo(): Deferred<InfoUsuario> = coroutineScope {
    async(Dispatchers.Default) {
        val amigosDeferred = async(Dispatchers.IO) { [...] }
        val datosPersonalesDeferred = async(Dispatchers.IO) { [...]    }
        val gruposDeferred = async(Dispatchers.IO) { [...] }
    
        InfoUsuario(
            amigosDeferred.await(),
            datosPersonalesDeferred.await(),
            gruposDeferred.await()
        )
    }
}

Puede que esperases que obtenerInfo() fuera a retornar inmediatamente porque lanza un async(), que sabemos que es asíncrono, e inmediatamente nos devuelve un Deferred<InfoUsuario>, y que el cálculo de lo que hay dentro del async() va a ser concurrente a otrasFunciones() hasta que llamemos a infoUsuario.await().

Craso error porque obtenerInfo() no retornará hasta que retorne coroutineScope(), y coroutineScope() no retornará hasta que todas las corrutinas hija se completen. Por lo tanto, aunque su corrutina corrutina raíz sea un async(), tiene que esperar a que se complete, y no va a completarse hasta que no se completen las tres corrutinas que engloba, y estas a su vez, a pesar de devolver también sus respectivos Deferred, no se completarán hasta que acaben sus llamadas. Y eso quiere decir que, aunque las tres corrutinas van a suceder de forma concurrente entre sí (esto sí es verdad) y tengamos el Deferred<InfoUsuario> desde el principio, coroutineScope() no lo va a devolver hasta que terminen todas, por lo que para cuando launch() obtenga el Deferred<InfoUsuario> de obtenerInfo(), el resultado va a estar ya terminado y verdaderamente el Deferred sobrará.

Pasaría exactamente igual si en lugar de envolverlo todo en un async() devolviéramos el último async():

suspend fun obtenerInfo(): Deferred<InfoUsuario> = coroutineScope {
    async(Dispatchers.Default) {
        val amigosDeferred = async(Dispatchers.IO) { [...] }
        val datosPersonalesDeferred = async(Dispatchers.IO) { [...]    }
        val gruposDeferred = async(Dispatchers.IO) { [...] }

        async(Dispatchers.Default) {
            InfoUsuario(
                amigosDeferred.await(),
                datosPersonalesDeferred.await(),
                gruposDeferred.await()
            )
        }
}

Aunque devolvamos el Deferred del último async(), coroutineScope() no lo va a devolver hasta que haya sido completado, haciéndolo inútil.

E incluso pasaría exactamente igual si sacásemos el último async() del método para quitar cualquier await() y devolviéramos una lista de Deferred.

suspend fun obtenerInfo(): List<Deferred<List<*>> = coroutineScope {
    val amigosDeferred = async(Dispatchers.IO) { [...] }
    val datosPersonalesDeferred = async(Dispatchers.IO) { [...]    }
    val gruposDeferred = async(Dispatchers.IO) { [...] }
    listOf(amigosDeferred, datosPersonalesDeferred, gruposDeferred)
}

El método no devolvería una lista de Deferred hasta que no se hubieran completado todos los async().

En resumen: suspensión y concurrencia son conceptos opuestos. Si ves suspend, lo que hay dentro jamás va a ser concurrente a lo que hay fuera porque no saldrá hasta que no termine. No puedes querer que algo suspenda y al mismo tiempo se ejecute concurrentemente a lo que está suspendiendo.

¿Qué es realmente suspender?

Una función de suspensión solo puede llamarse dentro de una corrutina o dentro de otra función de suspensión. Lo que hace es paralizar la ejecución del código en el que ha sido llamado hasta que termine de ejecutarse la función de suspensión llamada. Es decir, es como si le dijeras al código "espérate aquí hasta que termine esto otro". Mira este ejemplo:

fun llamarAMama() {
    viewModelScope.launch {
        val numero = buscarNumero("Mamá") // <-- La ejecución se paraliza en esta línea hasta que termine el método
        marcarNumeroLiveData.value = numero
    }
}

suspend fun buscarNumero(nombre: String): String {
    return getContactos().getNumero(nombre)
}

Lo que hace la función de suspensión es encapsular todo el código que viene después de su llamada y hacer que se llame cuando termine. Es decir, al compilar el código anterior tendríamos algo como esto (que solo lo ponemos así por simplificar el concepto, aunque en realidad es un mecanismo más complejo llamado Continuation<T>):

fun llamarAMama() {
    viewModelScope.launch {
        buscarNumero("Mamá") {
            marcarNumeroLiveData.value = it
        }
    }
}

fun buscarNumero(nombre: String, cuandoTermine: (String) -> Unit) {
    val numero = getContactos().getNumero(nombre)
    cuandoTermine(numero)
}

Este callback —que aquí hemos llamado "cuandoTermine"— llamará al resto del código que había en la corrutina "cuando termine" la función de suspensión.

Esto es la suspensión en esencia: meter un callback implícito para forzar que algo no ocurra hasta que no finalice el método. En otras palabras, gracias al callback se ha suspendido el código que había en la corrutina. La función de suspensión no ejecutará las líneas posteriores a esta hasta que no termine.

Y te parecerá absurdo, ¿verdad? El código de una corrutina se ejecuta dentro de un hilo (en este caso el principal) —y dentro de un mismo hilo, la ejecución es síncrona—, por lo tanto ese código ya era síncrono. Tanto si le poníamos suspend como si no, hasta que no se ejecutase el método buscarNumero(), no se iba a llamar a la siguiente línea, ¿no?

Pues sí, en efecto, este ejemplo es absurdo, porque el código síncrono ya tiene esa propiedad, pero ayuda a ilustrar la suspensión. Como dijimos anteriormente, suspender no tiene sentido si no vas a cambiar de contexto, es decir, si no quieres resincronizar un código asíncrono.

Garantizar la seguridad del hilo principal

Siempre que crees una función de suspensión pública que ejecute un trabajo costoso, debes garantizar que el código al que llamas se va a ejecutar de forma segura para el hilo principal.

"Garantizar la seguridad del hilo principal" quiere decir que, dado que una función de suspensión permite ser llamada desde cualquier corrutina o función de suspensión que se esté ejecutando en cualquier hilo, no tienes la garantía de estar siempre en un hilo secundario; puedes estar en el principal. Así una función de suspensión debe encargarse siempre de cambiar de contexto para evitar bloquear el hilo principal.

Por ejemplo, vamos a llamar a una función que obtiene unos números de teléfono (List<Int>) de la agenda dados unos nombres:

viewModelScope.launch(Dispatchers.Default) {
    // Hilo secundario
    val nombres = listOf("Paco", "Pepe", "Paula", "Petra")
    val telefonos = obtenerTelefonos(nombres)
}

suspend fun obtenerTelefonos(nombres: List<String>): List<Int> =
    coroutineScope {
        nombres.map { async { buscarTelefonoEnLaAgenda(it) } }.awaitAll()
    }

suspend fun buscarTelefonoEnLaAgenda(nombre: String): Int {
    delay(4000L)
    return 12345678
}

La función buscarTelefonoEnLaAgenda() va a simular un trabajo largo (porque en la agenda puede haber muchos contactos o porque tal vez los contactos tengan que recuperarse mediante un ContentProvider), así que mejor la llamamos en una corrutina que utilice un hilo secundario; y además podemos recuperar cada contacto concurrentemente porque unos no dependen de otros, así que usamos varios async() para aligerar el trabajo.

¿Te parece que todo está bien? Pues no. Esto no es del todo correcto, ya que la función obtenerTelefonos() no tiene forma de garantizar que siempre vaya a ser llamada desde una corrutina que se esté ejecutando desde un hilo secundario. Cualquiera puede ir y hacer esto:

viewModelScope.launch {
    // Hilo principal
    val telefonos = obtenerTelefonos(otrosNombres)
}

Por lo que nos arriesgaríamos a que obtenerTelefonos() puediera bloquear el hilo principal ya que, al no estar indicándole otro dispatcher, este async() haría las cosas en el hilo principal. Así que por eso es nuestra obligación garantizar su seguridad, y para ello debemos cambiar siempre de contexto al crear una función de suspensión. Podemos hacerlo así:

suspend fun obtenerTelefonos(nombres: List<String>): List<Int> =
    withContext(Dispatchers.Default) {
        nombres.map { async { buscarTelefonoEnLaAgenda(it) } }.awaitAll()
    }

Fíjate en que ya no indicamos el dispatcher en async() porque ya lo lleva withContext(), por lo que cada async() ya lo va a utilizar, y cada uno se ejecutará en cualquiera de los hilos provistos por este dispatcher.

Además, como Android indica, utilizar withContext() no tiene penalización en el rendimiento cuando lo llamamos desde el mismo dispatcher aunque lo llamemos varias veces. Es decir, que esto no afecta al rendimiento:

withContext(Dispatchers.IO) {
  withContext(Dispatchers.Default) {
    withContext(Dispatchers.IO) {

    }
  }
}

withContext() te mantiene en el hilo si cambias al mismo dispatcher, incluso si cambias de Default a IO, ya que usan el mismo pool de hilos. Es lo que, en el propio código de la función withContext() está descrito como FAST PATH, por lo que resulta un muy buen aliciente para que siempre garantices la seguridad del hilo principal en todas tus funciones de suspensión.

¿Cuándo sí y cuándo no tendría que garantizar la seguridad del hilo principal?

Ten en cuenta esto: lo hace que la ejecución de una función sea segura para el hilo principal (que se ejecute en un hilo secundario) no es el modificador suspend, sino el hecho de que, por convención, en las funciones de suspensión cambiemos de contexto a uno que use un hilo secundario para realizar las tareas costosas. Por lo tanto, siempre que crees una función de suspensión que sea la que va a hacer directamente algo costoso, deberías llamar a withContext().

Además, para una mayor optimización de los hilos, querrás utilizar siempre el dispatcher adecuado a cada situación (de forma que no estemos bloqueando un hilo de IO para operaciones de CPU ni un hilo Default para operaciones de E/S).

Normalmente, aquello que programamos serán computaciones (mapeos, cálculos de cantidades y bucles) y para estas, lo que necesitas es Dispatchers.Default. Será raro que uses Dispatchers.IO para operaciones de E/S, salvo que el acceso a los archivos lo hagas tú a mano directamente o con un ContentProvider.

¿Por qué? Porque lo más seguro es que utilices las bibliotecas de Room y Retrofit, y ambas ya siguen el principio de garantizar la seguridad del hilo principal, por lo que llamar a cualquiera de sus funciones de suspensión es seguro desde el hilo principal. Es decir, que ya cambian ellas de dispatcher para sus operaciones; así que, para saber que dispatcher utilizar en tu corrutina, solo debes tener en cuenta el resto de operaciones que vas a realizar:

  • Si solo vas a hacer la llamada y a pasarle los datos a un LiveData, puedes lanzar tu corrutina con Dispatchers.Main:

    viewModelScope.launch {
        val comidas: List<ComidaDBO> = dao.getListaComidas()
        comidasLiveData.value = comidas
      }
    
      // DAO de Room
      suspend fun getListaComidas(): List<ComidaBO>
    

    La implementación de los métodos del DAO es generada por Room, quien ya cambiará a Dispatchers.IO solo para esas operaciones; y lo que queda fuera de la ecuación es poner datos a un LiveData, que no es una operación de E/S, por lo que es absurdo utilizar Dispatchers.IO, ya además que nos obligaría a tener que usar postValue().

  • Si hubiera un pre- o un postprocesamiento de datos (mapeo de DTO a BO y/o VO) usa Dispatchers.Default:

    viewModelScope.launch(Dispatchers.Default) {
        val peliculas: List<PeliculaBO> = ws.getListaPeliculas()
        val vos = peliculas.toVO()
        peliculasLiveData.postValue(vos)
      }
    
      // Service de Retrofit
      suspend fun getListaPeliculas(): List<PeliculaBO>
    

    La implementación de Retrofit se encargará de cambiar a Dispatchers.IO para la operación de entrada/salida, así que lo único que queda en tu código es un mapeo, que es una operación de CPU, por lo que quiers ejecutarla en un hilo de Dispatchers.Default, y no en uno de E/S. Incluso podrías extraer el mapeo a una función que garantizara la seguridad del hilo principal y utilizar Dispatchers.Main:

    viewModelScope.launch {
        peliculasLiveData.value = ws.getListaPeliculas().mapToVO()
      }
    
    suspend fun getListaPeliculas(): List<PeliculaBO> {
      withContext(Dispatchers.Default) { this.toVO() }
    }

    Y además, en este ejemplo, si la corrutina hubiese sido lanzada con Dispatchers.Default o con Dispatchers.IO, no habría penalización alguna al llamar a withContext().

¿Es async() intercambiable con withContext()?

Respuesta corta: sí, pero solo si usas async() mal. Comparemos ambas funciones:

async() withContext()
Lanza una nueva corrutina También lanza una corrutina, aunque no sea evidente
Opcionalmente indicas un contexto Obliga a que indiques un contexto (aunque no tiene penalización cuando es el mismo)
Es una función de extensión de CoroutinteContext Es una función de suspensión
Permite obtener un resultado suspendiendo la corrutina más adelante Permite obtener un resultado suspendiendo la corrutina inmediatamente

Dadas estas diferencias:

  1. Si en tu código tienes esta línea...

    val resultado: Float = async(Dispatchers.Default) {
          calcularResultado() 
        }.await()

    ... es completamente intercambiable; además, te lo sugerirá el IDE con un resaltado amarillo. Es intercambiable porque es exactamente lo mismo, solo que con una sintaxis más sencilla.

  2. Por otro lado, withContext() puede utilizarse directamente en una función de suspensión:

    suspend fun cocinar(): Comida = withContext(Dispatchers.Default) {
        hacerLaComida()
    }

    Pero te obliga a que indiques un contexto. Esto está hecho así para favorecer la seguridad del hilo principal.

  3. Para usar async() necesitas tener el CoroutineScope, es decir, debes o estar dentro del cuerpo de una corrutina o dentro de una función de extensión de CoroutineScope:

    // Dentro de una corrutina
    viewModelScope.launch { // this: CoroutineScope
        async {
            
        }
    }
    
    // Dentro de una función de extensión de CoroutineScope
    fun CoroutineScope.calcularAlgo(): Algo {
        val deferred = async { hacerCalculos() }
        // [...]
        return deferred.await()
    }
    
    // withContext() también es una función de suspensión constructora de ámbito
    suspend fun calcularOtraCosa(): Algo {
        return withContext(Dispatchers.Default) { // this: CoroutineScope
            val deferred = async { hacerCalculos() }
            // [...]
            return deferred.await()
        }
    }

Comentarios

Publicar un comentario

Entradas populares de este blog

Tratamiento de excepciones en las corrutinas

Venga, explícame cómo se usan