web-dev-qa-db-fra.com

Pourquoi les threads affichent de meilleures performances que les coroutines?

J'ai écrit 3 programmes simples pour tester l'avantage de performance des coroutines par rapport aux threads. Chaque programme effectue de nombreux calculs simples courants. Tous les programmes ont été exécutés séparément les uns des autres. Outre le temps d'exécution, j'ai mesuré l'utilisation du processeur via Visual VM IDE.

  1. Le premier programme effectue tous les calculs en utilisant 1000-threaded bassin. Ce morceau de code affiche les pires résultats (64326 ms) par rapport aux autres en raison de fréquents changements de contexte:

    val executor = Executors.newFixedThreadPool(1000)
    time = generateSequence {
      measureTimeMillis {
        val comps = mutableListOf<Future<Int>>()
        for (i in 1..1_000_000) {
          comps += executor.submit<Int> { computation2(); 15 }
        }
        comps.map { it.get() }.sum()
      }
    }.take(100).sum()
    println("Completed in $time ms")
    executor.shutdownNow()
    

first program

  1. Le deuxième programme a la même logique mais au lieu de 1000-threaded pool qu'il utilise uniquement n-threaded pool (où n est égal au nombre de cœurs de la machine). Il montre de bien meilleurs résultats (43939 ms) et utilise moins de threads ce qui est bien aussi.

    val executor2 = Executors.newFixedThreadPool(4)
      time = generateSequence {
      measureTimeMillis {
        val comps = mutableListOf<Future<Int>>()
        for (i in 1..1_000_000) {
          comps += executor2.submit<Int> { computation2(); 15 }
        }
        comps.map { it.get() }.sum()
      }
    }.take(100).sum()
    println("Completed in $time ms")
    executor2.shutdownNow()
    

second program

  1. Le troisième programme est écrit avec des coroutines et montre une grande variance dans les résultats (de 41784 ms à 81101 ms). Je suis très confus et je ne comprends pas très bien pourquoi ils sont si différents et pourquoi les coroutines sont parfois plus lentes que les threads (considérant que les petits calculs asynchrones sont un fort des coroutines ). Voici le code:

    time = generateSequence {
      runBlocking {
        measureTimeMillis {
          val comps = mutableListOf<Deferred<Int>>()
          for (i in 1..1_000_000) {
            comps += async { computation2(); 15 }
          }
          comps.map { it.await() }.sum()
        }
      }
    }.take(100).sum()
    println("Completed in $time ms")
    

third program

J'ai lu beaucoup de choses sur ces coroutines et comment elles sont implémentées dans kotlin, mais dans la pratique, je ne les vois pas fonctionner comme prévu. Suis-je en train de mal faire mon benchmarking? Ou peut-être que j'utilise mal les coroutines?

12
Praytic

De la manière dont vous avez réglé votre problème, vous ne devriez pas vous attendre à bénéficier des coroutines. Dans tous les cas, vous soumettez un bloc de calcul non divisible à un exécuteur. Vous ne tirez pas parti de l'idée de la suspension de la coroutine, où vous pouvez écrire du code séquentiel qui est réellement coupé et exécuté par morceaux, éventuellement sur différents threads.

La plupart des cas d'utilisation de coroutines tournent autour du code de blocage: éviter le scénario où vous monopolisez un thread pour ne rien faire mais attendre une réponse. Ils peuvent également être utilisés pour entrelacer des tâches gourmandes en ressources processeur, mais il s'agit d'un scénario plus spécial.

Je suggérerais de comparer 1 000 000 de tâches qui impliquent plusieurs étapes de blocage séquentielles, comme dans conférence KotlinConf 2017 de Roman Elizarov :

suspend fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

où tous les requestToken(), createPost() et processPost() impliquent des appels réseau.

Si vous en avez deux implémentations, une avec suspend fun Et une avec des fonctions de blocage régulières, par exemple:

fun requestToken() {
   Thread.sleep(1000)
   return "token"
}

vs.

suspend fun requestToken() {
    delay(1000)
    return "token"
}

vous constaterez que vous ne pouvez même pas configurer pour exécuter 1 000 000 d'appels simultanés de la première version, et si vous réduisez le nombre à ce que vous pouvez réellement réaliser sans OutOfMemoryException: unable to create new native thread, l'avantage de performance des coroutines devrait être évident .

Si vous souhaitez explorer les avantages possibles des coroutines pour les tâches liées au processeur, vous avez besoin d'un cas d'utilisation où il n'est pas inutile de les exécuter séquentiellement ou en parallèle. Dans vos exemples ci-dessus, cela est traité comme un détail interne non pertinent: dans une version, vous exécutez 1000 tâches simultanées et dans l'autre, vous n'en utilisez que quatre, il s'agit donc d'une exécution presque séquentielle.

Hazelcast Jet est un exemple d'un tel cas d'utilisation parce que les tâches de calcul sont co-dépendantes: la sortie d'un autre est l'entrée d'un autre. Dans ce cas, vous ne pouvez pas en exécuter quelques-uns jusqu'à la fin, sur un petit pool de threads, vous devez les entrelacer pour que la sortie mise en mémoire tampon n'explose pas. Si vous essayez de mettre en place un tel scénario avec et sans coroutines, vous constaterez une fois de plus que vous allouez autant de threads qu'il y a de tâches, ou que vous utilisez des coroutines suspendables, et cette dernière approche l'emporte. Hazelcast Jet implémente l'esprit des coroutines en clair Java API. Ceci est discuté dans son manuel de référence . Son approche bénéficierait énormément du modèle de programmation de la coroutine, mais actuellement c'est Java pur.

Divulgation: l'auteur de cet article appartient à l'équipe d'ingénierie Jet.

17
Marko Topolnik

Les coroutines ne sont pas conçues pour être plus rapides que les threads, c'est pour une consommation inférieure RAM et une meilleure syntaxe pour les appels asynchrones.

6
Garywzh

Les coroutines sont conçues pour être des fils légers. Il utilise moins de RAM, car lorsque vous exécutez 1 000 000 de routines simultanées, il n'a pas besoin de créer 1 000 000 de threads. Coroutine peut vous aider à optimiser l'utilisation des threads et à rendre l'exécution plus efficace, et vous n'avez plus besoin de vous soucier des threads. Vous pouvez considérer une coroutine comme un exécutable ou une tâche, que vous pouvez publier dans un gestionnaire et exécutée dans un thread ou un pool de threads.

0
Weidian Huang