About robust thread pools | Eddie'Blog
About robust thread pools

About robust thread pools

eddie 313 2021-04-14

本文转载:https://www.javacodegeeks.com/2012/03/threading-stories-about-robust-thread.html

我的主题系列的另一个博客。 这次是关于线程池,特别是健壮的线程池设置。 在Java中,线程池由 的 实现。 ThreadPoolExecutor Java 5中引入 类 该类的Javadoc组织得很好。 因此,我不遗余力地在此处进行一般性介绍。 基本上, ThreadPoolExecutor的 作用是创建和管理线程,这些线程处理由任意客户端提交到工作队列的可运行任务。 这是一种异步执行工作的机制,这在多核计算机和云计算时代是一项重要功能。

为了在广泛的上下文中有用, ThreadPoolExecutor 提供了一些可调整的参数。 很好,但是这也让我们(开发人员)决定为我们的具体案例选择正确的设置。 这是 的最大构造函数 ThreadPoolExecutor 。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) { ... }

线程池类型

就资源消耗和所导致的系统稳定性而言,上面构造器中显示的某些参数非常明智。 根据构造函数的不同参数设置,可以区分线程池的一些基本类别。 这是 提供的一些默认线程池设置 Executors 类 。

public static ExecutorService newCachedThreadPool() {
   return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                 60L, TimeUnit.SECONDS,
                                 new SynchronousQueue<Runnable>());
}
 
public static ExecutorService newFixedThreadPool(int nThreads) {
   return new ThreadPoolExecutor(nThreads, nThreads,
                                 0L, TimeUnit.MILLISECONDS,
                                 new LinkedBlockingQueue<Runnable>());
}

在“缓存的线程池”中,线程数不受限制。 这是由引起 maximumPoolSize 的 Integer.MAX_VALUE的 会同 的SynchronousQueue 。 如果将任务以突发方式提交到该线程池,则可能会为每个任务创建一个线程。 在这种情况下,创建的线程在空闲60秒后会终止。 第二个示例显示“固定线程池”,其中 maximumPoolSize 设置为特定的固定值。 池线程数永远不会超过该值。 如果任务突然爆发并且所有线程都忙,那么它们将在工作队列(这里是 排队 LinkedBlockingQueue )中 。 此固定线程池中的线程永不消亡。 无限制池的缺点很明显:两种设置都可能导致JVM内存故障( OutOfMemoryErrors 如果幸运的话,会出现 )。

让我们看一下一些有限的线程池设置:

ThreadPoolExecutor pool = 
       new ThreadPoolExecutor(0, 50, 
                              60, TimeUnit.SECONDS, 
                              new SynchronousQueue<Runnable>());
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
 
ThreadPoolExecutor pool = 
       new ThreadPoolExecutor(50, 50, 
                              0L, TimeUnit.MILLISECONDS, 
                              new LinkedBlockingQueue<Runnable>(100000));
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

第一个代码段创建了一个受缓冲的线程池,其线程数限制为50。如果任务突发,并且所有线程都忙,则 对 的调用。 ThreadPoolExecutor.execute() 现在通过发出a来拒绝 方法 RejectedExecutionException 。 通常这不是我通常想要的,因此我通过将rejected-execution-handler设置为 来更改饱和策略 CallerRunsPolicy 。 此策略将工作推回给调用者。 也就是说,发出异步执行任务的客户端线程现在将同步运行任务。 您可以通过实现自己的 来开发自己的饱和度策略 RejectedExecutionHandler 。 第二个代码段创建一个包含50个线程的固定线程池和一个工作队列,该工作队列的值限制为100000个任务。 如果工作队列已满,则饱和策略会将工作推回客户端。 高速缓存的池按需创建线程,如果线程空闲60秒,则终止线程。 固定池使线程保持活动状态。

线程池边界

