【问题标题】:Why threads are showing better performance than coroutines?为什么线程表现出比协程更好的性能?
【发布时间】:2018-06-14 19:58:13
【问题描述】:

我编写了 3 个简单的程序来测试协程相对于线程的性能优势。每个程序都会进行很多常见的简单计算。所有程序都彼此分开运行。除了执行时间,我还通过Visual VM IDE 插件测量了 CPU 使用率。

  1. 第一个程序使用1000-threaded 池进行所有计算。由于频繁的上下文变化,这段代码显示了与其他代码相比最差的结果 (64326 ms):

    val executor = Executors.newFixedThreadPool(1000)
    time = generateSequence {
      measureTimeMillis {
        val comps = mutableListOf<Future<Int>>()
        for (i in 1..1_000_000) {
          comps += executor.submit<Int> { computation2(); 15 }
        }
        comps.map { it.get() }.sum()
      }
    }.take(100).sum()
    println("Completed in $time ms")
    executor.shutdownNow()
    

  1. 第二个程序具有相同的逻辑,但不是1000-threaded 池,它只使用n-threaded 池(其中n 等于机器的核心数量)。它显示了更好的结果 (43939 ms) 并使用更少的线程,这也很好。

    val executor2 = Executors.newFixedThreadPool(4)
      time = generateSequence {
      measureTimeMillis {
        val comps = mutableListOf<Future<Int>>()
        for (i in 1..1_000_000) {
          comps += executor2.submit<Int> { computation2(); 15 }
        }
        comps.map { it.get() }.sum()
      }
    }.take(100).sum()
    println("Completed in $time ms")
    executor2.shutdownNow()
    

  1. 第三个程序是用协程编写的,结果差异很大(从41784 ms81101 ms)。我很困惑,不太明白为什么它们如此不同以及为什么协程有时比线程慢(考虑到小的异步计算是协程的 forte)。代码如下:

    time = generateSequence {
      runBlocking {
        measureTimeMillis {
          val comps = mutableListOf<Deferred<Int>>()
          for (i in 1..1_000_000) {
            comps += async { computation2(); 15 }
          }
          comps.map { it.await() }.sum()
        }
      }
    }.take(100).sum()
    println("Completed in $time ms")
    

我实际上阅读了很多关于这些协程以及它们如何在 kotlin 中实现的信息,但在实践中,我并没有看到它们按预期工作。我做我的基准测试错了吗?或者也许我使用错了协程?

【问题讨论】:

  • 您在协程示例中使用默认的协程调度程序(即CommonPool)。尝试使用与您在其他测试中使用的相同类型的线程池。
  • 请公布computation2()的代码。结果有点取决于你在做什么,委婉地说

标签: kotlin benchmarking kotlin-coroutines


【解决方案1】:

按照您设置问题的方式,您不应该期望协程有任何好处。在所有情况下,您都将不可分割的计算块提交给执行者。您没有利用协同程序暂停的想法,您可以在其中编写实际上被分割并分段执行的顺序代码,可能在不同的线程上。

协程的大多数用例都围绕着阻塞代码:避免你占用一个线程只做等待响应的场景。它们也可用于交错处理 CPU 密集型任务,但这是一种更特殊的情况。

我建议对 1,000,000 个涉及多个连续阻塞步骤的任务进行基准测试,例如 Roman Elizarov's KotlinConf 2017 talk

suspend fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

其中所有requestToken()createPost()processPost() 都涉及网络调用。

如果您有两种实现方式,一种使用suspend funs,另一种使用常规阻塞函数,例如:

fun requestToken() {
   Thread.sleep(1000)
   return "token"
}

对比

suspend fun requestToken() {
    delay(1000)
    return "token"
}

您会发现您甚至无法设置执行第一个版本的 1,000,000 次并发调用,如果您将数量降低到没有OutOfMemoryException: unable to create new native thread 时实际可以达到的水平,那么协程的性能优势应该是显而易见的.

如果您想探索协程对于 CPU 密集型任务的可能优势,您需要一个用例,无论您是顺序执行还是并行执行它们都不是无关紧要的。在上面的示例中,这被视为无关紧要的内部细节:在一个版本中,您运行 1,000 个并发任务,而在另一个版本中,您只使用四个,因此几乎是顺序执行。

Hazelcast Jet 是这种用例的一个示例,因为计算任务是相互依赖的:一个人的输出是另一个人的输入。在这种情况下,您不能只运行其中的一些直到完成,在一个小线程池上,您实际上必须交错它们,这样缓冲的输出就不会爆炸。如果您尝试使用或不使用协程来设置这样的场景,您会再次发现您分配的线程数与任务数一样多,或者您正在使用可挂起的协程,而后一种方法会胜出。 Hazelcast Jet 在纯 Java API 中实现了协程的精神。它的方法将极大地受益于协程编程模型,但目前它是纯 Java。

披露:本文作者属于 Jet 工程团队。

【讨论】:

    【解决方案2】:

    协程并不是为了比线程更快而设计的,它是为了降低 RAM 消耗并为异步调用提供更好的语法。

    【讨论】:

    • 但是协程的设计并不比线程慢,而且协程被设计为比线程更轻量级的事实也应该使它们更快——尽管特定的基准表明它们不是必然
    • 没有人说“协程被设计成比线程慢”。这只是一个副作用。 “轻量级”并不意味着“它应该有资格因此更快”,“轻量级”意味着它使用更少的内存。
    • 轻量级可以应用于内存或 CPU 或两者兼有 - 我并不是说轻量级总是意味着更快,但我经常将其视为副作用
    • 你仍然没有解释为什么协程比线程慢 - 你只是告诉我们不要期望那个
    • 就像旁注一样,协程实际上使用了线程,但是它们的设置方式是您可以将工作负载分散到多个线程上,同时仍然是线程安全的,因为协程可以等待其他协程完成不阻塞他们正在运行的线程。因此,它们既不比线程慢也不快,只是一些工作负载将从这个概念中受益匪浅,这使得工作负载更快,而其他工作负载则由于继承开销而变慢。
    【解决方案3】:

    协程被设计为轻量级线程。它使用较低的 RAM,因为当您执行 1,000,000 个并发例程时,它不必创建 1,000,000 个线程。协程可以帮助你优化线程的使用,让执行更有效率,你不再需要关心线程。您可以将协程视为可运行或任务,您可以将其发布到处理程序中并在线程或线程池中执行。

    【讨论】:

      猜你喜欢
      • 2011-09-26
      • 2015-02-03
      • 2010-10-20
      • 2021-10-12
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多