Kotlin: withContext () vs Async-await

91

He estado leyendo documentos de Kotlin , y si entendí correctamente, las dos funciones de Kotlin funcionan de la siguiente manera:

  1. withContext(context): cambia el contexto de la corrutina actual, cuando se ejecuta el bloque dado, la corrutina vuelve al contexto anterior.
  2. async(context): Inicia una nueva corrutina en el contexto dado y si invocamos la tarea .await()devuelta Deferred, suspenderá la corrutina de llamada y se reanudará cuando regrese el bloque que se ejecuta dentro de la corrutina generada.

Ahora para las siguientes dos versiones de code:

Versión 1:

  launch(){
    block1()
    val returned = async(context){
      block2()
    }.await()
    block3()
  }

Versión 2:

  launch(){
    block1()
     val returned = withContext(context){
      block2()
    }
    block3()
  }
  1. En ambas versiones block1 (), block3 () se ejecutan en el contexto predeterminado (commonpool?) Donde como block2 () se ejecuta en el contexto dado.
  2. La ejecución general es sincrónica con el orden block1 () -> block2 () -> block3 ().
  3. La única diferencia que veo es que version1 crea otra corrutina, mientras que version2 ejecuta solo una corrutina mientras cambia de contexto.

Mis preguntas son:

  1. ¿No es siempre mejor usar en withContextlugar de async-awaitya que es funcionalmente similar, pero no crea otra corrutina? Un gran número de corrutinas, aunque livianas, aún podrían ser un problema en aplicaciones exigentes.

  2. ¿Hay algún caso que async-awaitsea ​​más preferible withContext?

Actualización: Kotlin 1.2.50 ahora tiene una inspección de código donde puede convertir async(ctx) { }.await() to withContext(ctx) { }.

Mangat Rai Modi
fuente
Creo que cuando lo usas withContext, siempre se crea una nueva corrutina independientemente. Esto es lo que puedo ver en el código fuente.
stdout
@stdout ¿No async/awaitcrea también una nueva corrutina, según OP?
IgorGanapolsky

Respuestas:

126

Una gran cantidad de corrutinas, aunque livianas, aún podría ser un problema en aplicaciones exigentes

Me gustaría disipar este mito de que "demasiadas corrutinas" son un problema cuantificando su costo real.

Primero, debemos desenredar la corrutina en sí del contexto de la corrutina al que está adjunta. Así es como crea solo una corrutina con una sobrecarga mínima:

GlobalScope.launch(Dispatchers.Unconfined) {
    suspendCoroutine<Unit> {
        continuations.add(it)
    }
}

El valor de esta expresión es Jobmantener una corrutina suspendida. Para retener la continuación, la agregamos a una lista en el alcance más amplio.

Evalué este código y concluí que asigna 140 bytes y tarda 100 nanosegundos en completarse. Así de ligera es una corrutina.

Para la reproducibilidad, este es el código que utilicé:

fun measureMemoryOfLaunch() {
    val continuations = ContinuationList()
    val jobs = (1..10_000).mapTo(JobList()) {
        GlobalScope.launch(Dispatchers.Unconfined) {
            suspendCoroutine<Unit> {
                continuations.add(it)
            }
        }
    }
    (1..500).forEach {
        Thread.sleep(1000)
        println(it)
    }
    println(jobs.onEach { it.cancel() }.filter { it.isActive})
}

class JobList : ArrayList<Job>()

class ContinuationList : ArrayList<Continuation<Unit>>()

Este código inicia un montón de corrutinas y luego duerme para que tenga tiempo de analizar el montón con una herramienta de monitoreo como VisualVM. Creé las clases especializadas JobListy ContinuationListporque esto facilita el análisis del volcado de pila.


Para obtener una historia más completa, utilicé el siguiente código para medir también el costo de withContext()y async-await:

import kotlinx.coroutines.*
import java.util.concurrent.Executors
import kotlin.coroutines.suspendCoroutine
import kotlin.system.measureTimeMillis

const val JOBS_PER_BATCH = 100_000

var blackHoleCount = 0
val threadPool = Executors.newSingleThreadExecutor()!!
val ThreadPool = threadPool.asCoroutineDispatcher()

fun main(args: Array<String>) {
    try {
        measure("just launch", justLaunch)
        measure("launch and withContext", launchAndWithContext)
        measure("launch and async", launchAndAsync)
        println("Black hole value: $blackHoleCount")
    } finally {
        threadPool.shutdown()
    }
}

fun measure(name: String, block: (Int) -> Job) {
    print("Measuring $name, warmup ")
    (1..1_000_000).forEach { block(it).cancel() }
    println("done.")
    System.gc()
    System.gc()
    val tookOnAverage = (1..20).map { _ ->
        System.gc()
        System.gc()
        var jobs: List<Job> = emptyList()
        measureTimeMillis {
            jobs = (1..JOBS_PER_BATCH).map(block)
        }.also { _ ->
            blackHoleCount += jobs.onEach { it.cancel() }.count()
        }
    }.average()
    println("$name took ${tookOnAverage * 1_000_000 / JOBS_PER_BATCH} nanoseconds")
}

