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

Pra*_*tic 9 benchmarking kotlin kotlin-coroutines

我编写了3个简单的程序来测试协程的线程性能优势.每个程序都进行了许多常见的简单计算.所有程序都是彼此分开运行的.除了执行时间,我通过Visual VMIDE插件测量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()
    
    Run Code Online (Sandbox Code Playgroud)

第一个计划

  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()
    
    Run Code Online (Sandbox Code Playgroud)

第二个计划

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

    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")
    
    Run Code Online (Sandbox Code Playgroud)

第三个方案

我实际上已经阅读了很多关于这些协同程序以及它们如何在kotlin中实现的内容,但实际上我并没有看到它们按预期工作.我做基准错误了吗?或者我可能使用coroutines错了?

Mar*_*nik 18

你设置问题的方式,你不应该期望从协同程序中获得任何好处.在所有情况下,您都会向执行者提交一个不可分割的计算块.你没有利用协程暂停的概念,在那里你可以编写实际上被切断并分段执行的顺序代码,可能在不同的线程上.

大多数协同程序的使用案例都围绕阻塞代码:避免你让线程不做任何事情而是等待响应的情况.它们也可能用于交错CPU密集型任务,但这是一种更加特殊的方案.

我建议对涉及几个连续阻塞步骤的1,000,000个任务进行基准测试,例如在Roman Elizarov的KotlinConf 2017讲话中:

suspend fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}
Run Code Online (Sandbox Code Playgroud)

所有的requestToken(),createPost()processPost()涉及网络电话.

如果您有两个这样的实现,一个使用suspend funs,另一个使用常规阻塞函数,例如:

fun requestToken() {
   Thread.sleep(1000)
   return "token"
}
Run Code Online (Sandbox Code Playgroud)

suspend fun requestToken() {
    delay(1000)
    return "token"
}
Run Code Online (Sandbox Code Playgroud)

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

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

Hazelcast Jet是这种用例的一个例子,因为计算任务是相互依赖的:一个输出是另一个输入.在这种情况下,您不能只运行其中的一些直到完成,在一个小线程池上,您实际上必须交错它们,以便缓冲输出不会爆炸.如果您尝试使用和不使用协同程序设置这样的场景,您将再次发现您要么分配与任务一样多的线程,要么使用可挂起的协程,后一种方法获胜.Hazelcast Jet在普通Java API中实现了协同程序的精神.这在其参考手册中讨论.它的方法将从协程编程模型中获益,但目前它是纯Java.

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


Gar*_*wzh 6

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

  • 但是协程并没有被设计成比线程慢,而且协程被设计成比线程更轻量级的事实也应该使它们有资格更快 - 尽管那个特定的基准表明它们不是必需的 (3认同)
  • 就像旁注协程实际上使用线程一样,但它们的设置方式使您可以在多个线程上分散工作负载,同时仍然是线程安全的,因为协程可以等待其他协程完成而不会阻塞它们正在运行的线程. 因此,它们既不比线程慢,也不比线程快,只是一些工作负载将从这个概念中受益匪浅,这使工作负载更快,而其他工作负载由于继承开销而变慢。 (3认同)
  • 没有人说“协程的设计比线程慢”。这只是一个副作用。“轻量级”并不意味着“它应该有资格然后变得更快”,“轻量级”意味着它使用更少的内存。 (2认同)