使用线程池以获取最佳资源利用率
Java 多线程编程论坛中最常见的一个问题就是各种版本的 "我怎么样才可以创建一个线程池?" 几乎在每个服务器应用里,都会出现关于线程池和工作队列的问题。本文中,Brian Goetz 就线程池原理、基本实现和调优技术、需要避开的一些常见误区等方面进行共享。
为何要用线程池?
有很多服务器应用,比如 Web 服务器,数据库服务器,文件服务器,或者邮件服务器,都会面对处理大量来自一些远程请求的小任务。请求可能会以很多种方式到达服务器,比如通过一种网络协议(比如 HTTP,FTP 或者 POP),通过一个 JMS 队列,或者通过轮询数据库。不管请求是如何到达的,对于服务应用来说通常是每个独立任务的处理很短暂但是请求的数量却很大。
构建一个服务器应用的很简单的一个模式就是在每个请求到达时新创建一个线程并在该线程中对该次请求进行处理。这种方式对于样品级应用来说工作的很好,但当你想要将其部署作为一个服务器应用时,很多明显的缺陷开始凸显出来。这种 "线程-每-请求" 的方式的缺点之一是:为每个请求都新创建一个线程的开销是巨大的;为每个请求新建一个线程将会花费更多时间,而且服务器在线程的创建和销毁的资源开销上会比其实际处理用户请求的过程的开销还要大。
除了线程的创建和销毁的开销之外,活动的线程也会占用一些系统资源。在一台 JVM 中创建过多的线程会导致系统内存溢出,或者会因为过度的内存消耗而带来超负载。为了防止资源超载,服务器应用需要一些手段来对指定时间内有多少请求会被同时处理进行限制。
线程池就线程生命周期开销和资源不足这两个问题同时给出了解决方案。通过线程的多任务复用,线程的创建开销分摊到了多个任务之上。更多优惠,由于请求到来时该线程已经存在了,所以线程创建所带来的延迟也被消除了。因此,请求可以得到立即服务,这让应用程序响应更快。另外,通过适当地调整线程池中线程的数量,你可以通过强制超过请求阀值时的更多请求去等待直到有一个线程空闲出来去处理它这种手段来防止资源超载。
线程池的替代方案
线程池并非服务应用使用多线程的唯一方案。如上文所提到的,某些场景下为每个新任务新建一个线程也是完全合理的。但是,如果任务创建的频率很高而且任务的持续时间很短,为每个任务新建一个线程将会带来性能问题。
另一个常见的线程模型是为某种特别类型的任务设置一个单独的后台线程和任务队列。AWT 和 Swing 使用了这种模型,其中有一个 GUI 事件线程,任何导致用户接口改变的工作都必须在该线程中执行。但是,因为只有一个 AWT 线程,在 AWT 线程中执行可能需要很长时间来完成的任务是不可取的。因此,Swing 程序常常会为 UI 关联的长时间执行的任务要求额外的工作者线程。
在特定场景下,"线程-每-任务" 和 "单个-后台-线程" 这两种方式都可以运行的很完美。"线程-每-任务" 方式在具有少量长时间运行任务的场景下工作的很棒。"单个-后台-线程" 方式在调度可预见性不是很重要的场景下工作的很棒,因为这里大都是一些后台运行的低优先级任务。但是,大多数的服务器应用面向的是处理大量短暂的任务或子任务,这时候就希望有一个低开销有效地处理这些任务的机制,而且还要具备资源管理和时间可预见性的一些措施。无疑线程池能够提供给我们这些优点。
工作队列
根据线程池的实际实现来看,"线程池" 一词有些误导,一个线程池 "明显的" 实现,在大多数情况下并不会完全产生我们所期望的结果。实际上,"线程池" 一词的出现比 Java 平台的诞生还要早,它很可能是一个缺少面向对象的环境下的产物。但这一名词还是会被广泛地使用下去。
在客户端类等待一个可用的线程时,我们可以很简单地实现一个线程池类,把任务丢给该线程执行,然后在它执行结束之后将该线程返回给线程池,这种线程池的实现方法有几个潜在的不良影响。比方说,当线程池为空的时候,会发生什么?任何想要传递一个任务给线程池的调用者会发现线程池是空的,调用者线程会阻塞在等待线程池给它一个可用的线程中。通常,我们想要使用后台线程的原因之一就是阻止提交线程的阻塞。这种导致调用者线程阻塞,比如一个线程池 "明显的" 实现的情况,让我们碰到了想来寻求解决的类似一个问题(译者注:调用者线程想要通过新线程防止阻塞,却在请求线程池一个线程的时候发生了阻塞)。
我们通常想要的是结合了一组工作者线程的一个工作队列,它会使用 wait() 和 notify() 通知等待线程有无新工作到来。这个工作队列一般实现为具有一个相关监控对象的某种链表。以下代码演示了一个简单的池化工作队列。这个模式,使用了一个 Runnable 对象队列,是一个常见的调度和工作队列的规范,尽管 Thread API 没有强加这种特殊要求。
public class WorkQueue {
private final int nThreads;
private final PoolWorker[] threads;
private final LinkedList queue;
public WorkQueue(int nThreads) {
this.nThreads = nThreads;
queue = new LinkedList();
threads = new PoolWorker[nThreads];
for (int i = 0; i < nThreads; i++) {
threads[i] = new PoolWorker();
threads[i].start();
}
}
public void execute(Runnable r) {
synchronized (queue) {
queue.addLast(r);
queue.notify();
}
}
private class PoolWorker extends Thread {
public void run() {
Runnable r;
while (true) {
synchronized (queue) {
while (queue.isEmpty()) {
try {
queue.wait();
} catch (InterruptedException ignored) {
}
}
r = (Runnable) queue.removeFirst();
}
// If we don't catch RuntimeException,
// the pool could leak threads
try {
r.run();
} catch (RuntimeException e) {
// You might want to log something here
}
}
}
}
}
你可能已经注意到以上代码的实现使用了 notify() 而不是 notifyAll()。大多数专家建议使用 notifyAll() 取代 notify(),原因是:在使用 notify() 时有一点风险,它只适合于特殊条件下。换句话讲,使用恰当的话,notify() 的性能比 notifyAll() 更加理想;尤其是,notify() 带来更少的上下文切换,这一点在服务器应用里很重要。
上面代码中的工作队列满足安全使用 notify() 的要求。所以放心在你的代码里用吧,但在其他情况下使用 notify() 的时候就要小心了。
使用线程池的一些陷阱
尽管线程池对于构建多线程应用是个很强大的机制,但它也不是没有缺点的。使用线程池构建的应用会面临其他多线程应用所面对的一样的并发风险,比如同步错误和死锁,此外线程池还有其他的一些特有缺陷,比如 线程池-关联 死锁,资源不足,还有线程泄漏。
死锁
任何多线程应用都会面临死锁的风险。彼此双方都在等待一个事件,而这个事件只能有对方提供,这样一对进程或者线程我们称之为死锁。死锁最简单的情况是线程 A 持有了对象 X 的独占锁,线程 A 在等待对象 Y 的锁,而线程 B 恰恰持有了对象 Y 的独占锁,线程 B 在等待对象 X 的锁。除非有某种办法能够打破这种锁等待(Java 锁机制不能支持这个),否则的话这一对死锁线程将会永久地等待下去。
既然死锁是所有多线程编程都将面临的风险,线程池为我们引入了另一种死锁:线程池中所有线程都在阻塞等待队列中另一个任务的执行结果,但是另一个任务无法得到执行,因为池中压根儿就没用空闲的可用线程。这种情况在线程池用于一些相互影响对象的模拟实现中可能会出现,这些模拟对象彼此发送查询然后作为任务队列进行执行,发起查询的对象同步等待响应。
资源不足
线程池的优点之一是他们在大多数情况下比其他的调度机制具备更好的性能,比如我们上面所讨论的那几种。但这个取决于你有没有恰当地配置了线程池大小。线程占用大量的资源,包括内存和其他系统资源。除了线程对象所必须的内存之外,每个线程还需要两个执行调用栈,这个栈可能会很大。此外,JVM 可能还会为每个 Java 线程创建一个本地线程,这样将会占用额外的系统资源。最后,虽然线程之间切换的调度开销很小,大量的线程上下文切换也会影响到你的应用性能。
如果线程池过大的话,这些众多线程所消耗的资源将会明显影响到系统性能。时间会浪费在线程之间的切换上,配置有比你实际需要更多的线程会引起资源不足的问题,因为池中线程所占用的资源如果用在其他任务上可能会更高效。除了这些线程本身所使用的资源之外,服务请求时所做的工作可能会需要额外资源,比如 JDBC 连接,套接字,或者文件。这些也是有限的资源,而且对它们进行过高并发请求的话可能会导致失效,比如无法分配一个 JDBC 连接。
并发错误
线程池以及其他队列机制依赖于 wait() 和 notify() 方法的使用,这可能会变得很棘手。如果编码不当的话,很可能会导致通知丢失,结果就是池中的线程都处于一个空闲的状态,而实际上队列中有任务需要处理。在使用这些工具的时候要打起十二万分的精神;即便是专家在用它们的时候也经常会失误。幸运的是,可以使用一些现成的实现,这些实现久经考验,比如下文将会讨论到的
你无须自行编码 实现的 java.util.concurrent 包。
线程泄漏
各种各样的线程池中存在的一个重大的危险就是线程泄漏,当一个线程被从线程池中移除去执行一个任务,任务执行结束之后却没有返还给线程池的时候,就会出现这种危险。出现这种情况的一种方式是当任务抛出一个 RuntimeException 或一个 Error 时。如果线程池类没有捕捉到这些,该线程将会傻傻地存在于线程池之中,而线程池的线程数量则会被永久地减一。当这种情况发生的次数足够多的时候,线程池最终将为空(无可用线程),而系统则会瘫痪,因为已经没有线程来处理任务了。
瘫痪的任务,比如那些永久等待不保证可用资源或者等待已经回家了的用户输入的任务,也可以造成相等于线程泄漏一样的后果。如果一个线程永久地被这样一个任务所占用了的话,它造成的影响和从池中移除是一样的。像这样的任务应该要么给它们一个线程池之外的线程,要么控制一下它们的等待时间。
请求过载
服务器很可能会被铺天盖地而来的请求所淹没。这种情况下,我们可能并不想让每个进来的请求都放进我们的工作队列,因为等待执行的任务队列也可能会占用过多系统资源并导致资源不足。这时候要做什么就取决于你的决定了,比如你可以通过一个表示服务器暂时太忙的响应来拒绝这些请求。
高效线程池使用指南
你只需要遵循一些简单的指导方针,线程池就可以成为你构建服务应用的一个非常有效的方法:
- 不要把同步等待其他任务执行结果的任务放进任务队列。这将导致上文所描述那种死锁,池中所有线程都在等待一个任务的执行结果,而队列中的这个任务无法得到执行因为所有线程都在使用中。
- 可能长时间操作的任务放入线程池的时候要慎重。如果程序必须要等待一个资源,比如一个 I/O 的完成,定义一个最长等待时间,然后失败或稍后重新执行。这就保证了通过将一个线程从一个可能会完成的任务中释放出来而最终一些其他任务得到成功执行。
- 理解你的任务。想要有效地调整线程池大小,你需要理解队列中那些任务要做的事情。它们是 CPU 密集型操作吗?它们会长时间占用 I/O 吗?你的答案会影响到你对你的应用的配置。如果这些任务来自不同的类、有着截然不同的特征,为不同类型的任务定制不同的工作队列也许更行得通,这样每个池都能够得到有据配置。
线程池大小配置
调整线程池的大小在很大程度上是一件避免两个错误的事情:拥有过多或过少的线程。幸运的是,对于大多数应用而言太多或太少之间的中间地带还是很宽广的。
回顾应用中使用线程的两个主要优点:在等待一个诸如 I/O 之类的慢操作的时候进程能够继续进行,利用多个处理器的可用性。在一个 N 处理器主机上运行一个计算密集型的应用,通过设置线程数量为 N 增加额外的线程可能会提高吞吐量,但添加的额外线程超过 N 的话就没有什么好处了。确实,过多的线程甚至会降低性能因为会带来额外的上下文切换开销。
线程池最佳大小取决于可用处理器的数量和工作队列中任务的性质。对于在一个 N-处理器 系统中一个的将持有完全计算密集型任务的工作队列,通常获得 CPU 最大利用率的话是配置线程池大小为 N 或 N + 1 个线程。
对于可能要等待 I/O 完成的任务,比如,一个从 socket 中读取一个 HTTP 请求的任务 - 你需要增加线程池的线程的数量超出可用处理器的数量,因为所有的线程都在同一时间工作。通过分析,你可以为一个典型的请求估算出等待时间(WT)和服务时间(ST)之间的比率。比如我们称这个比率为 WT/ST,对于一个 N-处理器系统,你需要大约 N * (1 + WT/ST) 个线程来保持处理器得到充分利用。
处理器利用率并非配置线程池大小的唯一依据。因为在线程池增长的时候,你可能会遇到调度器的局限性,内存可用性,或者其他系统资源,比如 socket 的数量,打开文件的处理,或者数据库连接等问题。
无需自行编码
Doug Lea 写了一个杰出的开源并发工具包,java.util.concurrent,包含了互斥,信合,能够在并发访问下性能表现良好的集合类诸如队列和哈希表,以及一些工作队列的实现。这个包里的 PooledExecutor 类是一个高效的、被广泛使用的、基于工作队列的一个线程池的正确实现。不用再尝试着自己去写代码实现了,那样很容易出错,你可以考虑使用 java.util.concurrent 包里的一些工具。更多详细资料参见下文的
结论
线程池是构建服务器应用的很有用的一个工具。它的概念很简单,但在实现或者使用的时候需要注意一些问题,比如死锁,资源不足,以及 wait() 和 notify() 的复杂性。如果你发现自己的应用需要一个线程池,考虑一下使用 java.util.concurrent 包里的某个 Executor 类,比如 PooledExecutor,不要去从头写一个。如果你发现你在创建一些要处理简短任务的线程,你就应该考虑使用线程池了。