如上所示,有两种定义线程池的基本方法:有界和无界线程池。 无限制的线程池(如 的默认线程池) Executors 类 可以正常工作,只要您不突发地提交太多任务即可。 如果发生这种情况,无界线程池可能会损害您的系统稳定性。 高速缓存的线程池创建了太多线程,或者固定线程池中有太多任务排队。 这封信较难实现,但仍有可能。 对于生产用途,最好将边界设置为一些有意义的值,例如最后两个线程池设置中的值。 因为定义这些“有意义的界限”可能很棘手,所以我开发了一个小程序对我有用。

/**
 * A class that calculates the optimal thread pool boundaries. It takes the desired target utilization and the desired
 * work queue memory consumption as input and retuns thread count and work queue capacity.
 * 
 * @author Niklas Schlimm
 * 
 */
public abstract class PoolSizeCalculator {
 
 /**
  * The sample queue size to calculate the size of a single {@link Runnable} element.
  */
 private final int SAMPLE_QUEUE_SIZE = 1000;
 
 /**
  * Accuracy of test run. It must finish within 20ms of the testTime otherwise we retry the test. This could be
  * configurable.
  */
 private final int EPSYLON = 20;
 
 /**
  * Control variable for the CPU time investigation.
  */
 private volatile boolean expired;
 
 /**
  * Time (millis) of the test run in the CPU time calculation.
  */
 private final long testtime = 3000;
 
 /**
  * Calculates the boundaries of a thread pool for a given {@link Runnable}.
  * 
  * @param targetUtilization
  *            the desired utilization of the CPUs (0 <= targetUtilization <= 1)
  * @param targetQueueSizeBytes
  *            the desired maximum work queue size of the thread pool (bytes)
  */
 protected void calculateBoundaries(BigDecimal targetUtilization, BigDecimal targetQueueSizeBytes) {
  calculateOptimalCapacity(targetQueueSizeBytes);
  Runnable task = creatTask();
  start(task);
  start(task); // warm up phase
  long cputime = getCurrentThreadCPUTime();
  start(task); // test intervall
  cputime = getCurrentThreadCPUTime() - cputime;
  long waittime = (testtime * 1000000) - cputime;
  calculateOptimalThreadCount(cputime, waittime, targetUtilization);
 }
 
 private void calculateOptimalCapacity(BigDecimal targetQueueSizeBytes) {
  long mem = calculateMemoryUsage();
  BigDecimal queueCapacity = targetQueueSizeBytes.divide(new BigDecimal(mem), RoundingMode.HALF_UP);
  System.out.println("Target queue memory usage (bytes): " + targetQueueSizeBytes);
  System.out.println("createTask() produced " + creatTask().getClass().getName() + " which took " + mem
    + " bytes in a queue");
  System.out.println("Formula: " + targetQueueSizeBytes + " / " + mem);
  System.out.println("* Recommended queue capacity (bytes): " + queueCapacity);
 }
 
 /**
  * Brian Goetz' optimal thread count formula, see 'Java Concurrency in Practice' (chapter 8.2)
  * 
  * @param cpu
  *            cpu time consumed by considered task
  * @param wait
  *            wait time of considered task
  * @param targetUtilization
  *            target utilization of the system
  */
 private void calculateOptimalThreadCount(long cpu, long wait, BigDecimal targetUtilization) {
  BigDecimal waitTime = new BigDecimal(wait);
  BigDecimal computeTime = new BigDecimal(cpu);
  BigDecimal numberOfCPU = new BigDecimal(Runtime.getRuntime().availableProcessors());
  BigDecimal optimalthreadcount = numberOfCPU.multiply(targetUtilization).multiply(
    new BigDecimal(1).add(waitTime.divide(computeTime, RoundingMode.HALF_UP)));
  System.out.println("Number of CPU: " + numberOfCPU);
  System.out.println("Target utilization: " + targetUtilization);
  System.out.println("Elapsed time (nanos): " + (testtime * 1000000));
  System.out.println("Compute time (nanos): " + cpu);
  System.out.println("Wait time (nanos): " + wait);
  System.out.println("Formula: " + numberOfCPU + " * " + targetUtilization + " * (1 + " + waitTime + " / "
    + computeTime + ")");
  System.out.println("* Optimal thread count: " + optimalthreadcount);
 }
 
