第七章——取消与关闭

7.1 任务取消

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

  • 用户请求取消。用户点击图形界面程序中的 “取消” 按钮 等,用户的主动行为。
  • 有时间限制的操作。例如,某个应用程序需要在有限时间内搜索问题空间,并在这个时间内选择最佳的解决方案。
  • 应用程序事件。例如,应用程序对某个问题空间进行分解并搜索,从而使不同的任务可以搜索问题空间中的不同区域。当其中一个任务找到了解决方案时,所有其他仍在搜索的任务都将被取消。
  • 错误。网页爬虫程序搜索相关的页面,并将页面或摘要数据保存到硬盘。当一个爬虫任务发生错误时(例如,磁盘空间已满),那么所有搜索任务都会取消。
  • 关闭。当一个程序或服务关闭时,必须对正在处理和等待处理的工作执行某种操作。在平缓关闭过程中,当前正在执行的任务将继续执行直到完成,而在立即关闭过程中,当前的任务则可能取消。

Java 中没有一种安全的抢占式方法来停止线程,因此也就没有安全的抢占式方法来停止任务。只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。

其中一种协作机制能设置某个 “已请求取消(Cancellation Requested)” 标志,而人物将定期地查看该标志。如果设置了这个标志,那么任务将提前结束。

// 程序清单 7-1
public class PrimeGenerator implements Runnable {
    private final List primes = new ArrayList<>();
    private volatile boolean cancelled;  // 使用自定义的 “已请求取消” 标志
    
    @Override
    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 get() {
        return new ArrayList(primes);
    }
}

上面的 PrimeGenerator 持续地枚举素数,直到它被取消。cancel 方法将设置 cancelled 标志,并且主循环在搜索下一个素数之前会首先检查这个标志。它的典型用法如下:

// 程序清单 7-2
List aSecondOfPrimes() throws InterruptedException {
        PrimeGenerator generator = new PrimeGenerator();
        new Thread(generator).start();
        try {
            SECONDS.sleep(1);
        } finally {
            generator.cancel();
        }
        return generator.get();
    }
7.1.1 中断

上面的程序清单 7-1 中,通过一个自定义标志位来让任务退出的方法可能会产生一个严重的问题——如果任务调用了一个阻塞的方法,例如 BlockingQueue.put,那么任务可能永远不会检查取消标志,因此永远不会结束。

// 程序清单 7-3
public class BrokenPrimeProducer extends Thread {
    private final BlockingQueue queue;
    private volatile boolean cancelled = false;

    BrokenPrimeProducer(BlockingQueue queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        try {
            BigInteger p = BigInteger.ONE;
            while (!cancelled) {
                queue.put(p = p.nextProbablePrime());
            }
        } catch (InterruptedException consumed) {}
    }

    public void cancel() {
        cancelled = true;
    }
}

static void consumePrimes() throws InterruptedException {
    BlockingQueue primes = new ArrayBlockingQueue<>(10);
    BrokenPrimeProducer producer = new BrokenPrimeProducer(primes);
    try {
        while (needMorePrimes()) {
            consume(primes.take());
        }
    } finally {
        producer.cancel();
    }
}

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

其实,Java 线程中原生就有用于用户中断任务的机制。线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能的情况下停止当前工作,并转而执行其他的工作。

每个线程都有一个 boolean 类型的中断状态。当中断线程时,这个线程的中断状态将被设置为 true。在 Thread 中包含了中断线程以及查询线程中断状态的方法,如下所示:

// 程序清单 7-4
public class Thead {
    public void interrupt() { ... }
    public boolean isInterrupted() { ... }
    public static boolean interrupted() { ... }
}

interrupt 方法能中断目标线程,而 isInterrupted 方法能返回目标线程的中断状态。静态的 interrupted 方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。

  • 阻塞库方法,例如 Thread.sleepObject.wait 等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态,抛出 InterruptedException,表示阻塞操作由于中断而提前结束。

因此,吐过在调用这些阻塞方法之前,线程就已经处于阻中断状态,那么这些阻塞方法将会立即抛出 InterruptedException

  • 当线程在非阻塞状态下中断时,它的中断状态将被设置,但是并不会抛出异常,任务只能通过在代码中判断中断状态来退出任务。通过这样的方法,终端操作将变得 “有黏性” —— 如果不触发 InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。

