Kotlin: withContext () vs Async-await

91

J'ai lu la documentation de Kotlin , et si j'ai bien compris les deux fonctions de Kotlin fonctionnent comme suit:

  1. withContext(context): change le contexte de la coroutine courante, lorsque le bloc donné s'exécute, la coroutine revient au contexte précédent.
  2. async(context): Démarre une nouvelle coroutine dans le contexte donné et si nous appelons .await()la Deferredtâche retournée , elle suspendra la coroutine appelante et reprendra lorsque le bloc s'exécutant à l'intérieur de la coroutine engendrée reviendra.

Maintenant, pour les deux versions suivantes de code:

Version 1:

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

Version 2:

  launch(){
    block1()
     val returned = withContext(context){
      block2()
    }
    block3()
  }
  1. Dans les deux versions block1 (), block3 () s'exécute dans le contexte par défaut (commonpool?) Où comme block2 () s'exécute dans le contexte donné.
  2. L'exécution globale est synchrone avec l'ordre block1 () -> block2 () -> block3 ().
  3. La seule différence que je vois est que version1 crée une autre coroutine, où en tant que version2 n'exécute qu'une seule coroutine lors du changement de contexte.

Mes questions sont:

  1. N'est-il pas toujours préférable d'utiliser withContextplutôt que async-awaitcar il est fonctionnellement similaire, mais ne crée pas une autre coroutine. Un grand nombre de coroutines, bien que légères, peut encore poser problème dans les applications exigeantes.

  2. Y a-t-il un cas async-awaitplus préférable withContext?

Mise à jour: Kotlin 1.2.50 a maintenant une inspection de code où il peut convertir async(ctx) { }.await() to withContext(ctx) { }.

Mangat Rai Modi
la source
Je pense que lorsque vous utilisez withContext, une nouvelle coroutine est toujours créée indépendamment. C'est ce que je peux voir à partir du code source.
stdout
@stdout Ne crée-t-il pas async/awaitégalement une nouvelle coroutine, selon OP?
IgorGanapolsky le

Réponses:

126

Un grand nombre de coroutines, bien que légères, pourrait encore être un problème dans les applications exigeantes

Je voudrais dissiper ce mythe du "trop ​​de coroutines" étant un problème en quantifiant leur coût réel.

Tout d'abord, nous devons démêler la coroutine elle-même du contexte de coroutine auquel elle est attachée. Voici comment vous créez juste une coroutine avec une surcharge minimale:

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

La valeur de cette expression est un Jobmaintien d'une coroutine suspendue. Pour conserver la suite, nous l'avons ajoutée à une liste dans le cadre plus large.

J'ai comparé ce code et conclu qu'il alloue 140 octets et prend 100 nanosecondes à compléter. Voilà donc à quel point une coroutine est légère.

Pour la reproductibilité, voici le code que j'ai utilisé:

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

Ce code démarre un tas de coroutines, puis se met en veille pour que vous ayez le temps d'analyser le tas avec un outil de surveillance comme VisualVM. J'ai créé les classes spécialisées JobListet ContinuationListparce que cela facilite l'analyse du vidage du tas.


Pour obtenir une histoire plus complète, j'ai utilisé le code ci-dessous pour également mesurer le coût de withContext()et 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()
    }
}

C'est la sortie typique que j'obtiens du code ci-dessus:

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

Oui, cela async-awaitprend environ deux fois plus de temps withContext, mais ce n'est toujours qu'une microseconde. Vous auriez à les lancer dans une boucle serrée, ne faisant presque rien d'autre, pour que cela devienne "un problème" dans votre application.

En utilisant, measureMemory()j'ai trouvé le coût de mémoire suivant par appel:

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

Le coût de async-awaitest exactement 140 octets plus élevé que withContextle nombre que nous avons obtenu en tant que poids de la mémoire d'une coroutine. Ceci ne représente qu'une fraction du coût total de mise en place du CommonPoolcontexte.

Si l'impact performances / mémoire était le seul critère pour choisir entre withContextet async-await, la conclusion devrait être qu'il n'y a pas de différence pertinente entre eux dans 99% des cas d'utilisation réels.

La vraie raison est qu'une withContext()API plus simple et plus directe, notamment en termes de gestion des exceptions:

  • Une exception qui n'est pas gérée dans async { ... }entraîne l'annulation de son travail parent. Cela se produit indépendamment de la façon dont vous gérez les exceptions de la correspondance await(). Si vous ne l'avez pas préparé coroutineScope, cela peut faire tomber votre application entière.
  • Une exception non gérée dans est withContext { ... }simplement levée par l' withContextappel, vous la gérez comme n'importe quelle autre.

withContext se trouve également être optimisé, tirant parti du fait que vous suspendez la coroutine parent et attendez l'enfant, mais ce n'est qu'un bonus supplémentaire.

async-awaitdevrait être réservé aux cas où vous voulez réellement la concurrence, de sorte que vous lanciez plusieurs coroutines en arrière-plan et n'attendiez qu'ensuite sur elles. En bref:

  • async-await-async-await - ne fais pas ça, utilise withContext-withContext
  • async-async-await-await - c'est la façon de l'utiliser.
Marko Topolnik
la source
En ce qui concerne le coût supplémentaire de la mémoire de async-await: Lorsque nous utilisons withContext, une nouvelle coroutine est également créée (pour autant que je puisse le voir à partir du code source), alors pensez-vous que la différence peut venir d'ailleurs?
stdout
1
@stdout La bibliothèque a évolué depuis que j'ai exécuté ces tests. Le code de la réponse est censé être entièrement autonome, essayez de l'exécuter à nouveau pour valider. asynccrée un Deferredobjet, qui peut également expliquer une partie de la différence.
Marko Topolnik
~ " Pour conserver la suite ". Quand devons-nous conserver cela?
IgorGanapolsky le
1
@IgorGanapolsky Il est toujours conservé mais généralement pas d'une manière visible pour l'utilisateur. Perdre la suite équivaut à Thread.destroy()- l'exécution s'évanouissant dans les airs.
Marko Topolnik le
22

N'est-il pas toujours préférable d'utiliser withContext plutôt que asynch-await car il est fonctionnellement similaire, mais ne crée pas une autre coroutine. Un grand nombre de coroutines, bien que légères, pourraient encore être un problème dans les applications exigeantes

Y a-t-il un cas asynch-await est plus préférable à withContext

Vous devez utiliser async / await lorsque vous souhaitez exécuter plusieurs tâches simultanément, par exemple:

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 vous n'avez pas besoin d'exécuter plusieurs tâches simultanément, vous pouvez utiliser withContext.

Dmitry
la source
13

En cas de doute, rappelez-vous ceci comme une règle de base:

  1. Si plusieurs tâches doivent se produire en parallèle et que le résultat final dépend de leur achèvement, utilisez async.

  2. Pour renvoyer le résultat d'une seule tâche, utilisez withContext.

Yogesh Umesh Vaity
la source
1
Les deux asyncet le withContextblocage sont-ils dans une portée suspendue?
IgorGanapolsky le
3
@IgorGanapolsky Si vous parlez de blocage du thread principal asyncet withContextque vous ne bloquez pas le thread principal, ils ne suspendront le corps de la coroutine que pendant une longue tâche en cours d'exécution et en attente d'un résultat. Pour plus d'informations et d'exemples, consultez cet article sur Medium: Opérations asynchrones avec Kotlin Coroutines .
Yogesh Umesh Vaity