1

1、背景

我们使用线程池来有效地使系统工作负载与系统资源保持一致。这个系统工作负载应该是可以独立运行的任务,比如一个web应用的每一个Http请求都可以属于这个类别,我们可以处理每一个请求而不用考虑另一个Http请求。

我们期望我们的应用程序具有良好的吞吐量和良好的响应能力。为了实现这一点,首先我们应该将我们的应用程序工作划分为独立的任务,然后我们应该以有效利用 CPU、RAM(利用率)等系统资源的方式运行这些任务。通过使用线程池,目标是在有效使用系统资源的同时运行这些单独的任务。

如果忽略磁盘和网络,给定单个 CPU 资源,按顺序执行A和B总是比通过时间切片“同时”执行A和B快,这是计算的基本定律。一旦线程数超过 CPU 内核数,添加更多线程就会变慢,而不是变快。

比如在8核服务器上,理想状态下将线程数设置为 8 将提供最佳性能,超出此范围的任何事情都会由于上下文切换的开销而开始变慢。但在实际情况中不能忽略Disk和Network。

2、Java原生线程池

   关于线程池的详细实现原理:线程池的基本原理,线程池生命周期管理,具体设计等等,能想到的基本都有,非常详细;
   Java 的Executors类提供了一些不同类型的线程池;
  • static ExecutorService newSingleThreadExecutor()

    创建一个 Executor,它使用单个工作线程在无界队列中运行。
    
  • static ExecutorService newCachedThreadPool()

    newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。实现原理将corePoolSize设置为0,将maximumPoolSize设置为Integer.MAX_VALUE,使用的synchronousQueue(无界),也就是说来了任务就创建线程运行,当线程空闲超过60秒,就销毁线程。是大小无界的线程池。比如,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器。
    
  • static ExecutorService newFixedThreadPool​(int nThreads)

    创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。这个创建的线程池corePoolSize和maximum PoolSize 值是相等的,它使用的LinkedBlockingQueue(无界队列)。适用于为了满足资源管理要求,而需要限制当前线程数量的应用场景。
    
  • static ScheduledExecutorService newScheduledThreadPool(int corePoolSize)

    创建一个定长线程池,支持定时及周期性任务执行。适用于多个后台线程执行周期任务,同时为了满足资源管理的需求而限制后台线程的数量的应用场景。
    

    newSingleThreadExecutor(),因为这个池只有 1 个线程,因此我们提交到这个线程池的每个任务都是顺序工作的,没有并发,如果我们有可以独立运行的任务,这个配置在我们的应用程序吞吐量和响应能力方面并不好。

    newCachedThreadPool(),因为这个池会为提交到池的每个任务创建一个新线程或使用现有线程。对于某些场景(例如,如果我们的任务是短期任务,此池使用对于我们的独立任务可能很有意义。如果我们的任务不是短暂的,使用这种线程池会导致在应用程序上创建许多线程。如果我们创建的线程超过阈值,那么我们就不能有效地使用 CPU 资源,因为 CPU 的大部分时间都花在线程或上下文切换上,而不是真正的工作。这再次导致我们的应用程序响应能力和吞吐量下降。

    我们需要线程池newFixedThreadPool​(int nThreads),我们应该选择理想的大小来增加我们的应用程序吞吐量和响应能力(我假设我们有可以独立运行的任务)。重点是选择不要太多也不要太小。前者导致CPU花费太多时间进行线程切换而不是真正的任务,也会导致过多的内存使用问题,后者导致CPU空闲,而我们有应该处理的任务。

⚠️:只有任务都是同类型并且相互独立时,线程池的效率达到最佳

2.1、问题

2.1.1、线程饥饿死锁

在线程池中所有正在执行任务的线程都由于等待其他仍处于工作队列中的任务而阻塞
  例1:(饥饿或死锁)在单线程池中,正在执行的任务阻塞等待队列中的某个任务执行完毕
  例2:(饥饿或死锁)线程池不够大时,通过栅栏机制协调多个任务时
  例3:(饥饿)由于其他资源的隐性限制,每个任务都需要使用有限的数据库连接资源,那么不管线程池多大,都会表现出和连接资源相同的大小。

每当提交了一个有依赖性的Executor任务时,要清楚地知道可能会出现线程"饥饿"死锁,因此需要在代码或配置Executor地配置文件中记录线程池的大小限制或配置限制。以下代码对死锁的产生做了举例。

package com.flydean;

import org.junit.Test;

import java.util.concurrent.*;

public class ThreadPoolDeadlock {

    ExecutorService executorService= Executors.newSingleThreadExecutor();

    public class RenderPageTask implements Callable<String> {
        public String call() throws Exception{
            Future<String> header, footer;
            header= executorService.submit(()->{
                return "加载页眉";
            });
            footer= executorService.submit(()->{
                return "加载页脚";
            });
            return header.get()+ footer.get();
        }
    }

    public void submitTask(){
        executorService.submit(new RenderPageTask());
    }
}

产生死锁分析:

   RenderPageTask任务中有2个子任务分别是“加载页眉”和“加载页脚”。当提交RenderPageTask任务时,实际上是向线程池中添加了3个任务,但是由于线程池是单一线程池,同时只会执行一个任务,2个子任务就会在阻塞在线程池中。而RenderPageTask任务由于得不到返回,也会一直堵塞,不会释放线程资源让子线程执行。这样就导致了线程饥饿死锁。

2.1.2、运行时间较长的任务

  线程池的大小应该超过执行时间较长的任务的数量,否则可能造成线程池中线程均服务于长时间任务导致其它短时间任务也阻塞导致性能下降

缓解策略:限定任务等待资源的时间,如果等待超时,那么可以把任务标示为失败,然后中止任务或者将任务重新返回队列中以便随后执行。这样,无论任务的最终结果是否成功,这种方法都能确保任务总能继续执行下去,并将线程释放出来以执行一些能更快完成的任务。例如Thread.join、BlockingQueue.put、CountDownLatch.await以及Selector.select等

2.1.3、长短交融情况

混合了长时间运行的事务和非常短的事务的系统通常最难使用任何连接池进行调整。在这些情况下,创建两个池实例可以很好地工作(例如,一个用于长时间运行的作业另一个用于“实时”查询或者将一个运行时间较长的任务提交到单线程的Executor中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池中)。

2.1.4、使用ThreadLocal的任务

只有当线程本地值的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池的线程中不应该使用 ThreadLocal做值传递。

阿里的TransmittableThreadLocal来让线程池提交任务时进行ThreadLocal的值传递。

2.2、线程池大小设定

线程池大小调优的计算的本质是:瓶颈资源的处理时间与cpu一次任务运行时间的比值关系计算。当线程数=(瓶颈资源处理时间/cpu时间)+1时,即在瓶颈资源阻塞当前线程时,仍然有“刚刚好”个数的其他线程去处理类似的任务,此时恰好能达到cpu资源和瓶颈资源和谐共处的唯美状态。

2.2.1、相关概念

I/O密集型 (I/O-bound)

   I/O bound 指的是系统的CPU效能相对硬盘/内存的效能要好很多,此时,系统运作,大部分的状况是 CPU 在等 I/O (硬盘/内存) 的读/写,此时 CPU Loading 不高。

计算密集型 (CPU-bound)

   CPU bound 指的是系统的 硬盘/内存 效能 相对 CPU 的效能 要好很多,此时,系统运作,大部分的状况是 CPU Loading 100%,CPU 要读/写 I/O (硬盘/内存),I/O在很短的时间就可以完成,而 CPU 还有许多运算要处理,CPU Loading 很高。 
在多重程序系统中,大部份时间用来做计算、逻辑判断等CPU动作的程序称之CPU bound。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,便是属于CPU bound的任务;除此之外,加解密、压缩解压缩、搜索排序等业务也是CPU密集型的业务。

TPS(Transactions Per Second)

   概念:服务器每秒处理的事务数,一个事物是用户发起查询请求到服务器做出响应这算一次。划重点,在针对单接口,TPS 可以认为是等价于 QPS 的,如访问 ‘order.html’ 这个页面而言,是一个 TPS。而访问 ‘order.html’ 页面可能请求了 3 此服务器(如调用了 css、js、order 接口),这实际就算产生了三个 QPS

   所以,总结下就是,在针对单接口的时候 TPS = QPS ,否则 TPS 就要看实际的请求次数了。

QPS

  在一定并发度下,服务器每秒可以处理多少请求,通常我们要算的是在资源充分利用而不过度的前提下的合理QPS。

最佳线程数量

   刚好消耗完服务器的瓶颈资源的临界线程数
   备注:瓶颈资源可以是CPU,可以是内存,可以是锁资源,也可以是IO资源

响应时间

响应时间是用户请求发出和服务器返回之间的时间差。
这个过程包括DNS解析、网络数据传输、服务器计算、网络数据返回
其中,服务器计算时间又可细分为:
Web Server响应的时间;
 App Server响应的时间;
 CPU执行时间;
 线程等待时间(DB、存储、rpc调用等导致的IO等待,sleep,wait等等)

2.2.2、调优计算分析

考虑应用类型分析线程池调优:

对于混合型的应用:CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时))