对中断操作的正确理解是:它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己(这些时刻也被称为取消点)。有些方法,例如 waitsleepjoin 等,将严格地处理这种请求,它们在收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。

在使用静态的 interrupted 时应小心,因为它会清除当前线程的中断状态。如果在调用 interrupted 时返回了 true,那么除非你想屏蔽这个中断,否则必须对它进行处理 —— 可以抛出 InterruptedException,或者通过再次调用 interrupt 来恢复中断状态。

BrokenPrimeProducer(程序清单 7-3) 中的问题很容易解决:使用中断而不是 boolean 标志来请求取消:

// 程序清单 7-5
public class PrimeProducer extends Thread {
    private final BlockingQueue queue;
    
    PrimeProducer(BlockingQueue queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        try {
            BigInteger p = BigInteger.ONE;
            while (!Thread.currentThread().isInterrupted()) {  // 非阻塞方法,检查中断标志位
                queue.put(p = p.nextProbablePrime());  // 阻塞方法,抛出异常
            }
        } catch (InterruptedException consumed) {  
            /* 允许线程退出 */
        }
    }
}

在每次迭代循环中,有两个位置可以检测出中断:在阻塞的 put 方法调用中,以及在循环开始处查询中断状态时。由于调用了阻塞的 put 方法,因此在循环开始处并不一定需要进行显示的检测,但执行检测却会使 PrimeProducer 对中断具有更高的响应性,因为它是在启动寻找素数任务之前检查中断的,而不是在任务完成之后。

7.1.3 响应中断

在调用可中断的阻塞函数时,例如 Thread.sleepBlockingQueue.put 等,有两种实用策略可用于处理 InterruptedException

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

传递 InterruptedException 与将 InterruptedException 添加到 throws 子句中一样容易:

程序清单 7-6
BlockingQueue queue;
public Task getNextTask() throws InterruptedException {
    return queue.take();
}

如果不想或无法传递 InterruptedException (或许是通过 Runnable 来定义任务的),那么需要寻找另一种方法来保存中断请求。一种标准方法就是通过再次调用 interrupt 来恢复中断状态。

对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法并在发现后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获 InterruptedException 时恢复状态:

// 程序清单 7-7
public Task getNextTask(BlockingQueue queue) {
    boolean interrupted = false;
    try {
        while (true) {
            try {
                return queue.take();
            } catch (InterruptedException e) {
                interrupted = true;
                // 重新尝试
                // 不应在这里调用 Thread.currentThread().interrupt() 来重置标志位
            }
        }
    } finally {
        if (interrupted) {
            Thread.currentThread().interrupt();
        }
    }
}

上面代码 getNextTask 是不可取消的任务,在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获 InterruptedException 时恢复状态。因为如果过早地设置中断状态,就可能引起无限循环,因为大多数可中断的阻塞方法都会在入口处检查中断状态,并且当发现该状态已被设置时会立即抛出 InterruptedException

通常,可中断的方法在阻塞或进行重要的工作前首先检查中断,从而尽快地响应中断。

在取消过程中可能涉及除了中断状态之外的其他状态。中断可以用来获得线程注意,并且由中断线程保存的信息,可以为中断的线程提供进一步的指示。
例如,当一个由 ThreadPoolExecutor 拥有的工作者线程检测到中断时,它会检查线程池是否正在关闭。如果是,它会在结束之前执行一些线程池清理工作,否则它可能创建一个新线程将线程池恢复到合理的规模。

7.1.4 示例:计时运行

程序清单 7-2 中,一个匿名线程将启动 PrimeGenerator,并在 1 秒钟后通过调用 PrimeGenerator.cancel 来设置中断标志位(虽然这个方法是不合适的),最终 PrimeGenerator 发现中断,然后停止,并使线程结束。
这个程序出了标志位上的错误,还存在一个严重的错误:如果 PrimeGenerator 在指定时限内抛出了一个未检查的异常,那么这个异常可能会被忽略,因为 PrimeGenerator 是在另一个独立的线程中运行(将其启动的匿名线程),而这个线程并不会显示地处理异常。

