任务通常是一些抽象的且离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。
任务执行
在线程中执行任务
当围绕“任务执行”来设计应用程序时,第一步就是找出清晰的任务边界。在理想情况下,各个任务之间是相互独立的:任务并不依赖其他任务的状态、结果或边界效应。(任务的独立性)独立性有助于实现并发,因为如果存在足够的处理资源,那么这些独立的任务都可以并行执行。
在正常的负载下,服务器应用程序应同时表现出良好的吞吐量和快速的响应性。应用程序提供商希望程序支持尽可能多的用户,从而降低每个用户的服务成本,而用户则希望获得尽快的响应。而且,当负荷过载时,应用程序的性能应该是逐渐降低,而不是直接失败。要实现上述目标,应该选择清晰的任务边界以及明确的任务执行策略。
大多数服务器应用程序都提供一种自然的任务边界选择方式:以独立的客户请求为边界。如Web服务器、邮件服务器、文件服务器以及数据库服务器等。将独立的请求作为任务边界,既可以实现任务的独立性,又可以实现合理的任务规模。 所以,接下里将关注任务的执行策略。
单个线程串行执行多个任务
在单个线程串行执行多个任务是最简单的任务执行策略。示例代码如下:
class SingleThreadWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while(true) {
Socket connection = socket.accept();
handleRequest(connection);
}
}
}
虽然单个线程串行执行多个任务的执行策略是正确的,但是在实际生产环境中的执行性能却很糟糕。因为每次只能处理一个请求。当服务器正在处理请求时,新到来的连接必须等待直到请求处理完成,然后服务器将再次调用accept。
由于Web请求的处理中包含一组不同的运算与IO操作,而这些操作通常会由于网络阻塞或连通性问题而被阻塞。此外,服务器还可能处理文件I/O或者数据库请求,这些操作同样会阻塞。在单线程的服务器中,阻塞不仅会推迟当前请求的完成时间,还经彻底阻塞等待中的请求被处理。同时,服务器的资源利用率非常低,因为当单线程在等待I/O操作完成时,CPU将处于空闲状态。
单线程+IO多路复用
这种技术最早接触是在Redis中。Java支持NIO后,也可实现该执行策略。后续再展开
每个线程执行一个任务
通过为每个任务创建一个新的线程来提供服务,可实现更高的响应性。示例代码如下:
class ThreadPerTaskWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while(true) {
final Socket connection = socket.accept();
// 创建一个任务
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
// 为每个任务创建一个线程
new Thread(task).start();
}
}
}
为每个任务分配一个线程的执行策略有以下三个特性:
(1) 任务处理过程从主线程中分离出来,使得主循环能够更快地重新等待下一个到来的连接,从而提高响应性。
(2) 任务可以并行处理,从而能同时服务多个任务,从而提高吞吐量。
(3) 任务处理代码必须是线程安全的。因为多个任务会并发的调用任务处理代码。
在生产环境中,“为每个任务分配一个线程”的做法存在缺陷:
(1) 线程生命周期的开销非常高。线程的创建和销毁的代价不可忽略。平台不同,实际的开销也有所不同。
(2) 资源消耗。活跃的线程将消耗系统资源,如内存。如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。如果已存在足够多的线程使所有CPU保持忙碌状态,那么再创建更多的线程反而会降低性能。
(3) 稳定性。在可创建的线程数量上存在一个限制。这个阈值将随着平台的不同而不同,并且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈的大小,以及底层操作系统对线程的限制等。
在一定的范围内,增加线程可以提高系统的吞吐量,但是一旦超过了这个范围,再创建更多的线程只会降低程序的执行速度。
Executor框架(异步的任务执行框架)
相比前面的执行策略(串行执行多个任务、单线程+IO多路复用、为每个任务分配一个线程),使用Executor框架则可为灵活且强大的异步任务执行框架提供基础。该框架能支持多种不同类型的任务执行策略。Executor定义如下:
public interface Executor {
void execute(Runnable command);
}
Executor框架提供了一种标准方法将任务的提交过程与执行过程解耦开来。Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监测等机制。
Executor基于生产者-消费者模式,提交任务的操作相当于生产者(生成待完成的工作单元–任务),执行任务的线程则相当于消费者(执行这些工作单元)。如果要在程序中实现一个生产者-消费者的设计,那么最简单的方式通常是使用Executor。
基于”Executor框架+线程池“构建服务器的示例如下:
class TaskExecutionWebServer {
private static final int NUMBER_THREAD = 100;
private static final Executor exec = Executors.newFixedThreadPool(NUMBER_THREAD);
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while(true) {
final Socket connection = socket.accept();
// 创建一个任务
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
exec.execute(task);
}
}
}
在TaskExecutionWebServer中,通过使用Executor将请求处理任务的提交与任务的实际执行解耦开来,并且只需要替换Executor的实现,即可改变服务器的行为。如可以将TaskExecutionWebServer修改为类似ThreadPerTaskWebServer的行为,示例代码如下:
public class ThreadPerTaskExecutor implements Executor {
public void execute(Runnable command) {
new Thread(command).start();
}
}
通过将任务的提交与执行解耦开来,可以更灵活的修改任务执行策略。从而在部署阶段,选择与可用硬件资源最匹配的执行策略。
通过使用Executor,可以快速实现各种调优、管理、监视、记录日志、错误报告和其他功能。
Executor生命周期
Executor的实现通常会创建线程来执行任务,但JVM只有在所有线程全部终止后才会退出。因此,如果无法正确地关闭Executor,那么JVM将无法结束。
由于Executor以异步方式来执行任务,所以在任何时刻,之前提交任务的状态不是立即可见的。同时,不同的任务其执行状态可能不同。有些任务可能已经完成,有些可能正在运行,而其他的任务可能在队列中等待执行。
为解决执行服务的生命周期问题,Executor扩展了ExecutorService接口,添加了一些用于生命周期管理的方法。ExecutorService中的生命周期管理方法如下:
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
}
ExecutorService的生命周期有三种状态:运行、关闭和已终止。ExecutorService在初始创建时处于运行状态。shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成。shutdownNow方法执行粗暴的关闭过程:将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
ExecutorService关闭后提交的任务将由”拒绝执行处理器(Rejected Execution Handler)“处理,它会抛弃任务,或使得execute方法抛出一个未检查的RejectedExecutionException。
等所有任务完成后,ExecutorService将转入终止状态。可以调用awaitTermination方法来等待ExecutorService到达终止状态或通过isTerminated来轮询其是否已终止。
携带结果的任务Callable与Future
Executor框架使用Runnable作为其基本的任务表示形式。Runnable是一种有很大局限的抽象,其中一个问题是它不能返回一个值或抛出一个受检查的异常。
许多任务实际上是存在延迟的计算——执行数据库查询,从网络上获取资源,计算某个复杂的功能。对这些任务,Callable是一种更好的抽象:它认为主入口点(即call)将返回一个值,并可能抛出一个异常。注意:在Executor中存在一些辅助方法将其他类型的任务封装为一个Callable。如Runnable和java.security.PrivilegedAction。
Runnable和Callable描述的都是抽象的计算任务。
Future表示一个任务的生命周期,并提供相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。在Future规范中包含的隐含意义是,任务的生命周期只能前进,不能后退。当某个任务完成后,它就永远停留在”完成“状态上。Future提供get方法获取任务的状态。如果任务已经完成,get会立即返回或抛出异常,如果任务没有完成,get将阻塞直到任务完成。如果任务出现异常,那么get将该异常封装成ExecutionExcepiton并重新抛出。如果获取被封装的原始异常,可调用getCause方法。如果任务被取消,那么get将抛出CancellationException。
public interface Callable<V> {
V call() throws Exception;
}
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException, CancellationException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, CancellationException, TimeoutExcepti0on;
}
CompletionService:Executor与BlockingQueue
如果向Executor提交一组计算任务,并且希望完成后获得结果,那么可以保留每个任务关联的Future,然后使用get方法,通过轮询的方式来判断任务是否完成。这种方式虽然可行,但有一种更简便的方式——CompletionService。
CompletionService将Executor和BlockingQueue的功能融合在一起。用户可以将Callable任务提交给CompletionService执行,然后使用类似队列操作的take和poll方法来获得已完成的结果,而这个结果会在完成时封装成Future。ExecutorCompletionService实现了CompletionService,并将计算部分委托给一个Executor。
CompletionService的作用相当于一组计算的句柄,这与Future作为单个计算的句柄是类似的。
为任务设置实现时限
有时,任务如果无法在指定时间内完成,那么将不再需要它的结果,此时可放弃这个任务。
在支持时间限制的Future.get方法支持这种需求:将结果可用时,它将立即返回,如果在指定时间限制内没有计算出结果,则将抛出TimeoutException。
取消与关闭
有时,我们希望提前结束任务或线程,或许是因为用户取消了操作,或者应用程序需要被快速地关闭。
要使任务和线程安全、快速、可靠地停止,并不是一件容易的事情。Java没有提供任何机制来安全的终止线程。(Thread的stop和suspend方法虽提供这样机制,但存在严重的缺陷)但Java提供了中断,这是一种协调机制,能够使用一个线程终止另一个线程的当前工作。
- (1)stop方法会破坏数据在程序中的一致性。
stop方法在终止一个线程时会强制中断线程的执行,不管run方法是否执行完毕,并且还会释放这个线程所持有的所有锁对象。这会造成数据不一致。以银行转账为例,从A账户向B账户转账500元为例,第一步是从A账户中减去500元,假如到这时线程就被stop,那么这个线程就会释放它所取得锁,然后其他的线程继续执行,这样A账户就莫名其妙的少了500元且B账户也没有收到钱。
- (2)suspend方法会带来死锁。
调用suspend方法后,仍会占有锁,一直等到其他线程调用resume方法,它才能继续向下执行。假如有A,B两个线程,A线程在获得某个锁之后被suspend阻塞,这时A不能继续执行,线程B在获取相同的锁之后才能调用resume方法将A唤醒,但是此时的锁被A占有,B不能继续执行,也就不能及时的唤醒A,此时A,B两个线程都不能继续向下执行而形成了死锁。
任务、线程或服务立即停止会使共享的数据结构处于不一致的状态,而使用协作的方式,可以在需要停止时,先清除当前执行的工作,然后再结束。相比立即停止的方式,协作的方式提供了更好的灵活性,因为任务本身的代码比发出取消请求的代码更清楚如何执行清除工作。
生命周期结束(End-of-Lifecycle)的问题会使任务、服务以及程序的设计和实现等过程变得复杂,而生命周期结束的问题在程序设计中非常重要但却经常被忽略。
对于一个行为良好的软件能很完善的处理失败、关闭和取消等过程。
任务取消
如果外部代码能在某个操作正常完成之前将其置入”完成“状态,那么这个操作就可以成为可取消的(Cancellable)。
取消某个操作的原因很多:
(1) 用户请求取消。用户点击图形界面程序中的”取消“按钮,或者调用接口发出”取消“请求。
(2) 有时间限制的操作。某个应用程序需要在有限时间内搜索问题空间,并在这个时间内选择最佳的解决方案。
(3) 应用程序事件。如应用程序对某个问题空间进行分解并搜索,当其中一个任务找到解决方案时,所有其他人在搜索的任务都将被取消。
(4) 错误。网页爬虫程序搜索相关页面,并将页面或摘要数据保存到硬盘。当一个爬虫任务发生错误(如磁盘空间已满)时,那么所有搜索任务都会取消。
(5) 关闭。当一个程序或服务关闭时,必须对正在处理和等待处理的工作执行平滑(正在执行的任务将继续执行直到完成)或暴力(取消当前的任务)的关闭操作。
在Java中没有安全的抢占式方法来停止线程,因此也就没有安全的抢占式方法来停止任务。只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。
协作机制的实现方式有很多种,一种方式是引入”已请求取消(Cancellation Requested)“标志。在任务中定义”已请求取消“标志后,任务定期查看该标志。如果设置了该标志,那么任务将提前结束。示例代码如下:
@ThreadSafe
public class PrimeGenerator implements Runnable {
@GuardedBy("this")
private final List<BigInteger> primeCollection = new ArrayList<>();
private volatile boolean cancelled = false;
public void run() {
BigInteger p = BigInteger.ONE;
while(!cancelled)
p = p.nexeProbablePrime();
synchronized(this) {
primeCollection.add(p);
}
}
public void cancel() {
cancelled = true;
}
public synchronized List<BigInteger> get() {
return new ArrayList<>(primeCollection);
}
}
一个可取消的任务必须具有取消策略(”Cancellation Policy“)。在这个取消策略中,将详细地定义取消操作的”How“、”When“以及”What“,即其他代码如何取消请求任务、任务在核实检查是否已经请求了取消,以及在响应取消请求时该执行哪些操作。
基于”已请求取消(Cancellation Requested)“标志在真正退出的时候需要花费一定的时间。然而,如果使用这种方法的任务调用了一个阻塞方法,如BlockingQueue.put,那么可能会产生一个更严重的问题————任务可能永远不会检查取消标志,因此永远不会结束。示例代码如下:
class BrokenPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
private volatile boolean cancelled = false;
BrokenPrimeProducer(BlockingQueue queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while(!cancelled) {
queue.put(p=p.nextProbablePrime());
}
} catch(InterruptedException consumed) {
}
}
public void cancel() {
cancelled = true;
}
}
void consumePrimes() throws InterruptedException {
BlockingQueue<BigInteger> primeQueue = ...;
BrokenPrimeProducer producer = new BrokenPrimeProducer(primeQueue);
producer.start();
try {
while(needMorePrimes()){
consume(primeQueue.take());
}
} finally {
producer.cancel();
}
}
对于上述场景,生产者生产素数,并将其放入阻塞队列。如果生产者的速度超过了消费者的处理速度,那么队列将很快被填满,put方法也会阻塞。当生产者阻塞在put方法中时,如果消费者不再消费队列,而选择取消生产者任务,那么尽管消费者可调用cacel方法来设置cancelled标志,但是此时生产者却永远不会检查这个标志,因为它无法从阻塞的put方法中恢复过来。
基于以上原因,基于"已请求取消(Cancellation Requested)"标志的方式并未获得推广。比较成熟的方案是基于线程中断的协作方式。
在Java的API和语言规范中,并没有将中断与任何取消语义关联起来,但事实上,如果在取消之外的其他操作使用中断,那么都是不合适的,并且很难支撑起更大的应用。通常,中断是实现取消的最合理方式(中断是实现任务取消的事实标准)
线程中断就是让一个线程可以告诉另一个线程,它在合适的或可能的情况下停止当前工作,并转而执行其他的工作。
Thread提供的中断方法如下:
public class Thread {
// 中断目标线程
public void interrupt() {
...
}
// 返回目标线程的中断状态(为true时,表示处于中断状态)
public boolean isInterrupted() {
...
}
// 清除当前线程的中断状态,并返回它之前的值(清除中断状态的唯一方法)
public static boolean interrupted() {
}
}
对中断操作的正确理解是:中断操作并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己(这些时刻也被称为取消点)。所以,调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
使用中断实现任务取消的示例代码如下:
class InterruptedPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
BrokenPrimeProducer(BlockingQueue queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while(!Thread.currentThread().isInterrupted()) {
queue.put(p=p.nextProbablePrime());
}
} catch(InterruptedException consumed) {
// 允许线程退出
}
}
public void cancel() {
interrupt();
}
}
阻塞方法会检查线程何时中断,并且在发现中断时提前返回。这些方法在响应中断时执行的操作包括:清除中断状态、抛出InterruptedException,表示阻塞操作由于中断而提前返回。JVM并不能保证阻塞方法检测到中断的速度,但在实际情况中响应中断的速度还是非常快的。
对应上述示例来说,put方法是一个阻塞方法,所以不一定需要(除非主动)进行显式的检测,但显式执行检测会使方法对中断具有更高的响应性。
中断策略
任务中应该包含取消策略,而线程中应包含中断策略。中断策略规定线程如何解释某个中断请求——当发现中断请求时,应该做哪些工作(如果需要的话),哪些工作单元对于中断来说是原子操作,以及以多快的速度来响应中断。
**最合理的中断策略是:尽快退出,并在必要时进行清理,通知某个所有者该线程已经退出。**其他的中断策略有:暂停服务或重新开始服务等。
区分任务和线程对中断的反应很重要。一个中断请求可以有一个或多个接受者——中断线程池中的某个工作者线程意味着同时“取消当前任务”和“关闭工作者线程”。
当检测到中断请求时,任务并不需要放弃所有的操作——它可以退出处理中断请求,并直接到某个更合适的时刻。所以任务需要记住中断请求,并在完成当前任务后破案出InterruptedException或者表示已收到中断请求。这种方式能够保证在更新过程中发生中断时,数据结构不被破坏。
任务不应对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并在这些服务中包含特定的中断策略。
**执行任务取消操作的代码同样不应该对执行该任务取消操作的线程的中断策略做出任何假设。**线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,如关闭(shutdown)方法。
Java的中断功能没有提供抢占式中断机制,而且还强迫开发人员必须处理InterruptedException。但是,推迟中断请求的处理,可以让开发人员制定更灵活的中断策略,从而使应用程序在响应性和健壮性之间实现合理的平衡。
可中断阻塞
阻塞根据是否可处理中断,将其分为两类:可中断的阻塞和不可中断的阻塞。根据阻塞功能实现的阻塞函数,这里重点介绍下可中断的阻塞函数对中断的处理。当调用可中断的阻塞函数时(如BlockingQueue的put方法),有两种使用策略可用于处理InterruptedException:
(1) 传递异常,在执行某个特定于任务的清除操作之后,从而使你的方法也成为可中断的阻塞方法。
(2) 恢复中断状态,从而使调用栈的上层代码能够对其进行处理。
注意:只有实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。
接下来将以计时运算为例,介绍下可中断阻塞的中断处理编码。
许多问题永远也无法解决(如枚举所有的素数,π的位数),而某些问题需要在有限的时间内,获取已找到的部分解。一种简单的实现是:
private static final ScheduleExecutorService cancelExec = ...;
public static void timedRun(Runnable task, long timeout, TimeUnit unit) {
final Thread taskThread = Thread.currentThread();
// 安排一个取消任务,在线程运行指定时间后,中断这个线程。
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}
}, timeout, unit);
task.run();
}
这种方式解决从任务中抛出未检查异常的问题,因为timedRun的调用者可以捕获该异常。
这种非常简单,但是却破坏了以下规则:在中断线程前,应该了解它的中断策略。由于timedRun可以从任意一个线程中调用,所以它无法知道这个调用线程的中断策略。而调用线程也无法决定使用的中断策略。为此,引入另一种实现:
public static void timedRun(final Runnable task, long timeout, TimeUnit unit) throw InterruptedException {
// 定义内部类
class RethrowableTask implements Runnable {
// 线程间共享变量,使用volatile关键字修饰
private volatile Throwable throwable;
public void run() {
try {
task.run();
} catch(Throwable throwable) {
this.throwable = throwable
}
}
void rethrow() {
if(throwable != null) {
throw launderThrowable(throwable);
}
}
}
RethrowableTask rethrowableTask = new RethrowableTask();
final Thread taskThread = new Thread(rethrowableTask);
taskThread.start();
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}
},timeout, unit);
taskThread.join(unit.toMillis(timeout));
rethrowableTask.rethrow();
}
使用Thread的join方法后,执行任务的线程拥有自己的执行策略,即使任务不响应中断,限时运行的方法仍能返回到它的调用者。timedRun将执行一个限时的join方法,并在join方法返回后,检查任务是否有异常抛出,如果有,则会在调用timedRun的线程中再次抛出该异常。
上述实现中依赖一个限时的join方法。而join方法无法区分线程正常退出而返回,还是因join超时而返回,所以这种方法也不推荐使用。
另一种实现可中断阻塞的方式是使用Future和任务执行框架来构建timedRun。示例代码如下:
public static void timedRun(Runnable runnable, long timeout, TimeUnit unit) throws InterruptedException {
Future<?> task = taskExec.submit(runnable);
try {
task.get(timeout, unit);
} catch(TimeoutException e) {
// 接下来任务将被取消
} catch(ExecutionException e) {
// 如果在任务中抛出异常,那么重新抛出该异常
throw launderThrowable(e.getCause());
} finally {
// 如果任务已经结束,那么取消任务不会带来任何影响
task.cancel(true); // 设置为true,表示如果任务正在执行,那么将被中断
}
}
除非清楚线程的中断策略,否则不要中断线程。那么在何种情况下调用cancel可将参数设置为true呢?执行任务的线程是在标准Executor中运行,而标准Executor实现了一种中断策略使得任务可以通过中断被取消,所以如果任务在标准Executor中运行,并通过它们的Future来取消任务,那么可以将canle方法的参数设置为true(mayInterruptIfRunning)。
不可中断阻塞
并非所有的可阻塞方法或阻塞机制都能响应中断:如果一个线程由于执行同步的Socket I/O或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。
(1) java.io包中的同步Socket I/O。服务器应用程序中,最常见的阻塞I/O形式就是对套接字进行读取和写入。虽然InputStream和OutputStream的read和write等方法不会响应中断,但通过关闭底层的套接字,可以使得由于执行read或write等方法而被阻塞的线程抛出一个SocketException。
(2) java.io包中的同步I/O。当中断一个正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptedException并关闭链路。当关闭一个InterruptibleChannel时,将导致所在链路操作上阻塞的线程都抛出AsynchronousCloseException。
(3) Selector的异步I/O。如果一个线程在调用Selector.select方法(在java.nio.channels中)时阻塞,那么调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回。
(4) 获取某个锁。如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程认为其会获得锁,所以不会理会中断请求。但Lock类提供了lockInterruptibly方法。该方法允许在等待一个锁的同时仍能响应中断。
代码示例(后续补充)
停止基于线程的服务
应用程序通常会创建拥有多个线程的服务,如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期更长。如果应用程序准备退出,那么提供多个线程的服务的线程也需要结束。由于无法通过抢占式的方法停止线程,因此服务中的线程需要自行结束。
正确的封装原则是:除非拥有某个线程,否则不能对该线程进行操控。但是,在线程API中,并没有对线程所有权给出正式的定义:线程由Thread表示,并且可以被共享。然而,线程有一个相对的所有者,即创建该线程的类。线程的所有者拥有某个线程,所以可对该线程进行操控(如中断该线程或修改该线程的优先级等等)。同理,线程池是其工作者线程的所有者,所以可以使用线程池中断工作者线程。
注意,线程的所有权是不可传递的:应用程序拥有服务,服务拥有工作者线程,但应用程序并不能拥有工作者线程。所以,不能由应用程序直接停止工作者线程。一种正确的处理方式是,服务应该提供生命周期方法(Lifecycle Method)来关闭自己以及其拥有的线程。这样,当应用程序关闭该服务时,服务就可关闭其拥有的工作者线程。换句话说,拥有多个线程的服务需要提供线程关闭机制。以ExecutorService为例,提供了shutdown和shutdownNow等方法。
接下来以日志服务为例,说明如何对基于线程的服务提供线程关闭机制。
日志的方式方式有很多种,一种简单的实现是在代码中插入println语句。虽然诸如PrintWriter这种的字符流类是线程安全的,且不需要显式同步,但是这种内联日志功能会给一些高容量(Highvolume)应用带来一定的性能开销。另一种替代方法是通过log方法将日志消息放入某个队列,并由其他线程来处理。
public class LogWriter {
private final BlockingQueue<String> queue;
private final LoggerThread logger;
public LogWriter(Writer writer) {
this.queue = new LinkedBlockingQueue<String>)(CAPACITY);
this.logger = new LoggerThread(writer);
}
public void start() {
logger.start();
}
public void log(String message) throws InterruptedException {
queue.put(message);
}
private class LoggerThread extends Thread {
private final PrintWriter writer;
...
public void run() {
try {
while(true) {
writer.println(queue.take());
}
} catch(InterruptedExcepiton ignored) {
} finally {
writer.close();
}
}
}
}
上述实现,将日志操作在独立的日志线程中执行。产生日志消息的线程不会直接将消息写入输出流,而是由LogWriter通过BlockingQueue将消息提交给日志线程,并由日志线程写入。这是一种多生产者单消费者(Multiple-Producer, Single-Consumer)的设计方式:每个调用log的操作都相当于一个生产者,而后台的日志线程则相当于消费者。
为了使LogWriter这样的服务在生产环境中发挥实际作用,还需实现一种终止日志线程的方法,保证服务关闭该时其拥有的工作者线程也可关闭,从而避免JVM无法正常关闭。
在上述实现中,可以很容易停止日志线程。因为上述日志线程会反复调用take方法,而take方法可响应中断(可中断阻塞方法)。如果将日志线程修改为当捕获InterruptedException时退出,那么只需中断日志线程就能停止服务。
然而,如果只是使日志线程退出,还不是一种完备的关闭机制。这种直接关闭的做法有两个问题:
(1) 丢失那些正在等待被写入到日志的信息。(数据丢失)
(2) 仅关闭日志线程会导致阻塞队列在写满后,使其他生产者线程被阻塞。当取消一个生产者–消费者操作时,需要同时取消生产者和消费者。但是上述示例中,由于生产者并非专门的线程,所以要取消它们很困难。
另一种关闭LogWriter的方法是:设置某个“已请求关闭”的标志,以避免进一步条日志消息。示例代码如下:
public void log(String message) throws InterruptedException {
if(!shutdownRequested) {
queue.put(message);
} else {
throw new IllegalStateException("Logger is shutdown.");
}
}
在收到关闭请求后,消费者会把队列中的所有消息写入日志,并解除所有在调用log时阻塞的生产者。然而,这个方法存在竞态条件,使得这个方法并不可靠。log方法的实现是“先判断再运行”的代码序列:生产者发现该服务还没有关闭,因此在关闭服务后仍会将日志消息放入队列。这同样会存在生产者在调用log时阻塞并无法解除阻塞状态的问题。(因为已请求关闭开关已经打开)
尽管可以通过一些技巧(如在宣布队列被清空之前,让消费者等待数秒),但这些方案都没从根本上解决问题,即使很小的概率也可能导致程序发生故障。
为LogWriter提供可靠关闭操作的方法是解决竞态条件问题,因此要使日志消息的提交操作成为原子操作。由于put方法本身是阻塞方法,所以不希望在将消息加入队列时去持有一个锁。这里采取的方式是:通过原子方式来检查关闭请求,并且有条件的递增一个计数器,以“保持”提交消息的权利。示例代码如下:
public class LogService {
private final BlockingQueue<String> queue;
private final LoggerThread loggerThread;
private final PrintWriter printWriter;
@GuardedBy("this")
private boolean isShutdown;
@GuardedBy("this")
private int reservations;
public void start() {
loggerThread.start();
}
public void stop() {
synchronized (this) {
isShutdown = true;
}
loggerThread.interrupt();
}
public void log(String message) throws InterruptedException {
synchronized (this) {
if(isShutdown) {
throw new IllegalStateException(...);
}
++reservations;
}
queue.put(message);
}
private class LoggerThread extends Thread {
public void run() {
try {
while(true) {
try {
synchronized(LogService.this){
if(isShutdown && reservations == 0) {
break;
}
}
String message = queue.take();
synchronized(LogService.this) {
--reservations;
}
writer.println(message);
}catch(InterruptedException) {
// retry
}
}
} finally {
writer.close();
}
}
}
}
还有一种方式是将管理线程的工作委托给一个ExecutorService,而不是由其自行管理。示例代码如下:
public calss LogService {
private final ExecutorService exec = newSingleThreadExecutor();
...
public void start(){
}
public void stop() throw InterruptedException {
try {
exec.shutdown();
exec.awaitTermination(TIMEOUT, UNIT);
} finally {
writer.close();
}
}
public void log(String message) {
try {
exec.execute(new WriteTask(message));
} catch(RejectedExecutionException ignored) {
}
}
}
还有一种关闭“生产者-消费者”服务的方式是使用“毒丸”对象:“毒丸”是指一个放在队列上的对象,其含义是“当得到这个对象时,立即停止。”在FIFO队列中使用“毒丸”对象后,将确保消费者在关闭之前首先完成队列中的所有工作,而生产者在提交“毒丸”对象后,将不会再提交任何工作。示例代码如下:
public class IndexingService {
private static final File POISON = new File("");
private final IndexThread consumer = new IndexThread();
private final CrawlerThread producer = new CrawlerThread();
private final BlockingQueue<File> queue;
private final FileFilter fileFilter;
private final File root;
Class CrawlerThread extends Thread {
public void run() {
try {
crawl(root);
}catch(InterruptedException e) {
// 处理中断
} finally {
while(true) {
try {
queue.put(POISON);
break;
} catch(InterruptedException e) {
// 重新尝试
}
}
}
}
private void crawl(File root) throws InterruptedException {
...
}
}
public class IndexThread extends Thread {
public void run() {
try {
while(true){
File file = queue.take();
if(file == POISON) {
break;
} else {
indexFile(file);
}
}
} catch(InterruptedException consumed) {
// 处理异常
}
}
}
}
只有在生产者和消费者的数量都已知的情况下,才可以使用“毒丸”对象。只有在无界队列中,“毒丸对象”才能可靠地工作。另外,上述解决方案可以扩展到生产多个“毒丸”对象或消费多个“毒丸”对象的情况。
shutdownNow的局限性
ExecutorService的shutdownNow方法会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务。
这意味着我们仅能知道已提交尚未开始的任务,无法知道已开始尚未结束的任务。
由于在良好的设计中,任务在返回的时都维持线程的中断状态,所以可将其作为切入点,记录那些线程中断时,正在执行的任务。示例代码如下:
public class TrackingExecutor extends AbstractExecutorService {
private final ExecutorService exec;
private final Set<Runnable> tasksCancelledAtShutdown = Collections.SynchronizedSet(new HashSet<Runnable>());
...
public List<Runnable> getCancelledTasks() {
if(!exec.isTerminated) {
throw new IllegalStateException(...);
}
return new ArrayList<Runnable>(tasksCancelledAtShutdown);
}
public void execute(final Runnable runnable) {
exec.execute(new Runnable(){
try {
runnable.run();
} finally {
// 统计线程中断时,正在执行的任务
if(isShutdown() && Thread.currentThread.isInterrupted()){
tasksCancelledAtShutdown.add(runnable);
}
}
});
}
}
网络爬虫程序的工作通常都是无穷尽的,因此在关闭网络爬虫程序时,期望保存其状态,以便在稍后重新启动。使用TrackingExecutorService来保存未完成的任务的示例代码如下:
public abstract class WebCrawler {
private volatile TrackingExecutor exec;
@GuardedBy("this")
private final Set<URL> urlCollectionToCrawl = new HashSet<>();
...
private synchronized void start() {
exec = new TrackingExecutor(Executors.newCachedThreadPool());
for(URL url : urlCollectionToCrawl) {
submitCrawlTask(url);
}
urlCollectionToCrawl.clear();
}
public synchronized void stop() throws InterruptedException {
// 统计正在执行的任务
try {
saveUncrawled(exec.shutdownNow());
if(exec.awaitTermination(TIMEOUT, UNIT)) {
saveUncrawled(exec.getCancelledTasks());
}
} finally {
exec = null;
}
}
protected abstract List<URL> processPage(URL url);
private void saveUncrawled(List<Runnable> uncrawled) {
for(Runnable task : uncrawled) {
urlCollectionToCrawl.add(((CrawlTask) task).getPage());;
}
}
private void submitCrawlTask(URL url) {
exec.execute(new CrawlTask(url));
}
private class CrawlTask implements Runnable {
private final URL url;
...
public void run(){
for(URL link : processPage(url)) {
if(Thread.currentThread().isInterrupted()){
return;
}
submitCrawlTask(link);
}
}
public URL getPage() {
return url;
}
}
}
上述方案存在一个不可避免的竞态条件,从而产生“误报”问题:一些被任务已取消的任务实际上已经执行完成。产生这个问题的原因在于,在任务执行最后一条指令以及线程池将任务记录为“结束”的两个时刻之间,线程池可能被关闭。如果任务是幂等的(Idempotent,即将任务执行多次与执行一次会得到相同的结果),那么就不会存在太大问题(仅浪费一小部分算力和时间),在网络爬虫程序中就是这种情况。否则,在应用程序中必须考虑这种风险,并对“误报”问题做好准备。
处理非正常的线程终止(多线程场景下异常处理)
在单线程程序中,由于发生了一个未捕获异常而终止时,程序将停止运行,并产生与程序正常输出完全不同的栈堆栈信息。而多线程程序中,某个线程故障后,通常现象不会如此明显。尽管在控制台可能会输出栈追踪信息,但没有人会观察控制台。此外,当线程故障时,应用程序可能看起来仍在工作,所以这个失败的线程操作可能会被忽略。由此,在多线程程序中,需要检测并防止程序中“遗漏”的线程。(“线程泄露”)
导致线程提前死亡的最主要原因是RuntimeException。对于RuntimeException,用来表示出现某种编程错误或其他不可修复的错误,因此通常不会被捕获。它们不会在调用堆栈中逐层传递,而是默认在控制台输出栈跟踪信息,并终止线程。
线程非正常退出可能是良性的,也可能是恶性的,这要取决于线程在应用程序中的作用。所以要根据影响制定相应的处理策略。如在线程池中一个线程的丢失并不会带来严重的影响,而在GUI程序中如果丢失事件分派线程,会造成应用程序停止处理事件,并且GUI将会失去响应。
任何代码都可能抛出一个RuntimeException,每当调用另一个方法时,都要对它的行为保持怀疑,不要盲目地认为它一定会正常返回,或者一定抛出在方法中声明的某个已检查异常。对调用的代码越不熟悉,就越应该对其代码行为保持怀疑。
在线程中调用任务时,如果任务代码未知或者不可信时,应使用try-catch代码块调用这些任务,这样就能捕获那些未检查的异常或者考虑捕获RuntimeException。线程池内部构建的工作线程代码结构示例如下:
public void run() {
Throwable thrown = null;
try {
while(!isInterrupted()) {
runTask(getTaskFromWorkQueue());
}
} catch(Throwable e) {
thrown = e;
} finally {
threadExited(this, thrown);
}
}
除了使用主动的方法来解决未检查异常,在Thread API中同样提供了UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。这两种方法互为补充,可有效的防止“线程泄露”问题。
当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器。如果未提供任何异常处理器,那么默认的行为是将栈跟踪信息输出到System.err。UncaughtExceptionHandler接口定义如下:
public interface UncaughtExceptionHandler {
void uncaughtException(Thread thread, Throwable e);
}
异常处理器如何处理未捕获异常,取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的堆栈信息写入到日志中。将异常写入日志的 UncaughtExceptionHandler实现类的示例代码如下:
public class UEHLogger implements Thread.UncaughtExceptionHandler {
public void uncaughtException(Thread t, Throwable e) {
Logger logger = Logger.getAnonymousLogger();
logger.log(Level.SEVER, "Thread terminated with exception: " + t.getName(), e);
}
}
注意,只有通过execute提交任务,才能将它抛出的异常交给UncaughtExceptionHandler,而通过submit提交的任务,无论是抛出的未检查异常还是已检查异常,都将被任务时任务返回状态的一部分。如果一个由submit提交的任务抛出了异常,那么这个异常将被Future.get封装在ExecutionException中重新抛出。
JVM关闭
JVM即可以正常关闭,也可强行关闭。正常关闭的触发方式有多种,包括:(1)最后一个“正常(非守护)”线程结束时;(2)调用System.exit时;(3) 通过其他特定于平台的方法关闭时(如SIGINT信号或者键入Ctrl-C)。强行关闭的方式有:(1) 调用Runtime.halt;(2)在操作系统中“杀死”JVM进程(如发送SIGKILL命令)。
在关闭JVM时,如果是正常关闭JVM,那么JVM先会调用所有已注册的关闭钩子。如果有(守护或非守护)线程仍在运行,那么这些线程将与关闭进程并发执行。注意JVM并不会停止或中断任何在关闭时仍运行的线程,当JVM最终结束时,这些线程将被强行结束。当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器,然后再停止。如果强行关闭JVM,那么将不会运行关闭钩子,仍在运行的线程也会被强行结束,也不会执行终结器。
关闭钩子
在正常关闭中,JVM首先调用所有已注册的关闭钩子(Shutdown Hook)。关闭钩子是指通过Runtime.addShutDownHook注册的但尚未开始的线程。JVM不保证已注册关闭钩子的调用顺序。关闭钩子应该是线程安全的:它们在访问共享数据时,必须使用同步机制,并且要避免发生死锁。这与其他并发代码要求一致。而且,关闭钩子不应对程序状态或JVM关闭原因进行假设,所以关闭钩子的代码必须考虑周全。最后钩子必须尽快退出,因为它们会延迟JVM的结束时间,而用户可能希望JVM能尽快终止。
关闭钩子可以用来实现服务或应用程序的清理工作,如删除临时文件或清除无法由操作系统自动清除的资源等。
由于关闭钩子将并发执行,因此在处理日志文件关闭等公共操作时,为避免并发问题,可使用同一关闭钩子。这样将多个关闭操作串行执行,而不是并行执行,可以消除许多潜在故障。
守护线程(Daemon Thread)
无论JVM是正常关闭还是强行关闭,守护线程都会被抛弃。
线程可分为两种:普通线程和守护线程。在JVM启动时创建的所有线程中,除了主线程,其他的线程都是守护线程。
当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程,而守护线程创建的线程仍是守护线程。
普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。当一个线程退出时,JVM会检查其他正在运行的线程,如果这些线程都是守护线程,那么JVM会正常退出操作。
当JVM退出时,所有仍在运行的守护线程都将被抛弃——既不会执行finally代码块,也不会执行回卷栈。
应尽量少的使用守护线程——很少有操作能在不进行清理的情况下被安全地抛弃。特别是守护线程中执行可能包含I/O操作的任务,那将是一种危险的行为。守护线程最好执行“内部”任务,如周期性地从内存的缓存中移除过期的数据等。
终结器
终结器用来作为垃圾回收的一种补充——保证一些持久化的资源被释放。
由于终结器可在某个由JVM管理的线程中执行,因此终结器访问的任何状态都可能被多个线程访问,所以在使用终结器时,要对共享数据的访问操作进行同步。在大多数情况下,使用finally代码块和显式的close方法,能够比使用终结器更好地管理资源。唯一的例外是:当需要管理对象,并且该对象持有的资源是通过本地方法获得的。
基于以上原因,要尽量避免编写终结器或使用包含终结器的类(除非是平台库的类)。
总结
通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor框架将任务提交和执行策略解耦开来,同时还支持多种不同类型的执行策略。
Java并没有提供抢占式的机制来取消操作或终结线程,而是提供了一种协作式的中断机制来实现取消操作,而这要依赖于如何构建取消操作的协议,以及能否始终遵循这些协议。
原创不易,如果本文对您有帮助,欢迎关注我,谢谢 ~_~