线程池在IO密集型和CPU密集型中选型策略

124 阅读9分钟

一、概念

池化技术是一种预先分配并管理某类资源的策略,其主要目的是促进资源的重复使用并降低系统开销。在业务系统开发中,这种策略通常应用在对系统性能有较大影响的地方,如服务器的并发处理,其中包括线程池、内存池、数据库连接池、Redis连接池、HTTP连接池等,都需要预先分配特定数量的资源实例,这些实例会在被获取使用后重新返回到池中而不是被销毁。

池化技术的主要优势:

1、资源重复使用:因为资源被预先创建并保存在池中,所以当需要这些资源时可以直接获取使用,而不需要重新创建,减少了系统的消耗。

2、资源使用限制:将资源预先分配并保存在池中,可以有效地对整个系统对资源的使用进行限制,如同设置一个资源的使用上限。

3、避免碎片化:一次性集中分配大量资源,可以避免内存碎片化的问题。

概括来说,池化技术是一种高效管理资源的方法,其核心思想是“提前准备、重复使用”,以提升系统性能并减少系统消耗。

本文主要介绍池化技术应用领域—线程池。

二、线程池

线程池的工作原理相似于操作系统的缓冲区设计。首先,线程池会预先启动若干个线程,并保持在睡眠状态,等待任务的到来。当有新的请求时,从线程池中唤醒一个线程进行处理,处理完毕后,线程再次进入睡眠状态,等待下一次的任务。

使用线程池预先创建线程的原因在于,如果在大量并发请求的情况下,若每次请求都新建一个线程,由于线程创建过程的时间开销,将导致大量时间被浪费在线程的创建和销毁上,可能产生系统拥塞,降低性能。而预先创建的线程可以立即用于处理请求,从而提高系统的响应速度和处理能力。

1、JDK线程池

JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它的核心类

    /**
     * 用给定的初始参数创建一个新的ThreadPoolExecutor。
     */
    public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
                              int maximumPoolSize,//线程池的最大线程数
                              long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                              TimeUnit unit,//时间单位
                              BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
                              ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
                              RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
                               ) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

JDK线程池的工作流程如下:

1.请求过来之后,进去线程池之后,首先查看核心线程池当前是否有空余的线程,如果有,则创建线程进行执行;

2.如果核心线程池corePoolSize没有多余的线程,那么判断WorkerQueue队列是否已经满;

 如果已满,那么进行判断进行步骤3的处理;

 如果未满,进入WorkerQueue队列,等待线程池中空余线程Thread的处理;

3.判断maxmumPoolSize是否已满

   如果没有剩余线程,直接执行线程池指定的拒绝策略;

   如果还有剩余线程,直接创建线程执行的任务,处理当前的请求;

以上流程可以发现,JDK线程池在达到核心线程数时,是优先添加队列,这样比较适合CPU密集型任务

2、tomcat线程池

Tomcat的线程池Executor除了实现Lifecycle接口外,基本和JDK的ThreadPoolExecutor一致,以前是直接继承了JDK的ThreadPoolExecutor,并改写部分逻辑,改写部分逻辑,然后再通过组合的方式使用。

主要区别是线程工厂、任务队列和拒绝策略上。

因为Tomcat的任务属于IO密集型,大概率不会长时间占用CPU资源。即期望任务堆积时,优先创建线程来处理,而不是入队,但是又不想任务被丢弃或交给调用者处理(想始终交给线程池处理)。

所以做了如下改造:

阻塞任务队列继承了LinkedBlockingQueue(无界),但是自身持有ThreadPoolExecutor的引用,又改写了插入方法:

public class TaskQueue extends LinkedBlockingQueue {

  private transient volatile ThreadPoolExecutor parent = null;  

 

  @Override