 /**
  * Runs the {@link Runnable} over a period defined in {@link #testtime}. Based on Heinz Kabbutz' ideas
  * (http://www.javaspecialists.eu/archive/Issue124.html).
  * 
  * @param task
  *            the runnable under investigation
  */
 public void start(Runnable task) {
  long start = 0;
  int runs = 0;
  do {
   if (++runs > 5) {
    throw new IllegalStateException("Test not accurate");
   }
   expired = false;
   start = System.currentTimeMillis();
   Timer timer = new Timer();
   timer.schedule(new TimerTask() {
    public void run() {
     expired = true;
    }
   }, testtime);
   while (!expired) {
    task.run();
   }
   start = System.currentTimeMillis() - start;
   timer.cancel();
  } while (Math.abs(start - testtime) > EPSYLON);
  collectGarbage(3);
 }
 
 private void collectGarbage(int times) {
  for (int i = 0; i < times; i++) {
   System.gc();
   try {
    Thread.sleep(10);
   } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    break;
   }
  }
 }
 
 /**
  * Calculates the memory usage of a single element in a work queue. Based on Heinz Kabbutz' ideas
  * (http://www.javaspecialists.eu/archive/Issue029.html).
  * 
  * @return memory usage of a single {@link Runnable} element in the thread pools work queue
  */
 public long calculateMemoryUsage() {
  BlockingQueue<Runnable> queue = createWorkQueue();
  for (int i = 0; i < SAMPLE_QUEUE_SIZE; i++) {
   queue.add(creatTask());
  }
  long mem0 = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
  long mem1 = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
  queue = null;
  collectGarbage(15);
  mem0 = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
  queue = createWorkQueue();
  for (int i = 0; i < SAMPLE_QUEUE_SIZE; i++) {
   queue.add(creatTask());
  }
  collectGarbage(15);
  mem1 = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
  return (mem1 - mem0) / SAMPLE_QUEUE_SIZE;
 }
 
 /**
  * Create your runnable task here.
  * 
  * @return an instance of your runnable task under investigation
  */
 protected abstract Runnable creatTask();
 
 /**
  * Return an instance of the queue used in the thread pool.
  * 
  * @return queue instance
  */
 protected abstract BlockingQueue<Runnable> createWorkQueue();
 
 /**
  * Calculate current cpu time. Various frameworks may be used here, depending on the operating system in use. (e.g.
  * http://www.hyperic.com/products/sigar). The more accurate the CPU time measurement, the more accurate the results
  * for thread count boundaries.
  * 
  * @return current cpu time of current thread
  */
 protected abstract long getCurrentThreadCPUTime();
 
}

该程序将为您的工作队列的最大容量和所需的线程数找到理想的线程池边界。 该算法基于Brian Goetz和Heinz Kabutz博士的工作,您可以在Javadoc中找到引用。 计算固定线程池中的工作队列所需的容量相对简单。 您所需要的只是工作队列的目标大小(以字节为单位)除以提交的任务的平均大小(以字节为单位)。 不幸的是,计算最大线程数并不是一门精确的科学。 但是,如果在程序中使用公式,则可以避免工作队列太大和线程过多的有害极端情况。 计算理想的池大小取决于等待时间,以计算任务的时间比率。 等待时间越长,达到给定利用率所需的线程就越多。 所述 PoolSizeCalculator 要求所需的目标利用率和所需的最大工作队列内存消耗作为输入。 基于对对象大小和CPU时间的调查,它返回理想的设置,以获得最大线程数和线程池中的工作队列容量。

