引言
前面的章节介绍了任务执行框架及其实际应用的一些内容。
本篇开始将分析在使用任务执行框架时需要注意的各种情况,并介绍对线程池进行配置与调优的一些方法。
1. 任务和执行策略间的隐性耦合
我们知道,Executor
框架可以将任务的提交与任务的执行策略解耦开来。虽然这极大地方便执行策略的制定和执行,但实际上并不是所有的任务都适用所有的执行策略。
有些类型的任务需要明确地指定执行策略,例如:
依赖性任务。大多数的任务,不会依赖于其他任务的执行时序或结果,这些任务可以随意地修改线程池的大小和配置,最终也只是会影响任务的执行性能。但当提交给线程池的任务需要依赖其他的任务,那就隐式地约束了执行策略,这时候就必须小心地控制执行策略以避免产生活跃性问题【这里会在下面的《线程饥饿死锁》详细说明】。
使用线程封闭机制的任务。在单线程的
Executor
中,它执行时能够确保任务不会并发地执行。因为对象可以被封闭在任务线程中,所以我们在访问这些对象时也可以不需要同步,即使它们不是线程安全的。这就看出了任务与执行策略之间的隐性耦合,即任务要求其执行所在的Executor
是单线程的。对响应时间敏感的任务。如果将一个运行时间较长的任务提交到单线程的
Executor
中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,那么将大大降低其管理的服务的响应性。像GUI
的应用程序就对响应时间很敏感,用户不可能忍受点击按钮后,需要很长的延迟才得到响应。使用
ThreadLocal
的任务。我们知道,ThreadLocal
可以在每个线程中创建一个变量,然后在线程生命周期内保持该变量的值,多个线程如果同时访问ThreadLocal
变量时,它们只会获得自己独立的变量副本,而互相之间不会产生影响。然而,只要条件允许,Executor
可以自由地重用这些线程。因此只有当线程本地值的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal
才有意义,而在线程池的线程中也不应该使用ThreadLocal
在任务之间传递值。
我们来假设下,如果将运行时间较长的与运行时间较短的任务混合在一起,会怎样?
我们知道,在 Java
中线程是由操作系统来进行调度的,而操作系统的调度策略通常是基于时间片轮转或者优先级抢占等算法。当一个线程运行时间过长时,它可能会占用太多的 CPU
时间片,导致其他线程没有机会执行,从而影响了整个系统的响应速度和吞吐量。
如果提交的任务依赖与其他的任务,但没有实现正确的线程间通信机制来确保它们的执行顺序和依赖关系,那么就可能会产生如下的严重后果:
竞态条件: 当多个线程同时访问共享资源时,可能会导致竞态条件。例如,在一个任务中检查某个条件是否满足,并在另一个任务中修改该条件,就可能会出现竞态条件。如果没有使用适当的同步措施来保护这些共享资源,将会导致程序出现不可预测的错误。
死锁: 当两个或多个线程相互等待对方释放已经占用的锁时,就可能会出现死锁。例如,当一个线程等待另一个线程完成任务并释放对象锁时,而另一个线程也在等待该线程释放对象锁时,就会出现死锁。
饿死: 当一个或多个线程一直无法获取到需要的资源时,就可能会出现饿死问题。例如,如果高优先级的任务一直占用了某个共享资源,低优先级的任务可能永远无法获得该资源,从而无法完成自己的任务。
性能下降: 当多个线程相互竞争共享资源时,可能会导致系统的整体性能下降。例如,在多个线程同时访问数据库时,由于每个数据库连接只能处理一个请求,如果同时有太多的请求被提交,将会导致系统响应变慢甚至崩溃。
只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳状态。
1.1 线程饥饿死锁
上面提到,只要线程池中的任务需要无限期地等待一些必须由池中其他任务才能提供的资源或条件【例如某个任务等待另一个任务的返回值或执行结果】,那么就有可能产生饥饿和死锁。
下面我们来看如下的示例:
1 | import java.util.concurrent.Callable; |
我们来简单分析下上面的示例:
- 首先,
RenderPageTask
向Executor
提交了两个任务来获取网页的页眉和页脚; - 接着,调用
renderBody
方法来绘制页面; - 然后,等待获取页眉和页脚任务的结果;
- 最后,将页眉、页面主体和页脚组合起来并形成最终的页面。
由于 ThreadDeadLock
使用单线程的 Executor
,那么显然在等待子任务的结果时,它会经常发生死锁。
1.2 运行时间较长的任务
前面我们已经了解了,运行时间较长的任务,会让系统的响应速度和吞吐量大大降低。
如果任务阻塞的时间过长,那么即使不出现死锁,也会阻塞线程池,甚至还会增加执行时间较短任务的服务时间,从而影响整体的响应性。
那有没有什么方法可以缓解执行时间较长任务造成的影响呢?
当然是有的,那就是要 限定任务等待资源的时间,而不要无限制地等待。
在 Java
平台类库的大多数可阻塞方法中,都同时定义了 限时版本 和 无限时版本,例如:
Thread.join
BlockingQueue.put
CountDownLatch.await
Selector.select
如果等待超时,那么就可以把这个任务标识为失败,然后中止任务或者将任务重新放回队列以便随后执行;这样,无论任务的最终结果是否成功,都可以将线程释放出来以执行一些能更快完成的任务,从而都能确保任务总能够继续执行下去。
注意:如果在线程池中总是充满了被阻塞的任务,那么也可能表明线程池的规模过小,这时候就需要调整线程池的大小,以满足要求。
2. 设置线程池的大小
在我们的应用代码中,通常不会固定线程池的大小,而应该通过某种配置来读取和设置,或者根据 Runtime.getRuntime().availableProcessors
来动态计算。
下面我们来考虑一下如何设置正确地设置线程池的大小 ?
这里考虑以下几个因素:
任务类型: 如果您的应用程序主要是
CPU
密集型任务,则理想的线程池大小通常等于可用处理器核心数。如果您的应用程序包含大量的I/O
密集型任务(如网络请求、文件读写等),则可以适当增加线程池大小,以充分利用空闲时间。线程任务平均执行时间: 如果线程任务执行时间很短(几毫秒或更少),那么可以使用较小的线程池来最大化线程复用。否则,如果线程任务执行时间很长(几秒钟或更多),那么线程池应该足够大,以避免出现线程饥饿问题。
内存大小和硬件资源: 理想的线程池大小还应该考虑可用的内存大小和其他硬件资源,以确保不会过度消耗系统资源。
要想正确地设置线程池的大小,必须分析 计算环境、资源预算 和 任务的特性。如果需要执行不同类别的任务,并且它们之间的行为相差很大,那么应该考虑使用多个线程池,从而使每个线程池可以根据各自的工作负载来调整。
对于计算密集型的任务【也称为 CPU
密集型任务】,在拥有 $N_{CPU}$ 个处理器的系统上,当线程池的大小为 $N_{CPU}$ + 1 时,通常能实现最优的利用率。
即使当计算密集型的线程偶尔由于页缺失故障或者其他原因而暂停时,这个 ”额外“ 的线程也能确保
CPU
的时钟周期不会被浪费。
对于 I/O
密集型任务,由于线程并不会一直执行,因此线程池的规模应该更大。
当我们考虑了上面的各种因素之后,就可以使用如下的公式来计算线程池的理想大小:
$$
N_{threads} = N_{CPU} * U_{CPU} * (1 + \frac{W}{C})
$$
根据上述这个公式,我们可以通过动态调整线程池大小来达到最佳性能。其中,
- $N_{CPU}$ :可用的处理器核心数,即
CPU
的数目; - $U_{CPU}$ :目标
CPU
利用率(0 <= $U_{CPU}$ <= 1); - $\frac{W}{C}$ :平均等待时间与平均工作时间之比。
我们可以通过 Runtime
来获取 CPU
的数目:
1 | int N_CPUS = Runtime.getRuntime().availableProcessors(); |
当然 CPU
周期并不是唯一影响线程池大小的资源,还包括 内存、文件句柄、套接字句柄 和 数据库连接 等。
那么这些资源对线程池的约束条件该如何计算呢?
首先计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,最后的计算所得就是线程池大小的上限。
注意:
- 当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大小将会相互影响。
- 如果每个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的大小。
- 当线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小又将限制连接池的大小。
总结
《Java并发编程学习》系列停更了有一段时间,接下来笔者将继续不定期地更新这一系列,感谢大家多多支持!!!
热爱,可抵岁月漫长,共勉 !