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 JobList
e ContinuationList
perché 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-await
impiega 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 withContext
al 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 withContext
e 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 withContext
chiamata, 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-await
dovrebbe 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.
withContext
, una nuova coroutine viene sempre creata a prescindere. Questo è quello che posso vedere dal codice sorgente.