《Java并发编程实战》学习笔记--取消与关闭

写在前面

任务和线程的启动是一件非常容易的事情。在大多时候,我们都会让它们从开始运行到结束,或者让它们自行停止。然而,有的时候我们希望提前结束任务或者是线程:有可能是它们运行时发生了错误;有可能是用户取消了操作,或者是应用程序需要被快速关闭。可是要是任务和线程快速、安全地停下来,并不是一件十分容易的事情。Java中也没有提供任何安全的机制能够使它们停下来(虽然Thread.stop和suspend等方法提供了这样的机制,但是它们存在一些严重的缺陷,应该避免使用)。但Java提供了中断(Interruption),这是一种协作机制,能够使一个线程阻止另一个线程现在正在进行的工作。这种良好的协作机制是必须的,我们不希望某任务、线程或者是服务立即停止,因为这种立即停止会使得共享的数据结构处于不一致的状态。相反,在编写任务和服务的时候我们可以使用一种协作机制:当需要停止的时候,它们会首先清楚当前正在执行的工作,然后再结束。这提供了更好的灵活性,因为任务本身的代码比发出取消请求的代码更清楚该如何清楚该如何执行清楚工作。

生命周期结束(End-of-lifecycle)的问题会使任务、服务以及程序的设计以及实现的过程变得复杂,并且这个在程序设计的过程当中尝尝被忽略,但它又是非常重要的。一个运行良好的软件和一个运行情况很糟糕的软件的区别就在于:行为良好的软件能很完善地处理失败、关闭和取消等过程。

下面将给出各种实现取消和中断的机制,以及如何编写任务和服务,是它们能够对取消请求做出响应。

任务取消

如果外部代码能够在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以成为可取消的(Cancellable)。取消某个操作的原因有很多:

  • 用户请求取消:
    用户点击“取消”按钮或者通过管理接口来发送取消请求;
  • 有时间限制的操作:
    例如某个应用程序需要在某个特定的时间完成并返回,如果到了规定的时间没有完成,那么当计时器超时时则取消正在进行的任务;
  • 应用程序事件:
    例如,应用程序对某个问题空间进行分解并搜索,从而使不同的任务可以搜索问题空间中的不同区域。当其中一个任务找到了解决方案时,所有其他正在进行的搜索任务都将被取消。
  • 错误:
    当程序在运行当中发生了错误时,程序应该保存当前的状态然后取消接下来的任务。
  • 关闭:
    当程序或者服务关闭时,必须对正在运行或处理的工作执行某种操作。在平缓的关闭过程中,当前工作将继续执行直到完成,而在立即关闭的过程中,当前任务(工作)可能被取消。

能设置某个“已请求取消(Cancellation Requested)”标志的协作机制:
任务将定期查看该标志。如果设置了该标志,那么任务将提前结束。
下面的PrimeGenerator使用了简单的取消策略:客户端通过调用cancel来请求取消,PrimeGenerator在每次搜索素数之前都先检查一下是否存在取消请求,如果存在则退出。

//其中的PrimeGenerator持续地枚举素数,直到它被取消。calcel方法将设置cancelled标志,并且主循环
//在搜索下一个素数之前会首先检查这个标志。(为了让这个过程可靠地工作,标志cancelled必须为volatile)
public class PrimeGenerator implements Runnable{
        private final List primes = new ArrayList();
        //使用volatile类型的域来保存取消状态
        private volatile boolean cancelled;
        public void run(){
            //从1开始
            BigInteger p = BigInteger.ONE;
            while(!cancelled){
                //每次获得下一个素数
                p = p.nextProbablePrime();
                synchronized(this){
                    primes.add(p);
                }
            }
        }
        public void cancel(){
            cancelled = true;
        }
        public synchronized List get(){
            return new ArrayList(primes);
        }
}
//让素数生成器运行一秒钟之后取消。素数生成器可能并不会在刚好运行了一秒钟的时候取消,因为在请求
//取消时刻和run方法中循环执行下一次检查之间可能存在延迟。cancle方法由finally调用,即使是在调用
//sleep时被中断也能取消素数生成器的运行。如果素数生成器没有被取消,那么它将一直运行下去,
//不断消耗CPU时钟周期,使得JVM不能正常退出。
List aSecondPrimes() throws InterruptedException{
    PrimeGenerator generator = new PrimeGenerator();
    new Thread(generator).start();
    try{
        SECONDS.sleep(1);
    }finally{
        generator.cancel();
    }
    return generator.get();
}
可靠的取消策略

