《Java并发编程实践》三(7):任务取消

通过Executor框架提交一个任务是很简单的,绝大多数情况下,我们让任务代码执行完毕;换句话说,任务自身决定如何结束。如果调用者想终止一个任务该怎么办?上一章介绍的Future.cancel方法看起来正好满足我们的需求,然而如何安全、快速、可靠地取消一个异步任务或线程,要比这句代码看起来要复杂得多,值得我们用一整章来学习。

任务取消

在任务正常结束前,取消一个任务可能有以下几种动机:

  • 用户要求:比如用户点击取消按钮;
  • 超时:比如应用在一个无限的空间内搜寻一个最优解,肯定要限制时间;
  • ERROR:一组相关的任务在并发执行,任何一个出错,需要全部任务停止;
  • shutdown:应用程序优雅关闭,要求每个执行中的任务妥善地关闭;

在Java的世界里,不存在一种从外部安全地、强行关闭线程的方法(其他语言应该也没有),由于task在线程中执行,所以task也一样。关闭异步任务只能通过协商机制,请求代码和任务代码遵循某种协议。

典型的关闭协议是这样的,请求代码在任务上设置一个取消标记,任务在某些执行点检查这个标记,然后提前结束自己的工作。这里有两个核心思想:

  • 必须由任务自身来结束自己,因为只有任务自己知道在哪个地方提前结束是安全的,不会留下错误的状态;
  • 任务甚至可以选择不取消自己,因此请求代码无法预期任务何时取消,也无法判断任务是否真的被取消了,除非它知道任务的执行细节。
  • 能够响应取消请求的任务,被称之为可取消的任务

下面的质数产生器PrimeGenerator采用了上述关闭协议

@ThreadSafe
public class PrimeGenerator implements Runnable {
	@GuardedBy("this")
	private final List<BigInteger> primes = new ArrayList<BigInteger>();
	private volatile boolean cancelled;
	public void run() {
		BigInteger p = BigInteger.ONE;
		while (!cancelled) {
			p = p.nextProbablePrime();
			synchronized (this) {
			primes.add(p);
			}
		}
	}
	public void cancel() { 
		cancelled = true; 
	}
}

Interruption

PrimeGenerator接受到cancel请求后,一小段时间后就会停止。不过,要是任务正在执行一个阻塞方法(比如BlockingQueue.poll),那问题就完全不可控了,因为它在阻塞结束之前不会有机会检查cancel标记。

此时我们需要使用java中断机制(Interruption),中断是java提供的一种通用的异步任务关闭协议;第5章说过java库里包含的阻塞方法都会抛出InterruptionException,这个行为正是中断机制的一部分。

java关于中断机制的使用动机,并没有任何规定和语法限制,但是将中断用于任务取消之外的场景是脆弱的,难以维护的。

不要将java中断用于任务取消之外的目的,就像不要用异常来做代码跳转一样。

java中断机制介绍

java的中断机制,大概包含以下几个要点:

  • 每个线程有一个boolean类型的interrupted状态值;
  • Thread.interrupt()方法设置目标线程的中断状态为true;
  • Thread.isInterrupted()方法查询中断状态;
  • Thread.isInterrupted(true)方法查询且清除中断状态;
  • 静态方法Thread.interrupted(),这是一个命名很坑的方法,它执行的是currentThread().isInterrupted(true)

从命名上来讲,java的中断机制实在糟糕到无以复加,因为它并不能”中断“任何事物。

对于进入阻塞状态的线程,比如调用了Thread.sleep或Object.wait,JVM会检测当前线程的中断状态,并使阻塞方法抛出InterruptedException,且清除线程的阻塞状态。JVM并不保证从设置中断状态到抛出异常之间的时延,但这通常非常快。

线程中断策略

每个线程应当有合适的中断策略,所谓中断策略是指,线程如何应对中断请求,大致需要考虑以下几点:

  • 哪些操作对中断来说是原子的,不能提前结束;
  • 响应中断需要多长时间;
  • 响应中断时需要执行什么行为。

如果客户代码并不知道目标线程的中断策略,武断地调用Thread.interrupt()方法是很危险的。

另外,除非我们创建专门执行某个任务的线程,否则我们无法一站式地实现线程的中断策略。比如线程池内的线程,它必须和任务代码互相配合来实现合理的中断策略。

任务的中断策略

任务通常并不拥有执行它的线程,因此它不能对该线程的中断策略做任何假定,此时任务对中断的合理响应如下:

  • 尽快结束自己;
  • 保留线程已被中断的事实,让线程拥有者有机会来处理中断。

