任务的取消

大多数情况下,任务运行完后会自动结束。然而,有时我们希望提前结束任务或线程,可能是因为用户取消了操作,或者应用程序需要被快速关闭。但是,Java并没有提供任务机制来安全地终止线程。虽然如此,但Java提供了线程中断,中断是一种协作机制,能使一个线程终止另一个线程的当前工作。

我们很少希望某个任务、线程或服务立即停止,因为这种立即停止会使共享数据处于不一致的状态,而使用协作机制的方式:当需要停止时,它们首先会清除当前正在执行的工作,然后再结束。这提供了更好的灵活性,因为任务本身的代码比发出取消请求的代码更清楚如何执行清除工作。

一、任务取消

1.使用volatile状态变量来控制

操作被取消的原因有很多,比如超时,异常,请求被取消等等。

一个可取消的任务要求必须设置取消策略,即如何取消,何时检查取消命令,以及接收到取消命令之后如何处理。

最简单的取消办法就是利用取消标志位,如下所示。这段代码用于生成素数,并在任务运行一秒钟之后终止。其取消策略为:通过改变取消标志位取消任务,任务在每次生成下一随机素数之前检查任务是否被取消,被取消后任务将退出。

@ThreadSafe
public class PrimeGenerator implements Runnable {
    private static ExecutorService exec = Executors.newCachedThreadPool();

    @GuardedBy("this") private final List<BigInteger> primes = new ArrayList<BigInteger>();
    //必须是volatile
    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;
    }

    public synchronized List<BigInteger> get() {
        return new ArrayList<BigInteger>(primes);
    }

    static List<BigInteger> aSecondOfPrimes() throws InterruptedException {
        PrimeGenerator generator = new PrimeGenerator();
        exec.execute(generator);
        try {
            SECONDS.sleep(1);
        } finally {
            generator.cancel();
        }
        return generator.get();
    }
}

然而,该机制最大的问题就是无法应用于阻塞方法,例如BlockingQueue.put()。可能会产生一个很严重的问题——任务可能因阻塞而永远无法检查取消标识,导致任务永远不会结束。

class BrokenPrimeProducer extends Thread {
    private final BlockingQueue<BigInteger> queue;
    private volatile boolean cancelled = false;

    BrokenPrimeProducer(BlockingQueue<BigInteger> 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;
    }
}

2.中断 

线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能情况下停止当前工作。

虽然线程的取消和中断没有必然联系,但是在实践中发现:中断是实现取消的最合理方式。

对中断操作的正确理解是:它并不会真正的中断线程,而是给线程发出中断通知,告知目标线程有人希望你退出。目标线程收到通知后如何处理完全由目标线程自行决定,这是非常重要的。线程收到中断通知后,通常会在下一个合适的时刻(被称为取消点)中断自己。有些方法,如wait、sleep和join等将严格地处理这种请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。

对于前面BrokenPrimeProducer的问题很容易解决(和简化),使用中断而不是boolean标识来请求取消。

public class PrimeProducer extends Thread {
    private final BlockingQueue<BigInteger> queue;

    PrimeProducer(BlockingQueue<BigInteger> 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();
    }
}

代码中有两次检查中断请求:

①第一次是在循环开始前,显示检查中断请求;

②第二次是在put方法,该方法为阻塞的,会隐式的检测当前线程是否被中断;

2.1中断策略

正如任务中应该包含取消策略一样,线程同样也需要有中断策略:发现中断请求时,应该做那些工作(如果需要),以多快速度来响应中断(立即响应,还是推迟响应)。

最合理的中断策略是某种形式的线程级别的取消操作或服务级别的取消操作:尽快退出,在必要时进行清理,通知某个所有者线程该线程已经退出。

此外,还可以建立其它的中断策略,例如暂停服务或重新开始服务,但对于哪些包含非标准中断策略的线程或者线程池,这些中断策略只能用于知道这些策略的任务中。

任务不会在其自己拥有的线程中执行,而是在某个服务(如线程池)拥有的线程中执行。对于非线程所有者的代码来说(例如,对于线程池而言,任何在线程池实现意外的代码),应该

