Grande número de corrotinas, embora leves, ainda podem ser um problema em aplicações exigentes
Eu gostaria de dissipar esse mito de que "muitas corrotinas" são um problema, quantificando seu custo real.
Primeiro, devemos separar a própria co - rotina do contexto da co - rotina ao qual ela está anexada. É assim que você cria apenas uma co-rotina com sobrecarga mínima:
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
O valor desta expressão é Job
manter uma co-rotina suspensa. Para manter a continuação, nós a adicionamos a uma lista em um escopo mais amplo.
Eu comparei esse código e concluí que ele aloca 140 bytes e leva 100 nanossegundos para ser concluído. Então é assim que uma co-rotina é leve.
Para reprodutibilidade, este é o código que usei:
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 um monte de corrotinas e depois dorme para que você tenha tempo de analisar o heap com uma ferramenta de monitoramento como o VisualVM. Eu criei as classes especializadas JobList
e ContinuationList
porque isso torna mais fácil analisar o despejo de heap.
Para obter uma história mais completa, usei o código abaixo para medir também o custo de 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()
}
}
Esta é a saída típica que obtenho do código acima:
Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds
Sim, async-await
leva o dobro do tempo withContext
, mas ainda é apenas um microssegundo. Você teria que iniciá-los em um loop fechado, sem fazer quase nada além disso, para que isso se tornasse "um problema" em seu aplicativo.
Usando measureMemory()
, encontrei o seguinte custo de memória por chamada:
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
O custo de async-await
é exatamente 140 bytes maior do que withContext
o número que obtivemos como o peso da memória de uma co-rotina. Isso é apenas uma fração do custo total de configuração do CommonPool
contexto.
Se o impacto no desempenho / memória fosse o único critério para decidir entre withContext
e async-await
, a conclusão teria de ser que não há diferença relevante entre eles em 99% dos casos de uso reais.
O verdadeiro motivo é que withContext()
uma API mais simples e direta, especialmente em termos de tratamento de exceções:
- Uma exceção que não é tratada em
async { ... }
faz com que seu trabalho pai seja cancelado. Isso acontece independentemente de como você lida com as exceções da correspondência await()
. Se você não preparou um coroutineScope
para isso, ele pode desativar todo o seu aplicativo.
- Uma exceção não tratada dentro
withContext { ... }
simplesmente é lançada pela withContext
chamada, você a trata como qualquer outro.
withContext
também acontece de ser otimizado, aproveitando o fato de que você está suspendendo a co-rotina pai e aguardando o filho, mas isso é apenas um bônus adicional.
async-await
deve ser reservado para aqueles casos em que você realmente deseja simultaneidade, de forma que você inicie várias corrotinas em segundo plano e só então espere por elas. Em resumo:
async-await-async-await
- não faça isso, use withContext-withContext
async-async-await-await
- essa é a maneira de usá-lo.
withContext
, uma nova co-rotina é sempre criada independentemente. Isso é o que posso ver no código-fonte.