任务保留线程已被中断的事实有两种方式,第一种是保留线程的interrupted状态值,第二种是抛出InterruptedException异常;二者选择其一(但不能同时使用)。所有JDK库内的阻塞方法都选择第二种方式,抛出InterruptedException,因为它们期望调用者必须注意到这一点。

那么执行阻塞方法的任务代码捕获到InterruptedException该如何处理?

  • 如果任务代码拥有当前线程,那么贯彻既定的中断策略即可;
  • 否则应该先执行必要的清理工作,然后再重新抛出InterruptedException,或重新设置Thread的中断状态。

以下代码展示了上述策略:

@ThreadSafe
public class CancellableTask implements Runnable {

	public void run() throws InterruptedException {
		try {
			Thread.sleep()
		} catch(InterruptedException e) {
			throw e;
			//或者Thread.currentThread().interrupt();
		}
	}
}

至于任务到底是该抛出InterruptedException,还是设置Thread的中断状态,如果任务知道线程的中断策略,那么选择合适的即可;否则二者都可以。反过来讲,如果一个线程通常应该同时支持两种方式。

Future.cancel()

对于使用ExecutorService来执行任务的情景,使用Future来取消任务是最佳实践。

再回顾一下Future的cancel方法:

public interface Future<V> {
	boolean cancel(boolean mayInterruptIfRunning);
}

对于正在执行中的任务,cancel方法的mayInterruptIfRunning参数可设为true,这样可通过中断执行线程来中断任务。通过其他途径来中断Executor内的线程是不允许的,因为你不知道该线程正在执行哪个任务;而Future对象是Executor创建的,它会与Executor线程池互相配合来实现其任务中断策略。

使用Future.cancel的一个常见场景就是限时任务,以下是示例代码:

Future<?> task = taskExec.submit(r);
try {
	task.get(timeout, unit);
} catch (TimeoutException e) {
	// task will be cancelled below
} catch (ExecutionException e) {
	// exception thrown in task; rethrow
	throw launderThrowable(e.getCause());
} finally {
	// Harmless if task already completed
	task.cancel(true); // interrupt if running
}

上面异常处理方式是很值得研究的:

  • TimeoutException:不做任何处理,这是意料之内的异常,交给finally块来统一处理;
  • ExecutionException:这说明任务执行自身抛出了异常,此时将该异常往外抛是合理的;
  • finally:不管是任务异常、任务超时,还是调用线程自身被中断,该任务结果都不会有用了,统一取消任务。

cancel一个已经完成的任务是无害的,没有任何副作用。

处理不可中断的阻塞

有些阻塞是不可中断的,比如同步Socket I/O操作,或等待java监视器锁。对于此类操作,如果你想提前终止它,必须使用特定的手段。对于Socket I/O,可关闭socket连接;而对于java监视器锁,一句话:没辙,对于需要中断的场景,可考虑使用显示Lock,它的lockInterruptibly方法以一种可中断的方式阻塞。

关闭拥有线程的Service

应用程序经常创建那些包含线程的服务,比如ExecutorService就是典型。前面提到过一个原则,除非你拥有该线程,否则你不能直接操作该线程。线程所有权没有正式的定义,但是我们可认为创建且管理线程生命周期的Service拥有这些线程,因而当应用退出时,Service有义务关闭它拥有的线程。

注意,线程所有权没有所谓传递性,客户端代码拥有Service对象,并不意味着也应用Service的线程,所以Service应当提供一个类似shutDown的方法来关闭内部线程,而不能由客户代码越俎代庖。

案例:LogService

假设有一个负责日志打印的Service,为了提升响应性,使用一个后台线程来将日志写入文件,实现如下:

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 msg) throws InterruptedException {
		queue.put(msg);
	}
	private class LoggerThread extends Thread {
		private final PrintWriter writer;
		public void run() {
			try {
				while (true) writer.println(queue.take());
			} catch(InterruptedException ignored) {
			} finally {
				writer.close();
			}
		}
	}
}

这是一个典型的“多producer-单consumer”的设计模式,暂不考虑它的性能如何,我们仅考虑如何关闭它。简单地通过中断方式来关闭LoggerThread显然不可取,因为已提交的日志不会被打印,更重要的是调用log方法的客户线程会被阻塞住。

很容易想到,我们可以加一个关闭标记,关闭LogService的步骤如下:

  1. 先设置关闭标记;
  2. log方法不再允许新的日志进入;
  3. LoggerThread将队列里面的日志全部打印完成:
  4. LoggerThread退出。
public void log(String msg) throws InterruptedException {
	if (!shutdownRequested)
		queue.put(msg);
	else
		throw new IllegalStateException("logger is shut down");
}

