Kotlin: withContext () vs Async-erwarten


89

Ich habe Kotlin-Dokumente gelesen und wenn ich sie richtig verstanden habe, funktionieren die beiden Kotlin-Funktionen wie folgt:

  1. withContext(context): wechselt den Kontext der aktuellen Coroutine, wenn der angegebene Block ausgeführt wird, wechselt die Coroutine zurück zum vorherigen Kontext.
  2. async(context): Startet eine neue Coroutine im angegebenen Kontext. Wenn wir .await()die zurückgegebene DeferredTask aufrufen, wird die aufrufende Coroutine angehalten und fortgesetzt, wenn der Block, der in der erzeugten Coroutine ausgeführt wird, zurückkehrt.

Nun zu den folgenden zwei Versionen von code :

Version 1:

  launch(){
    block1()
    val returned = async(context){
      block2()
    }.await()
    block3()
  }

Version 2:

  launch(){
    block1()
     val returned = withContext(context){
      block2()
    }
    block3()
  }
  1. In beiden Versionen wird block1 (), block3 () im Standardkontext (commonpool?) Ausgeführt, während block2 () im angegebenen Kontext ausgeführt wird.
  2. Die Gesamtausführung ist synchron mit der Reihenfolge block1 () -> block2 () -> block3 ().
  3. Der einzige Unterschied, den ich sehe, ist, dass Version 1 eine andere Coroutine erstellt, wobei Version 2 nur eine Coroutine ausführt, während der Kontext gewechselt wird.

Meine Fragen sind:

  1. Ist es nicht immer besser zu verwenden, withContextals async-awaitweil es funktional ähnlich ist, aber keine andere Coroutine erstellt. Eine große Anzahl von Coroutinen ist zwar leicht, kann jedoch bei anspruchsvollen Anwendungen immer noch ein Problem darstellen.

  2. Gibt es einen Fall async-await, der vorzuziehen ist withContext?

Update: Kotlin 1.2.50 verfügt jetzt über eine Codeüberprüfung, in die konvertiert werden kann async(ctx) { }.await() to withContext(ctx) { }.


Ich denke, wenn Sie verwenden withContext, wird immer eine neue Coroutine erstellt, unabhängig davon. Das kann ich aus dem Quellcode ersehen.
Stdout

@stdout Erstellt async/awaitlaut OP nicht auch eine neue Coroutine?
IgorGanapolsky

Antworten:


120

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 JobListund ContinuationListweil 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-awaitdauert 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-awaitsind genau 140 Bytes höher als withContextdie 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 withContextund 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 coroutineScopehaben, wird möglicherweise Ihre gesamte Anwendung heruntergefahren.
  • Eine Ausnahme, die nicht innerhalb von behandelt wird, wird withContext { ... }einfach durch den withContextAufruf 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-awaitsollte 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.

In Bezug auf die zusätzlichen Speicherkosten von async-await: Wenn wir verwenden withContext, wird auch eine neue Coroutine erstellt (soweit ich aus dem Quellcode ersehen kann). Glauben Sie also, dass der Unterschied möglicherweise von einem anderen Ort stammt?
stdout

1
@stdout Die Bibliothek hat sich weiterentwickelt, seit ich diese Tests durchgeführt habe. Der Code in der Antwort sollte vollständig in sich geschlossen sein. Versuchen Sie erneut, ihn zur Validierung auszuführen. asyncerstellt ein DeferredObjekt, das möglicherweise auch den Unterschied erklärt.
Marko Topolnik

~ " Um die Fortsetzung beizubehalten ". Wann müssen wir das behalten?
IgorGanapolsky

1
@IgorGanapolsky Es wird immer beibehalten, aber normalerweise nicht für den Benutzer sichtbar. Der Verlust der Fortsetzung ist gleichbedeutend mit Thread.destroy()- die Ausführung verschwindet in Luft.
Marko Topolnik

21

Ist es nicht immer besser, withContext zu verwenden, als asynchrone zu warten, da es funktional ähnlich ist, aber keine weitere Coroutine erstellt? Große Anzahl Coroutinen, obwohl geringes Gewicht bei anspruchsvollen Anwendungen immer noch ein Problem sein kann

Gibt es einen Fall, in dem Asynch-Warten auf WithContext vorzuziehen ist?

Sie sollten async / await verwenden, wenn Sie mehrere Aufgaben gleichzeitig ausführen möchten, zum Beispiel:

runBlocking {
    val deferredResults = arrayListOf<Deferred<String>>()

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "1"
    }

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "2"
    }

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "3"
    }

    //wait for all results (at this point tasks are running)
    val results = deferredResults.map { it.await() }
    println(results)
}

Wenn Sie nicht mehrere Aufgaben gleichzeitig ausführen müssen, können Sie withContext verwenden.


9

Denken Sie im Zweifelsfall wie an eine Faustregel:

  1. Wenn mehrere Aufgaben parallel ausgeführt werden müssen und das Endergebnis von der Fertigstellung aller Aufgaben abhängt, verwenden Sie async.

  2. Verwenden Sie zum Zurückgeben des Ergebnisses einer einzelnen Aufgabe withContext.


Befinden sich beide asyncund das withContextBlockieren in einem Suspend-Bereich?
IgorGanapolsky

1
@IgorGanapolsky Wenn Sie über das Blockieren des Hauptthreads sprechen asyncund withContextden Hauptthread nicht blockieren, wird der Körper der Coroutine nur angehalten, während eine lange laufende Aufgabe ausgeführt wird und auf ein Ergebnis wartet. Weitere Informationen und Beispiele finden Sie in diesem Artikel zu Medium: Async Operations mit Kotlin Coroutines .
Yogesh Umesh Vaity
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.