对于cpu密集型的应用:在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。(为什么+1: 即使当计算密集型的线程偶尔由于缺失故障或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费。)

对于io密集型的应用:一般情况下,如果存在IO,那么肯定W/C>1(阻塞耗时一般都是计算耗时的很多倍),但是需要考虑系统内存有限(每开启一个线程都需要内存空间),这里需要上服务器测试具体多少个线程数适合(CPU占比、线程数、总耗时、内存消耗)。初始情况,保守点取1即,Nthreads=Ncpu*(1+1)=2Ncpu。

考虑响应时间角度分析线程池调优

最佳线程数量=(线程总时间/瓶颈资源时间)*瓶颈资源最佳线程数
QPS = 瓶颈资源最佳线程数*1000/线程RT,其中,RT为Response Time

举例:在一个4cpu的服务器上,有这样一个线程:
预处理数据耗时  15ms
调用rpc等待耗时 80ms
解析结果耗时    5ms

如果CPU计算为瓶颈资源,那么

   最佳线程数量 = ((RT) / RT中CPU执行时间) * CPU数量=( (15+80+5) / (15+5) ) * 4 cpu  =  20

如果调用rpc的方法加了同步锁,且这个锁是瓶颈资源,那么

   由于同步锁是个串行资源,并行数是1,所以
   最佳线程数量 = (RT / RT中的lock时间) * 1 = ((15+80+5) /80) * 1个串行锁   =   1.25

