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 Job
maintien 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 JobList
et ContinuationList
parce 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-await
prend 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-await
est exactement 140 octets plus élevé que withContext
le 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 CommonPool
contexte.
Si l'impact performances / mémoire était le seul critère pour choisir entre withContext
et 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' withContext
appel, 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-await
devrait ê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.
withContext
, une nouvelle coroutine est toujours créée indépendamment. C'est ce que je peux voir à partir du code source.