《Java 并发编程·线程与内存模型》6.2 两阶段终止与协作式取消

两阶段终止只有一个骨架:volatile 停止标志、interrupt 唤醒阻塞、join 限时等待。本文给出可直接使用的 Worker 完整类,在本机对比只发信号、立即中断、超时升级三条关停路径,并用真实输出说明 Future.cancel(true) 只是请求:任务不检查中断时,cancel 返回 true 也停不下来。

承接的问题

6.1 说明了 interrupt 的语义:它只是设置标志,线程必须自己检查。但真实的服务不是单个线程,而是一个「取任务—执行—再取任务」的循环,外加一个队列。要把它干净地停下来,需要一套协议,而不是一次 interrupt() 调用。

这套协议叫两阶段终止(two-phase termination):

  1. 阶段一:发出停止请求。设置一个所有线程都能看到的标志,拒绝新任务。
  2. 阶段二:等待在途工作结束。给一个明确的上限,超时之后才升级为强制手段。

Thread.stop() 曾经是「一步到位」的答案,但它在 JDK 25 上已经直接抛 UnsupportedOperationException。历史教训是:强行终止会让对象停留在半更新状态,锁没释放、缓冲区写了一半、事务没提交。协作式取消慢一点,但状态可控。

三个动作,一个都不能少

一个正确的关停流程由三个动作组成:

动作作用少了会怎样
volatile boolean running = false让工作线程看见停止请求线程永远在 while (true) 里打转
thread.interrupt()把线程从 poll/take/sleep 里叫醒空闲线程要等下一次轮询超时才退出
thread.join(timeout)确认线程真的结束方法返回了,线程还在写数据

running 必须用 volatile 修饰。第 3 章的 happens-before 规则保证:写线程对 running 的写,对随后读到 false 的线程可见。如果只是普通字段,工作线程可能一直读到缓存里的 true,关停就变成了「偶尔有效」。

interrupt() 在这里的职责很窄:只负责唤醒,不负责终止。它让阻塞在 poll 上的线程立刻返回,然后由 running 决定是否退出。把两者混在一起——用中断标志代替停止标志——就会出现在途任务被误杀的问题,下面的实测会看到。

join(timeout) 是阶段二的边界。没有超时的 join() 等于把系统的可用性交给一个可能已经死循环的线程;有超时但没有后续动作,又等于假装关停成功。正确做法是:超时后记录日志、升级中断、再给一个短窗口。

完整的 Worker 类

下面这个类把三个动作封装在一起,可以直接放进 conc-core:

public final class Worker implements AutoCloseable {
    private final BlockingQueue<Runnable> tasks;
    private final Thread thread;
    private final Object lifecycle = new Object();
    private volatile boolean running = true;

    public Worker(int capacity, String name) {
        this.tasks = new ArrayBlockingQueue<>(capacity);
        this.thread = Thread.ofPlatform().name(name).start(this::loop);
    }

    public void submit(Runnable task) {
        synchronized (lifecycle) {
            if (!running) {
                throw new IllegalStateException("worker stopped");
            }
            tasks.add(task);
        }
    }

    private void loop() {
        while (running) {
            Runnable task;
            try {
                task = tasks.poll(200, TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                if (!running) {
                    break;                         // 阶段二:中断只为唤醒
                }
                continue;                          // 意外中断:继续工作
            }
            if (task == null) {
                continue;
            }
            try {
                task.run();
            } catch (RuntimeException e) {
                System.err.println("task failed: " + e);
            }
        }
    }

    public List<Runnable> shutdown(Duration timeout) throws InterruptedException {
        synchronized (lifecycle) {
            running = false;                       // 阶段一:拒绝新任务
        }
        thread.join(timeout.toMillis());           // 阶段二:限时等待
        if (thread.isAlive()) {
            thread.interrupt();                    // 超时才升级为硬中断
            thread.join(500);
        }
        List<Runnable> pending = new ArrayList<>();
        tasks.drainTo(pending);
        return pending;
    }

    public boolean isAlive() {
        return thread.isAlive();
    }

    @Override
    public void close() {
        synchronized (lifecycle) {
            running = false;
        }
        thread.interrupt();
    }
}

几个设计选择值得单独说明。

为什么用 poll(200ms) 而不是 take()。 take() 会无限阻塞,常规的唤醒手段只有中断或投一个哨兵任务;poll 带超时,即使中断丢失,线程最多 200ms 后也会自己检查 running。这是「不依赖单一信号」的防御性设计,代价是空闲时每 200ms 醒一次。

为什么 shutdown 返回 List<Runnable>。 队列里还没开始的任务不会被自动执行,也不应该被静默丢弃。把它们取出来交给调用方,是「取消边界」的一部分:调用方可以选择重新入队、写补偿表或落盘,而不是让它们随线程一起消失。

为什么 InterruptedException 在循环里分两种情况。 如果 running 已经是 false,中断是关停流程的一部分,直接退出;如果不是,说明是外部误中断,直接继续工作即可。这里没有重新 interrupt(),因为线程马上就要继续跑;只有在退出循环、需要把信号交给上层时,才应该恢复标志。

为什么 submit 和 shutdown 都要拿 lifecycle 锁。 只检查 running 再 add 存在竞态:关停线程可能正好在两步之间置位并排空队列,任务就既没执行、也没被返回。把「读标志 + 入队」与「置标志」放进同一把锁,才能保证关停之后不会再有任务溜进队列。

为什么 close() 直接中断。 AutoCloseable 语义通常用于 try-with-resources 的快速清理,不等在途任务。需要优雅关停时显式调用 shutdown(Duration)。

三种关停路径的取舍

路径在途任务队列任务延迟适用场景
只发信号 + poll 超时跑完返回给调用方最坏一个轮询周期任务可重入、不能丢
发信号后立即 interrupt被打断返回给调用方接近 0任务可丢弃、要求快停
信号 + join(timeout) + 超时升级先等,超时才断返回给调用方由 timeout 决定通用默认

实测:三种关停路径

用一个 8 容量、轮询 200ms 的 Worker,提交 3 个 50ms 的短任务和 1 个 300ms 的长任务,20ms 后关停:

  完成 quick-0
[signal-only] 耗时 28ms, 完成 1 个, 遗留 3 个, 线程存活=false

quick-0 是正在执行的那个,它跑完后循环看到 running == false,线程退出;quick-1、quick-2 和 slow 还在队列里,作为 pending 返回。28ms 接近 quick-0 剩下的执行时间(关停发生在提交后 20ms,任务总长 50ms),与轮询周期无关——轮询超时只在队列空、线程空闲时才决定退出延迟。

同一份任务,改为「发信号后立即中断」:

  取消 slow
  任务异常: java.util.concurrent.CancellationException: slow
[interrupt-now] 耗时 0ms, 完成 0 个, 线程存活=false

slow 正在 Thread.sleep(3000),中断立刻把它从睡眠里拽出来,任务抛出 CancellationException,完成计数为 0。这就是「在途任务被误杀」:阶段一刚刚发出停止请求,阶段二还没来得及等,工作就被打断了。如果你的任务有副作用(写库、发消息),这种关停方式必须配合补偿或幂等。

第三种:任务完全不检查中断,shutdown(300ms) 会怎样?

[timeout] 300ms 阶段二超时, 耗时 515ms, 线程存活=true, 遗留 0
[timeout] 再 interrupt 后线程存活=true
  聋任务自然结束

join(300ms) 等满 300ms,interrupt() 对纯 CPU 循环无效,再等 200ms 仍然活着,方法只能返回 false 语义的结果(调用方通过 isAlive() 判断)。515ms 就是 300 + 200 加少量开销。两阶段终止不是万能的:它只能请求,不能强制。 真正兜底的是进程级手段(下一节的 shutdown hook 也只负责最后一程)。

Future.cancel(true) 的局限

线程池把取消包装成 Future.cancel(boolean mayInterruptIfRunning)。很多代码把它当成「停止任务」的保证,实际上它只是「尝试中断执行该任务的线程」。先看一个完全不看中断标志的任务:

Future<?> f = pool.submit(() -> {
    long end = System.nanoTime() + 1_500_000_000L;
    long n = 0;
    while (System.nanoTime() < end) {
        n++;
    }
    System.out.println("[ignore] 任务仍跑完, 迭代 " + n);
});
Thread.sleep(100);
boolean cancelled = f.cancel(true);

本机输出摘录:

[ignore] cancel(true)=true, isCancelled=true, isDone=true
[ignore] get() 抛 CancellationException
[ignore] 任务仍跑完, 迭代 109861993

cancel(true) 返回 true,isCancelled() 是 true,isDone() 也是 true,get() 立刻抛 CancellationException——但任务还在跑,1.5 秒后才自然结束。Future 的状态机描述的是「调用方还能不能拿到结果」,不是「任务有没有停止」。

任务吞掉中断时也一样:

[swallow] 捕获后吞掉, 继续执行
[swallow] catch 之后继续跑到自然结束
[swallow] cancel(true)=true
[swallow] 300ms 后 isDone=true, 但任务打印还在后面

cancel(true) 在 100ms 时发出中断,任务捕获后没有恢复标志,继续执行到自然结束。isDone 早已是 true,进程却还在干活。

cancel(true) 的能力边界可以列成一张清单:

局限说明
只是请求任务不检查中断、或不进入可中断阻塞,就停不下来
状态提前完成isDone() 在 cancel 返回时即为 true,不代表线程已退出
无法确认停止时刻没有「等待任务真正结束」的 API,只能配合自己的 latch
可能停在半路中断点由 JVM 决定,可能正好在两次写之间
不负责回滚已经发生的副作用不会撤销
中断发给线程,不是任务无法保证中断恰好落在目标任务上

对比之下,cancel(false) 对还没开始的任务是可靠的:

[queued] cancel(false)=true, isCancelled=true

任务还在队列里,取消只是把它标记为不执行,没有任何并发风险。所以一个实用规则是:能取消尚未开始的任务,就不要去中断正在执行的任务;必须中断时,任务自身要按 6.1 的规范响应中断,并在 finally 里做清理。

shutdownNow() 也是同一类语义。本机实测:

[shutdownNow] 未启动任务数=5, awaitTermination=true, isShutdown=true, isTerminated=true

shutdownNow() 返回 5 个从未启动的任务,并向 2 个在途线程发中断;awaitTermination(1s) 能等到它们结束,是因为这两个任务恰好响应了中断。如果它们像前面的「聋任务」一样,awaitTermination 就会返回 false,线程池也不会真正终止。

在 libraryd-conc 里落地

把 Worker 用到批量写借阅流水上:

public final class LoanBatchService {
    private final Worker worker;
    private final LoanRepository loans;