一个可靠的取消策略应该有自己的“HWW”原则:

  • How:外部代码如何请求取消该任务?
  • When:外部代码何时取消该任务?
  • What:在响应取消操作时应该进行哪些操作?

通常,中断是实现取消的最合理的方式。

上面的PrimeGenerator中的取消机制最终会使搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而,如果使用这种方法的任务调用了一个阻塞方法,例如BlockingQueue.put,那么可能会产生一个更为严重的问题:任务可能永远不会检查取消标志,因此永远不会结束。

如下面程序所示:
生产者生产素数,并将它们放入一个阻塞队列。如果生产者的速度超过了消费者的处理速度,队列将被填满,put方法就会被阻塞。当生产者在put方法中阻塞时,如果消费者希望取消生产者任务,那么将发生什么情况呢?它可以调用cancel方法来设置cancelled标志,但此时生产者却永远不能检查这个标志,因为它无法从阻塞的put方法中恢复过来(因为消费者此时已经停止从队列中取出素数,所以put方法将一直保持阻塞状态)。

class BrokenPrimeGenerator extends Thread{
        private final BlockingQueue queue;
        private volatile boolean cancelled;
        BrokenPrimeGenerator(BlockingQueue queue){
            this.queue = queue;
        }
        public void run(){
            BigInteger p = BigInteger.ONE;
            while(!cancelled){
                queue.put(p = p.nextProbablePrime());
                }catch(InterruptedException consumed){}
            }
        }
        public void cancel(){
            cancelled = true;
        }
        
        void consumePrimes() throws InterruptedException{
            BlockingQueue primes = new BlockingQueue();
            BrokenPrimeProducer producer = new BrokenPrimeProducer(primes);
            producer.start();
            try{
                while(needMorePrimes){
                    consume(primes.take())
                }finally{
                    producer.cancel();
                }
            }
    }

