为什么线程表现出比协程更好的性能?

Why threads are showing better performance than coroutines?

我写了 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. 第二个程序具有相同的逻辑,但它只使用 n-threaded 池而不是 1000-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 中实现的内容,但实际上我没有看到它们按预期工作。我的基准测试做错了吗?或者也许我错误地使用了协程?

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

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

协程的大多数用例都围绕着阻塞代码:避免占用线程除了等待响应什么都不做的情况。它们也可以用于交错 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.

披露:此 post 的作者属于 Jet 工程团队。

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