Eine große Anzahl von Coroutinen ist zwar leicht, kann jedoch bei anspruchsvollen Anwendungen immer noch ein Problem darstellen
Ich möchte diesen Mythos von "zu vielen Coroutinen" als Problem zerstreuen, indem ich ihre tatsächlichen Kosten quantifiziere.
Zunächst sollten wir die Coroutine selbst von dem Coroutine-Kontext trennen, an den sie gebunden ist. So erstellen Sie nur eine Coroutine mit minimalem Overhead:
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
Der Wert dieses Ausdrucks ist a Job
Halten einer suspendierten Coroutine. Um die Fortsetzung beizubehalten, haben wir sie einer Liste im weiteren Bereich hinzugefügt.
Ich habe diesen Code verglichen und festgestellt, dass er 140 Bytes zuweist und 100 Nanosekunden dauert . So leicht ist eine Coroutine.
Für die Reproduzierbarkeit ist dies der Code, den ich verwendet habe:
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>>()
Dieser Code startet eine Reihe von Coroutinen und schläft dann, sodass Sie Zeit haben, den Heap mit einem Überwachungstool wie VisualVM zu analysieren. Ich habe die spezialisierten Klassen erstellt JobList
und ContinuationList
weil dies die Analyse des Heap-Dumps erleichtert.
Um eine vollständigere Geschichte zu erhalten, habe ich den folgenden Code verwendet, um auch die Kosten von withContext()
und zu messen 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()
}
}
Dies ist die typische Ausgabe, die ich aus dem obigen Code erhalte:
Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds
Ja, async-await
dauert ungefähr doppelt so lange withContext
, aber es ist immer noch nur eine Mikrosekunde. Sie müssten sie in einer engen Schleife starten und fast nichts anderes tun, damit dies zu einem "Problem" in Ihrer App wird.
Mit habe measureMemory()
ich folgende Speicherkosten pro Anruf ermittelt:
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
Die Kosten für async-await
sind genau 140 Bytes höher als withContext
die Zahl, die wir als Speichergewicht einer Coroutine erhalten haben. Dies ist nur ein Bruchteil der Gesamtkosten für die Einrichtung desCommonPool
Kontexts.
Wenn die Auswirkung auf Leistung / Speicher das einzige Kriterium wäre, um zwischen withContext
und zu entscheiden async-await
, müsste die Schlussfolgerung sein, dass es in 99% der realen Anwendungsfälle keinen relevanten Unterschied zwischen ihnen gibt.
Der wahre Grund ist, dass withContext()
eine einfachere und direktere API, insbesondere im Hinblick auf die Ausnahmebehandlung:
- Eine Ausnahme, die nicht in behandelt wird, führt dazu,
async { ... }
dass der übergeordnete Job abgebrochen wird. Dies geschieht unabhängig davon, wie Sie mit Ausnahmen vom Abgleich umgehen await()
. Wenn Sie noch keine vorbereitet coroutineScope
haben, wird möglicherweise Ihre gesamte Anwendung heruntergefahren.
- Eine Ausnahme, die nicht innerhalb von behandelt wird, wird
withContext { ... }
einfach durch den withContext
Aufruf ausgelöst . Sie behandeln sie wie jede andere.
withContext
wird auch optimiert, indem die Tatsache genutzt wird, dass Sie die Coroutine der Eltern aussetzen und auf das Kind warten, aber das ist nur ein zusätzlicher Bonus.
async-await
sollte für die Fälle reserviert werden, in denen Sie tatsächlich Parallelität wünschen, damit Sie mehrere Coroutinen im Hintergrund starten und erst dann darauf warten. Zusamenfassend:
async-await-async-await
- Tu das nicht, benutze withContext-withContext
async-async-await-await
- so kann man es benutzen.
withContext
, wird immer eine neue Coroutine erstellt, unabhängig davon. Das kann ich aus dem Quellcode ersehen.