  public boolean offer(Runnable o) {

   //we can't do any checks

    if (parent==null) {

      return super.offer(o);

    }

    //we are maxed out on threads, simply queue the object

    // 核心线程数=最大线程数,无脑添加

    if (parent.getPoolSize() == parent.getMaximumPoolSize()) {

      return super.offer(o);

    }

    //we have idle threads, just add it to the queue

    // 有空闲线程(即核心线程有空闲的),则添加,核心线程自己会去取任务执行

    if (parent.getSubmittedCount()<=(parent.getPoolSize())) {

      return super.offer(o);

    }

    //if we have less threads than maximum force creation of a new thread

    // 核心线程数小于最大线程数,这里改写了JDK线程池的第4步逻辑,不是等队列满了再新建线程,而是优先新建线程

    if (parent.getPoolSize()<parent.getMaximumPoolSize()) {

      return false;

    }

    //if we reached here, we need to add it to the queue

    // 其他情况,直接添加

    return super.offer(o);

  }

}

  1. 当活跃线程数达到最大线程数,即不能再创建新线程时,将执行拒绝策略。这里对应原生JDK的第5步:

如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满,则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

但此时队列其实是没满的,只是满足了上述最后一个if (parent.getPoolSize()<parent.getMaximumPoolSize()),直接返回的false。

Tomcat的处理也是抛出异常,但是在异常处理时,又强制将任务插入队列:

@Override

public void execute(Runnable command) {

  submittedCount.incrementAndGet();

  try {

    executeInternal(command);

  } catch (RejectedExecutionException rx) {

    if (getQueue() instanceof TaskQueue) {

      // If the Executor is close to maximum pool size, concurrent

      // calls to execute() may result (due to Tomcat's use of

      // TaskQueue) in some tasks being rejected rather than queued.

      // If this happens, add them to the queue.

      final TaskQueue queue = (TaskQueue) getQueue();

      if (!queue.force(command)) {

        submittedCount.decrementAndGet();

        throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull"));

      }

    } else {

      submittedCount.decrementAndGet();

      throw rx;

    }

  }

}

强制插入force其实就是直接super.offer而已。

总结下来就是Tomcat的线程池总是优先尝试新建线程,如果达到上限了,再尝试将任务放入阻塞队列。由于是IO密集型任务,执行时间一般都不会太长,所以阻塞队列大概率不会排队太多造成OOM

此外Tomcat还自定义了线程工厂,这个比较简单,只是在新建线程时,将调用工厂的类加载器传递给线程上下文加载器

@Override

public Thread newThread(Runnable r) {

  TaskThread t = new TaskThread(group, r, namePrefix + threadNumber.getAndIncrement());

  t.setDaemon(daemon);

  t.setPriority(threadPriority);

 

  // Set the context class loader of newly created threads to be the

  // class loader that loaded this factory. This avoids retaining

  // references to web application class loaders and similar.

   

  t.setContextClassLoader(getClass().getClassLoader());

  return t;

}

总结tomcat线程池工作流程如下:

3、dubbo线程池

dubbo默认线程池配置是通过以下申明的

@SPI("fixed")

public interface ThreadPool {

 

  /**

   * 线程池

   *

   * @param url 线程参数

   * @return 线程池

   */

  @Adaptive({Constants.THREADPOOL_KEY})

  Executor getExecutor(URL url);

 

}

通过线程模型可以看出,

ThreadPool可以选择以下几种

  • fixed 固定大小线程池,启动时建立线程,不关闭,一直持有。(缺省)

  • cached 缓存线程池,空闲一分钟自动删除,需要时重建。

  • limited 可伸缩线程池,但池中的线程数只会增长不会收缩。只增长不收缩的目的是为了避免收缩时突然来了大流量引起的性能问题。

  • eager 优先创建Worker线程池。在任务数量大于corePoolSize但是小于maximumPoolSize时,优先创建Worker来处理任务。当任务数量大于maximumPoolSize时,将任务放入阻塞队列中。阻塞队列充满时抛出RejectedExecutionException。(相比于cached:cached在任务数量超过maximumPoolSize时直接抛出异常而不是将任务放入阻塞队列)