程序清单 7-8 中给出了在指定时间内运行一个任意的 Runnable 的示例。它在调用线程中运行任务,并安排了一个取消任务,在运行指定的时间间隔后中断它。因为在调用线程中显式地调用任务的 run() 方法来启动任务,因此当任务抛出未检查异常时,该异常会被 timedRun 的调用者捕获(调用者代码受开发者控制,因此能够处理异常)。

// 程序清单 7-8
private static final ScheduledExecutorService cancelExec = ...;
public static void timedRun(Runnable r,
                            long timeout,
                            TimeUnit unit) {
    final Thread taskThread = Thread.currentThread();
    cancelExec.schedule(() -> taskThread.interrupt(), timeout, unit);  // 指定时间后中断调用者线程
    r.run();  // 调用者显示地 run 这个任务
}

上面的代码貌似解决了异常捕获的问题,但是却带来了另一个问题:由于 timedRun 可以从任意线程中调用,因此它无法知道这个调用线程的中断策略。如果任务在超时之前就完成了,那么计时器结束使调用者线程中断时,调用者线程也许已经结束了,那么中断将不会产生任何效果。也许已经在执行其他任务了,那么中断将影响其他任务。我们在不知道调用者线程处于哪种状态下去中断它,结果一定是不好的。

而且,如果任务不响应中断,那么 timedRun 会在任务结束时才返回,此时可能已经超过了指定的时限,这样的话,超时的设定就并没有达到预期的效果:超时的意义不是在计时结束时停止任务(你可能无法控制其停止),而是使调用者能够意识到在超时后,任务还未执行完,调用者可以有下一步的决策。因此,如果任务不响应中断,在上面的代码中,调用者线程将无法收到任何信息(run 方法不会在超时的时候返回,也不会在超时发生时抛出异常)。

我们再看 程序清单 7-9 能够解决上面两个问题:

// 程序清单 7-9
public static void timedRun(final Runnable r,
                            long timeout,
                            TimeUnit unit) throws InterruptedException {
    class RethrowableTask implements Runnable {
        private volatile Throwable t;
        @Override
        public void run() {
            try {
                r.run();
            } catch (Throwable t) {
                this.t = t;
            }
        }
        void rethrow() {
            if (t != null) {
                throw launderThrowable(t);
            }
        }
    }
    RethrowableTask task = new RethrowableTask();
    final Thread taskThread = new Thread(task);
    taskThread.start();
    cancelExec.schedule(() -> taskThread.interrupt(), timeout, unit);
    taskThread.join(unit.toMillis(timeout));
    task.rethrow();
}

上面代码能够解决 程序清单 7-8 中的两个问题:

  1. 任务抛出异常时,希望通知给调用者线程。上面依然是一个匿名线程 taskThread 在运行任务,当任务抛出异常时,RethrowableTask 将异常 catch 住,并保存到自己的成员变量 t 中,方便调用者线程之后通过 rethrow 方法来获取异常。
  2. 任务不响应超时的中断。在 程序清单 7-8 的分析中我们已经说了,超时的根本目的是让调用者线程意识到超时事件的发生。代码清单 7-9 通过 cancelExec.shedule 在超时的时候中断任务,通过 taskThread.join 方法使调用者线程在超时的时候能够马上继续执行 task.rethrow。通过上面两种机制来使调用者线程意识到超时事件。

但是 代码清单 7-9 的缺陷也非常明显:通过 taskThread.join 方法阻塞了调用者线程,而且 join 本身也存在缺陷,无法知道执行控制是因为线程正常退出而返回还是因为 join 超时而返回。

7.1.5 通过 Future 来实现取消
// 程序清单 7-10
public static void timedRun(final 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);  // 如果任务正在运行,那么将被中断
    }
}

其实为了解决上面的问题,我们已经有了一中抽象机制来管理任务的生命周期,处理异常,以及实现取消,即 Future
程序清单 7-10 中,ExecutorService.submit 将返回一个 Future 来描述任务。Future 拥有一个 cancel 方法,带有一个 boolean 类型的参数 mayInterruptIfRunning,表示任务是否能够接收中断。如果 mayInterruptIfRunningtrue 并且任务当前正在某个线程中运行,那么这个线程能被中断(如果看源码就知道,其实就是调用了 Thread.interrupt 方法)。如果为 false,那么意味着 “若任务还没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。
那么在什么情况下调用 cancel 可以将参数指定为 true?执行任务的线程是由标准 Executor 创建的,它实现了一种中断策略使得任务可以通过中断被取消,所以如果任务在标准 Executor 中运行,并通过它们的 Future 来取消任务,那么可以设置 mayInterruptIfRunning