小心地保存中断状态,这样拥有线程的代码才能对中断做出响应,即使非所有者代码也可以做出响应。(当为一户人家打扫房屋时,即使主人不在,也不应该把这段时间内收到的邮件扔掉,而是应该收起来等主人回来后交给他们处理,尽管你可以阅读它们的杂志)。

这就是为什么大多数可阻塞的库方法都是抛出中断异常(InterruptedException)作为中断响应。它们永远不会在某个由自己拥有的线程中运行,因此它们为任务或库代码实现了最合理的取消策略:尽快退出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。

当检测到中断请求时,任务并不需要立刻放弃所有的操作,它可以推迟处理中断请求到某个更适合的时刻。既然要推迟处理,就需要记住中断请求,并在完成当前任务后抛出中断异常,或者表示已收到中断请求。

无论任务把中断视为取消,还是其它某个中断响应操作,都应该小心地保存执行线程的中断状态。如果除了将中断异常传递给调用者外,还需要进行其它操作,则因该在捕获中断异常之后回复中断状态。

Thread.currentThread().interrupt();

切记,只有实现了线程中断策略的代码才能屏蔽中断请求,在常规的任务和库代码中都不应该屏蔽中断请求。

虽然有人质疑Java没有提供抢占式的中断机制,但是开发人员通过处理中断异常的方法,可以定制更为灵活的中断策略,从而在响应性和健壮性之间做出合理的平衡。

2.2响应中断

