Sejumlah besar coroutine, meski ringan, masih bisa menjadi masalah dalam aplikasi yang menuntut
Saya ingin menghilangkan mitos tentang "terlalu banyak coroutine" yang menjadi masalah dengan menghitung biaya sebenarnya.
Pertama, kita harus memisahkan coroutine itu sendiri dari konteks coroutine yang dilampirkan. Ini adalah cara Anda membuat coroutine dengan overhead minimum:
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
Nilai dari ekspresi ini adalah Job
holding coroutine yang ditangguhkan. Untuk mempertahankan kelanjutan, kami menambahkannya ke daftar dalam cakupan yang lebih luas.
Saya membandingkan kode ini dan menyimpulkan bahwa ia mengalokasikan 140 byte dan membutuhkan 100 nanodetik untuk menyelesaikannya. Jadi, begitulah ringannya coroutine.
Untuk reproduktifitas, ini adalah kode yang saya gunakan:
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>>()
Kode ini memulai sekumpulan coroutine dan kemudian tidur sehingga Anda punya waktu untuk menganalisis heap dengan alat pemantauan seperti VisualVM. Saya membuat kelas khusus JobList
dan ContinuationList
karena ini membuatnya lebih mudah untuk menganalisis heap dump.
Untuk mendapatkan cerita yang lebih lengkap, saya menggunakan kode di bawah ini untuk mengukur juga biaya withContext()
dan 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()
}
}
Ini adalah keluaran khas yang saya dapatkan dari kode di atas:
Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds
Ya, async-await
membutuhkan waktu sekitar dua kali lebih lama withContext
, tetapi itu masih hanya satu mikrodetik. Anda harus meluncurkannya dalam putaran yang ketat, hampir tidak melakukan apa pun selain itu, agar hal itu menjadi "masalah" di aplikasi Anda.
Menggunakan measureMemory()
saya menemukan biaya memori berikut per panggilan:
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
Biayanya async-await
tepat 140 byte lebih tinggi dari withContext
, angka yang kami dapatkan sebagai bobot memori satu coroutine. Ini hanyalah sebagian kecil dari biaya lengkap untuk menyiapkan CommonPool
konteks.
Jika pengaruh kinerja / memori adalah satu-satunya kriteria untuk memutuskan antara withContext
dan async-await
, kesimpulannya adalah bahwa tidak ada perbedaan yang relevan di antara keduanya dalam 99% kasus penggunaan nyata.
Alasan sebenarnya adalah withContext()
API yang lebih sederhana dan lebih langsung, terutama dalam hal penanganan pengecualian:
- Pengecualian yang tidak ditangani dalam
async { ... }
menyebabkan pekerjaan induknya dibatalkan. Ini terjadi terlepas dari bagaimana Anda menangani pengecualian dari pencocokan await()
. Jika Anda belum menyiapkannya coroutineScope
, ini dapat menurunkan seluruh aplikasi Anda.
- Pengecualian yang tidak ditangani dalam
withContext { ... }
hanya dilemparkan oleh withContext
panggilan, Anda menanganinya seperti yang lain.
withContext
juga kebetulan dioptimalkan, memanfaatkan fakta bahwa Anda menangguhkan coroutine induk dan menunggu turunannya, tetapi itu hanya bonus tambahan.
async-await
harus disediakan untuk kasus-kasus di mana Anda benar-benar menginginkan konkurensi, sehingga Anda meluncurkan beberapa coroutine di latar belakang dan baru kemudian menunggunya. Pendeknya:
async-await-async-await
- jangan lakukan itu, gunakan withContext-withContext
async-async-await-await
- begitulah cara menggunakannya.
withContext
, coroutine baru selalu dibuat apa pun. Inilah yang dapat saya lihat dari kode sumber.