那么如果程序能够响应中断,就可以使用中断作为取消机制了,不是吗?
那再看看下面这个程序:
这里是通过使用中断而不是boolean标志来请求取消。在每次迭代循环当中,有两个位置可以检测出中断:在阻塞的put方法调用中,以及在循环开始处查询中断状态时。由于调用了阻塞的put方法,因此这里不一定需要显示的检测,但执行检测却会使PrimeProducer对中断具有更高的响应性,因为它是在启动寻找素数任务之前检查中断的,而不是在任务完成之后。如果可中断的阻塞方法的调用频率不高,不足以获得足够的响应性,那么显式地检测中断状态能起到一定的帮助作用。

 class PrimeProducer extends Thread{
        private final BlockingQueue queue;
        private volatile boolean cancelled;
        
        PrimeProducer(BlockingQueue queue){
            this.queue = queue;
        }

        public void run(){
            BigInteger p = BigInteger.ONE;
            while(!Thread.currentThread().isInterrupted(){
                queue.put(p = p.nextProbablePrime());
                }catch(InterruptedException consumed){
                    /*允许线程退出*/
                }
            }
        
        public void cancel(){
            interrupt();
        }
}

关于中断策略

就像任务中应该包含取消策略一样,线程同样应该包含中断策略。中断策略规定线程如何解释中断请求,即当中断发生的时候应该做哪些工作,哪些工作单元对于中断来说是原子操作,以及以多快的速度来相应中断等等。

其实,最合理的中断策略应该是某种形式的线程级(Thread - Level)服务级(Service - Level)取消操作:
尽快退出,在必要的时候进行清理,并通知所有者线程自己已经退出。此外,我们还可以制定其他的中断策略,比如说暂停服务或者是重新开始服务等。但对于那些包含非标准中断策略的线程或线程池来说,中断策略只能用于能知道这些策略的任务当中。

一个中断请求可以有多个响应者,并且如果对于非线程所有者来说,应该小心地保存中断状态,这样拥有线程的代码才能对中断做出正确的反应。其实这也就是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断响应。他们永远不会在某个由自己拥有的线程中运行,因此它们为任务或库代码实现了最合理的取消策略:尽快退出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以才去进一步的操作。

当任务接到中断请求的时候,任务不一定要立马放弃所有的操作-----它可以推迟处理中断请求,并直到某个更合适的时刻。因此我们需要记住中断请求,并且在完成任务之后抛出InterruptedException或者表示已经收到中断请求。这项技术能够保证在中断发生时不会破坏数据结构。

还有,任务不该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。无论任务把中断视为取消还是其他某个中断响应操作,都应该小心地保存这些中断状态。如果除了将InterruptedException传递给调用者之外还要继续执行其他操作,那就应该在捕获InterruptedException之后恢复中断的状态:Thread.currentThread().interrupt()。

由于每个线程拥有各自的中断策略,除非我们知道中断对于该线程的含义,否则就不应该中断这个线程。

下面来看一个不合理的中断方法(计时运行示例):

 private static final ScheduleExecutorService cancelExex = new ScheduleExecutorService();
 public static void timedRun(Runnable r, long timeout, TimeUnit unit){
     final Thread taskThread = Thread.currentThread();
     cancelExex.schedule(new Runnable(){
         public void run(){ taskThread.interrupt();}
     }, timeout, unit);
     r.run();
 }

上面是一种很简单的方法,但是破坏了以下原则:在中断线程之前不了解它的中断策略。由于timedRun可以被任意一个线程调用,又因为它无法知道这个调用线程的中断策略。如果任务在超时之前完成,那么中断timedRun所在线程的取消任务将在timedRun返回到调用者之后启动。我们不知道这种情况之下将运行什么代码,但结果一定是不好的。而且如果任务不响应中断,那么timedRun会在任务结束时才返回,此时可能已经超过指定的时限(或者还没有超过时限)。如果某个限时运行的服务没有在指定的时间内返回,那么将对调用者带来负面影响。

守护线程

有的时候我们希望能够创建一些线程来完成一些辅助性的工作,但是我们又不希望此类线程会阻碍到JVM的关闭。在这种情况之下就应该使用守护线程(Daemon Thread)。

线程可以分为两种:一种是普通线程,一种是守护线程。在JVM启动之时创建的所有线程当中,除了主线程之外,其他都是守护线程。当我们创建一个新的线程时,新的线程将继承创建它的线程的守护状态。因此在默认情况之下,主线程创建的所有线程都是普通线程

普通线程和守护线程的主要差异仅仅在于当线程退出时发生的操作:当一个线程退出时,JVM会检查其他正在运行的线程,如果发现这些线程都是守护线程,那么JVM会正常退出。当JVM停止时,所有仍然存在的守护线程都将被抛弃----既不会执行finally代码块,也不会执行回卷栈,而JVM也是直接退出。

所以我们应尽可能少地使用守护线程----很少有操作能够在不进行清理的情况下被安全地抛弃。特别是如果在守护线程中执行可能包含I/O操作的任务中,那么将是一种危险的行为。守护线程最好用于执行“内部”任务,例如周期性地从内存缓存中移除逾期的数据。

你可能感兴趣的:(《Java并发编程实战》学习笔记--取消与关闭)