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ć.
withContext, zawsze tworzony jest nowy program, niezależnie od tego. Oto, co widzę z kodu źródłowego.