这个方法看起来正确,但是存在竞争条件,因为步骤1,3,4和步骤2不再同一个线程中执行,步骤3完成后,仍然可能有客户代码正在执行log方法且被阻塞住。

对于并发集合类型,我们应当建立这个认知:除非有额外的锁机制来保证,否则只能认为集合处于一种连续的变化当中,集合中的元素数量是无法准确判断的。

当笔者看到LogService第二个实现方案,直觉认为它是OK的,很快被打脸,这说明设计线程安全的程序是多么不易,如果不进行严谨的竞争条件分析,光凭直觉后果惨不忍睹。另一方面,也说明我们应该尽量使用成熟的并发策略模式,不要自作聪明。

关闭ExecutorService

ExecutorService提供两个关闭方法:

  • shutDown:优雅关闭,等待所有任务(包括队列中的任务)全部完成;
  • shutDownNow:强行关闭,内部尝试中断正在执行的任务线程,并且返回所有未开始执行的任务。

实际使用中,可以使用shutDown+awaitTermination来做一个折中:

ExecutorService exec = ...
exec.shutdown();
exec.awaitTermination(TIMEOUT, UNIT);

shutDownNow方法返回尚未开始的任务列表,这个列表是不准确的,因为ExecutorService内部也是一个生产者和消费者模型,无法准确地识别任务是否已经开始(就像LogService无法准确获取日志queue的大小)。

总之一句话,ExecutorService没有提供任何机制来告诉调用者,一个任务是否已经开始,如果有此需求,需要Task自身添加额外的判定接口。

线程异常结束

当线程内的任务抛出未捕获的异常,线程将在打印异常栈之后推出。一个线程异常退出可能对程序没有什么影响,也有可能导致程序不再工作。

任何代码都可能抛出异常,尤其当我们调用第三方代码时,肯定不期望意外的异常引起整个系统瘫痪,于是产生捕获RuntimeException的动机。

绝大多数情况下,我们的代码运行在框架管理的线程中(比如Executor),此时我们可以在遭遇RuntimeException时,执行必要的清理工作后重新抛出该异常。线程管理框架会妥善处理这种情况:让该线程退出,并创建新的线程来处理后续任务。

如果我们自己来管理线程,最好也使用类似的方案(*再次说明妥善管理线程是多么不易的一件事)。

Uncaught exception handler

java的Thread可以设置一个UncaughtExceptionHandler,通过它,在线程遭遇未捕获异常而即将退出时,我们能收到通知。如果Thread没有设置UncaughtExceptionHandler,默认行为是在System.err打印异常栈。

UncaughtExceptionHandler执行的操作一般是在程序日志中记录一下,也可以执行更复杂的操作:比如重新启动线程。

对于ThreadPoolExecutor,如果我们想在任务抛出未捕获异常时得到通知,有以下几种途径:

  • 将所有任务再包装一层Runnable或Callable,用来捕获任务的异常;
  • 给ThreadPoolExecutor设置一个ThreadFactory,后者创建线程时设置UncaughtExceptionHandler;
  • 派生ThreadPoolExecutor并复写afterExecute方法。

可能让人迷惑的一个事实是:通过Executor.execute提交的任务抛出的异常才被当做未捕获异常(上面的手段凑效);而对于ExecutorService.submit提交的任务,它抛出的异常被当做一种执行结果放入Future。

JVM关闭

JVM能以一种有序的方式关闭,或突然关闭。前者发生在所有(非Daemon)现场退出、System.exit被调用或收到SIGINT信号(linux平台下)。突然关闭的方式则有调用Runtime.halt,杀死进程。

有序关闭的情况下,JVM首先执行所有注册的shutdown hooks;shutdownhook可以认为是未开始执行的线程,通过Runtime.addShutdownHook来注册。

JVM不保证shutdown hook以何种顺序执行,因而每个具体的shutdown hook在执行时可能面临的是一个关闭到一半的应用;而且如果还有其他应用线程没有退出,他们会和shutdown hook并发执行。

因此,shutdown hook的代码需要具备非常好防御性,只能执行非常安全的操作:

  • hook代码必须是线程安全的;
  • 不能依赖其他可能被关闭的服务,比如日志系统;
  • 只执行必要的清理工作,不执行任何业务逻辑;
  • 如果要保证多个关闭操作的顺序性,将他们放在单个hook内。

当所有的hook执行完了,runFinalizersOnExit被设置为true,JVM运开始行对象的finaliz方法,然后JVM进程结束。在这个过程中JVM不会尝试中断任何应用线程,这些线程都会随进程结束而突然死亡。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值