Loading

Java并发——取消与关闭

《Java并发编程实战》一书的笔记

这一章好难啃,感觉每一句话都是人话,但是我读不懂,读好几遍了。

任务取消

首先说明任务和线程的概念,一个任务代表一段要执行的代码,它要放到线程中去执行。

很多场景下我们都需要取消一个已经提交了的任务,比如我现在有一个数字生成的任务,它可以无限的生成下去,但我希望它运行一段时间后就停止:

public static void main(String[] args) {
    // 任务类
    class NumberProduceTask implements Runnable {
        private final Random random = new Random();

        @Override
        public void run() {
            while (true) {
                System.out.println(random.nextInt(10));
            }
        }
    }

    // 初始化任务对象
    NumberProduceTask task = new NumberProduceTask();

    // 创建运行任务的线程
    Thread taskThread = new Thread(task);
    taskThread.start();
    
}

标志

最简单的办法,可以通过设置一个结束标志,让线程每次循环时检测这个标志,为了保证可见性,这个标志必须是volatile的。

public static void main(String[] args) throws InterruptedException {

    class NumberProduceTask implements Runnable {
        private final Random random = new Random();
+       private volatile boolean cancel = false;

+       public void cancel() {
+           this.cancel = true;
+       }

        @Override
        public void run() {
-           while (true) {
+           while (!cancel) {
                System.out.println(random.nextInt(10));
            }
        }
    }

    NumberProduceTask task = new NumberProduceTask();
    Thread taskThread = new Thread(task);
    taskThread.start();

+   Thread.sleep(1000);
+   task.cancel();
}

上面的代码运行后,主线程停止1秒,然后停止任务,这时,由于任务线程除了这个任务外没有其它事情可做了,所以它也会结束,然后程序退出。

Java中没有一种机制能够立即停止线程,Java只提供基于协作的线程停止方式,即外部向线程或任务提交通知,然后线程或任务接到通知后处理当前必须要处理完的状态后尽快停止。当然,它们也可以不停止。
如果使用一种强硬的停止线程或任务的手段不由分说的停止它们,那么它们停止时正在操作的对象可能处于某种不一致的状态,这样太危险了。

中断

如果我们觉得这个生成器生成数字的速度太快了,我们可能会在其中每次循环加上一点延时:

@Override
public void run() {
    while (!cancel) {
        try {
            Thread.sleep(2000);
            System.out.println(random.nextInt(10));
        } catch (InterruptedException e) { }
    }
}

如上代码添加了2000毫秒的延时,而主线程中1000毫秒就取消任务,所以按理说应该是什么也不输出,结果却输出了一个数。

这是因为,我们只在循环条件处判断了任务是否已经被取消的标志,Thread.sleep的阻塞过程中并不会判断这个标志,所以等待两秒钟后第一个数输出了。Thread.sleep总归是一个会停止的操作,如果任务当时正调用一个不会停止或很长时间也不停止的阻塞操作呢?比如阻塞队列的take方法。结果是,任务永远不会停止。

Java提供了和我们刚刚设置的标志位类似的中断机制:

  1. interrupt方法设置线程的中断状态
  2. isInterrupted方法判断线程当前是否已经被设置了中断状态
  3. 静态方法Thread.interrupted清除当前线程的中断状态,并返回清除前是否处于中断状态

Java中大部分类库中的阻塞方法都会检测到线程处于中断状态,并立即结束阻塞,抛出InterruptedException并清除所在线程的中断状态。于是我们可以把我们的任务代码改成这样:

class NumberProduceTask implements Runnable {
    private final Random random = new Random();
    private volatile Thread workThread;

    // DONT DO THIS!!!
    // public void cancel() {
    //   workThread.interrupt();
    // }

    @Override
    public void run() {
        workThread = Thread.currentThread();
        while (!workThread.isInterrupted()) {
            try {
                Thread.sleep(2000);
                System.out.println(random.nextInt(10));
            } catch (InterruptedException e) {
                // 恢复线程的中断状态
                workThread.interrupt();
            }
        }
    }

}

// ... 省略一些代码
NumberProduceTask task = new NumberProduceTask();
Thread taskThread = new Thread(task);
taskThread.start();

Thread.sleep(1000);
taskThread.interrupt(); // 这里改用中断任务所在的线程

首先,run方法记录了当前任务工作的线程workThread,然后它每次循环判断工作线程是否被打断,而Thread.sleep也能检测到工作线程被打断,并抛出一个InterruptedException,清除工作线程的中断状态。这样,就不会出现上面使用标志位时的任务不能及时结束的情况了。

关于在catch里,我们为什么要再次调用workThread.interrupt,那是因为Thread.sleep抛出异常后会清除线程的工作状态,如果这里我们不调用这个,运行该任务的线程将不会发现这次中断,相当于这次中断被我们给隐藏了起来。在当前的代码里倒还好,因为我们的线程只执行了这一个任务,并且也没有什么接收中断的需求,所以不恢复中断状态也行。但是当该任务运行在它所不熟知的线程中时,问题可能就显现了。

中断策略

正如同任务中会包含取消策略一样,线程也应该包含中断策略。中断一个线程可能同时意味着:“取消当前任务”和“关闭工作者线程”

任务通常会在某个服务(如线程池)拥有的线程中执行,对于非线程所有者的代码来说(比如正在执行的任务代码),应该小心的保存中断状态,这样拥有线程的代码才能对中断做出响应。

任务不应该对执行它的线程的中断策略做任何假设,除非该线程被专门设计服务于该任务。无论任务把中断视为取消还是什么其他操作,任务都应该保存执行线程的中断状态,你可以向上抛出InterruptedException或捕获它并重新调用workThread.interrupt()

正如任务不应该对执行线程的中断策略做任何假设,执行任务取消操作的代码也不应该对线程的中断策略做出假设。线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,如关闭(shutdown)方法。

线程拥有者

Java中未明确定义线程拥有者的概念,我觉得可以理解为,创建线程的一方。比如线程池就是线程池中工作线程的拥有者。

响应中断

你的任务可以以如下方式响应中断:

  1. 传递InterruptedException(无需立即传递,可能在某些清除代码之后),使你的方法也成为可中断的阻塞方法
  2. 当你不能(比如你在Runnable中)或不想传递该异常,也可以恢复中断状态,使调用栈中的上层代码能够对其进行处理,像上面的NumberProducerTask一样。

有时你可能在做一些不可取消的操作,那你需要在发现中断时记录,并且在操作执行完毕后响应这次中断:

注意这里恢复中断的位置,如果是在catch中直接恢复就会导致死循环,所以这里使用了记录——恢复的方式。

示例:计时运行

还是刚刚那个数字生成的问题,我们的数字生成任务是可以一直运行的,它需要你主动取消。我们现在打算开发一个timedRun方法来运行一个任务,并等待一段时间后取消该任务。

下面的这个版本解决了一个问题,就是Runnable在调用者线程中运行,那么它其中抛出的异常可以被调用者线程捕获到。但它也有一个问题,就是timedRun可以在任意线程被调用,它并不知道调用者线程的中断策略,但它直接调用了taskThread.interrupt,如果任务r在定时任务开始前就执行完成了,那么再执行taskThread.interrupt就不一定发生啥了,反正发生的肯定不是啥好事,也不是啥正确的事。

private static final ScheduledExecutorService cancelExec =
        Executors.newScheduledThreadPool(1);

public static void timedRun(Runnable r, long timeout, TimeUnit unit) {
    final Thread taskThread = Thread.currentThread();
    cancelExec.schedule(() -> taskThread.interrupt(), timeout, unit);
    r.run(); // 由于在调用者线程调用,所以可以捕获到其中的异常,但是也产生了一个问题
    // timedRun并不了解调用者线程的中断策略,如果指定超时时间后, r.run已经执行完毕,那么没人会知道发生什么
}

如果任务r不响应中断,那么timedRun就必须等它运行结束后返回。

下面新的timedRun解决了这个问题,它将任务放到独立的线程中处理,并且如果任务不支持取消,timedRun方法也会在join结束后正常返回。对于异常,则是封装了一个RethrowableTask进行记录和重抛出。


public static void timedRun2(Runnable r, long timeout, TimeUnit unit) throws Throwable {
    RethrowableTask task = new RethrowableTask(r);
    Thread taskThread = new Thread(task);
    taskThread.start();
    cancelExec.schedule(() -> taskThread.interrupt(), timeout, unit);
    taskThread.join(unit.toMillis(timeout));
    task.rethrow();
}

static class RethrowableTask implements Runnable {
    private final Runnable r;
    private volatile Throwable e;

    public RethrowableTask(Runnable r) {
        this.r = r;
    }

    @Override
    public void run() {
        try {
            r.run();
        } catch (Throwable e) {
            this.e = e;
        }
    }

    public void rethrow() throws Throwable {
        if (e != null) throw e;
    }

}

该示例也有个问题,就是不知道是线程正常退出还是join超时返回。

通过Future来实现取消

ExecutorService.submit方法提交一个任务,返回一个FutureFuture.cancel方法可以用来取消该任务,同时future.get方法可以设置一个超时时间。

private static final ExecutorService taskExecutor = Executors.newFixedThreadPool(10);
public static void timedRun3(Runnable r, long timeout, TimeUnit unit) throws ExecutionException {
    Future future = taskExecutor.submit(r);
    try {
        future.get(timeout, unit);
    } catch (ExecutionException e) {
        // rethrow it
        // 这里是任务抛出的运行时异常
        throw e;
    } catch (InterruptedException e) {
        // 这里是当`get`的调用者线程被打断
    } catch (TimeoutException e) {
        // get超时
    } finally {
        // 不管怎样,timedRun方法结束,取消任务
        future.cancel(true);
    }
}

futurecancel方法,当任务启动前调用时,该任务不会被启动,如果调用时该任务已启动,那么boolean类型参数mayInterruptIfRunning就决定了该方法的行为,如果它为true,则运行该任务的线程将interrupt,否则只能允许当前任务执行完成。当调用时这个任务已完成,那么不会有任何效果。

除非你清除任务底层线程的中断策略,否则不要尝试中断线程(传入true)。如果线程由标准的Executor创建,则可以传入true,因为它们被规定任务可以通过中断取消。

处理不可中断的阻塞

Java类库中也有很多阻塞方法是不响应中断的,对于这种操作,对那个线程提交中断请求除了设置那个线程的中断状态外就没什么效果了。这些方法如下:

  1. java.io包中的同步Socket I/O:通过socket获得的readwrite方法都是阻塞的且不会响应中断,但是通过关闭底层套接字可以使得由于执行readwrite而阻塞的线程抛出一个SocketException
  2. java.nio包中的同步I/O:当中断正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptException并关闭链路,当关闭一个InterruptibleChannel时,将导致所有在链路上操作上阻塞的线程都会抛出AsynchronousCloseException,大多数标准的Channel都实现了InterruptibleChannel
  3. Selector的异步IO:没学过
  4. 获取某个锁:如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断。

通过改写线程的interrupt方法实现

public class ReaderThread extends Thread {
    private final Socket socket;
    private final InputStream inputStream;

    public ReaderThread(Socket socket) throws IOException {
        this.socket = socket;
        this.inputStream = this.socket.getInputStream();
    }

    @Override
    public void interrupt() {
        try {
            socket.close();
        } catch (IOException ignored) { }
        finally {
            super.interrupt();
        }
    }

    @Override
    public void run() {
        try {
            byte[] buf = new byte[1024];
            inputStream.read(buf);
        } catch (IOException e) {
            // 允许线程退出
        }
    }
}

通过newTaskFor来封装非标准的取消

上面通过改写interrupt方法确实能够实现需求,但是那样也要求了你的任务与线程耦合,任务只能在这个特定的ReaderThread中运行,如果你使用线程池或者其它什么线程,你就没法这样做了。

基于Java的ExecutorFuture架构,每一个任务都可以通过Future.cancel取消,但是取消任务的逻辑也是内置写死的,即尝试中断运行该任务的线程(通过Java的interrupt机制)或任由它运行完毕,这在上面有过介绍,不过我们可以通过继承拓展Executor的功能,实现通过cancel方法来自定义自己的取消逻辑

这里我们将新建一个CancellingExecutorCancellableTask,通过它们的协同工作来达到定制自己的取消操作的目的,最终效果如下:

public static void main(String[] args) {
    
    // 创建执行器
    CancellingExecutor executor = ...;

    // 提交可定制化取消的任务
    executor.submit(new BaseCancellableTask<Integer>() {
        @Override
        public Integer call() throws Exception {
            // 执行任务
            return null;
        }

        @Override
        public void cancel() {
            // 当任务被取消,可以执行类似socket.close()之类的代码
        }
    });
    
}

查看AbstractExecutorServicesubmit方法接收一个Callable对象,并通过newTaskFor方法返回一个Future

public <T> Future<T> submit(Callable<T> task) {
    if (task == null) throw new NullPointerException();
    RunnableFuture<T> ftask = newTaskFor(task);
    execute(ftask);
    return ftask;
}

而默认的newTaskFor方法只简单的用FutureTask包装传入的Callable

protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
    return new FutureTask<T>(callable);
}