程序清单 7-10 中,交任务提交给了一个 ExecutorService,并通过一个定时的 Future.get 来获得结果。如果 get 在返回时抛出了一个 TimeoutException,那么任务将通过它的 Future 来取消。为了简化,程序清单 7-10 中,在 finally 块中将直接调用 Future.cancel,因为取消一个已完成的任务不会带来任何影响。注意 TimeoutException 不是任务所抛出的异常,因此任务还在执行,需要通过 Future.cancel 来中断任务。我们去看 cancel 源码可以知道,其实就是调用了 Thread.interrupt 方法来发出中断请求。如果任务抛出了 InterruptedException,那么将被一个 try 代码块忽略。

public boolean cancel(boolean mayInterruptIfRunning) {
    if (!(state == NEW && STATE.compareAndSet
          (this, NEW, mayInterruptIfRunning ? INTERRUPTING : CANCELLED)))
        return false;
    try {    // in case call to interrupt throws exception
        if (mayInterruptIfRunning) {
            try {
                Thread t = runner;
                if (t != null)
                    t.interrupt();  // 如果线程中运行的任务抛出 InterruptedException,那么将被忽略
            } finally { // final state
                STATE.setRelease(this, INTERRUPTED);
            }
        }
    } finally {
        finishCompletion();
    }
    return true;
}

如果任务在完成前就抛出了一个异常,那么该异常将被 catch (ExecutionExecution e) 捕获,并由 launderThrowable 重新抛出以便由调用者来处理异常。

7.1.6 处理不可中断的阻塞

Java 库中,许多可阻塞的方法都是通过提前返回或者抛出 InterruptedException 来响应中断请求的,从而使开发人员更容易构建出能响应取消请求的任务。然而,并非所有的可阻塞方法或阻塞机制都能响应中断:如果一个线程由于执行同步的 Socket I/O 或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。这些情况其实都写在了 Thread.interrupt 方法的注释中:

Thread.interrupt

Java.io 包中的同步 Socket I/O。在服务器应用程序中,最常见的阻塞 I/O 形式就是对套接字进行读取和写入。虽然 InputStreamOutputStream 中的 readwrite 等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行 readwrite 等方法而被阻塞的线程抛出一个 SocketException

Java.io 包中的同步 I/O。当中断一个正在 InterruptibleChannel 上等待的线程时,将抛出 ClosedByInterruptException 并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出 ClosedByInterruptException)。当关闭一个 InterruptibleChannel 时,将导致所有在链路操作上阻塞的线程都抛出 AsynchronousCloseException。大多数标准的 Channel 都实现了 InterruptibleChannel

Selector 的异步 I/O。如果一个线程在调用 Selector.select 方法时阻塞了,那么调用 closewakeup 方法会使线程抛出 ClosedSelectorException 并提前返回。

获取某个锁。如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程认为它肯定会获得锁,所以将不会理会中断请求。但是,在 Lock 类中提供了 lockInterruptibly 方法,该方法允许在等待一个锁的同时仍能响应中断。

下面我们对于第一种情况来看看如何封装非标准的取消操作。

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

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

    // 重写了 interrupt 方法,使其技能处理标准的中断,也能关闭底层的套接字。
    @Override
    public void interrupt() {
        try {
            socket.close();
        } catch (IOException e) { }
        finally {
            super.interrupt();
        }
    }

    @Override
    public void run() {
        try {
            byte[] buf = new byte[BUFSZ];
            while (true) {
                int count = in.read(buf);
                if (count < 0) {
                    break;
                } else if (count > 0) {
                    processBuffer(buf, count);
                }
            }
        } catch (IOException e) {
            /* 允许线程退出 */
        }
    }
}

上面的 ReaderThread 管理了一个套接字连接,它采用同步方式从该套接字中读取数据,并将接收到的数据传递给 processBuffer。为了结束某个用户的连接或者关闭服务器,ReaderThread 改写了 interrupt 方法,使其既能处理标准的中断,也能关闭底层的套接字。因此,无论 ReaderThread 是在 read 方法中阻塞还是在某个可中断的阻塞方法中阻塞,都可以被中断并停止执行当前的工作。

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

