前言

为了加快程序处理速度,我们会将问题分解为若干个并发执行的任务。然后创建线程池,将任务委托给线程池中的线程,以便它们可以并发执行。
在高并发的情况下采用线程池,可以有效降低线程创建、释放的时间花销和资源开销。如果不使用线程池,创建过多的线程时会导致内存消耗过度、线程切换频繁。
我们希望消耗最少的资源,尽可能地创建任务,那么在高并发的情况下,该如何选择最优的线程数量呢?选择原则又是什么呢?

设置线程池的大小

线程池的理想大小取决于被提交任务的类型以及部署系统的特性。在代码中通常不会固定线程池的大小,而应该通过某种配置机制来提供,或者根据 Runtime.availableProcessors 来动态计算。
幸运的是,要设置线程池的大小并不困难,只需要避免 过大过小 这两种极端情况。如果线程池过大,那么大量的线程将在相对很少的 CPU 和内存资源上发生竞争,这不仅会导致更高的内存使用,而且还可能耗尽资源。如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐率。
要想正确地设置线程池的大小,必须分析计算环境、资源预算和任务的特性。在部署中有多少个 CPU?多大的内存?任务是计算密集型、I/O 密集型还是二者皆可?它们是否需要像 JDBC 连接这样的稀缺资源?如果需要执行不同类别的任务,并且它们之间的行为相差很大,那么应该考虑使用多个线程池,从而使每个线程池可以根据各自的工作负载来调整。

阻塞时间和计算时间

对于计算密集型的任务,一个有 Ncpu 个处理器的系统通常通过使用一个 Ncpu + 1 个线程的线程池来获得最优的利用率(计算密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下 CPU 周期不会中断工作)。
对于包含了 I/O 和其他阻塞操作的任务,不是所有的线程都会在所有的时间被调度,因此你需要一个更大的池。为了正确地设置线程池的长度,你必须估算出任务花在等待的时间与用来计算的时间的比率;这个估算值不必十分精确,而且可以通过一些监控工具获得。你还可以选择另一种方法来调节线程池的大小,在一个基准负载下,使用几种不同大小的线程池运行你的应用程序,并观察 CPU 利用率的水平。
给出下列定义:

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

当然,CPU 周期并不是唯一你可以使用线程池管理的资源。其他可以约束资源池大小的资源包括:内存、文件句柄、套接字句柄和数据库连接等。计算这些类型资源池的大小约束是更容易的:计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个人物的需求量,所得结果就是线程池大小的上限。
当任务需要使用池化的资源时,比如数据库连接,那么线程池的长度和资源池的长度会相互影响。如果每一个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的有效大小。
类似地,当线程池中的任务是连接池的唯一消费者时,那么线程池的大小反而又会限制了连接池的有效大小。
线程等待时间所占比例越高,需要越多线程。线程 CPU 时间所占比例越高,需要越少线程。这就可以划分成两种任务类型:
I/O 密集型:一般情况下,如果存在 I/O,那么肯定 W/C > 1(阻塞耗时一般是计算耗时的很多倍),但是需要考虑系统内存有限(每开启一个线程都需要内存空间),这里需要在服务器上测试具体多少个线程数适合(CPU占比、线程数、总耗时、内存消耗)。如果不想去测试,取 1 即可,Nthreads = Ncpu x (1 + 1) = 2Ncpu。
计算密集型:假设没有等待 W = 0,则 W/C = 0。 Nthreads = Ncpu。
根据短板效应,真实的系统吞吐量并不能单纯根据CPU来计算。那要提高系统吞吐量,就需要从“系统短板”(比如网络延迟、IO)着手:

  • 尽量提高短板操作的并行化比率,比如多线程下载技术;
  • 增强短板能力,比如用 NIO 替代 IO;

    阻塞系数

如果所有的任务都是计算密集型的,则创建处理器可用核心数那么多个线程就可以了。在这种情况下,创建更多的线程对程序性能而言反而是不利的。因为当有多个任务处于就绪状态时,处理器核心需要在线程间频繁进行上下文切换,而这种切换对程序性能损耗较大。但如果任务都是 I/O 密集型的,那么我们就需要开更多的线程来提高性能。
当一个任务执行 I/O 操作时,其线程将被阻塞,于是处理器可以立即进行上下文切换以便处理其他就绪线程。如果我们只有处理器可用核心数那么多个线程的话,则即使有待执行的任务也无法处理,因为我们已经拿不出更多的线程供处理器调度了。
如果任务有 50% 的时间处于阻塞状态,则程序所需线程数为处理器可用核心数的两倍。 如果任务被阻塞的时间少于 50%,即这些任务是计算密集型的,则程序所需线程数将随之减少,但最少也不应低于处理器的核心数。如果任务被阻塞的时间大于执行时间,即该任务是 I/O 密集型的,我们就需要创建比处理器核心数大几倍数量的线程。
我们可以计算出程序所需线程的总数,总结如下:

  1. Nthreads = Ncpu / (1 - 阻塞系数)
  2. 其中阻塞系数的取值在 0 1 之间。

计算密集型任务的阻塞系数为 0,而 I/O 密集型任务的阻塞系数则接近 1。一个完全阻塞的任务是注定要挂掉的,所以我们无须担心阻塞系数会达到 1。
为了更好地确定程序所需线程数,我们需要知道下面两个关键参数:

  • 处理器可用核心数;
  • 任务的阻塞系数;

第一个参数很容易确定,我们甚至可以用之前的方法在运行时查到这个值。
但确定阻塞系数就稍微困难一些。我们可以先试着猜测,抑或采用一些性能分析工具或 java.lang.management API 来确定线程花在系统 I/O 操作上的时间与 CPU 密集任务所耗时间的比值。
由于对 Web 服务的请求大部分时间都花在等待服务器响应上了,所以阻塞系数会相当高,因此程序需要开的线程数可能是处理器核心数的若干倍。
假设阻塞系数是 0.9,即每个任务 90% 的时间处于阻塞状态而只有 10% 的时间在干活,则在双核处理器上我们就需要开 20 个线程。如果有很多任务要处理的话,我们可以在 8 核处理器上开到 80 个线程来处理该任务。

总结

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