所以我们通过Executor框架提交的任务返回的Futurecancel方法,最终都会调用到这个FutureTaskcancel,而这也是默认的取消逻辑固化的位置

所以我们可以通过重写newTaskFor方法来返回不同的RunnableFuture,来达到自定义我们自己的取消逻辑的目的,这样我们就可以在取消时做一些别的事,比如socket.close()

public class CancellingExecutor extends ThreadPoolExecutor {

    // ...

    @Override
    protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
        if (callable instanceof CancellableTask) return ((CancellableTask<T>) callable).newTask();
        return super.newTaskFor(callable);
    }

}

CancellingExecutor没有直接继承AbstractExectorService而是选择继承更高层的ThreadPoolExecutor。然后它重写了newTaskFor方法,在其内部判断,如果是一个CancellableTask的示例就调用它的newTask方法返回RunnableFuture,否则正常调用父类的newTaskFor

这样一来,我们便可以在CancellableTask中返回我们自定义的RunnableFuture,来重写我们的取消逻辑。

CancellableTask是这样一个接口,它继承Callable,所以它可以被submitExecutorService中,从而到达newTaskFor中,它提供一个cancel方法来自定义取消逻辑,同时它提供newTask方法供CancellingExecutor调用来获得一个RunnableFuture

public interface CancellableTask<T> extends Callable<T> {
    void cancel();
    RunnableFuture<T> newTask();
}

