【问题标题】:Nested Futures lead to worse performance嵌套期货导致性能更差
【发布时间】:2021-06-03 11:51:48
【问题描述】:

我有一组任务。每个这样的任务通常都很小,除了一些很大的。最初我每个任务都有一个Future。然而,这导致我不得不为少数Futures 等待相当长的时间,这些Futures 在空闲时有很多CPU 的任务更大。我想通过检查任务是否超过一定大小来改变这一点,如果是,则将任务再次拆分为多个 Futures 解决子任务。然而,这会导致性能更差,因为第一组期货突然按顺序执行。没有任何任务或子任务是相关的,因此可以相互独立地解决。

以下是此行为的概念证明。在 Scala 2.13.4 中测试。

import scala.concurrent.{Await, Future, blocking}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.sys.process._

object Main {

       val workSet : List[Int] = List(
           3000,
           3000,
           3000,
           12000,
           3000,
           3000,
           3000,
           12000,
           3000,
           3000,
           3000,
           3000,
           3000,
       )

       def stupidWait(wait : Int) : Int = {
           println(s"waiting for $wait")
           val start = System.currentTimeMillis()
           while ( (start + wait) > System.currentTimeMillis()) {}
           1
       }

       def smartWait(wait : Int) : Int = {
           val work = if(wait > 3000) {
             List(3000,3000,3000,3000)
           } else {
             List(3000)
           }
           println(s"smart working with ${work}")
           val future = Future.sequence {
               work.map {
                  wait => Future { stupidWait(wait) }
               }
           }
           Await.ready(future, Inf)
           1
       }

       def time() : String = {
           "date".!!
       }

       def main(args : Array[String]) : Unit = {
           println(s"${time()} dumb start")
           val futureI = Future.sequence {
               workSet.map {
                   wait => Future { stupidWait(wait) }
               }
           }
           Await.ready(futureI,Inf)
           println(s"${time()} dumb end")
           println(s"${time()} smart start")
           val futureII = Future.sequence {
               workSet.map {
                   wait => Future { smartWait(wait) }
               }
           }
           Await.ready(futureII,Inf)
           println(s"${time()} smart end")
       }

}

输出是:

Thu Mar  4 08:09:42 PM CET 2021
 dumb start
...
Thu Mar  4 08:09:54 PM CET 2021
 dumb end
Thu Mar  4 08:09:54 PM CET 2021
 smart start
...
Thu Mar  4 08:10:39 PM CET 2021
 smart end

我原本预计智能等待至少会一样快甚至更快,因为较大的等待时间现在被分成 4 个可能并行的等待时间。然而事实并非如此。

为什么 smartWait 没有更快,我必须如何更改代码才能使 smartWait 按预期工作?