当调用可中断的阻塞函数时,例如Thread.sleep或者BlockingQueue.put等,有两种实用策略可用于响应中断

  • 传递异常(可能在执行某个特定于任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法。
  • 恢复中断状态,从而使调用栈中的上层代码能够对其进行处理

①传递异常

将InterrupedException传递给调用者:

    BlockingQueue<Task> queue;
    ……
    //抛出InterruptedException,从而将中断传递给调用者
    public Task getNextTask() throws InterruptedException{
        return queue.take();
    }

②恢复中断状态

如果不想或无法传递InterruptedException(或者通过Runnable来定义任务),那么需要寻找另外的方式来保存中断请求。一种标准的方法就是通过再次调用interrupt来恢复中断状态。你不能屏蔽InterruptedException,例如在catch块中捕获到异常却不做任务处理,除非在你的代码中实现了线程的中断策略。虽然PrimeProducer屏蔽了中断,但这是因为它已经知道线程将要结束,因此在调用栈中已经没有上层代码需要知道中断信息。由于大多数代码并不知道它们将在哪个线程中运行,因此应该保存中断状态。

对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获InterruptedException时恢复状态。如果过早地设置中断状态,就可能引起无限循环,因为大多数可中断的阻塞方法都会在入口处检查中断状态,并且当发现该状态已被设置时会立即抛出InterruptedException。(通常,可中断的方法会在阻塞或进行重要的工作前首先检查中断,从而尽快地相应中断)

不可取消的任务在退出前恢复中断:

public TaskgetNextTask(BlockingQueue<Task>queue){
   booleaninterrupted=false;
   try{
      while(true){
          try{
              return queue.take();
          }
          catch (InterruptedException e){
             interrupted=true;
             // 重新尝试
          }
      }
   }
   finaly{
      if (interrupted) 
          Thread.currentThread().interrupt();
   }
}

如果代码不会调用可中断的阻塞方法,那么仍然可以通过在任务代码中轮询当前线程的中断状态来响应中断。要选择合适的轮询频率,就需要在效率和响应性之间进行权衡。如果响应性要求较高,那么不应该调用那些执行时间较长并且不响应中断的方法,从而对可调用的库代码进行一些限制。

3.通过Future来实现取消

Future是JDK库中的类,可用来管理任务的生命周期,自然也可以来取消任务,调用Future.cancel方法就是用中断请求结束任务并退出,这也是Executor的默认中断策略。

通常,使用现有库中的类比自行实现更好,所以我们可以直接使用Future来实现任务的取消。

看下面的实现:将任务提交给一个ExecutorSevice,并通过一个定时的Future.get来获取结果。如果get返回时抛出了TimeoutException,那么任务将通过它的Future来取消。(为了简化,这里在finally中直接调用Future.cancel,因为取消一个已完成的任务不会带来任何影响)。如果任务在被取消前就被抛出一个异常,那么该异常将被重新抛出以便由调用者来处理。

通过Future来取消任务:

public class TimedRun {
    private static final ExecutorService taskExec = Executors.newCachedThreadPool();

    public static void timedRun(Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
        Future<?> task = taskExec.submit(r);
        try {
            task.get(timeout, unit);
        } catch (TimeoutException e) {
            // 因超时而取消任务
        } catch (ExecutionException e) {
            // 任务异常,重新抛出异常信息
            throw launderThrowable(e.getCause());
        } finally {
            // 如果该任务已经完成,将没有影响
            // 如果任务正在运行,将因为中断而被取消
            task.cancel(true); // interrupt if running
        }
    }
}

这里给出了一种良好的编程习惯:取消哪些不再需要结果的任务。

当Future.get抛出InterruptedException或TimeoutException时,如果不再需要结果,那么就可以使用Future.cancel来取消任务。这是一种良好的编程习惯。

4.处理不可中断的阻塞

参考: https://www.jianshu.com/p/613286f4245e

5.采用newTaskFor来封装非标准的取消

二、停止基于线程的服务

与其它封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程。用通俗易懂的话讲就是应用程序管理服务,服务管理工作者线程,而应用程序不能直接管理工作者线程,因此应用程序不能直接停止工作者线程,而是应该由服务提供生命周期方法来关闭它自己以及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。比如,在ExecutorService中就提供了shutdown和shutdownNow等方法。同样,在其它拥有线程的服务中也应该提供类似的关闭机制。

1.关闭ExecutorService

ExecutorService提供了两种关闭方法:

  • 使用shutdown正常关闭。
  • 使用shutdownNow 强行关闭。

在进行强行关闭时, shutdownNow 首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。

这两种关闭方式的差别在于各自的安全性和响应性:

强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而正常关闭虽然速度慢,但却更安全,因为 ExecutorService会一直等到队列中的所有任务都执行完成后才关闭。在其他拥有线程的服务中也应该考虑提供类似的关闭方式以供选择。

简单的程序可以直接在main 函数中启动和关闭全局的 ExecutorService。而在复杂程序中,通常会将 ExecutorService 封装在某个更高级别的服务中,并且该服务能提供自己的生命周期方法。例如下面程序清单中,它将管理线程的工作委托给一个ExecutorService,而不是由其自行管理。通过封装 ExecutorService,可以将所有权链从应用程序扩展到服务以及线程,所有权链上的各个成员都将管理它所拥有的服务或线程的生命周期。

/**
 * 封装ExecutorService实现日志服务
 */
public class LogService {
    private final ExecutorService exec = Executors.newSingleThreadExecutor();
    private final PrintWriter writer;

    public LogService(PrintWriter writer) {
        this.writer = writer;
    }

    public void start(){

    }

    public void log(String msg) {
        try {
            exec.execute(new WriteTask(msg));
        } catch (RejectedExecutionException ignored) {
        }
    }

    public void stop(long timeout, TimeUnit unit) throws InterruptedException {
        try {
            exec.shutdown();
            // 关闭服务后, 阻塞到所有任务被执行完毕或者超时发生,或当前线程被中断
            exec.awaitTermination(timeout, unit);
        } finally {
            writer.close();
        }
    }
}

2."毒丸"对象

“毒丸”是指一个放在队列上的对象,其含义是:“当得到这个对象时,立即停止。”在FIFO 队列中,“毒丸”对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交“毒丸”对象之前提交的所有工作都会被处理,而生产者在提交了“毒丸”对象后,将不会在提交任何工作。在下面的程序清单中给出了一个单生产者——单消费者的桌面搜索示例,使用了“毒丸”对象来关闭服务。

/**
 *  通过“毒丸”对象来关闭服务
 */
public class IndexingService {
    private static final int CAPACITY = 1000;
    //毒丸
    private static final File POISON = new File("");
    private final IndexerThread consumer = new IndexerThread();
    private final CrawlerThread producer = new CrawlerThread();
    private final BlockingQueue<File> queue;
    //private final FileFilter fileFilter;
    private final File root;

    public IndexingService(File root) {
        this.root = root;
        this.queue = new LinkedBlockingQueue<File>(CAPACITY);
        
    }

    private boolean alreadyIndexed(File f) {
        return false;
    }

    //IndexingService的生产者线程
    class CrawlerThread extends Thread {
        public void run() {
            try {
                crawl(root);
            } catch (InterruptedException e) { /* 发生异常 */
            } finally {
                while (true) {
                    try {
                        System.out.println("放入“毒丸”");
                        queue.put(POISON);//放入毒丸
                        break;
                    } catch (InterruptedException e1) { /* 重试 */
                    }
                }
            }
        }

        private void crawl(File root) throws InterruptedException {
            File[] entries = root.listFiles();
            if (entries != null) {
                for (File entry : entries) {
                    if (entry.isDirectory())
                        crawl(entry);
                    else if (!alreadyIndexed(entry)){
                        System.out.println("放入生产者队列文件:"+entry.getName()+" 来自线程:"+Thread.currentThread().getName());
                        queue.put(entry);
                    }
                }
            }
        }
    }

    //IndexingService的消费者线程
    class IndexerThread extends Thread {
        public void run() {
            try {
                while (true) {
                    File file = queue.take();
                    //遇到毒丸,终止
                    if (file == POISON){
                        System.out.println("遇到“毒丸”,终止");
                        break;
                    }   
                    else
                        indexFile(file);
                }
            } catch (InterruptedException consumed) {
            }
        }

        public void indexFile(File file) {
            System.out.println("消费者取出文件:"+file.getName()+" 来自线程:"+Thread.currentThread().getName());
            /* ... */
        };
    }

    public void start() {
        producer.start();
        consumer.start();
    }

    public void stop() {
        producer.interrupt();
    }

    public void awaitTermination() throws InterruptedException {
        consumer.join();
    }

}

只有在生产者和消费者的数量都已知的情况下,才可以使用”毒丸“对象。

这种解决方案可以扩展到多个生产者的情况只需每个生产者都想队列放入一个”毒丸“对象,并且消费者仅当在接收到N个生产者的”毒丸“对象时才停止。

这种解决方案也可以扩展到多个消费者的情况:只需生产者将N个”毒丸“对象放入队列。

然而,当生产者和消费者的数量较大时,这种方法将变的难以使用。只有在无界队列中,”毒丸“对象才能可靠地工作。

三、处理非正常的线程终止(待完成)

参考:多线程异常处理

导致线程提前死亡的最主要原因就是RuntimeException。由于这些异常表示出现了某种编程错误或者其它不可修复的错误,因此它们通常不会被捕获。它们不会再调用栈中逐层传递,而是默认在控制台中输出栈追踪信息,并终止线程。

线程非正常退出的后果是否严重要取决于线程的作用。比如,线程池中丢失一个线程可能会对性能带来一定影响,但如果程序能在包含了50个线程的线程池上运行良好,那么在包含了49个线程的线程池上通常也能运行良好。然而,对于GUI程序,如果丢失了事件分配线程,那么造成的影响会非常显著——应用程序将停止处理事件并且GUI会因此失去响应。

如果run()方法中的代码抛出NullPointerException而失败,可以将代码放在try-catch代码块中,这样就能捕获未检查的异常了。或者也可以使用try-finally代码块来确保框架能够知道线程非正常退出的情况,并做出正确的响应。

未捕获异常的处理

在Thread中提供了UncaughtExceptionHandler,它能检测出某个由于未捕获的异常而终结的情况。

这两种方法是互补的,通过将二者结合在一起,就能有效地防止线程泄露问题。

当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给UncaughtExceptionHandler异常处理器,如果没有提供异常处理器,那么默认的行为是将追踪信息输出到Sytem.err,即控制台。

异常处理器如何处理未捕获异常,取决于服务质量的需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中。异常处理器还可以采取更直接的响应,例如尝试重新启动线程,关闭应用程序,或者执行其它修复或诊断操作。

在运行时间较长的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器异常至少会将异常信息记录到日志中。

比如,线程池这样长时间运行的程序,就可以为其设置一个UncaughtExceptionHandler,需要为ThreadPoolExecutor的构造函数提供一个ThreadFactory。

猜你喜欢

转载自www.cnblogs.com/rouqinglangzi/p/10819377.html
今日推荐