然后提供一个BaseCancellableTask,它提供了newTaskFor返回一个RunnableFuture,在其中重写它的cancel方法,调用到自己的cancel上。

public abstract class BaseCancellableTask<T> implements CancellableTask<T> {
    @Override
    public RunnableFuture<T> newTask() {
        return new FutureTask<T>(this){
            @Override
            public boolean cancel(boolean mayInterruptIfRunning) {
                try {
                    BaseCancellableTask.this.cancel();
                } finally {
                    return super.cancel(mayInterruptIfRunning);
                }
            }
        };
    }
}

要记得,用户会根据submit返回的Future来执行取消,而不是你传递的Callable,所以这里写成了这样,相当于FutureTaskcancel进行了一层包装,实际调用Callablecancel

这个代码可能有点绕,建议读者自己编写一遍。

停止基于线程的服务

应用程序可能创建其中具有多个线程的服务(如线程池),在应用程序退出时这些服务所拥有的线程也需要被结束。

在使用这种服务时,线程的拥有者是服务,所以你并不能直接关闭这些服务中的工作者线程,而是由服务提供统一的接口来关闭。ExecutorService接口中提供了shutdownshutdownNow来关闭服务。

shutdown方法被调用后,executor不会再接收新的任务,对于新任务都会直接被回绝,但是它会等待先前已经被提交的任务执行完。

