面试官问:高并发下,你都怎么选择最优的线程数?

点击上方 Java后端选择 设为星标

优质文章,及时送达


来源 | https://urlify.cn/YFbMNf

为了加快程序处理速度,我们会将问题分解成若干个并发执行的任务。并且创建线程池,将任务委派给线程池中的线程,以便使它们可以并发地执行。在高并发的情况下采用线程池,可以有效降低线程创建释放的时间花销及资源开销,如不使用线程池,有可能造成系统创建大量线程而导致消耗完系统内存以及 “过度切换”(在 JVM 中采用的处理机制为时间片轮转,减少了线程间的相互切换) 。

但是有一个很大的问题摆在我们面前,即我们希望尽可能多地创建任务,但由于资源所限我们又不能创建过多的线程。那么在高并发的情况下,我们怎么选择最优的线程数量呢?选择原则又是什么呢?

一、理论分析

关于如何计算并发线程数,有两种说法。

第一种,《Java Concurrency in Practice》即《java 并发编程实践》8.2 节 170 页

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

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

给定下列定义:

Ncpu = CPU的数量
Ucpu = 目标CPU的使用率, 0 <= Ucpu <= 1
W/C = 等待时间与计算时间的比率
为保持处理器达到期望的使用率,最优的池的大小等于:
Nthreads = Ncpu x Ucpu x (1 + W/C)

你可以使用 Runtime 来获得 CPU 的数目:

int N_CPUS = Runtime.getRuntime().availableProcessors();

当然,CPU 周期并不是唯一你可以使用线程池管理的资源。其他可以约束资源池大小的资源包括:内存、文件句柄、套接字句柄和数据库连接等。计算这些类型资源池的大小约束非常简单:首先累加出每一个任务需要的这些资源的总童,然后除以可用的总量。所得的结果是池大小的上限。

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

如上,在《Java Concurrency in Practice》一书中,给出了估算线程池大小的公式:

Nthreads = Ncpu x Ucpu x (1 + W/C),其中
Ncpu = CPU核心数
Ucpu = CPU使用率,0~1
W/C = 等待时间与计算时间的比率

第二种,《Programming Concurrency on the JVM Mastering》即《Java 虚拟机并发编程》2.1 节 12 页

为了解决上述难题,我们希望至少可以创建处理器核心数那么多个线程。这就保证了有尽可能多地处理器核心可以投入到解决问题的工作中去。通过下面的代码,我们可以很容易地获取到系统可用的处理器核心数:

Runtime.getRuntime().availableProcessors();

所以,应用程序的最小线程数应该等于可用的处理器核数。如果所有的任务都是计算密集型的,则创建处理器可用核心数那么多个线程就可以了。在这种情况下,创建更多的线程对程序性能而言反而是不利的。因为当有多个仟务处于就绪状态时,处理器核心需要在线程间频繁进行上下文切换,而这种切换对程序性能损耗较大。但如果任务都是 IO 密集型的,那么我们就需要开更多的线程来提高性能。

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

如果任务有 50% 的时间处于阻塞状态,则程序所需线程数为处理器可用核心数的两倍。如果任务被阻塞的时间少于 50%,即这些任务是计算密集型的,则程序所需线程数将随之减少,但最少也不应低于处理器的核心数。如果任务被阻塞的时间大于执行时间,即该任务是 IO 密集型的,我们就需要创建比处理器核心数大几倍数量的线程。我们可以计算出程序所需线程的总数,总结如下:

  • 线程数 = CPU 可用核心数 /(1 - 阻塞系数),其中阻塞系数的取值在 0 和 1 之间。

  • 计算密集型任务的阻塞系数为 0,而 IO 密集型任务的阻塞系数则接近 1。一个完全阻塞的任务是注定要挂掉的,所以我们无须担心阻塞系数会达到 1。

为了更好地确定程序所需线程数,我们需要知道下面两个关键参数:

  • 处理器可用核心数;

  • 任务的阻塞系数;

第一个参数很容易确定,我们甚至可以用之前的方法在运行时查到这个值。但确定阻塞系数就稍微困难一些。我们可以先试着猜测,抑或采用一些性能分析工具或 java.lang.management API 来确定线程花在系统 IO 操作上的时间与 CPU 密集任务所耗时间的比值。如上,在《Programming Concurrency on the JVM Mastering》一书中,给出了估算线程池大小的公式:

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

对于说法一,假设 CPU 100% 运转,即撇开 CPU 使用率这个因素,线程数 = Ncpu x (1 + W/C)。

