当平行流打滑时

我们在多核处理器和多任务应用程序领域生活了很长时间,并且我们知道提高性能的最明显方法是在多个线程或进程之间并行执行任务。更准确地说,首先,在处理器资源允许的范围内。但是,缺乏经验甚至经验丰富的开发人员在看似明显的情况下可能会遇到许多陷阱。在本文中,作者采用了最简单的代码,在一个线程中测量了它的性能,将其并行化,正确地期待了更好的结果,但是出了点问题...



开始一个例子



下面我们将用Java语言编写一个简单的应用程序(作者使用了Java 14,但Java 8也很好),使用应用程序内部的计数器来衡量其性能,并尝试通过在多个线程中执行代码来改善结果。重现该示例所需的全部是任何Java开发环境,或者只是jdk和visualvm实用程序,它们将帮助我们诊断出现的问题。该示例有意不使用各种基准来衡量性能和其他高级工具-在这种情况下,它们是多余的。该测试用例在Windows上的具有4个物理和8个逻辑内核的Intel Core i7处理器上运行。



因此,让我们创建一个简单的应用程序,该应用程序将循环执行一个负担处理器的计算任务,即阶乘的计算。此外,循环中的每个任务也将计算一个从1到25范围内的数字的阶乘。采用浮动范围可使示例更接近实际。以下是work()函数的代码:



void work(int power) {
        for (int i = 0; i < power; i++) {
            long result = factorial(RandomUtils.nextInt(1, 25));
        }
        if (counter.incrementAndGet() % LOG_STEP == 0) {
            System.out.printf("%d    %d %n", counter.longValue(), (long) ((System.currentTimeMillis() - startTime) / 1000));
        }
}


该函数接收输入的用于计算阶乘的循环数,该循环数由常量指定:



private static final int POWER_BASE = 1000000;


完成变量中指定的一定数量的任务后



private static final int LOG_STEP = 10;


记录已完成任务的数量及其执行的总时间。work



()函数还使用:



//    
private long startTime;
//    
private AtomicLong counter = new AtomicLong();

//    
private long factorial(int power) {
        if (power == 1) return power;
        else return power * factorial(power - 1);
}


应该注意的是,在一个线程中一次执行work()函数大约需要20毫秒,因此最后对共享计数器变量的同步调用(可能是瓶颈)不会产生问题,因为每个线程发生的次数不超过20次ms,这大大超过了counter.incrementAndGet()的执行时间。换句话说,与访问同步计数器相关联的线程之间的争用不应显着影响实验结果,因此可以忽略。



让我们在一个线程中运行以下代码,然后查看结果:



startTime = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
        work(POWER_BASE);
}


在控制台中,我们看到以下输出:



10个任务在0秒内完成

...

100个任务在2秒内完成

...

500个任务在10秒内完成



因此,在一个线程中,我们获得的性能等于每秒50个任务或每个任务20 ms。



并行化代码



如果在一个线程中获得的性能为X,那么在没有附加负载的情况下在4个处理器上获得的性能,我们可以预期性能约为4 * X,即它将提高4倍。看来很合逻辑。好吧,让我们尝试!



让我们介绍一个具有固定线程数的简单池:



private ExecutorService executorService = Executors.newFixedThreadPool(POOL_SIZE);


不变:



private static final int POOL_SIZE = 1;


我们将在1到16的范围内更改并修复结果。



重新设计启动代码:



startTime = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
        executorService.execute(() -> work(POWER_BASE));
}


默认情况下,线程池中任务队列的大小为Integer.MAX_VALUE,我们向线程池添加的任务最多为Integer.MAX_VALUE个任务,因此任务队列不应溢出。



走!



首先,让我们将POOL_SIZE常量设置为8个线程:



private static final int POOL_SIZE = 8;


运行应用程序并查看控制台:



10在3秒内

完成的任务20在6秒内

完成的任务30在8秒内

完成的任务40在10秒内

完成的任务50在14秒内

完成的任务60在16秒内

完成的任务70在19秒内完成的任务

80在20秒内

完成的任务90在23秒内

完成的任务100在24秒内

完成的任务110在26秒内

完成的任务120在28秒内

完成的任务130在29秒内

完成的任务140在31秒内

完成的任务150在33秒内

完成的任务160在36秒内

完成170个任务在46秒内完成