让我们来看一个例子。 下面的代码片段显示了如何 使用 PoolSizeCalculator 在1.0(= 100%)期望利用率和100000字节最大工作队列大小的情况下 。

public class MyPoolSizeCalculator extends PoolSizeCalculator {
 
 public static void main(String[] args) throws InterruptedException, 
                                               InstantiationException, 
                                               IllegalAccessException,
                                               ClassNotFoundException {
  MyThreadSizeCalculator calculator = new MyThreadSizeCalculator();
  calculator.calculateBoundaries(new BigDecimal(1.0), 
                                 new BigDecimal(100000));
 }
 
 protected long getCurrentThreadCPUTime() {
  return ManagementFactory.getThreadMXBean().getCurrentThreadCpuTime();
 }
 
 protected Runnable creatTask() {
  return new AsynchronousTask(0, "IO", 1000000);
 }
  
 protected BlockingQueue createWorkQueue() {
  return new LinkedBlockingQueue<>();
 }
 
}

MyPoolSizeCalculator 扩展了抽象 PoolSizeCalculator 。 您需要实现三种模板方法: getCurrentThreadCPUTime , creatTask 和 createWorkQueue 。 该代码段将标准Java管理扩展应用于CPU时间测量(第13行)。 如果JMX不够准确,则可以考虑其他框架(例如 SIGAR API )。 当任务是同构且独立时,线程池最有效。 因此,createTask方法将创建一个单一类型的Runnable任务的实例(第17行)。 将研究此任务以计算等待时间与CPU时间的比率。 最后,我需要创建一个工作队列实例来计算已提交任务的内存使用情况(第21行)。 该程序的输出显示了工作队列容量和最大池大小(线程数)的理想设置。 这些是我 I / O密集型 的结果 AsynchronousTask 在双核计算机上执行 。

Target queue memory usage (bytes): 100000  
createTask() produced com.schlimm.java7.nio.threadpools.AsynchronousTask which took 40 bytes in a queue  
Formula: 100000 / 40  
* Recommended queue capacity (bytes): 2500  
Number of CPU: 2  
Target utilization: 1.0  
Elapsed time (nanos): 3000000000  
Compute time (nanos): 906250000  
Wait time (nanos): 2093750000  
Formula: 2 * 1.0 * (1 + 2093750000 / 906250000)  
* Optimal thread count: 6.0  

“推荐的队列容量”和“最佳线程数”是重要的值。 我的 的理想设置 AsynchronousTask 如下:

ThreadPoolExecutor pool = 
       new ThreadPoolExecutor(6, 6, 
                              0L, TimeUnit.MILLISECONDS, 
                              new LinkedBlockingQueue<Runnable>(2500));
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

使用这些设置,您的工作队列不能增长到大于所需的100000字节。 而且,由于所需利用率为1.0(100%),因此使池大于6个线程是没有意义的(等待时间与计算时间之比为3 –对于每个计算时间间隔l,紧随其后的是三个等待时间间隔)。 程序的结果在很大程度上取决于您处理的任务的类型。 如果任务是同质的并且计算量很大,则程序可能会建议将池大小设置为可用CPU的数量。 但是,如果任务有等待时间,例如在I / O密集型任务中,程序将建议增加线程数以达到100%的利用率。 还要注意,某些任务在处理了一段时间后会更改其等待时间以计算时间比率,例如,如果I / O操作的文件大小增加了。 这个事实建议开发一个自调整线程池(我的后续博客之一)。 无论如何,您都应该使线程池的大小可配置,以便可以在运行时进行调整。

好的,目前就强大的线程池而言。 希望您喜欢它。 如果最大池大小的公式不是100%准确,也不要怪我。 正如我所说,这不是一门精确的科学,它是关于理想池大小的想法。

参考: 来自我们 “线程故事:关于健壮的线程池” JCG合作伙伴 Niklas的 。