shutdownNow方法被调用后也不再接收新任务,同时它尝试关闭所有正在执行的任务(需要任务响应中断),对于已提交未执行的任务会直接丢弃,返回所有尚未启动的任务清单。

示例:日志服务

有时你需要将ExecutorService封装起来提供更高层的服务抽象。

我们主要观察日志系统的stoplog方法,当log方法被调用,一个写出任务提交到executor中,由于此时服务可能已经被关闭,所以提交请求可能被拒绝,这里我们忽略被拒绝的请求。但是对于已提交的请求,我们必须让它们打印出来,所以在stop中我们使用了shutdown而非shutdownNow

public class LogService {
    private final ExecutorService executor = Executors.newSingleThreadExecutor();
    private final PrintWriter writer = new PrintWriter(System.out);


    public void start() {}

    public void stop() throws InterruptedException {
        try {
            executor.shutdown();
            executor.awaitTermination(Integer.MAX_VALUE, TimeUnit.SECONDS);
        } finally {
            writer.close();
        }
    }

    public void log(String msg) {
        try {
            executor.execute(() -> writer.println(msg));
        } catch (RejectedExecutionException e) {}
    }
}

原书中还从头到尾编写了一个不用Executor框架的日志服务,这里贴上最终代码,不做说明:

public class ClosableLogWritter {
    private final BlockingQueue<String> queue;
    private final LoggerThread logger;
    private volatile boolean isStopped = false;
    private volatile int remainMsgCount = 0;