我们看到了什么?而不是性能的预期提高,它从每个任务的20ms下降到270ms下降了十倍以上。但这还不是全部!关于170个已完成任务的消息是日志中的最后一条消息。然后,该应用程序似乎已完全停止。



在处理造成程序异常行为的原因之前,让我们了解动态情况,并通过将POOL_SIZE常量设置为适当的值来依次删除4和16个线程的日志。



记录4个线程:



10在2秒内

完成的任务20在4秒内

完成的任务30在6秒内

完成的任务40在8秒内

完成的任务50在10秒内

完成的任务60在13秒内

完成的任务70在15秒内完成的任务

80个任务在18秒内

完成90个任务在21秒内

完成100个任务在33秒内完成



第一个90个任务与8个线程在大约同一时间完成,然后又需要12秒才能完成另外10个任务,并且应用程序挂起。



记录16个线程:



10在2秒内

完成的任务20在3秒内

完成的任务30在6秒内

完成的任务40在8秒内 完成的任务

...

290在51秒内

完成的任务300在52秒





完成的任务310在63秒内完成的任务对于310个任务,该应用程序被冻结,并且像以前的情况一样,最后10个任务花费了10秒钟以上才能完成。



让我们总结一下:



并行执行任务会导致性能下降10



或更多倍。在所有情况下,应用程序挂起,线程越少挂起速度越快(我们将回到这一事实)



寻找问题



显然,我们的代码有问题。但是,您如何找到原因呢?为此,我们将使用visualvm实用程序。我们将在执行应用程序之前启动它,并在启动应用程序后在visualvm界面中切换到所需的Java进程。该应用程序可以直接从开发环境中启动。当然,这通常是错误的,但是在我们的示例中,它不会影响结果。



首先,我们查看“监视器”选项卡,发现内存有问题。







在不到一分钟的时间内,4GB的内存就耗尽了!因此,该应用程序停止了。但是记忆去了哪里?



重新启动该应用程序,然后按“监视器”选项卡上的“堆转储”按钮。删除并打开内存转储后,我们看到:







在“按实例大小划分的类”部分中,LinkedBlockingQueue $ Node类占用了1 GB以上的空间。它不过是线程池任务队列的顶部。第二大类是将任务本身添加到线程池中。为此,在“按实例数划分的类”部分中,我们看到了第一类和第二类的实例数之间的对应关系(该匹配并不完全准确,这显然是由于首先创建了一个任务,然后才创建了新的队列顶部,并且由于时差)乘以线程数,我们在实例数上会有细微的差异)。



现在让我们数一数。我们在一个循环(Integer.MAX_VALUE)中创建大约20亿个任务,即大约2GB的任务。任务的执行速度比创建任务慢,因此队列大小不断增长。即使每个任务仅需要8个字节的内存,最大队列大小也将是:



8 * 2GB = 16GB



总堆大小为4GB,没有足够的内存也就不足为奇了。实际上,如果我们不中断应用程序的执行(该应用程序的日志停止了),过一会儿,我们将看到著名的OutOfMemoryError甚至没有visualvm,只需查看代码,我们就可以猜测内存的运行方向。



让我们记住,运行任务的线程数量越少,应用程序停止的速度就越快。我们现在可以尝试解释这一点。线程数越少,应用程序运行的速度就越快(为什么-我们尚未发现),任务队列填满和内存已满的速度也就越快。



好了,解决内存溢出问题非常简单。让我们创建一个常量而不是Integer.MaxValue:



私有静态最终int MAX_TASKS = 1024 * 1024;


让我们如下更改代码:



startTime = System.currentTimeMillis();
for (int i = 0; i < MAX_TASKS; i++) {
        executorService.execute(() -> work(POWER_BASE));
}


现在剩下的是启动应用程序,并确保一切与内存保持一致:







我们继续分析



我们再次启动我们的应用程序,逐渐增加线程数并修复结果。



1个线程-10秒内

执行500个任务2个线程-21秒内执行500个任务

4个线程-37秒内执行500个任务

8个线程-49秒内执行

500个任务16个线程-57秒内



执行500个任务如我们所见,增加时500个任务的执行时间线程数不会减少,但会增加,而10个任务的每个部分的执行速度是一致的,并且线程不再冻结。



让我们再次使用visualvm实用程序,并在应用程序运行时进行线程转储。为了获得最准确的图像,最好在16个线程上进行转储。有很多用于分析线程转储的实用程序,但是在我们的情况下,您可以在visualvm界面中滚动浏览所有名称为“ pool-1-thread-1”,“ pool-1-thread-2”等的线程,并查看以下内容:







在转储时,大多数线程会生成下一个随机数以计算阶乘。事实证明,这是最耗时的功能。那为什么?为了弄清楚,让我们进入Random.next()的源代码,然后看以下内容:



private final AtomicLong seed;

protected int next(int bits) {
        long oldseed, nextseed;
        AtomicLong seed = this.seed;
        do {
            oldseed = seed.get();
            nextseed = (oldseed * multiplier + addend) & mask;
        } while (!seed.compareAndSet(oldseed, nextseed));

        return (int)(nextseed >>> (48 - bits));
}


所有线程共享种子变量的单个实例,可以使用AtomicLong类同步对其进行访问。这意味着在生成每个随机数时,线程会排队访问该变量,而不是并行执行。因此,生产率不会提高。但是她为什么跌倒?答案很简单。在并行化执行时,会花费额外的资源来支持并行处理,特别是在线程之间切换处理器上下文时。事实证明,已经出现了额外的开销,并且线程仍然无法并行工作,因为它们竞争对种子变量值的访问,并在调用seed.compareAndSet()时排队。线程之间争夺有限资源,也许并行计算时性能下降的最常见原因。



让我们如下更改work()函数的代码:



void work(int power) {
        for (int i = 0; i < power; i++) {
            long result = factorial(20);
        }
        if (counter.incrementAndGet() % LOG_STEP == 0) {
            System.out.printf("%d    %d %n", counter.longValue(), (long) ((System.currentTimeMillis() - startTime) / 1000));
        }
}


并再次检查在不同的线程数的性能:



1个螺纹- 1000个任务17秒

2个线程-在10秒1000个任务

4个线程-在5秒内1000个任务

8个线程-在4秒1000个任务

16个线程-在4秒1000级的任务



现在结果接近我们的预期。4个线程上的性能提高了约4倍。此外,由于并行化受到处理器资源的限制,因此性能的提高实际上已停止。让我们看一下在4和8个线程上通过visualvm捕获的处理器负载图。







从图中可以看到,有4个线程,超过50%的处理器资源是空闲的,有8个线程,几乎100%的处理器都在使用。这意味着在此示例中,限制为8个线程,进一步的性能只会降低。在我们的示例中,性能增长已经在4个线程上停止,但是如果这些线程执行了同步I / O而不是计算阶乘,那么很可能会显着提高其提供性能提升的并行化极限。读者可以自己检查这一点,并将结果写在文章的评论中。



如果我们谈论实践,那么可以指出两个重要点:



并行化通常在线程数最多达到处理器核心数的2倍时有效(当然,在没有其他处理器负载的情况下)



,实际上CPU利用率不应超过80%以确保容错能力



减少线程之间的竞争



谈论性能变得无所适从,我们忘记了一件事。通过将对代码中对RandomUtils.nextInt()的调用更改为常量,我们更改了应用程序的业务逻辑。让我们回到旧算法,同时避免性能问题。我们发现调用RandomUtils.nextInt()会导致每个线程使用相同的种子变量来生成随机数,同时,这是完全可选的。在我们的示例中使用代替



RandomUtils.nextInt(1, 25)


ThreadLocalRandom类:



ThreadLocalRandom.current().nextInt(1, 25)


将解决竞争问题。现在,每个线程将使用其自己的内部变量实例来生成下一个随机数。



为每个线程使用单独的变量,而不是同步访问线程之间共享的类的单个实例,是一种通过减少线程之间的争用来提高性能的常用技术。尽管有更高级的工具,例如Mapped Diagnostic Context,但java.lang.ThreadLocal类可用于在线程上下文中存储变量的值。



最后,我想指出的是,减少线程之间的竞争不仅是一项技术任务,而且还是一项逻辑任务。在我们的示例中,每个线程都可以使用自己的变量实例而不会出现任何问题,但是如果我们需要一个实例来共享所有实例,例如共享计数器,该怎么办?在这种情况下,您将不得不重构算法本身。例如,在每个流的上下文中存储一个计数器,并根据每个流的计数器的值定期或根据请求计算总计数器的值。



结论



因此,有3点会影响并行处理的性能:



  • CPU资源
  • 线程之间的竞争
  • 其他间接影响整体结果的因素



All Articles