现在假设将方法二的公式等于方法一公式,即 Ncpu /(1 - 阻塞系数)= Ncpu x (1 + W/C),推导出:阻塞系数 = W / (W + C),即阻塞系数 = 阻塞时间 /(阻塞时间 + 计算时间),这个结论在方法二后续中得到印证,如下:

由于对 Web 服务的请求大部分时间都花在等待服务器响应上了,所以阻塞系数会相当高,因此程序需要开的线程数可能是处理器核心数的若干倍。假设阻塞系数是 0.9,即每个任务 90% 的时间处于阻塞状态而只有 10% 的时间在干活,则在双核处理器上我们就需要开 20 个线程(使用第 2.1 节的公式计算)。如果有很多只股票要处理的话,我们可以在 8 核处理器上开到 80 个线程来处理该任务。

由此可见,说法一和说法二其实是一个公式。

二、实际应用

那么实际使用中并发线程数如何设置呢?我们先看一道题目:

假设要求一个系统的 TPS(Transaction Per Second 或者 Task Per Second)至少为 20,然后假设每个 Transaction 由一个线程完成,继续假设平均每个线程处理一个 Transaction 的时间为 4s。那么问题转化为:

如何设计线程池大小,使得可以在 1s 内处理完 20 个 Transaction?

计算过程很简单,每个线程的处理能力为 0.25TPS,那么要达到 20TPS,显然需要 20/0.25=80 个线程。

这个理论上成立的,但是实际情况中,一个系统最快的部分是 CPU,所以决定一个系统吞吐量上限的是 CPU。增强 CPU 处理能力,可以提高系统吞吐量上限。在考虑时需要把 CPU 吞吐量加进去。

分析如下(我们以说法一公式为例):

Nthreads = Ncpu x (1 + W/C)

即线程等待时间所占比例越高,需要越多线程。线程 CPU 时间所占比例越高,需要越少线程。这就可以划分成两种任务类型:

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

计算密集型 假设没有等待 W = 0,则 W/C = 0。Nthreads = Ncpu。

根据短板效应,真实的系统吞吐量并不能单纯根据 CPU 来计算。那要提高系统吞吐量,就需要从 “系统短板”(比如网络延迟、IO)着手:

  • 尽量提高短板操作的并行化比率,比如多线程下载技术;

  • 增强短板能力,比如用 NIO 替代 IO;

第一条可以联系到 Amdahl 定律,这条定律定义了串行系统并行化后的加速比计算公式:加速比 = 优化前系统耗时 / 优化后系统耗时 加速比越大,表明系统并行化的优化效果越好。Addahl 定律还给出了系统并行度、CPU 数目和加速比的关系,加速比为 Speedup,系统串行化比率(指串行执行代码所占比率)为 F,CPU 数目为 N:Speedup <= 1 / (F + (1-F)/N)

当 N 足够大时,串行化比率 F 越小,加速比 Speedup 越大。

这时候又抛出是否线程池一定比但线程高效的问题?

答案是否定的,比如 Redis 就是单线程的,但它却非常高效,基本操作都能达到十万量级 / s。从线程这个角度来看,部分原因在于:

  • 多线程带来线程上下文切换开销,单线程就没有这种开销;

  • 锁;

当然 “Redis 很快” 更本质的原因在于:

Redis 基本都是内存操作,这种情况下单线程可以很高效地利用 CPU。而多线程适用场景一般是:存在相当比例的 IO 和网络操作。

总的来说,应用情况不同,采取多线程 / 单线程策略不同;线程池情况下,不同的估算,目的和出发点是一致的。

至此结论为:

IO 密集型 = 2Ncpu(可以测试后自己控制大小,2Ncpu 一般没问题)(常出现于线程中:数据库数据交互、文件上传下载、网络数据传输等等)

计算密集型 = Ncpu(常出现于线程中:复杂算法)

当然说法一中还有一种说法:

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

即,计算密集型 = Ncpu + 1,但是这种做法导致的多一个 CPU 上下文切换是否值得,这里不考虑。读者可自己考量。


-END-

如果看到这里,说明你喜欢这篇文章,请 转发、点赞。同时 标星(置顶)本公众号可以第一时间接受到博文推送。

1. 什么是集群?什么又是负载均衡?

2. 5 款非常好用的 Docker 工具

3. 永远不要在代码中使用「User」这个单词!

4. Spring Boot+Redis实现接口自动幂等

最近整理一份面试资料《Java技术栈学习手册》,覆盖了Java技术、面试题精选、Spring全家桶、Nginx、SSM、微服务、数据库、数据结构、架构等等。

获取方式:点“ 在看,关注公众号 Java后端 并回复 777 领取,更多内容陆续奉上。

喜欢文章,点个在看 

你可能感兴趣的:(数据库,多线程,java,编程语言,分布式)