【问题讨论】:

    标签: multithreading scala future


    【解决方案1】:

    执行程序可用的线程数受限于默认情况下您拥有的核心数(并行运行比执行它们的核心更多的东西通常是没有意义的)。

    我想,你可能有 8 个内核。因此,前 8 个等待立即开始,其他 5 个等待排队。然后 3 秒后,6 个线程完成了它们的任务,并拿起了剩下的 5 个。所以,再过 3 秒后,除了两个更大的任务之外,一切都完成了,这两个任务继续旋转了 6 秒。

    现在,“(显然不是这样)智能等待”会发生什么?

    我在您的输出中添加了自开始以来的秒数和线程名称,以便更轻松地跟踪正在发生的事情:

        0: scala-execution-context-global-16: smart working with List(3000)
        0: scala-execution-context-global-18: smart working with List(3000)
        0: scala-execution-context-global-21: smart working with List(3000, 3000, 3000, 3000)
        0: scala-execution-context-global-19: smart working with List(3000)
        0: scala-execution-context-global-17: smart working with List(3000, 3000, 3000, 3000)
        0: scala-execution-context-global-14: smart working with List(3000)
        0: scala-execution-context-global-20: smart working with List(3000)
        0: scala-execution-context-global-15: smart working with List(3000)
        0: scala-execution-context-global-22: waiting for 3000
        3: scala-execution-context-global-22: waiting for 3000
        3: scala-execution-context-global-20: waiting for 3000
        6: scala-execution-context-global-22: waiting for 3000
        6: scala-execution-context-global-20: waiting for 3000
        9: scala-execution-context-global-20: waiting for 3000
        9: scala-execution-context-global-22: waiting for 3000
       12: 
    
       12: scala-execution-context-global-22: waiting for 3000
       15: scala-execution-context-global-20: waiting for 3000
       15: scala-execution-context-global-17: smart working with List(3000)
       15: scala-execution-context-global-21: waiting for 3000
       15: scala-execution-context-global-22: waiting for 3000
       18: scala-execution-context-global-20: waiting for 3000
       18: scala-execution-context-global-19: smart working with List(3000)
       18: scala-execution-context-global-16: waiting for 3000
       18: scala-execution-context-global-21: waiting for 3000
       18: scala-execution-context-global-22: smart working with List(3000)
       18: scala-execution-context-global-18: smart working with List(3000)
       21: scala-execution-context-global-17: waiting for 3000
       21: scala-execution-context-global-20: waiting for 3000
       21: scala-execution-context-global-16: smart working with List(3000)
       21: scala-execution-context-global-15: waiting for 3000
       21: scala-execution-context-global-21: waiting for 3000
    

    看,8 个“智能”任务是如何首先启动的。它们都被阻塞了,因为池中没有线程可以执行内部期货。

    然后添加线程#22(第一列的最后一行带有0)。

    注意ForkJoinPool 实现中有一些非常聪明且复杂的逻辑,用于检测池中的所有线程都被阻塞等待某个条件,并启动一个额外的线程当这种情况发生时(另一个答案中提到的blocking 用于帮助实现这一点。遵循该建议将使该测试在大约 3 秒内完成……但是当您需要实际内核时,IRL 是相当无用的)。它并非总是有效,并且不适用于所有线程池实现。如果您使用了默认以外的其他执行程序(或以某种不同的方式被阻止),那么整个过程很可能会在此时被锁定。

    因此,它检测到死锁,并启动线程 22 来解决它。这个新线程从队列中取出一个提交的期货并运行它三秒钟。这释放了线程 22 和线程 20,因此又执行了两个任务(在 3 秒标记处)。这还需要 3 秒。看起来运行的任务属于较大的项目之一,所以没有释放额外的线程,我们仍然只有 20 和 22,其他的都被阻塞了。所以他们选择另外两个任务运行 3 秒,以此类推。

    请注意,到 12 秒时,实际运行的任务只有 7 个,仅占列表的 1/3 左右。

    您可以像这样继续跟踪输出以查看进展情况。

    使用future 的一个相当普遍的经验法则是永远不要阻塞活动线程。它实际上非常危险,并且可能会锁定整个过程(正如我上面所说,我们在这里没有得到它非常幸运)。

    在未来的外部进行分裂,这样您就不必等待内部未来返回,这可能是您的情况下最简单和最安全的解决方案:

        Future.traverse(
           workSet.flatMap { 
              case 3000 => Seq(3000)
              case => Seq.fill(4)(3000)
           }
       ) { n => Future(stupidWait(n)) }
    

    或者,为了尽量减少对原始代码的更改,只需让 smartWait 返回未来而不是等待它,然后在 main 中去掉外部的 Future 并执行 Future.traverse(workSet)(smartWait)。 这应该在大约 6 秒内完成。

    【讨论】:

    • 是的,我想知道为什么smartWait 比我预期的要快得多。 GC 无法解释 45 秒,尤其是在内核空闲时。此外,运行smartWait 的父Futures 按顺序执行。这是实际的时间槽。你的回答没有解决这个问题。您是否运行过 PoC?
    • @Sim 所以,我提到的减速的原因(有限的线程可用性)是正确的,但我弄错了事件的实际顺序。我更改了答案以更正它,希望它能让事情更清楚。
    • 是的,很好的解释。现在它是有道理的。虽然这对我来说很烦人,因为这基本上意味着我需要为这个问题 IRL 找到解决方法。我想知道是否有 waitOn(future) 这样的东西告诉 FJP 当前的 Future 可以暂停,直到引用的 future(s) 完成?
    • 在我的代码中用blocking 包围stupidWait 等效项实际上解决了这个问题。但是,我不明白为什么。但此评论可能会帮助未来的用户尝试此解决方案,即使您认为它可能没有 IRL 有用。
    • @Sim Re。 “waitOn(future) 告诉 FJP 当前的 Future 可以暂停,直到引用的 future(s) 完成?” –– 这正是 Await.ready(以及更普遍的 blocking)所做的(Await 使用 @ 987654339@ 在引擎盖下)。但就像我说的,它不适用于所有执行者,即使使用 FJP,它也不会总是正常工作。此外,“欺骗”执行者相信你会阻塞,而实际上你并没有阻塞,这弊大于利:过多的线程竞争 CPU 可能比线程饥饿更糟糕的 IRL。
    【解决方案2】:

    试试:

           def stupidWait(wait : Int) : Int = blocking {
               println(s"waiting for $wait")
               val start = System.currentTimeMillis()
               while ( (start + wait) > System.currentTimeMillis()) {}
               1
           }
    

    Future 并未针对最大可能的并行性进行优化(因为这通常意味着由于上下文切换和调度程序开销而导致性能下降),而是针对最大吞吐量(使用更少的线程)进行优化,并且因为您所做的工作没有意义(您'本质上只是将电子转化为热量),你应该告诉 ExecutionContext 你在语义上是阻塞的,这允许它创建更多的线程来保持并行性。

    一个更好的解决方案是将耗时的工作分成离散的块并将它们重新提交给 ExecutionContext,它也不会假设任何关于底层线程池的功能。这样既可以处理其他工作,又可以将对代码本身的影响降到最低。

    【讨论】:

    • 这是一个概念证明。我正在做“有意义”的工作,但要发布整个问题会有点大。这种阻塞是如何工作的?你必须用阻塞包围哪个部分?
    • 在 ExecutionContext 上运行代码时,希望及时将控制权返回给 ExecutionContext,以允许其他逻辑取得进展(协同调度)——如果你想运行更重的任务,将那段代码包围在一个“阻塞”块中是合适的,因为对于其他逻辑来说,它似乎会阻塞其他任务的进度。
    • 您当然也可以这样做:import scala.concurrent.{ blocking => timeConsuming } 或等效的,如果您希望您的代码更能说明正在发生的事情。
    • “阻塞”应该是指“不使用 CPU”,而不是“耗时”...
    • @Dima 由于我创建了blockingBlockContext,我将保留赋予定义含义的权利。 ;) 从进度 PoV 来看,自旋锁、park() 和 CPU 密集型操作之间没有区别。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2017-07-09
    • 2021-03-17
    • 1970-01-01
    • 1970-01-01
    • 2021-05-04
    • 1970-01-01
    • 2017-01-20
    相关资源
    最近更新 更多