Le grand nombre de coroutines, bien que légères, peut encore poser problème dans les applications exigeantes.
J'aimerais dissiper ce mythe selon lequel "trop de coroutines" est un problème en quantifiant leur coût réel.
Tout d'abord, il convient de démêler les coroutine de l'Union européenne et de l'Union européenne. contexte de la coroutine à laquelle elle est rattachée. C'est ainsi que l'on crée une coroutine avec un minimum de frais généraux :
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
La valeur de cette expression est un Job
en maintenant une coroutine suspendue. Pour conserver la continuation, nous l'avons ajoutée à une liste dans le cadre plus large.
J'ai analysé ce code et j'ai conclu qu'il allouait 140 octets et prend 100 nanosecondes à compléter. C'est donc dire la légèreté d'une coroutine.
Pour des raisons de 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 certain nombre de coroutines, puis s'endort afin que vous ayez le temps d'analyser le tas à l'aide d'un outil de surveillance tel que VisualVM. J'ai créé les classes spécialisées JobList
y ContinuationList
car cela facilite l'analyse du vidage du tas.
Pour obtenir un tableau plus complet, j'ai utilisé le code ci-dessous pour mesurer également le coût des 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()
}
}
Voici le résultat typique que j'obtiens avec le code ci-dessus :
Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds
Oui, async-await
prend environ deux fois plus de temps que l withContext
mais ce n'est qu'une microseconde. Il faudrait les lancer dans une boucle serrée, en ne faisant presque rien d'autre, pour que cela devienne "un problème" dans votre application.
Utilisation measureMemory()
J'ai trouvé les coûts de mémoire suivants par appel :
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
Le coût de la async-await
est exactement 140 octets plus haut que withContext
le nombre que nous avons obtenu comme poids mémoire d'une coroutine. Ce n'est qu'une fraction du coût total de la mise en place de l'application CommonPool
contexte.
Si l'impact sur les performances et la mémoire était le seul critère pour choisir entre withContext
y async-await
La conclusion devrait être qu'il n'y a pas de différence pertinente entre les deux dans 99 % des cas d'utilisation réelle.
La vraie raison est que withContext()
une API plus simple et plus directe, notamment en termes de gestion des exceptions :
- Une exception qui n'est pas gérée dans le cadre de
async { ... }
entraîne l'annulation de son job parent. Cela se produit quelle que soit la manière dont vous gérez les exceptions provenant de la fonction de correspondance await()
. Si vous n'avez pas préparé de coroutineScope
pour cela, cela peut faire chuter toute votre candidature.
- Une exception qui n'est pas traitée dans le cadre du
withContext { ... }
est tout simplement lancé par la fonction withContext
vous le traitez comme n'importe quel autre.
withContext
est également optimisée, car elle tire parti du fait que vous suspendez la coroutine parentale et attendez la coroutine enfant, mais ce n'est qu'un avantage supplémentaire.
async-await
devrait être réservé aux cas où l'on veut vraiment de la concurrence, de sorte que l'on lance plusieurs coroutines en arrière-plan et que l'on n'attende que sur elles. En résumé :
-
async-await-async-await
- ne le faites pas, utilisez withContext-withContext
-
async-async-await-await
- c'est ainsi qu'il faut l'utiliser.
0 votes
Je pense que lorsque vous utilisez
withContext
une nouvelle coroutine est toujours créée. C'est ce que je vois dans le code source.0 votes
@stdout ne le fait pas
async/await
créer également une nouvelle coroutine, selon l'OP ?