大量的协程,尽管很轻巧,但在苛刻的应用中仍然可能是一个问题
我想通过量化其实际成本来消除“协程过多”这个问题的神话。
首先,我们应该将协程本身从与之关联的协程上下文中解脱出来。这是您仅以最小的开销创建协程的方式:
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
,但是仍然只有一微秒。您必须紧密地启动它们,除此之外几乎什么也不做,以免成为应用程序中的“问题”。
使用 measureMemory()
我发现每个调用的以下内存成本:
Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes
的代价 async-await
正好比withContext
作为一个协程的内存权重获得的数字高140个字节。这只是建立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
,无论何时使用,总是会创建一个新的协程。这是我从源代码中可以看到的。