Kotlin: withContext () vs Async-await

91

Czytałem dokumentację Kotlin i jeśli dobrze zrozumiałem, dwie funkcje Kotlin działają w następujący sposób:

  1. withContext(context): przełącza kontekst aktualnego programu, gdy wykonywany jest dany blok, program przełącza się z powrotem do poprzedniego kontekstu.
  2. async(context): Uruchamia nowy program w podanym kontekście i jeśli wywołasz .await()zwrócone Deferredzadanie, zawiesza on wywołujący program i wznawia działanie, gdy powróci blok wykonywany wewnątrz zrodzonego programu.

Teraz dla następujących dwóch wersji code:

Wersja 1:

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

Wersja 2:

  launch(){
    block1()
     val returned = withContext(context){
      block2()
    }
    block3()
  }
  1. W obu wersjach block1 (), block3 () wykonuje się w domyślnym kontekście (commonpool?), Gdzie jako block2 () wykonuje się w danym kontekście.
  2. Cała realizacja jest synchroniczna z kolejnością blok1 () -> blok2 () -> blok3 ().
  3. Jedyną różnicą, jaką widzę, jest to, że version1 tworzy inny program, gdzie jako wersja2 wykonuje tylko jeden program podczas przełączania kontekstu.

Moje pytania to:

  1. Czy nie zawsze lepiej jest używać withContext, niż async-awaitjest funkcjonalnie podobne, ale nie tworzy kolejnego programu. Duża liczba programów, choć lekka, może nadal stanowić problem w wymagających zastosowaniach.

  2. Czy async-awaitjest lepsza sprawa niż withContext?

Aktualizacja: Kotlin 1.2.50 ma teraz inspekcję kodu, w której można go konwertować async(ctx) { }.await() to withContext(ctx) { }.

Mangat Rai Modi
źródło
Myślę, że kiedy używasz withContext, zawsze tworzony jest nowy program, niezależnie od tego. Oto, co widzę z kodu źródłowego.
wyjście
@stdout Nie async/awaittworzy również nowego programu, zgodnie z OP?
IgorGanapolsky

Odpowiedzi:

126

Duża liczba programów, choć lekka, może nadal stanowić problem w wymagających zastosowaniach

Chciałbym rozwiać ten mit, że „zbyt wiele programów” stanowi problem, określając ich faktyczny koszt.

Najpierw powinniśmy wyodrębnić sam program z kontekstu, do którego jest dołączony. W ten sposób tworzysz tylko coroutine z minimalnym narzutem:

GlobalScope.launch(Dispatchers.Unconfined) {
    suspendCoroutine<Unit> {
        continuations.add(it)
    }
}

Wartością tego wyrażenia jest Jobtrzymanie zawieszonego programu. Aby zachować kontynuację, dodaliśmy go do listy w szerszym zakresie.

Testowałem ten kod i doszedłem do wniosku, że przydziela 140 bajtów i trwa 100 nanosekund . A więc tak lekki jest coroutine.

Aby zapewnić powtarzalność, oto kod, którego użyłem:

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>>()

Ten kod uruchamia kilka programów, a następnie usypia, więc masz czas na analizę sterty za pomocą narzędzia do monitorowania, takiego jak VisualVM. Stworzyłem wyspecjalizowane klasy JobListi ContinuationListponieważ ułatwia to analizę zrzutu sterty.


Aby uzyskać pełniejszą historię, użyłem poniższego kodu, aby zmierzyć również koszt withContext()i 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()
    }
}

Oto typowe dane wyjściowe, które otrzymuję z powyższego kodu:

Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds

Tak, async-awaittrwa około dwa razy dłużej withContext, ale to wciąż tylko mikrosekunda. Trzeba by było uruchamiać je w ciasnej pętli, prawie nic poza tym, żeby stało się to „problemem” w Twojej aplikacji.

Używając measureMemory()znalazłem następujący koszt pamięci na połączenie:

Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes

Koszt async-awaitjest dokładnie 140 bajtów wyższy niż withContextliczba, którą otrzymaliśmy jako wagę pamięci jednego programu. To tylko ułamek całkowitego kosztu konfiguracji CommonPoolkontekstu.

