Una gran cantidad de corrutinas, aunque livianas, aún podría ser un problema en aplicaciones exigentes
Me gustaría disipar este mito de que "demasiadas corrutinas" son un problema cuantificando su costo real.
Primero, debemos desenredar la corrutina en sí del contexto de la corrutina al que está adjunta. Así es como crea solo una corrutina con una sobrecarga mínima:
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
El valor de esta expresión es Job
mantener una corrutina suspendida. Para retener la continuación, la agregamos a una lista en el alcance más amplio.
Evalué este código y concluí que asigna 140 bytes y tarda 100 nanosegundos en completarse. Así de ligera es una corrutina.
Para la reproducibilidad, este es el código que utilicé:
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>>()
Este código inicia un montón de corrutinas y luego duerme para que tenga tiempo de analizar el montón con una herramienta de monitoreo como VisualVM. Creé las clases especializadas JobList
y ContinuationList
porque esto facilita el análisis del volcado de pila.
Para obtener una historia más completa, utilicé el siguiente código para medir también el costo de 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()
}
}
Este es el resultado típico que obtengo del código anterior:
Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds
Sí, async-await
toma aproximadamente el doble de tiempo withContext
, pero sigue siendo solo un microsegundo. Tendría que ejecutarlos en un bucle cerrado, sin hacer casi nada además, para que eso se convierta en "un problema" en su aplicación.
Usando measureMemory()
encontré el siguiente costo de memoria por llamada:
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
El costo de async-await
es exactamente 140 bytes más alto que withContext
el número que obtuvimos como el peso de memoria de una corrutina. Esto es solo una fracción del costo total de configurar el CommonPool
contexto.
Si el impacto en el rendimiento / memoria fuera el único criterio para decidir entre withContext
y async-await
, la conclusión tendría que ser que no hay una diferencia relevante entre ellos en el 99% de los casos de uso reales.
La verdadera razón es que withContext()
una API más simple y directa, especialmente en términos de manejo de excepciones:
- Una excepción que no se maneja dentro
async { ... }
hace que se cancele su trabajo principal. Esto sucede independientemente de cómo maneje las excepciones de la coincidencia await()
. Si no ha preparado una coroutineScope
para ello, es posible que se elimine toda la aplicación.
- Una excepción que no se maneja dentro
withContext { ... }
simplemente es lanzada por la withContext
llamada, usted la maneja como cualquier otra.
withContext
también está optimizado, aprovechando el hecho de que está suspendiendo la corrutina principal y esperando al niño, pero eso es solo una ventaja adicional.
async-await
debe reservarse para aquellos casos en los que realmente desea la concurrencia, de modo que inicie varias corrutinas en segundo plano y solo luego las espere. En breve:
async-await-async-await
- no hagas eso, usa withContext-withContext
async-async-await-await
- esa es la forma de usarlo.
withContext
, siempre se crea una nueva corrutina independientemente. Esto es lo que puedo ver en el código fuente.