我们同样可以使用 newTaskFor 方法来进一步优化 ReaderThread 中封装的非标准取消计数,该方法时 Java 6ThreadPoolExecutor 中新增的功能。
newTaskFor 是一个工厂方法,它将创建 Future 来代表任务。newTaskFor 还能返回一个 RunnableFuture 接口,该接口扩展了 FutureRunnable (并由 FutureTask 实现)。

通过定制表示任务的 Future 可以改变 Future.cancel 的行为。例如,定制的取消代码可以实现日志记录或者收集取消操作的统计信息,以及取消一些不响应中断的操作。通过改写 interrupt 方法,ReaderThread 可以取消基于套接字的线程。同样,通过改写任务的 Future.cancel 方法也可以实现类似的功能。(这是因为,它们都是中断的操作方法)

// 程序清单 7-12
public interface CancellableTask extends Callable {
    void cancel();
    RunnableFuture newTask();
}

public class CancellingExecutor extends ThreadPoolExecutor {
    public CancellingExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
    }

    @Override
    protected  RunnableFuture newTaskFor(Callable callable) {
        if (callable instanceof CancellableTask) {  // 如果入参是我们自定义的可取消 Callable,那就我们自己构造 RunnableFuture
            return ((CancellableTask) callable).newTask();
        } else {
            return super.newTaskFor(callable);
        }
    }
}

public abstract class SocketUsingTask implements CancellableTask {
    private Socket socket;

    protected synchronized void setSocket(Socket s) {
        socket = s;
    }

    @Override
    public synchronized void cancel() {
        try {
            if (socket != null) {
                socket.close();
            }
        } catch (IOException ignored) {}
    }

    @Override
    public RunnableFuture newTask() {
        return new FutureTask(this) {
            @Override
            public boolean cancel(boolean mayInterruptIfRunning) {
                try {
                    SocketUsingTask.this.cancel();  // 外部调用 Future.cancel 时,会先调用这里关掉 socket
                } finally {
                    return super.cancel(mayInterruptIfRunning);  // 最后调用任务的 cancel
                }
            }
        };
    }
}

7.2 停止基于线程的服务

应用程序通常会创建拥有多个线程的服务,例如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期更长。
服务应该提供生命周期方法(Lifecycle Method)来关闭自己以及它所拥有的线程。在 ExecutorService 中提供了 shutdownshutdownNow 等方法。

7.2.1 示例:日志服务

我们来从一个日志程序开始。

// 程序清单 7-13
public class LogWriter {
    private final BlockingQueue queue;
    private final LoggerThread logger;
    
    public LogWriter(Writer writer) {
        this.queue = new LinkedBlockingQueue<>(10);
        this.logger = new LoggerThread((PrintWriter) 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 LoggerThread(PrintWriter writer) {
            this.writer = writer;
        }

        @Override
        public void run() {
            try {
                while (true) {
                    writer.println(queue.take());
                }
            } catch (InterruptedException ignored) {}
            finally {
                writer.close();
            }
        }
    }
}

上面的日志服务是一种多生产者单消费者(Multiple-Producer, Single-Consumer)的设计方式:每个调用 log 的操作都相当于一个生产者,通过 BlockingQueue 将消息提交给日志线程,相当于消费者。

上面的日志服务还需要实现一种终止日志线程的方法,从而避免使 JVM 无法正常关闭。要停止日志线程是很容易的,因为它会反复调用 take,而 take 能响应中断。如果将日志线程修改为当捕获到 InterruptedException 时退出,那么只需中断日志线程就能停止服务。

然而,如果只是使日志线程退出,那么还不是一种完备的关闭机制。这种直接关闭的做法会丢失那些正在等待被写入到日志的信息,不仅如此,其他线程将在调用 log 时被阻塞,因为日志消息队列是满的,因此这些生产者线程将无法解除阻塞状态。当取消一个生产者-消费者操作时,需要同时取消生产者和消费者,但在这个示例中,由于生产者并不是专门的线程,因此要取消它们将非常困难。

另一种方法就是设置某个 “已请求关闭” 标志,以避免进一步提交日志消息:

// 程序清单 7-14
public void log(String msg) throws InterruptedException {
    if (!isShutdown) {
        queue.put(msg);
    } else {
        throw new IllegalStateException("logger is shut down");
    }
}

但是上面的 log 实现是一种 “先判断再运行” 的代码序列:生产者发现该服务还没有关闭,因此在关闭服务后仍然会将日志消息放入队列,这同样会使得生产者可能在调用 log 时阻塞并且无法解除阻塞状态。

上面方法的主要问题在于,存在竞态条件,因而我们需要使日志消息的提交操作成为原子操作。

// 程序清单 7-15
public class LogService {
    private final BlockingQueue queue;
    private final LoggerThread loggerThread;
    private final PrintWriter writer;
    