Gdyby wpływ na wydajność / pamięć był jedynym kryterium wyboru między withContexta async-await, wniosek musiałby być taki, że nie ma między nimi istotnej różnicy w 99% rzeczywistych przypadków użycia.

Prawdziwym powodem jest to, że withContext()prostszy i bardziej bezpośredni interfejs API, szczególnie pod względem obsługi wyjątków:

  • Wyjątek, który nie jest obsługiwany w ramach, async { ... }powoduje anulowanie jego zadania nadrzędnego. Dzieje się tak niezależnie od tego, jak obsłużysz wyjątki z dopasowywania await(). Jeśli nie przygotowałeś coroutineScopedo tego celu, może to spowodować uszkodzenie całej aplikacji.
  • Wyjątek, który nie został obsłużony w ciągu, withContext { ... }zostaje po prostu wyrzucony przez withContextwywołanie, a Ty obsługujesz go tak jak każdy inny.

withContext zdarza się również, że jest zoptymalizowany, wykorzystując fakt, że zawieszasz program rodzica i czekasz na dziecko, ale to tylko dodatkowa premia.

async-awaitpowinien być zarezerwowany dla tych przypadków, w których faktycznie chcesz współbieżności, aby uruchomić kilka programów w tle i dopiero potem na nie czekać. W skrócie:

  • async-await-async-await - nie rób tego, użyj withContext-withContext
  • async-async-await-await - w ten sposób można to wykorzystać.
Marko Topolnik
źródło
Odnośnie dodatkowego kosztu pamięci async-await: Kiedy używamy withContext, tworzony jest również nowy program (o ile widzę z kodu źródłowego), więc czy uważasz, że różnica może pochodzić z innego miejsca?
stdout
1
@stdout Biblioteka ewoluowała, odkąd przeprowadziłem te testy. Kod w odpowiedzi powinien być w pełni samowystarczalny, spróbuj uruchomić go ponownie, aby sprawdzić poprawność. asynctworzy Deferredobiekt, który może również wyjaśniać tę różnicę.
Marko Topolnik
~ „ Aby zachować kontynuację ”. Kiedy musimy to zachować?
IgorGanapolsky
1
@IgorGanapolsky Jest zawsze przechowywany, ale zwykle nie jest widoczny dla użytkownika. Utrata kontynuacji jest równoznaczna z Thread.destroy()- wykonaniem rozpływającym się w powietrzu.
Marko Topolnik
22

Czy nie zawsze lepiej jest używać withContext niż asynch-await, ponieważ jest funkcjonalnie podobny, ale nie tworzy kolejnego programu. Duże liczby rdzeni, choć lekkie, nadal mogą stanowić problem w wymagających zastosowaniach

Czy istnieje przypadek asynch-await jest lepszy niż withContext

Powinieneś używać async / await, gdy chcesz wykonywać wiele zadań jednocześnie, na przykład:

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)
}

Jeśli nie musisz jednocześnie uruchamiać wielu zadań, możesz użyć funkcji withContext.

Dmitry
źródło
13

W razie wątpliwości pamiętaj o następującej zasadzie:

  1. Jeśli wiele zadań musi być wykonywanych równolegle, a ostateczny wynik zależy od ukończenia wszystkich z nich, użyj async.

  2. Aby zwrócić wynik pojedynczego zadania, użyj withContext.

Yogesh Umesh Vaity
źródło
1
Czy oba asynci withContextblokowanie są w zawieszonym zakresie?
IgorGanapolsky
3
@IgorGanapolsky Jeśli mówisz o blokowaniu głównego wątku asynci withContextnie będziesz blokować głównego wątku, będą zawieszać treść programu tylko wtedy, gdy jakieś długotrwałe zadanie jest uruchomione i czeka na wynik. Aby uzyskać więcej informacji i zapoznać się z przykładem, zobacz ten artykuł na Medium: Async Operations with Kotlin Coroutines .
Yogesh Umesh Vaity