Kotlin: withContext () vs Async-await


93

Ho letto i documenti di Kotlin e se ho capito correttamente le due funzioni di Kotlin funzionano come segue:

  1. withContext(context): cambia il contesto della coroutine corrente, quando il blocco dato viene eseguito, la coroutine torna al contesto precedente.
  2. async(context): Avvia una nuova coroutine nel contesto dato e se richiamiamo .await()l' Deferredattività restituita , sospenderà la coroutine chiamante e riprenderà quando il blocco in esecuzione all'interno della coroutine generata ritorna.

Ora per le seguenti due versioni di code:

Versione1:

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

Versione2:

  launch(){
    block1()
     val returned = withContext(context){
      block2()
    }
    block3()
  }
  1. In entrambe le versioni block1 (), block3 () viene eseguito nel contesto predefinito (commonpool?) Dove come block2 () viene eseguito nel contesto dato.
  2. L'esecuzione complessiva è sincrona con l'ordine block1 () -> block2 () -> block3 ().
  3. L'unica differenza che vedo è che version1 crea un'altra coroutine, dove come version2 esegue solo una coroutine mentre cambia contesto.

Le mie domande sono:

  1. Non è sempre meglio usare withContextpiuttosto che async-awaitin quanto è funzionalmente simile, ma non crea un'altra coroutine. Un gran numero di coroutine, sebbene leggere, potrebbe ancora essere un problema nelle applicazioni impegnative.

  2. C'è un caso a cui async-awaitè più preferibile withContext?

Aggiornamento: Kotlin 1.2.50 ora ha un'ispezione del codice in cui può convertire async(ctx) { }.await() to withContext(ctx) { }.


Penso che quando usi withContext, una nuova coroutine viene sempre creata a prescindere. Questo è quello che posso vedere dal codice sorgente.
stdout

@stdout Non async/awaitcrea anche una nuova coroutine, secondo OP?
IgorGanapolsky

Risposte:


128

Un gran numero di coroutine, sebbene leggero, potrebbe ancora essere un problema nelle applicazioni impegnative

Vorrei sfatare questo mito secondo cui "troppe coroutine" sono un problema quantificando il loro costo effettivo.

In primo luogo, dovremmo districare la coroutine stessa dal contesto coroutine a cui è collegata. Ecco come creare solo una coroutine con un overhead minimo:

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

Il valore di questa espressione è a Job tenere una coroutine sospesa. Per mantenere la continuazione, l'abbiamo aggiunta a un elenco di portata più ampia.

Ho confrontato questo codice e ho concluso che alloca 140 byte e impiega 100 nanosecondi per il completamento. Ecco quanto è leggera una coroutine.

Per la riproducibilità, questo è il codice che ho usato:

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

Questo codice avvia un mucchio di coroutine e poi dorme in modo da avere il tempo di analizzare l'heap con uno strumento di monitoraggio come VisualVM. Ho creato le classi specializzate JobListe ContinuationListperché questo rende più facile analizzare il dump dell'heap.


Per ottenere una storia più completa, ho utilizzato il codice seguente per misurare anche il costo di 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()
    }
}

Questo è l'output tipico che ottengo dal codice sopra:

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

Sì, async-awaitimpiega circa il doppio del tempo withContext, ma è comunque solo un microsecondo. Dovresti avviarli in un ciclo serrato, senza fare quasi nulla, perché questo diventi "un problema" nella tua app.

Utilizzando measureMemory()ho trovato il seguente costo di memoria per chiamata:

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

Il costo di async-awaitè esattamente 140 byte superiore withContextal numero che abbiamo ottenuto come peso della memoria di una coroutine. Questa è solo una frazione del costo totale di installazione diCommonPool contesto.

Se l'impatto sulle prestazioni / memoria fosse l'unico criterio per decidere tra withContexte async-await, la conclusione dovrebbe essere che non vi è alcuna differenza rilevante tra loro nel 99% dei casi d'uso reali.

La vera ragione è che withContext()un'API più semplice e diretta, soprattutto in termini di gestione delle eccezioni:

  • Un'eccezione che non viene gestita all'interno async { ... }provoca l'annullamento del suo lavoro padre. Ciò accade indipendentemente da come gestisci le eccezioni dalla corrispondenza await(). Se non ne hai preparato uno coroutineScope, potrebbe far cadere l'intera applicazione.
  • Un'eccezione non gestita all'interno withContext { ... }viene semplicemente generata dalla withContextchiamata, la gestisci come qualsiasi altra.

withContext capita anche di essere ottimizzato, sfruttando il fatto che stai sospendendo la coroutine genitore e aspettando il bambino, ma questo è solo un ulteriore vantaggio.

async-awaitdovrebbe essere riservato a quei casi in cui si desidera effettivamente la concorrenza, in modo da avviare diverse coroutine in background e solo successivamente attendere su di esse. In breve:

  • async-await-async-await - non farlo, usa withContext-withContext
  • async-async-await-await - questo è il modo di usarlo.

Per quanto riguarda il costo della memoria extra di async-await: Quando usiamo withContext, viene creata anche una nuova coroutine (per quanto posso vedere dal codice sorgente), quindi pensi che la differenza potrebbe provenire da qualche altra parte?
stdout

1
@stdout La libreria si è evoluta da quando ho eseguito questi test. Il codice nella risposta dovrebbe essere completamente autonomo, prova a eseguirlo di nuovo per convalidarlo. asynccrea un Deferredoggetto, che può anche spiegare alcune delle differenze.
Marko Topolnik

~ " Per mantenere la continuazione ". Quando dobbiamo conservarlo?
IgorGanapolsky

1
@IgorGanapolsky Viene sempre mantenuto, ma di solito non è visibile all'utente. Perdere la continuazione equivale a Thread.destroy(): l'esecuzione svanisce nel nulla.
Marko Topolnik

24

Non è sempre meglio usare withContext piuttosto che asynch-wait in quanto è funzionalmente simile, ma non crea un'altra coroutine. Grandi numeri di coroutine, anche se la leggerezza potrebbe ancora essere un problema nelle applicazioni impegnative

C'è un caso asynch-await è più preferibile a withContext

È necessario utilizzare async / await quando si desidera eseguire più attività contemporaneamente, ad esempio:

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 non è necessario eseguire più attività contemporaneamente, è possibile utilizzare withContext.


15

In caso di dubbio, ricorda questo come una regola pratica:

  1. Se più attività devono svolgersi in parallelo e il risultato finale dipende dal completamento di tutte, quindi utilizzare async.

  2. Per restituire il risultato di una singola attività, utilizzare withContext.


1
Sia asynce il withContextblocco sono in un ambito di sospensione?
IgorGanapolsky

3
@IgorGanapolsky Se stai parlando di blocco del thread principale asynce withContextnon bloccherà il thread principale, sospenderanno solo il corpo della coroutine mentre alcune attività a lunga esecuzione sono in esecuzione e in attesa di un risultato. Per ulteriori informazioni ed esempi, vedere questo articolo su Medium: operazioni asincrone con Kotlin Coroutines .
Yogesh Umesh Vaity
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.