    private boolean isShutdown;
    private int reservations;   // 生产者提交的日志数量计数器
    
    public void start() {
        loggerThread.start();
    }
    
    public void stop() {
        synchronized (this) {   // 使关闭成为原子操作
            isShutdown = true;
        }
        loggerThread.interrupt();
    }
    
    public void log(String msg) throws InterruptedException {
        synchronized (this) {   // 通过 synchronized 来同步服务的关闭状态
            if (isShutdown) {
                throw new IllegalStateException();
            }
            ++reservations;
        }
        queue.put(msg);
    }
    
    private class LoggerThread extends Thread {
        @Override
        public void run() {
            try {
                while (true) {
                    try {
                        synchronized (LogService.this) {
                            if (isShutdown && reservations == 0) {  // 判一下 reservations == 0,是为了即使收到中断也要讲队列中的消息消费完
                                break;
                            }
                        }
                        String msg = queue.take();
                        synchronized (LogService.this) {
                            --reservations;
                        }
                        writer.println(msg);
                    } catch (InterruptedException e) { /* retry */ }
                }
            } finally {
                writer.close();
            }
        }
    }
}

上面的代码中,我们通过原子方式来检查关闭请求,并且有条件地递增一个计数器,以保证服务关闭后,消费者线程能够将队列中的消息消费完毕。

7.2.2 关闭 ExecutorService

ExecutorService 提供了两种关闭方法:使用 shutdown 正常关闭,以及使用 shutdownNow 强行关闭。在进行强行关闭时,shutdownNow 首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。

我们来看上一节中 LogService 的一种变化形式,它将管理线程的工作委托给一个 ExecutorService,而不是由其自行管理:

// 程序清单 7-16
public class LogService2 {
    private final ExecutorService exec = Executors.newSingleThreadExecutor();
    
    public void start() {}
    
    public void stop() throws InterruptedException {
        try {
            exec.shutdown();
            exec.awaitTermination(TIMEOUT, UNIT);
        } finally {
            writer.close();
        }
    }
    
    public void log(String msg) {
        try {
            exec.execute(new WriteTask(msg));
        } catch (RejectedExecutionException ignored) {}
    }
}

上面的 ExecutorService 是一个 newSingleThreadExecutor,它自带无界队列的单线程。使用正常的 shutdown 来控制日志服务的关闭,并且等待一段时间,使队列中未处理的消息能够被消费。而且,一旦关闭,execute 方法将通过抛出 RejectedExecutionException 来阻止新消息的提交。

7.2.3 “毒丸” 对象

另一种关闭生产者 - 消费者服务的方法就是使用 “毒丸(Poison Pill)” 对象:“毒丸” 是指一个放在队列上的对象,其含义是:“当得到这个对象时,立即停止”。“毒丸” 对象将确保在提交 “毒丸” 对象之前提交的所有工作都会被处理,而生产者在提交了 “毒丸” 对象后,将不会再提交任何工作。

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

7.2.5 shutdownNow 的局限性

当通过 shutdownNow 来强行关闭 ExecutorService 时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务保存起来以便之后进行处理。

然而,我们无法通过常规方法来找出哪些任务已经开始但尚未结束。这意味着我们无法在关闭过程中知道正在执行的任务的状态,除非任务本身会执行某种检查。要知道哪些任务还没有完成,你不仅需要知道哪些任务还没有开始,而且还需要直到当 Executor 关闭时哪些任务正在执行。

// 程序清单 7-21
public class TrackingExecutor extends AbstractExecutorService {
    private final ExecutorService exec;
    private final Set tasksCancelledAtShutdown = Collections.synchronizedSet(new HashSet());