fun measureMemory(name:String, block: (Int) -> Job) {
    println(name)
    val jobs = (1..JOBS_PER_BATCH).map(block)
    (1..500).forEach {
        Thread.sleep(1000)
        println(it)
    }
    println(jobs.onEach { it.cancel() }.filter { it.isActive})
}

val justLaunch: (i: Int) -> Job = {
    GlobalScope.launch(Dispatchers.Unconfined) {
        suspendCoroutine<Unit> {}
    }
}

val launchAndWithContext: (i: Int) -> Job = {
    GlobalScope.launch(Dispatchers.Unconfined) {
        withContext(ThreadPool) {
            suspendCoroutine<Unit> {}
        }
    }
}

val launchAndAsync: (i: Int) -> Job = {
    GlobalScope.launch(Dispatchers.Unconfined) {
        async(ThreadPool) {
            suspendCoroutine<Unit> {}
        }.await()
    }
}

Este es el resultado típico que obtengo del código anterior:

Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds

Sí, async-awaittoma aproximadamente el doble de tiempo withContext, pero sigue siendo solo un microsegundo. Tendría que ejecutarlos en un bucle cerrado, sin hacer casi nada además, para que eso se convierta en "un problema" en su aplicación.

Usando measureMemory()encontré el siguiente costo de memoria por llamada:

Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes

El costo de async-awaites exactamente 140 bytes más alto que withContextel número que obtuvimos como el peso de memoria de una corrutina. Esto es solo una fracción del costo total de configurar el CommonPoolcontexto.

Si el impacto en el rendimiento / memoria fuera el único criterio para decidir entre withContexty async-await, la conclusión tendría que ser que no hay una diferencia relevante entre ellos en el 99% de los casos de uso reales.

La verdadera razón es que withContext()una API más simple y directa, especialmente en términos de manejo de excepciones:

  • Una excepción que no se maneja dentro async { ... }hace que se cancele su trabajo principal. Esto sucede independientemente de cómo maneje las excepciones de la coincidencia await(). Si no ha preparado una coroutineScopepara ello, es posible que se elimine toda la aplicación.
  • Una excepción que no se maneja dentro withContext { ... }simplemente es lanzada por la withContextllamada, usted la maneja como cualquier otra.

withContext también está optimizado, aprovechando el hecho de que está suspendiendo la corrutina principal y esperando al niño, pero eso es solo una ventaja adicional.

async-awaitdebe reservarse para aquellos casos en los que realmente desea la concurrencia, de modo que inicie varias corrutinas en segundo plano y solo luego las espere. En breve:

  • async-await-async-await - no hagas eso, usa withContext-withContext
  • async-async-await-await - esa es la forma de usarlo.
Marko Topolnik
fuente
Con respecto al costo de memoria adicional de async-await: Cuando usamos withContext, también se crea una nueva corrutina (por lo que puedo ver en el código fuente), así que ¿cree que la diferencia podría provenir de otro lugar?
stdout
1
@stdout La biblioteca ha ido evolucionando desde que ejecuté estas pruebas. Se supone que el código de la respuesta es completamente autónomo, intente ejecutarlo nuevamente para validarlo. asynccrea un Deferredobjeto, que también puede explicar algunas de las diferencias.
Marko Topolnik
~ " Conservar la continuación ". ¿Cuándo debemos retener esto?
IgorGanapolsky
1
@IgorGanapolsky Siempre se conserva, pero normalmente no de forma visible para el usuario. Perder la continuación es equivalente a Thread.destroy(): la ejecución se desvanece en el aire.
Marko Topolnik
22

¿No es siempre mejor usar withContext en lugar de asynch-await ya que es funcionalmente similar, pero no crea otra corrutina? Corutinas de números grandes, aunque el peso ligero aún podría ser un problema en aplicaciones exigentes

¿Hay un caso que asynch-await sea más preferible que withContext?

Debe usar async / await cuando desee ejecutar varias tareas al mismo tiempo, por ejemplo:

runBlocking {
    val deferredResults = arrayListOf<Deferred<String>>()

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "1"
    }

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "2"
    }

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "3"
    }

    //wait for all results (at this point tasks are running)
    val results = deferredResults.map { it.await() }
    println(results)
}

Si no necesita ejecutar varias tareas al mismo tiempo, puede usar withContext.

Dmitry
fuente
13

En caso de duda, recuerde esto como una regla general:

  1. Si tienen que realizarse varias tareas en paralelo y el resultado final depende de que se completen todas, utilice async.

  2. Para devolver el resultado de una sola tarea, utilice withContext.

Yogesh Umesh Vaity
fuente
1
¿Están ambos asyncy el withContextbloqueo en un ámbito de suspensión?
IgorGanapolsky
3
@IgorGanapolsky Si está hablando de bloquear el hilo principal asyncy withContextno bloqueará el hilo principal, solo suspenderán el cuerpo de la corrutina mientras se ejecuta una tarea de larga duración y espera un resultado. Para obtener más información y ejemplos, consulte este artículo sobre Medio: operaciones asíncronas con Kotlin Coroutines .
Yogesh Umesh Vaity