/**

** EagerThreadPool 当核心线程池都忙时,创建一个新线程处理任务而不是添加到阻塞队列*

** When the core threads are all in busy,*

** create new thread instead of putting task into blocking queue.*

*/

public class EagerThreadPool implements ThreadPool {

@Override

public Executor getExecutor(URL url) {

String name = url.getParameter(THREAD_NAME_KEY, DEFAULT_THREAD_NAME);

int cores = url.getParameter(CORE_THREADS_KEY, DEFAULT_CORE_THREADS);

int threads = url.getParameter(THREADS_KEY, Integer.MAX_VALUE);

int queues = url.getParameter(QUEUES_KEY, DEFAULT_QUEUES);

int alive = url.getParameter(ALIVE_KEY, DEFAULT_ALIVE);

// init queue and executor

TaskQueue taskQueue = new TaskQueue(queues <= 0 ? 1 : queues);

EagerThreadPoolExecutor executor = new EagerThreadPoolExecutor(cores,

threads,

alive,

TimeUnit.MILLISECONDS,

taskQueue,

new NamedInternalThreadFactory(name, true),

new AbortPolicyWithReport(name, url));

taskQueue.setExecutor(executor);

return executor;

}

以下是添加队列的主要流程,我们可以看到TaskQueue 继承LinkedBlockingQueue队列,然后重写了offer方法。我们看下这个方法,首先判断EagerThreadPoolExecutor对象是否存在,然后判断线程池当前的任务小于线程池大小,就说明空闲等待的线程,这时候,将任务放入队列,然后让线程去处理。如果当前的任务有很多,这时候判断当前线程数小于最大线程数的时候让线程池去创建线程,这些条件都不满足的时候才往队列里扔。

@Override

public boolean offer(Runnable runnable) {

if (executor == null) {

throw new RejectedExecutionException("The task queue does not have executor!");

}

int currentPoolThreadSize = executor.getPoolSize();

// have free worker. put task into queue to let the worker deal with task.

if (executor.getSubmittedTaskCount() < currentPoolThreadSize) {

return super.offer(runnable);

}

// return false to let executor create new worker.

if (currentPoolThreadSize < executor.getMaximumPoolSize()) {

//如果发现当前线程池的数量还没有到最大的maxPoolSize,返回false; 告知 ThreadPoolExecutor ,插入到任务队列失败。

//这一步,需要先配合EagerThreadPoolExecutor.execute 一块看,EagerThreadPoolExecutor和TaskQueue是深度配合的

//EagerThreadPoolExecutor.execute的主逻辑是super.execute(command); 那么又回到 ThreadPoolExecutor.execute的调度逻辑

//结合上面ThreadPoolExecutor.execute的调度逻辑,我们想一想什么时候,会调用Queue的offer方法

//是的,当EagerThreadPoolExecutor.execute执行的时候,发现corePoolSize已经满了,会先把任务offer添加到任务队列里,如果任务队列满了,拒绝添加,那么线程池,会马上开始尝试创建新的线程。

//这里直接返回false,就是强制线程池立刻马上创建线程。

return false;

}

// currentPoolThreadSize >= max

return super.offer(runnable);

}

三、总结

以上分析显示,原生JDK线程池在核心线程满载后优先将任务加入阻塞队列,若队列也满载,会尝试创建新的线程,直到达到最大线程池后执行拒绝策略,这主要适合于CPU密集型任务。相对的,Tomcat作为一种IO密集型工具,优先选择创建新的线程,只有当线程池满载后,才将任务加入阻塞队列等待。与此同时,Dubbo提供了多种线程池策略,其中EagerThreadPool与Tomcat相似,适用于IO密集型任务,而其他三种线程池则使用阻塞队列,类似于JDK原生线程池,主要应用于CPU密集型任务。因此,在实际应用时,可以根据服务提供者需求选择对应的线程池。