同理,以xx为瓶颈资源为例,计算最佳线程数量

   最佳线程数量=(RT/xx瓶颈资源时间)* xx瓶颈资源的线程并行数

⚠️:由于最佳线程数会随着RT变化,理论上如果缩短在瓶颈资源上消耗的RT能够提升QPS的,缩短其他RT(即非瓶颈资源)则不行。QPS最终还是取决于瓶颈资源。

考虑线程饥饿锁:

   为避免死锁而计算池大小是一个相当简单的资源分配公式:池大小 = Tn x (Cm - 1) + 1其中Tn是最大线程数,Cm是单个线程同时保持的最大连接数。

   例如,假设三个线程 ( Tn =3 ),每个线程需要四个连接来执行某个任务 ( Cm =4 )。确保永远不可能发生死锁所需的池大小是:池大小 = 3 x (4 - 1) + 1 = 10

   另一个例子,最多有八个线程 ( Tn =8 ),每个线程需要三个连接来执行某个任务 ( Cm =3 )。确保永远不可能发生死锁所需的池大小是: 池大小 = 8 x (3 - 1) + 1 = 17

总结:

由于jdk原有线程池在实际使用中实有着这样那样的问题,并且合理配置线程池数量确实是一个比较难去把控的事情,目前各大厂对线程池都有了更好的封装,并不太需要业务手动的去配置线程的数量,但是能够真正了解线程池调优的计算原理并根据实际业务情况进行计算,确实是一件比较困难的事情。


放羊的皮卡卡
4 声望0 粉丝