多数のコルーチンは軽量ですが、要求の厳しいアプリケーションでは依然として問題になる可能性があります
「コルーチンが多すぎる」というこの神話を、実際のコストを数値化することで解消したいと思います。
まず、コルーチン自体を、それがアタッチされているコルーチンコンテキストから解きほぐす必要があります。これは、最小限のオーバーヘッドでコルーチンを作成する方法です。
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
この式の値はJob
、中断されたコルーチンを保持することです。継続を維持するために、より広い範囲のリストに追加しました。
私はこのコードのベンチマークを行い、140バイトを割り当て、完了するまでに100ナノ秒かかると結論付けました。これがコルーチンの軽量化です。
再現性のために、これは私が使用したコードです:
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>>()
このコードは一連のコルーチンを開始してからスリープするため、VisualVMなどの監視ツールを使用してヒープを分析する時間があります。私は専門クラスを作成JobList
し、ContinuationList
これは、簡単にヒープダンプを分析することができているため。
より完全なストーリーを得るために、以下のコードを使用して、withContext()
とのコストも測定しました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()
}
}
これは、上記のコードから得られる典型的な出力です。
Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds
はい、async-await
約2倍の時間がかかりますwithContext
が、それでも1マイクロ秒です。それがアプリの「問題」になるためには、タイトなループでそれらを起動する必要があり、それ以外はほとんど何もしません。
を使用しmeasureMemory()
て、呼び出しごとに次のメモリコストが見つかりました。
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
のコストは、1つのコルーチンのメモリの重みとして取得した数async-await
よりも正確に140バイト高くなりますwithContext
。これは、CommonPool
コンテキストを設定するための完全なコストのほんの一部です。
パフォーマンス/メモリへの影響がとの間withContext
で決定する唯一の基準であるasync-await
場合、結論は、実際のユースケースの99%でそれらの間に関連する違いがないということである必要があります。
本当の理由はwithContext()
、特に例外処理の観点から、よりシンプルで直接的なAPIです。
- 内で処理されない例外
async { ... }
により、その親ジョブがキャンセルされます。これは、マッチングからの例外をどのように処理するかに関係なく発生しますawait()
。準備していないcoroutineScope
と、アプリケーション全体がダウンする可能性があります。
- 内で処理されない例外は、
withContext { ... }
単にwithContext
呼び出しによってスローされます。他の例外と同じように処理します。
withContext
また、親コルーチンを一時停止して子を待っているという事実を利用して最適化されていますが、これは単なる追加のボーナスです。
async-await
実際に同時実行が必要な場合のために予約する必要があります。これにより、バックグラウンドで複数のコルーチンを起動し、それらを待機します。要するに:
async-await-async-await
—それをしないで、使用する withContext-withContext
async-async-await-await
—それがそれを使用する方法です。
withContext
と、関係なく常に新しいコルーチンが作成されると思います。これは私がソースコードから見ることができるものです。