大量的协同程序虽然轻量级,但在要求苛刻的应用程序中仍然可能是一个问题
I'd like to dispel this myth of "too many coroutines" being a problem by quantifying their actual cost.
First, we should disentangle the coroutine itself from the coroutine context to which it is attached. This is how you create just a coroutine with minimum overhead:
GlobalScope.launch(Dispatchers.Unconfined) {
suspendCoroutine<Unit> {
continuations.add(it)
}
}
这个表达式的值是Job
,表示挂起的协程.为了保持连续性,我们将其添加到范围更广的列表中.
我对这段代码进行了基准测试,得出的结论是它分配了140 bytes个资源,需要100 nanoseconds个时间才能完成.这就是协程的轻量级.
For reproducibility, this is the code I used:
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>>()
This code starts a bunch of coroutines and then sleeps so you have time to analyze the heap with a monitoring tool like VisualVM. I created the specialized classes JobList
and ContinuationList
because this makes it easier to analyze the heap dump.
To get a more complete story, I used the code below to also measure the cost of withContext()
and 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
Yes, async-await
takes about twice as long as withContext
, but it's still just a microsecond. You'd have to launch them in a tight loop, doing almost nothing besides, for that to become "a problem" in your app.
使用measureMemory()
,我发现每次通话的内存成本如下:
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
async-await
的开销正好比withContext
高出140个字节,withContext
是我们得到的作为一个协程内存权重的数字.这只是设置CommonPool
上下文的全部成本的一小部分.
如果性能/内存影响是决定withContext
和async-await
之间的唯一标准,那么得出的结论是,在99%的实际用例中,它们之间没有相关差异.
真正的原因是,withContext()
是一种更简单、更直接的API,尤其是在异常处理方面:
- An exception that isn't handled within
async { ... }
causes its parent job to get cancelled. This happens regardless of how you handle exceptions from the matching await()
. If you haven't prepared a coroutineScope
for it, it may bring down your entire application.
- An exception not handled within
withContext { ... }
simply gets thrown by the withContext
call, you handle it just like any other.
withContext
也恰巧得到了优化,利用了暂停父协同进程并等待子进程的事实,但这只是一个额外的好处.
应该为您实际需要并发的情况保留async-await
,这样您就可以在后台启动几个协程,然后才等待它们.简而言之:
async-await-async-await
— don't do that, use withContext-withContext
async-async-await-await
— that's the way to use it.