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 Job
trzymanie 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 JobList
i ContinuationList
ponieważ 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-await
trwa 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-await
jest dokładnie 140 bajtów wyższy niż withContext
liczba, którą otrzymaliśmy jako wagę pamięci jednego programu. To tylko ułamek całkowitego kosztu konfiguracji CommonPool
kontekstu.
Gdyby wpływ na wydajność / pamięć był jedynym kryterium wyboru między withContext
a 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ś coroutineScope
do 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 withContext
wywoł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-await
powinien 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.