    public LoanBatchService(LoanRepository loans, int capacity) {
        this.loans = loans;
        this.worker = new Worker(capacity, "loan-batch");
    }

    public void submit(Loan loan) {
        worker.submit(() -> loans.save(loan));
    }

    public List<Runnable> shutdown(Duration timeout) throws InterruptedException {
        // 返回值必须由调用方接管:重新入队、写补偿表或落盘
        return worker.shutdown(timeout);
    }

    public boolean isAlive() {
        return worker.isAlive();
    }
}

这段代码的取消边界有三个明确约定:

  • submit 在关停后抛 IllegalStateException,不会静默丢弃。调用方必须处理这个异常,通常是把请求转成 503 或写入待办表。
  • 在途的 loans.save 允许跑完。Loan 有 loanId,保存按 id 覆盖,重复执行是幂等的;这是「可以等」的前提。
  • 队列里的任务由调用方接管。shutdown 不会执行它们,也不会假装执行过。关停流程必须把返回的 List<Runnable> 落盘或重放,否则这些借阅记录就丢了。

如果业务要求「一条都不能丢」,更稳妥的是把队列本身持久化(数据库 outbox),Worker 只做消费者。内存队列 + 两阶段终止能保证「不丢正在执行的」,但保证不了「不丢还没开始的」——后者需要存储,不是线程 API 能解决的。

常见错误与边界

错误后果修法
running 不加 volatile工作线程看不见停止请求加 volatile,或用 AtomicBoolean
用中断标志代替停止标志在途任务被误杀两个信号分开:running 管意图,interrupt 管唤醒
join() 不设超时关停线程被一个死循环拖死必须给上限,超时后升级并记录
shutdown 丢弃 pending队列任务静默丢失返回给调用方或持久化
在 shutdown 里先 interrupt 再置标志任务可能在标志生效前被中断先置标志,再中断
用 cancel(true) 当「已停止」资源已关闭,任务还在写自己维护完成 latch,或以 isAlive/awaitTermination 为准

什么时候不要用两阶段终止:

  • 任务的副作用不可撤销、也不幂等(扣款、发短信)。此时取消必须走补偿事务或 outbox,不能只靠中断。
  • 关停窗口只有几百毫秒、任务又可能跑几分钟。与其假装优雅,不如明确「快速失败 + 启动时对账」。
  • 你想用它保证「恰好一次」。两阶段终止保证的是「尽量不丢、最终能停」,不是分布式事务。

小结

  • 两阶段终止的三个动作缺一不可:volatile 停止标志表达意图,interrupt 唤醒阻塞,join(timeout) 确认结束。
  • 只发信号会让在途任务跑完、队列任务遗留;立即中断会误杀在途任务;超时升级是通用折中,但无法强制一个不检查中断的任务。
  • 本机实测:只发信号 28ms 结束、遗留 3 个任务;立即中断 0ms 结束但取消了在途任务;300ms 超时升级最终耗时 515ms,线程仍然存活。
  • Future.cancel(true) 只是中断请求,isDone 提前变 true 不代表任务停止;能取消未开始的任务时优先 cancel(false),必须中断时任务要自己响应并清理。

单机线程能这样停,整个 JVM 退出时又该在哪里做最后一轮清理?6.3 讲 shutdown hook 的能力边界:它能保证什么、绝不保证什么,以及为什么多个 hook 之间的顺序不可依赖。

阅读导航:上一节 6.1 interrupt 的真实语义 | 下一节 6.3 shutdown hook 与释放顺序

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收