    public ClosableLogWritter() { this(new PrintWriter(System.out)); }

    public ClosableLogWritter(PrintWriter writer) {
        this.queue = new ArrayBlockingQueue<>(10);
        this.logger = new LoggerThread(writer);
    }

    public void start() {
        logger.start();
    }

    public void stop() {
        synchronized (this) {
            isStopped = true;
        }
        logger.interrupt();
    }

    public void log(String msg) throws InterruptedException {
        synchronized (this) {
            if (isStopped) throw new IllegalStateException("不能向一个已关闭的LogWritter中写入log");
            remainMsgCount++;
        }
        queue.put(msg);
    }

    class LoggerThread extends Thread {
        private final PrintWriter writer;
        public LoggerThread(PrintWriter writer) {
            this.writer = writer;
        }

        @Override
        public void run() {
            try {
                while (true) {

                    try {
                        // !!! 注意,一定要加对锁
                        synchronized (ClosableLogWritter.this) {
                            // 之所以使用remaimMsgCount而不是直接用queue.size(),是因为queue.put和queue.take这两个能够改变`size`的操作没有被ClosableLogWritter的锁保护
                            if (isStopped && remainMsgCount == 0) break;
                        }
                        String msg = queue.take();
                        synchronized (ClosableLogWritter.this) { remainMsgCount--; }
                        writer.println(msg);
                    } catch (InterruptedException e) {}
                }
            } finally {
                writer.close();
            }
        }
    }

}

示例:只执行一次的任务

invokeAllinvokeAny非常适合处理一批任务,然后其中的所有任务都完成或任意一个任务完成后结束。下面的checkMail方法向每一个主机上发起检测邮件的请求,当所有都执行完后得到结果,并关闭服务。

处理非正常的线程终止

通常我们的程序崩溃都是由于某处抛出了某种运行时异常,在多线程程序中,运行时异常很不起眼,它不会让整个程序崩溃退出,它只会在控制台中打印异常信息,甚至都不会有人看到它。

下图显示了一个线程池中的线程的run方法中常见的代码结构:

使用一个try-catch-finally语句块来捕获任务运行中的异常,而不是让这个线程异常退出,如果有异常,记录它,并且在finally块中调用threadExited通知线程池(或者说线程拥有者)该线程要退出了,请你采取一些措施比如添加一个新线程来代替我。

未捕获异常处理

上面是一种主动式的异常处理,JVM也提供了一种被动式处理——UncaughtExceptionHandler,它能检测出某个线程由于未捕获异常意外终结的情况,在Android开发中比较常用,经常在主线程中设置这个处理器,在程序crash时记录异常信息。

当一个线程由于未捕获异常而退出时,JVM会将这个事件上报给应用程序提供的UncaughtExceptionHandler,如果没提供就输出到System.err

要为线程池中的每个线程添加这个处理器,请使用ThreadFactory

只有通过execute提交的任务抛出的异常才会给未捕获异常处理器,通过submit提交的任务发生异常被视为返回状态的一部分

JVM关闭

正常情况下,如果不做什么强行操作的话,当最后一个非守护线程结束,JVM才会关闭。

守护线程

当你想要一个线程做一些辅助性工作,又不想它妨碍JVM的正常关闭时,就需要把它设置成守护线程(Daemon Thread)。

JVM自带的线程中,除了主线程其它的都是守护线程(如垃圾回收器)。一个线程创建的新线程将继承它的守护状态,所以默认情况下由主线程创建而来的所有线程都是非守护线程。

posted @ 2022-04-12 14:58  yudoge  阅读(381)  评论(0编辑  收藏  举报