    public List getCancelledTasks() {
        if (!exec.isTerminated()) {
            throw new IllegalStateException();
        }
        return new ArrayList<>(tasksCancelledAtShutdown);
    }

    @Override
    public void execute(final Runnable runnable) {
        exec.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    runnable.run();  // runnable 任务在返回时必须维持线程的中断状态
                } finally {
                    if (isShutdown()
                    && Thread.currentThread().isInterrupted()) {
                        tasksCancelledAtShutdown.add(runnable);  // 手机已经开始但还没正常完成的任务
                    }
                }
            }
        });
    }
}

上面的 TrackingExecutor 主要通过监控运行时任务的中断状态,来收集取消时还在执行的任务。下面我们用一个网页爬虫程序来演示一下 TrackingExecutor 的用法:

// 程序清单 7-22
public abstract class WebCrawler {
    private volatile TrackingExecutor exec;
    private final Set urlsToCrawl = new HashSet<>();

    public synchronized void start() {
        exec = new TrackingExecutor(Executors.newCachedThreadPool());
        for (URL url : urlsToCrawl) {
            submitCrawlTask(url);
        }
        urlsToCrawl.clear();
    }

    public synchronized void stop() throws InterruptedException {
        try {
            saveUncrawled(exec.shutdownNow());  // 保存 shutdownNow 返回的未执行任务
            if (exec.awaitTermination(TIMEOUT, UNIT)) {
                saveUncrawled(exec.getCancelledTasks());    // 等待一段时间后,存储 TrackingExecutor 收集的取消任务
            }
        } finally {
            exec = null;
        }
    }

    protected abstract List processPage(URL url);

    private void saveUncrawled(List uncrawled) {
        for (Runnable task : uncrawled) {
            urlsToCrawl.add(((CrawlTask) task).getPage());
        }
    }

    private void submitCrawlTask(URL u) {
        exec.execute(new CrawlTask(u));
    }

    private class CrawlTask implements Runnable {
        private final URL url;

        public CrawlTask(URL url) {
            this.url = url;
        }

        @Override
        public void run() {
            for (URL link : processPage(url)) {
                if (Thread.currentThread().isInterrupted()) {
                    return;
                }
                submitCrawlTask(link);  // 解析出来的网址又递归进行提交
            }
        }

        public URL getPage() {
            return url;
        }
    }
}

网页爬虫程序的工作通常是无穷尽的,因此当爬虫程序必须关闭时,我们通常希望保存它的状态,以便稍后重新启动。CrawlTask 提供了一个 getPage 方法,该方法能找出正在处理的页面。当爬虫程序关闭时,无论是还没有开始的任务,还是那些被取消的任务,都将记录它们的 URL,因此当爬虫程序重新启动时,就可以将这些 URL 的页面抓取任务加入到任务队列中。

7.3 处理非正常的线程终止

在任务处理线程(例如线程池中的工作者线程)的生命周期中,将通过某种抽象机制(例如 Runnable)来调用许多未知的代码,我们应该对在这些线程中执行的代码能否表现出正确的行为保持怀疑。这些线程应该在 try-catch 代码块中调用这些任务,这样就能捕获那些未检查的异常了,或者也可以使用 try-finally 代码块来确保框架能够直到线程非正常退出的情况,并做出正确的相应。

// 程序清单 7-23
public void run() {
    Throwable thrown = null;
    try {
        while (!isInterrupted()) {
            runTask(getTaskFromWorkQueue());
        }
    } catch (Throwable e) {
        thrown = e;
    } finally {
        threadExited(this, thrown);
    }
}

上面的 程序清单 7-23 中给出了如何在线程池内部构建一个工作者线程。如果任务抛出了一个未检查异常,那么它将使线程终结,但会首先通知框架该线程已经终结。然后,框架可能会用新的线程来代替这个工作线程,也可能不会,因为线程池正在关闭,或者当前已有足够多的线程能够满足需要。
我们也可以看看 ThreadPoolExecutor.runWorker() 方法中,运行任务时同样采用了上面的模式:

ThreadPoolExecutor.runWorker

你可能感兴趣的:(第七章——取消与关闭)