ExecutorService令人惊讶的性能收支平衡点 – 经验法则?

我试图弄清楚如何正确使用Java的Executors。 我意识到向ExecutorService提交任务有其自己的开销。 但是,我很惊讶它看到它的高度。

我的程序需要以尽可能低的延迟处理大量数据(股票市场数据)。 大多数计算都是相当简单的算术运算。

我试着测试一些非常简单的东西:“ Math.random() * Math.random()

最简单的测试在一个简单的循环中运行这个计算。 第二个测试在匿名Runnable中进行相同的计算(这应该衡量创建新对象的成本)。 第三个测试将Runnable传递给ExecutorService (这测量引入执行程序的成本)。

我在我的小型笔记本电脑上运行测试(2 cpus,1.5 gig ram):

 (in milliseconds) simpleCompuation:47 computationWithObjCreation:62 computationWithObjCreationAndExecutors:422 

(大约四次运行中,前两个数字最终相等)

请注意,执行程序所花费的时间远远多于在单个线程上执行的时间。 对于1到8之间的线程池大小,数字大致相同。

问题:我是否遗漏了一些明显的或预期的结果? 这些结果告诉我,我传递给执行程序的任何任务都必须进行一些非平凡的计算。 如果我正在处理数百万条消息,并且我需要对每条消息执行非常简单(且便宜)的转换,我仍然可能无法使用执行程序…尝试在多个CPU之间传播计算可能最终会比仅仅更昂贵在一个线程中完成它们。 设计决策变得比我原先想象的要复杂得多。 有什么想法吗?


 import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class ExecServicePerformance { private static int count = 100000; public static void main(String[] args) throws InterruptedException { //warmup simpleCompuation(); computationWithObjCreation(); computationWithObjCreationAndExecutors(); long start = System.currentTimeMillis(); simpleCompuation(); long stop = System.currentTimeMillis(); System.out.println("simpleCompuation:"+(stop-start)); start = System.currentTimeMillis(); computationWithObjCreation(); stop = System.currentTimeMillis(); System.out.println("computationWithObjCreation:"+(stop-start)); start = System.currentTimeMillis(); computationWithObjCreationAndExecutors(); stop = System.currentTimeMillis(); System.out.println("computationWithObjCreationAndExecutors:"+(stop-start)); } private static void computationWithObjCreation() { for(int i=0;i<count;i++){ new Runnable(){ @Override public void run() { double x = Math.random()*Math.random(); } }.run(); } } private static void simpleCompuation() { for(int i=0;i<count;i++){ double x = Math.random()*Math.random(); } } private static void computationWithObjCreationAndExecutors() throws InterruptedException { ExecutorService es = Executors.newFixedThreadPool(1); for(int i=0;i<count;i++){ es.submit(new Runnable() { @Override public void run() { double x = Math.random()*Math.random(); } }); } es.shutdown(); es.awaitTermination(10, TimeUnit.SECONDS); } } 

  1. 使用执行程序是关于利用CPU和/或CPU内核,因此如果创建一个充分利用CPU数量的线程池,则必须拥有与CPU /内核一样多的线程。
  2. 你说得对,创建新对象的成本太高了。 因此减少开支的一种方法是使用批次。 如果您知道要执行的计算的种类和数量,则可以创建批次。 因此,考虑在一个执行的任务中完成的千次计算。 您为每个线程创建批次。 计算完成后(java.util.concurrent.Future),您将创建下一批。 甚至可以在parralel中创建新批次(4个CPU – > 3个线程用于计算,1个线程用于批量配置)。 最后,您可能会获得更高的吞吐量,但内存需求更高(批量,配置)。

编辑:我改变了你的例子,我让它在我的小型双核x200笔记本电脑上运行。

 provisioned 2 batches to be executed simpleCompuation:14 computationWithObjCreation:17 computationWithObjCreationAndExecutors:9 

正如您在源代码中看到的那样,我也将批量配置和执行器生命周期从测量中取出。 与其他两种方法相比,这更公平。

自己看结果……

 import java.util.List; import java.util.Vector; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class ExecServicePerformance { private static int count = 100000; public static void main( String[] args ) throws InterruptedException { final int cpus = Runtime.getRuntime().availableProcessors(); final ExecutorService es = Executors.newFixedThreadPool( cpus ); final Vector< Batch > batches = new Vector< Batch >( cpus ); final int batchComputations = count / cpus; for ( int i = 0; i < cpus; i++ ) { batches.add( new Batch( batchComputations ) ); } System.out.println( "provisioned " + cpus + " batches to be executed" ); // warmup simpleCompuation(); computationWithObjCreation(); computationWithObjCreationAndExecutors( es, batches ); long start = System.currentTimeMillis(); simpleCompuation(); long stop = System.currentTimeMillis(); System.out.println( "simpleCompuation:" + ( stop - start ) ); start = System.currentTimeMillis(); computationWithObjCreation(); stop = System.currentTimeMillis(); System.out.println( "computationWithObjCreation:" + ( stop - start ) ); // Executor start = System.currentTimeMillis(); computationWithObjCreationAndExecutors( es, batches ); es.shutdown(); es.awaitTermination( 10, TimeUnit.SECONDS ); // Note: Executor#shutdown() and Executor#awaitTermination() requires // some extra time. But the result should still be clear. stop = System.currentTimeMillis(); System.out.println( "computationWithObjCreationAndExecutors:" + ( stop - start ) ); } private static void computationWithObjCreation() { for ( int i = 0; i < count; i++ ) { new Runnable() { @Override public void run() { double x = Math.random() * Math.random(); } }.run(); } } private static void simpleCompuation() { for ( int i = 0; i < count; i++ ) { double x = Math.random() * Math.random(); } } private static void computationWithObjCreationAndExecutors( ExecutorService es, List< Batch > batches ) throws InterruptedException { for ( Batch batch : batches ) { es.submit( batch ); } } private static class Batch implements Runnable { private final int computations; public Batch( final int computations ) { this.computations = computations; } @Override public void run() { int countdown = computations; while ( countdown-- > -1 ) { double x = Math.random() * Math.random(); } } } } 

由于以下原因,这不是对线程池的公平测试,

  1. 您根本没有利用池,因为您只有1个线程。
  2. 这项工作太简单了,无法certificate合并开销。 使用FPP在CPU上进行乘法只需要几个周期。

考虑到除了创建对象和运行作业之外,线程池必须执行以下额外步骤,

  1. 将作业放入队列中
  2. 从队列中删除作业
  3. 从池中获取线程并执行作业
  4. 将线程返回池中

当你有一个真正的工作和多个线程时,线程池的好处将是显而易见的。

我不认为这是完全现实的,因为每次进行方法调用时都要创建一个新的执行器服务。 除非您有非常奇怪的要求似乎不切实际 – 通常您在应用启动时创建服务,然后向其提交作业。

如果您再次尝试基准测试但是将服务初始化为字段 ,则在定时循环之外; 然后你会看到将Runnables提交给服务而不是自己运行它们的实际开销。

但我认为你并没有完全理解这一点 – 执行者并不是为了提高效率,他们在那里协调并将工作交给一个更简单的线程池。 它们总是比自己调用Runnable.run()效率低(因为在一天结束时执行程序服务仍然需要在事先做一些额外的内务处理之后)。 当你从需要异步处理的多个线程中使用它们时,它们真的很闪耀。

还要考虑到你正在查看一个基本固定成本的相对时间差(Executor开销是相同的,无论你的任务需要1ms还是1hr运行)与非常小的变量(你的普通可运行)相比。 如果执行程序服务额外花费5ms来运行1ms任务,那么这不是一个非常有利的数字。 如果运行5秒的任务需要额外花费5ms(例如,一个非平凡的SQL查询),那完全可以忽略不计,完全值得。

所以在某种程度上它取决于你的情况 – 如果你有一个非常时间关键的部分,运行许多小任务,不需要并行或异步执行,那么你将从执行者那里得不到任何东西。 如果您并行处理较重的任务并且想要异步响应(例如webapp),那么Executors就很棒。

它们是否是您的最佳选择取决于您的情况,但实际上您需要尝试使用真实的代表性数据进行测试。 我不认为从你所做的测试中得出任何结论是不合适的,除非你的任务真的那么微不足道(并且你不想重用执行器实例……)。

Math.random()实际上在单个随机数生成器上同步。 调用Math.random()会导致数字生成器出现重大争用。 事实上,你拥有的线程越多,它就越慢。

从Math.random()javadoc:

此方法已正确同步,以允许多个线程正确使用。 但是,如果许multithreading需要以很高的速率生成伪随机数,则可以减少每个线程拥有自己的伪随机数生成器的争用。

您提到的“开销”与ExecutorService无关,它是由Math.random上的多个线程同步引起的,从而产生锁争用。

所以是的,你错过了一些东西(下面的’正确’答案实际上并不正确)。

下面是一些Java 8代码,用于演示8个运行简单函数的线程,其中没有锁争用:

 import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.function.DoubleFunction; import com.google.common.base.Stopwatch; public class ExecServicePerformance { private static final int repetitions = 120; private static int totalOperations = 250000; private static final int cpus = 8; private static final List batches = batches(cpus); private static DoubleFunction performanceFunc = (double i) -> {return Math.sin(i * 100000 / Math.PI); }; public static void main( String[] args ) throws InterruptedException { printExecutionTime("Synchronous", ExecServicePerformance::synchronous); printExecutionTime("Synchronous batches", ExecServicePerformance::synchronousBatches); printExecutionTime("Thread per batch", ExecServicePerformance::asynchronousBatches); printExecutionTime("Executor pool", ExecServicePerformance::executorPool); } private static void printExecutionTime(String msg, Runnable f) throws InterruptedException { long time = 0; for (int i = 0; i < repetitions; i++) { Stopwatch stopwatch = Stopwatch.createStarted(); f.run(); //remember, this is a single-threaded synchronous execution since there is no explicit new thread time += stopwatch.elapsed(TimeUnit.MILLISECONDS); } System.out.println(msg + " exec time: " + time); } private static void synchronous() { for ( int i = 0; i < totalOperations; i++ ) { performanceFunc.apply(i); } } private static void synchronousBatches() { for ( Batch batch : batches) { batch.synchronously(); } } private static void asynchronousBatches() { CountDownLatch cb = new CountDownLatch(cpus); for ( Batch batch : batches) { Runnable r = () -> { batch.synchronously(); cb.countDown(); }; Thread t = new Thread(r); t.start(); } try { cb.await(); } catch (InterruptedException e) { throw new RuntimeException(e); } } private static void executorPool() { final ExecutorService es = Executors.newFixedThreadPool(cpus); for ( Batch batch : batches ) { Runnable r = () -> { batch.synchronously(); }; es.submit(r); } es.shutdown(); try { es.awaitTermination( 10, TimeUnit.SECONDS ); } catch (InterruptedException e) { throw new RuntimeException(e); } } private static List batches(final int cpus) { List list = new ArrayList(); for ( int i = 0; i < cpus; i++ ) { list.add( new Batch( totalOperations / cpus ) ); } System.out.println("Batches: " + list.size()); return list; } private static class Batch { private final int operationsInBatch; public Batch( final int ops ) { this.operationsInBatch = ops; } public void synchronously() { for ( int i = 0; i < operationsInBatch; i++ ) { performanceFunc.apply(i); } } } } 

对于25k操作(ms)的120次测试的结果时序:

  • 同步执行时间:9956
  • 同步批次执行时间:9900
  • 每批线程执行时间:2176
  • 执行人员执行时间:1922年

获奖者:执行官服务。

以下是我的机器上的结果(64位Ubuntu 14.0上的OpenJDK 8,Thinkpad W530)

 simpleCompuation:6 computationWithObjCreation:5 computationWithObjCreationAndExecutors:33 

肯定有开销。 但请记住这些数字是什么: 100k迭代的毫秒数。 在您的情况下,每次迭代的开销约为4微秒。 对我来说,开销大约是四分之一微秒。

开销是同步,内部数据结构,并且可能由于复杂的代码路径而缺乏JIT优化(当然比for循环更复杂)。

尽管有四分之一微秒的开销,你实际想要并行化的任务是值得的。


仅供参考,这将是一个非常糟糕的并行计算。 我将线程增加到8(核心数):

 simpleCompuation:5 computationWithObjCreation:6 computationWithObjCreationAndExecutors:38 

它没有让它变得更快。 这是因为Math.random()是同步的。

首先,微基准测试存在一些问题。 你做一个热身,这很好。 但是,最好多次运行测试,这应该让人感觉它是否已经真正热身以及结果的变化。 在单独的运行中对每个算法进行测试往往更好,否则在算法更改时可能会导致去优化。

任务很小,虽然我不完全确定有多小。 因此,更快的次数是毫无意义的。 在multithreading情况下,它将触及相同的易失性位置,因此线程可能导致非常糟糕的性能(每个线程使用一个Random实例)。 47毫秒的运行也有点短。

当然,为了一个微小的操作而去另一个线程并不会很快。 如果可能,将任务分成更大的尺寸。 JDK7看起来好像有一个fork-join框架,它试图通过优先在同一个线程上按顺序执行任务来支持分解和征服算法的精细任务,其中较大的任务由空闲线程拉出。

Fixed ThreadPool的最终目的是重用已经创建的线程。 因此,每次提交任务时都不需要重新创建新线程,因此可以看到性能提升。 因此,必须在提交的任务中进行停止时间。 只是在run方法的最后一个语句中。

您需要以某种方式分组执行,以便向每个线程提交更大的计算部分(例如,基于股票代码的构建组)。 通过使用Disruptor,我在类似场景中获得了最佳效果。 它的每个作业开销非常低。 对于分组工作仍然很重要,天真的循环通常会产生许多缓存未命中。

见http://java-is-the-new-c.blogspot.de/2014/01/comparision-of-different-concurrency.html