많은 수의 코 루틴은 가볍지 만 까다로운 애플리케이션에서는 여전히 문제가 될 수 있습니다.
실제 비용을 정량화하여 "너무 많은 코 루틴"이 문제가된다는 신화를 없애고 싶습니다.
첫째, 코 루틴 이 연결된 코 루틴 컨텍스트 에서 코 루틴 자체를 분리해야합니다 . 이것은 최소한의 오버 헤드로 코 루틴을 만드는 방법입니다.
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
시간은 약 두 배가 걸리지 withContext
만 여전히 1 마이크로 초입니다. 앱에서 "문제"가 되려면 그 외에는 거의 아무것도하지 않고 타이트한 루프로 실행해야합니다.
사용 measureMemory()
하여 다음과 같은 호출 당 메모리 비용을 찾았습니다.
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
비용은 코 루틴 하나의 메모리 가중치로 얻은 숫자 async-await
보다 정확히 140 바이트 더 높습니다 withContext
. 이는 CommonPool
컨텍스트 를 설정하는 데 드는 전체 비용의 일부에 불과합니다 .
성능 / 메모리 영향이 withContext
와 사이를 결정하는 유일한 기준 인 async-await
경우 실제 사용 사례의 99 %에서 둘 사이에 관련성있는 차이가 없다는 결론이 내려야 합니다.
진짜 이유는 withContext()
특히 예외 처리 측면에서 더 간단하고 직접적인 API이기 때문입니다 .
- 내부에서 처리되지 않는 예외로
async { ... }
인해 상위 작업이 취소됩니다. 이것은 일치하는 예외를 처리하는 방법에 관계없이 발생합니다 await()
. 준비하지 않은 경우 coroutineScope
전체 응용 프로그램이 중단 될 수 있습니다.
- 내에서 처리되지 않은 예외는
withContext { ... }
단순히 withContext
호출에 의해 throw되며 다른 것과 마찬가지로 처리합니다.
withContext
또한 부모 코 루틴을 일시 중단하고 자식을 기다리고 있다는 사실을 활용하여 최적화되지만 이는 추가 보너스 일뿐입니다.
async-await
실제로 동시성을 원하는 경우를 위해 예약해야합니다. 그래야 백그라운드에서 여러 코 루틴을 시작한 다음 기다릴 수 있습니다. 요컨대 :
async-await-async-await
—하지 마십시오. withContext-withContext
async-async-await-await
— 그것이 그것을 사용하는 방법입니다.
withContext
항상 새로운 코 루틴이 생성됩니다. 이것이 소스 코드에서 볼 수있는 것입니다.