Большое количество сопрограмм, хотя и легковесное, все еще может быть проблемой в требовательных приложениях
Я бы хотел развеять этот миф о том, что «слишком много сопрограмм» является проблемой, количественно определив их фактическую стоимость.
Во-первых, мы должны отделить сопрограмму от контекста сопрограммы , к которому она присоединена. Вот как вы создаете просто сопрограмму с минимальными издержками:
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
Значение этого выражения - Job
, содержащее приостановленную сопрограмму. Чтобы сохранить продолжение, мы добавили его в список в более широкой области.
Я протестировал этот код и пришел к выводу, что он выделяет 140 байт и занимает 100 наносекунд для завершения. Так вот, насколько легкий сопрограмма.
Для воспроизводимости, это код, который я использовал:
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>>()
Этот код запускает несколько сопрограмм, а затем спит, чтобы у вас было время проанализировать кучу с помощью инструмента мониторинга, такого как VisualVM. Я создал специализированные классы JobList
и ContinuationList
, потому что это облегчает анализ дампа кучи.
Чтобы получить более полную историю, я использовал приведенный ниже код, чтобы также измерить стоимость withContext()
и 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()
}
}
Это типичный вывод, который я получаю из приведенного выше кода:
Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds
Да, async-await
занимает примерно вдвое больше времени, чем withContext
, но это все равно всего лишь микросекунда. Вам придется запускать их в тесном цикле, практически ничего не делая, чтобы это стало «проблемой» в вашем приложении.
Используя measureMemory()
Я нашел следующую стоимость памяти за звонок:
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
Стоимость async-await
ровно на 140 байт выше, чем withContext
, число, которое мы получили как вес памяти одной сопрограммы. Это всего лишь часть полной стоимости установки контекста CommonPool
.
Если влияние на производительность / память было единственным критерием при выборе между withContext
и async-await
, следует сделать вывод, что между ними нет существенной разницы в 99% случаев реального использования.
Реальная причина в том, что withContext()
более простой и прямой API, особенно в отношении обработки исключений:
- Исключение, которое не обрабатывается в
async { ... }
, приводит к отмене родительского задания. Это происходит независимо от того, как вы обрабатываете исключения из соответствующего await()
. Если вы не подготовили coroutineScope
для него, это может привести к закрытию всего вашего приложения.
- Исключение, не обработанное в
withContext { ... }
, просто вызывается вызовом withContext
, вы обрабатываете его, как и любой другой.
withContext
также оказывается оптимизированным, используя тот факт, что вы приостанавливаете родительскую сопрограмму и ожидаете ребенка, но это всего лишь дополнительный бонус.
async-await
следует зарезервировать для тех случаев, когда вы действительно хотите параллелизма, чтобы вы запускали несколько сопрограмм в фоновом режиме и только потом ожидали их. Короче говоря:
async-await-async-await
& mdash; такой же как withContext-withContext
async-async-await-await
& mdash; это способ использовать его.