设置线程数的核心点

压测!压测!再压测!对性能要求比较高的场景,压测是最佳的方式!

并发编程适用场景

CPU 密集型

对于 CPU 密集型任务,希望最大限度地提高 CPU 利用率,但又不会因为过多的线程而压垮系统,否则会导致过多的上下文切换。

使用场景:加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。

确定线程池大小的公式如下:

线程数 = Ncpu

线程数 = Ncpu + 1

Ncpu:CPU 核数

// 获取CPU内核数量
int number = Runtime.getRuntime().availableProcessors();

对于密集型的任务,应用程序的最小线程数应该等于可用的处理器核数,如果所有任务都是密集型的,处理器的系统通常通过使用 Ncpu + 1 个线程的线程池来获得最优的利用率(计算密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作)。

在这种情况下,创建更多的线程对程序性能而言反而是不利的。因为当有多个任务处于就绪状态时,处理器核心需要在线程间频繁进行上下文切换,而这种切换对程序性能损耗较大。但如果任务被阻塞的时间大于执行时间,即该任务是 I/O 密集型的,我们就需要创建更多的线程来提高性能。

I/O密集型

对于 I/O 密集型任务,最佳线程数通常由 I/O 操作的性质和预期延迟决定。希望有足够的线程来保持 I/O 设备繁忙而不会使它们过载。

使用场景:数据库、文件的读写,网络通信等任务。这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。

当一个任务执行IO操作时,其线程被阻塞,处理器立即进行上下文切换以便处理其他就绪线程。如果只有处理器可用核心数个线程的话,则即使有待执行的任务也无法处理,因为已经拿不出更多的线程供处理器调度了。

对于包含了 I/O 和其他阻塞操作的任务,因此你需要一个更大的池子。为了正确地设置线程池的长度,你必须估算出任务花在等待的时间与用来计算的时间的比率;这个估算值不必十分精确,而且可以通过一些监控工具获得。你还可以选择另一种方法来调节线程池的大小,在一个基准负载下,使用 几种不同大小的线程池运行你的应用程序,并观察CPU利用率的水平。

公式一

线程数 = Ncpu * Ucpu * (1 + 平均等待时间 / 平均工作时间)

Ucpu:又称 CPU 利用率, 这是应用程序使用 CPU 时间的百分比。取值范围:0 <= Ucpu <= 1

线程的等待时间:线程的等待时间指的是线程在等待某个条件满足或等待其他线程完成时所花费的时间。在等待时间内,线程可能被挂起,不占用 CPU 资源。通常发生在等待某个条件、等待 I/O 操作完成、等待锁释放等情况下。在这段时间内,线程可能不执行任务。

示例:

synchronized (lock) {
    while (!condition) {
        try {
            lock.wait(); // 等待条件满足
            // 在这里,线程处于等待状态
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
// 在这里,等待完成后线程继续执行工作

线程的工作时间:线程的工作时间指的是线程正在执行任务的时间,也称为 CPU 时间。在这段时间内,线程在处理器上执行指令,执行其分配的任务。

示例:

Thread workerThread = new Thread(() -> {
    // 线程的工作时间
    // 执行任务
});

workerThread.start();
// 在这里,workerThread 正在工作

通过这个公式,可以计算出一个合理的线程数量,如果任务的平均等待时间长,线程数就随之增加;如果平均工作时间长,也就是上面的 CPU 密集型任务,线程数就随之减少。

假设 Ncpu=12,多个任务运算占总时间的 50%,可以运行的 线程数= 12 x 1 x (1 + 0.5 / 0.5) = 24 个线程。

公式二

线程数 = Ncpu / (1 - 阻塞系数) =  Ncpu * Ucpu = Ncpu * (Ucpu / (1 - 阻塞系数))

阻塞系数: 这是等待时间与服务时间的比率,它衡量线程等待 I/O 操作完成所花费的时间相对于执行计算所花费的时间,阻塞系数的取值在0和1之间(密集型任务的阻塞系数为0,而 I/O 密集型任务的阻塞系数则接近1)。

计算这些类型资源池的大小约束非常简单:首先累加出每一个任务需要的这些资源的总量,然后除以可用的总量,所得的结果是池大小的上限。线程数太少会使得程序整体性能降低,而过多的线程也会消耗内存等其他资源,所以如果想要更准确的话,必须进行压测,并监控 JVM 的线程情况以及 CPU 的负载情况,根据实际情况衡量应该创建的线程数,合理并充分利用资源。

当任务需要使用池化的资源时,比如数据库连接,那么线程池的长度和资源池的长度会相互影响。如果每一个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的有效大小;类似的,当线程池中的任务是连接池的唯一消费者时,那么线程池的大小反而又会限制了连接池的有效大小。

假设 Ncpu=12,多个任务阻塞率是 50%,可以运行的 线程数= 12 / (1 - 0.5) = 24 个线程。

综上所述得出如下结论:

如上所述的估算线程池大小公式:

线程数 = Ncpu /(1 - 阻塞系数)

对于公式一,假设 CPU 100% 运转,撇开 CPU 使用率因素,即:

线程数 = CPU核数 x (1 + 平均等待时间 / 平均工作时间)

现在假设将公式二的公式等于公式一,即

CPU核数 /(1 - 阻塞系数)= CPU核数 x (1 + 平均等待时间 / 平均工作时间)

推导出:

阻塞系数 = 平均等待时间 / (平均等待时间 + 平均工作时间)

阻塞系数 = 阻塞时间 /(阻塞时间 + 计算时间)

如下:

由于对 Web 服务的请求大部分时间都花在等待服务器响应上了,所以阻塞系数会相当高,因此程序需要开的线程数可能是处理器核心数的若干倍。

假设阻塞系数是 0.9,即每个任务 90% 的时间处于阻塞状态而只有 10% 的时间在干活,则在双核处理器上我们就需要开 20 个线程。假若有很多任务要处理的话,一个 8 核处理器上开到 80 个线程来处理该任务。所以:

  • 线程的平均工作时间所占比例越高,就需要越少的线程;
  • 线程的平均等待时间所占比例越高,就需要越多的线程;
  • 针对不同的程序,进行对应的实际测试就可以得到最合适的选择。

实际应用

下面应用中的计算都以 Ncpu = 12 做计算。

实例:假设一台服务器期望应用程序使用 50% 的可用 CPU 资源,怎么设计?

应用程序有两类任务:I/O 密集型任务和 CPU 密集型任务。

I/O 密集型任务的阻塞系数为 0.5,这意味着要花费 50% 的时间等待 I/O 操作完成。

线程数 = Ncpu * 0.5 * (1 + 0.5) = 9

CPU 密集型任务的阻塞系数为 0.1,这意味着要花费 10% 的时间等待 I/O 操作完成。

线程数 = Ncpu * 0.5 * (1 + 0.1) = 7

在此示例中,创建两个线程池,一个用于 I/O 密集型任务,另一个用于 CPU 密集型任务。I/O 密集型线程池将有 9 个线程,CPU 密集型线程池将有 7 个线程。

实例:假设一台服务器平均每秒处理一个事务的时间为 400ms,期望应用程序达到 20TPS(TPS:Transaction Per Second),怎么设计?

为了使应用程序能够达到 20TPS,首先计算出每个事务的平均处理时间,公式如下:

每个事务的平均处理时间(秒)= 每个事务的平均处理时间(毫秒)/ 1000ms

再计算每秒处理的事务量,公式如下:

每秒处理的事务量 = Ncpu * (1 / 每个事务的平均处理时间(秒))

因此,每秒处理事务的数量为:Ncpu * (1 / 0.4) = 12 * 2.5 = 30 个,可见已经高于期望目标了。

实例:假设一台服务器的平均工作时间(非阻塞时间或又称 CPU 调试时间)需要 5ms,DB 平均操作时间(I/O阻塞时间)需要 200ms,怎么设置线程大小?

一个线程的平均执行时间为:5 + 200 = 205ms。

按 CPU 使用率 100% 计算,公式如下:

线程数 = Ncpu * (1 + 平均等待时间 / 平均工作时间)

因此,线程数为: 12 * (1 + 200 / 5) = 492 个。

实例:结合上个实例,如果 DB 的上限是 1000QPS(Query Per Second),此时又该如何设置这个线程大小?

首先将每个查询的平均处理时间转换为秒,公式如下:

每个查询的平均处理时间(秒)= 每个查询的平均处理时间(毫秒)/ 1000ms

再计算每秒处理的查询量,公式如下:

每秒处理的查询量 = Ncpu * (1 / 每个查询的平均处理时间(秒))

因此,每秒处理的查询量为:Ncpu * (1 / 0.205) = 12 * 4.87 = 58.536 个。

那么如果使用上个实例算出的线程数(492),则每秒可处理的查询数量的公式如下:

每秒可处理的查询数量 = 线程数 * (1 / 每个查询的平均处理时间(秒))

因此,每秒可处理查询的数量是:492 * 1000 / 205 = 2400QPS。

此时每秒可处理查询的数量已经大于了 DB 的上限,所以线程数就要等比例减少,公式为如下:

新线程数 = 线程数 * (DB 的上限 / 每秒可处理的查询数量)

因此,新线程数为:492 * 1000 / 2400 = 205 个。
 

实例:假设一台服务器平均每秒处理一个查询的时间为 200ms,且这些查询没有 CPU 阻塞,期望应用程序达到 1000QPS(QPS:Query Per Second),怎么设计?

注意:需求已经查询表明 CPU 无阻塞的问题,可以理解为 CPU 密集型,所以就就意味着:线程数=Ncpu(如果为防止线程意外停止即:线程数 = Ncpu + 1)。

首先先将每个查询的平均处理时间转换为秒,公式如下:

每个查询的平均处理时间(秒)= 每个查询的平均处理时间(毫秒)/ 1000ms

再计算每秒处理的查询量,公式如下:

每秒处理的查询量 = Ncpu * (1 / 每个查询的平均处理时间(秒))

因此,每秒处理查询的数量为:Ncpu * (1 / 0.2) = 12 * 5 = 60 个。

由此可以看见 1000QPS 指标与单台服务器 60 个查询线程之间的差距,要想达到期望指标仅靠一台服务器是远远达不到的,这时就需要扩容服务器来达到所期望目标。

为了计算需要扩容多少台服务器,公式如下:

总服务器数量 = 每秒总查询量 / 每秒处理的查询量

因此,要想达到为 1000QPS 的指标需要 1000 / 60 = 17 台服务器才能达成目标。

实例:假设一台服务器平均每秒处理一个查询的时间为 200ms,且查询时间占总时间的 90%,期望应用程序达到 1000QPS(QPS:Query Per Second),怎么设计?

首先计算每个查询的实际处理时间,考虑到这些查询占总时间的90%,公式如下:

每个查询的实际处理时间 = 每个查询的平均处理时间 / 占总时间的比例 

因此,每个查询的实际处理时间为:200ms / 0.9 = 222.22ms

再计算每秒处理的查询量,公式如下:

每秒处理的查询量 = Ncpu * (1 / 每个查询的实际处理时间(秒))

因些,每秒处理的查询量为:Ncpu * (1 / 0.222) = 12 * 4.5 = 54 个查询。由此可以看见 1000QPS 指标与单台服务器 54 个查询线程之间的差距,要想达到期望指标仅靠一台服务器是远远达不到的,这时就需要扩容服务器来达到所期望目标。

为了计算需要扩容多少台服务器,公式如下:

总服务器数量 = 每秒总查询量 / 每秒处理的查询量

因此,要想达到为 1000QPS 的指标需要 1000 / 54 = 19 台服务器才能达成目标。

阿姆达尔定律(Amdahl)

根据上面的信息得出一个想法:如果增加 N 个 CPU 核数后性能是不是会嗖嗖嗖往上嗖?先不做问题解答,因为想要弄明白这个需要先弄明白 阿姆达尔定律(Amdahl) 是什么才能更好的理解。

什么是阿姆达尔定律(Amdahl)?

旨在用公式描述在并行计算中,多核处理器理论上能够提高多少倍速度。它代表了处理器并行运算之后效率提升的能力。

公式如下:

线程池大小设置多少比较合适?_服务器

说明:

  • S(N):是加速比(Speedup)
  • P:是并行化部分占总程序执行时间的比例
  • N:是并行化部分的处理器数目

加速比(Speedup enhanced

系统原来串行计算需要 6s,加速后只需要 3s,即:S。

计算公式如下:

加速比 = 原有运行时间(顺序执行的时间) / 并行计算加速后的时间 

因为,加速比为:6 / 3 = 2,由此可知加速比的值永远大于1。

部分提高(Fraction enhanced

部分提高是并行化部分占总程序执行时间的比例,即:P。

假若程序总共有 100 行代码,其中 50 行是可以通过并行计算的,那么这 50 行代码就是部分提高。但是实际上部分提高是一个比例数值,是并行计算代码 / 总代码量。

公式如下:

部分提高 = 串行代码 / 总代码量

因此,部分提高为:50 / 100 = 0.5,由此可见部分提高的值永远小于 1。

带入 阿姆达尔定律

分别把 部分提高 和 加速比增强 带入阿姆达尔定律中。部分提高 对应公式中的,即并行计算所占比例。加速比增强 对应,即并行节点处理个数。

加速比增强 为什么可以代替 Ncpu ?

想必这里可能有一点疑问,为什么 加速比增强 = 未加速前时间 / 加速后的时间,为什么就可以代表并行节点处理个数?

在理论上,单核处理器处理一个任务需要 200ms,那么双核处理它应该需要 100ms。时间上提速了2倍, CPU 个数上也提升了 2 倍,故两个可以替换。

总结

P 为并行计算所占比例,N 为并行节点处理个数。

当 1 - P = 0 时,没有串行,只有并行,最大加速比 S(N) = N;当 P = 0 时,只有串行,没有并行,最小加速比 S(N) = 1;当 cpu 核心数无限增多的时候,极限加速比 S(N) = 1 / (1 - P),这也就是加速比的上限。

由此我们可知,在并行系统中一味的增加运算资源,并不能永远成倍的提升系统整体性能。

实际应用

实例:假设查询操作占整体系统运行流程比例的 50%(P=0.5),并且为了保持 1000QPS,想要计算提升比例?

可以考虑增加并行处理的处理器数目,即 N。

公式如下:

线程池大小设置多少比较合适?_阿姆达尔定律_02

现在,我们可以通过计算不同 N 值下的加速比,找到一个合适的 N 值以实现整体性能提升。例如,当 N=12 时,公式如下:

线程池大小设置多少比较合适?_服务器_03

以上结果说明即使保证了 1000QPS,整体提升比例仍不超过2倍。

实例:假设查询操作占整体系统运行流程比例的 90%(P=0.9),并且为了保持 1000QPS,想要计算提升比例?

可以考虑增加并行处理的处理器数目,即 N。

公式如下:

线程池大小设置多少比较合适?_线程池大小_04

现在,我们可以通过计算不同 N 值下的加速比,找到一个合适的 N 值以实现整体性能提升。例如,当 N=12 时,公式如下:

线程池大小设置多少比较合适?_线程分配大小_05

以上结果说明即使保证了 1000QPS,整体提升比例可以达到近10 倍。

通过计算不同 N 值下的加速比,你可以看到整体系统的性能提升。