Kotlin: withContext () vs Async-await

93

Tenho lido a documentação do Kotlin e, se entendi corretamente, as duas funções do Kotlin funcionam da seguinte maneira:

  1. withContext(context): muda o contexto da co-rotina atual, quando o bloco dado é executado, a co-rotina volta para o contexto anterior.
  2. async(context): Inicia uma nova co-rotina no contexto dado e se chamarmos .await()a Deferredtarefa retornada , ela suspenderá a co-rotina de chamada e continuará quando o bloco em execução dentro da co-rotina gerada retornar.

Agora, para as seguintes duas versões de code:

Versão 1:

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

Versão 2:

  launch(){
    block1()
     val returned = withContext(context){
      block2()
    }
    block3()
  }
  1. Em ambas as versões, block1 (), block3 () executa no contexto padrão (commonpool?) Onde como block2 () é executado no contexto dado.
  2. A execução geral é sincronizada com a ordem block1 () -> block2 () -> block3 ().
  3. A única diferença que vejo é que a versão1 cria outra co-rotina, enquanto a versão2 executa apenas uma co-rotina ao alternar o contexto.

Minhas perguntas são:

  1. Não é sempre melhor usar withContextdo que async-awaitporque é funcionalmente semelhante, mas não cria outra co-rotina. Um grande número de corrotinas, embora leves, ainda podem ser um problema em aplicativos exigentes.

  2. Existe um caso que async-awaité mais preferível withContext?

Atualização: Kotlin 1.2.50 agora tem uma inspeção de código onde pode converter async(ctx) { }.await() to withContext(ctx) { }.

Mangat Rai Modi
fonte
Eu acho que quando você usa withContext, uma nova co-rotina é sempre criada independentemente. Isso é o que posso ver no código-fonte.
stdout de
@stdout Não async/awaitcria também uma nova co-rotina, de acordo com OP?
IgorGanapolsky de

Respostas:

128

Grande número de corrotinas, embora leves, ainda podem ser um problema em aplicações exigentes

Eu gostaria de dissipar esse mito de que "muitas corrotinas" são um problema, quantificando seu custo real.

Primeiro, devemos separar a própria co - rotina do contexto da co - rotina ao qual ela está anexada. É assim que você cria apenas uma co-rotina com sobrecarga mínima:

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

O valor desta expressão é Jobmanter uma co-rotina suspensa. Para manter a continuação, nós a adicionamos a uma lista em um escopo mais amplo.

Eu comparei esse código e concluí que ele aloca 140 bytes e leva 100 nanossegundos para ser concluído. Então é assim que uma co-rotina é leve.

Para reprodutibilidade, este é o código que usei:

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 um monte de corrotinas e depois dorme para que você tenha tempo de analisar o heap com uma ferramenta de monitoramento como o VisualVM. Eu criei as classes especializadas JobListe ContinuationListporque isso torna mais fácil analisar o despejo de heap.


Para obter uma história mais completa, usei o código abaixo para medir também o custo de withContext()e 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()
    }
}

Esta é a saída típica que obtenho do código acima:

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

Sim, async-awaitleva o dobro do tempo withContext, mas ainda é apenas um microssegundo. Você teria que iniciá-los em um loop fechado, sem fazer quase nada além disso, para que isso se tornasse "um problema" em seu aplicativo.

Usando measureMemory(), encontrei o seguinte custo de memória por chamada:

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

O custo de async-awaité exatamente 140 bytes maior do que withContexto número que obtivemos como o peso da memória de uma co-rotina. Isso é apenas uma fração do custo total de configuração do CommonPoolcontexto.

Se o impacto no desempenho / memória fosse o único critério para decidir entre withContexte async-await, a conclusão teria de ser que não há diferença relevante entre eles em 99% dos casos de uso reais.

O verdadeiro motivo é que withContext()uma API mais simples e direta, especialmente em termos de tratamento de exceções:

  • Uma exceção que não é tratada em async { ... }faz com que seu trabalho pai seja cancelado. Isso acontece independentemente de como você lida com as exceções da correspondência await(). Se você não preparou um coroutineScopepara isso, ele pode desativar todo o seu aplicativo.
  • Uma exceção não tratada dentro withContext { ... }simplesmente é lançada pela withContextchamada, você a trata como qualquer outro.

withContext também acontece de ser otimizado, aproveitando o fato de que você está suspendendo a co-rotina pai e aguardando o filho, mas isso é apenas um bônus adicional.

async-awaitdeve ser reservado para aqueles casos em que você realmente deseja simultaneidade, de forma que você inicie várias corrotinas em segundo plano e só então espere por elas. Em resumo:

  • async-await-async-await - não faça isso, use withContext-withContext
  • async-async-await-await - essa é a maneira de usá-lo.
Marko Topolnik
fonte
Em relação ao custo de memória extra de async-await: Quando usamos withContext, uma nova co-rotina também é criada (pelo que posso ver no código-fonte), então você acha que a diferença pode estar vindo de outro lugar?
stdout
1
@stdout A biblioteca tem evoluído desde que executei esses testes. O código na resposta deve ser totalmente autocontido, tente executá-lo novamente para validar. asynccria um Deferredobjeto, o que também pode explicar algumas das diferenças.
Marko Topolnik
~ " Para reter a continuação ". Quando precisamos reter isso?
IgorGanapolsky de
1
@IgorGanapolsky É sempre retido, mas geralmente não de forma visível para o usuário. Perder a continuação é equivalente a Thread.destroy()- a execução desaparecer no ar.
Marko Topolnik de
24

Não é sempre melhor usar withContext em vez de asynch-await, pois é funcionalmente semelhante, mas não cria outra co-rotina. Grande número de corrotinas, embora leve ainda possa ser um problema em aplicações exigentes

Existe um caso de espera-assíncrona é mais preferível do que withContext

Você deve usar async / await quando quiser executar várias tarefas simultaneamente, por exemplo:

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)
}

Se você não precisa executar várias tarefas ao mesmo tempo, pode usar o withContext.

Dmitry
fonte
15

Em caso de dúvida, lembre-se disso como uma regra prática:

  1. Se várias tarefas tiverem que acontecer em paralelo e o resultado final depender da conclusão de todas elas, use async.

  2. Para retornar o resultado de uma única tarefa, use withContext.

Yogesh Umesh Vaity
fonte
1
Ambos asynce o withContextbloqueio estão em um escopo suspenso?
IgorGanapolsky
3
@IgorGanapolsky Se você está falando sobre o bloqueio do thread principal asynce withContextnão bloqueará o thread principal, eles apenas suspenderão o corpo da co-rotina enquanto alguma tarefa de longa execução estiver em execução e aguardando um resultado. Para obter mais informações e exemplos, consulte este artigo sobre Meio: Operações assíncronas com corrotinas Kotlin .
Yogesh Umesh Vaity