qinghuan
← 回到知识探索

后端现场 / 可下载小项目

三个消费者只放一个结束标记,为什么程序还不退出?

把入队任务的结账、业务成功和线程退出分别观察:一个停止标记只会被一个消费者取走,queue.join 返回后仍可能有线程在等。

展开本文目录
  1. 下载完整练习包
  2. 队列取走一项,其他消费者就不会再取到它
  3. task_done 是入队项的结账,不是成功印章
  4. queue.join 与 Thread.join 回答不同问题
  5. 实验保护让错误结果能够被看见并正常收尾
  6. 运行后同时看三项证据
  7. 换成 Java:BlockingQueue 不自带任务销账
  8. 有专用关闭 API 时,仍要核对它承诺的事
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 了解生产者把任务放入队列、消费者取出任务的流程。
  • 能读懂 Python try/finally 和线程启动。
  • Python 3.10+,只使用标准库。
  • 需要补基础时:先补线程与共享状态
遇到不熟悉的词,先看这 1 个解释
队列未完成计数
Queue 记的一本待办账:每次 put 增加一项,每次 task_done 划掉一项。Queue.join 等这本账归零。
打开完整术语小抄 ↗

单消费者版本往队列里放一个特殊值,工作线程读到后退出。一改成三个消费者,所有任务似乎都处理完了,主程序却结束不了:停止标记也是普通队列中的一项,不会自动广播给每个线程。

我们启动三个真实工作线程,先只放一个停止标记,并等待队列结账。此时有两个线程仍活着。再补两个标记,并逐个等待线程退出,才能得到完整的关闭结果。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。另附可选 Java 标准库版本,需 JDK 17 或更新版本;实测 JDK 17 与 21。下方命令运行基础 Python 标准库示例;可选对照的环境和命令见对应 README。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 14 个文件
包里有什么
  • demo.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要
  • java/:Java 源码、中文 README、JDK 17/21 输出和验证记录
  • supplement-verification.json:附加实现的独立复跑记录

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压到独立练习文件夹,在含 demo.py 的目录打开终端运行。Windows本机3个真实工作线程,单/三停止标记、任务成功失败记录、两类join与6项检查;未实现动态消费者和多生产者生产框架 压缩包的 java/ 子目录另附 Java 标准库版本、中文 README、两份实测输出和验证记录;进入 java/ 后按 README 编译运行。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

队列取走一项,其他消费者就不会再取到它

本例的 stop 是一个专用对象,消费者用 is 判断是否为结束标记。它与正常任务放在同一个队列里,遵守相同的取出规则:一次 get 把这一项交给一个消费者,不会把对象复制给所有消费者。

读到 stop 的线程退出,另外两个线程仍循环调用 get,等待新任务。队列空了不等于以后永远没有任务,因此它们没有理由凭一次空结果自行退出;这正是生产者与消费者需要停止协议的原因。

这里消费者数量固定为三,且主线程是唯一生产者。先放完三项业务任务,再开始发送停止标记。这个前提让“每个消费者一个标记”足够清楚;有多个仍在生产的来源或动态消费者时,还要另外协调什么时候可以宣告输入结束。

task_done 是入队项的结账,不是成功印章

每次 put 会增加未完成项的计数,消费者处理后调用 task_done 使计数减少。queue.join 等这个计数归零,所以它关注入队项有没有得到配对的结账调用,而不是有多少个线程已经退出。

脚本把 task_done 放进取出一项后的 finally,因此成功任务、记录过失败的任务和停止标记各结账一次。停止标记虽然不执行业务计算,也确实经历了一次 put/get;漏掉它的结账会让 queue.join 等不到归零。

业务输入固定为 2、0、5,每项计算 10 // 输入值,也就是整数除法;成功列表保存(输入值,计算结果),失败列表只保存输入值。因此任务零触发除零错误,另外两项得到 (2, 5) 和 (5, 2)。队列仍能结账完毕,因为失败已经被明确记录并结束处理;这不意味着任务零成功了,也不意味着队列会替你重试。

queue.join 与 Thread.join 回答不同问题

第一次 queue.join 返回后,脚本确认只记录到一个退出的消费者,并等待那个线程实际结束。此时再数 is_alive,得到两个。这组证据比只看“队列长度为零”更直接地显示了还没完成的线程生命周期。

随后给剩下的两个线程各放一个 stop,再等队列结账,最后对每个线程执行带保护时限的 join,并检查 is_alive 全部为假。Thread.join 的返回值本身不能说明是否等到退出,所以代码还要读线程状态。

你可以把这两个问题分别写在关闭清单中:已接受的队列项有没有处理结果;运行这些任务的工作线程有没有结束。把两项都叫“全部完成”,很容易漏掉其中一种等待。

实验保护让错误结果能够被看见并正常收尾

主线程通过 Barrier 等三个消费者都已启动,再投递任务;结果按任务值排序打印,不依赖哪个线程恰好抢到哪一项。因此线程调度顺序可以不同,而我们关注的业务记录与退出数量保持可比较。

queue.join 本身没有超时参数,示例用一个监视线程执行它,主线程只等待三秒;消费者的 get 也有短超时,用来检查异常清理事件。finally 会通知退出、结清尚未取走的演示项并回收监视线程,避免故意观察错误时留下等待者。

这些保护属于实验设施,不是完整生产队列框架。特别是异常清理时结清未执行项,只用于回收本次教学程序,不能把那些项计入业务成功。正常输出中的成功与失败来自此前独立记录的处理结果。

运行后同时看三项证据

保存为 demo.py 后执行 python -B demo.py。程序不联网、不读写业务文件,只创建三个工作线程及有限的队列等待监视线程。结果中应同时保留成功列表、失败列表和两个阶段的存活线程数。

本次 Python 3.14.0、Windows 实跑完成六项检查:一个标记后存活数为二,三个标记后为零;成功任务是二和五,失败任务是零。最后 all_threads_stopped=True 在 finally 清理完成后才打印。

展开收起代码 · 小实验python · 107 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""One stop item is consumed once; queue.join is not Thread.join.

All waiting has a bounded escape path for this finite demonstration.
"""
from queue import Empty, Queue
from threading import Barrier, Event, Lock, Thread


def main():
    jobs = Queue()
    stop = object()
    abort = Event()
    ready = Barrier(4, timeout=3)
    lock = Lock()
    successes, failures, stopped = [], [], []
    monitors = []
    checks = 0

    def check(condition, message):
        nonlocal checks
        if not condition:
            raise AssertionError(message)
        checks += 1

    def worker(index):
        ready.wait()
        while not abort.is_set():
            try:
                item = jobs.get(timeout=0.1)
            except Empty:
                continue
            try:
                if item is stop:
                    with lock:
                        stopped.append(index)
                    return
                try:
                    result = 10 // item
                except ZeroDivisionError:
                    with lock:
                        failures.append(item)
                else:
                    with lock:
                        successes.append((item, result))
            finally:
                # The stop item and a failed work item also need one task_done.
                jobs.task_done()

    def wait_for_queue():
        monitor = Thread(target=jobs.join, name="queue-join-monitor")
        monitors.append(monitor)
        monitor.start()
        monitor.join(timeout=3)
        if monitor.is_alive():
            raise AssertionError("queue.join did not finish within the guard")

    workers = [Thread(target=worker, args=(i,), name=f"worker-{i}") for i in range(3)]
    try:
        for thread in workers:
            thread.start()
        ready.wait()
        for item in (2, 0, 5):
            jobs.put(item)
        jobs.put(stop)
        wait_for_queue()
        with lock:
            stopped_copy = list(stopped)
        check(len(stopped_copy) == 1, "one stop item must be consumed by one worker")
        workers[stopped_copy[0]].join(timeout=3)
        alive_after_one = sum(thread.is_alive() for thread in workers)
        check(alive_after_one == 2, "queue.join does not stop the other workers")
        check(sorted(successes) == [(2, 5), (5, 2)], "successful work is recorded separately")
        check(failures == [0], "task_done is not a business-success certificate")
        print(f"after_one_stop: queue_join_returned=True; workers_alive={alive_after_one}")
        print(f"work_successes={sorted(successes)}; work_failures={failures}")

        for _ in range(alive_after_one):
            jobs.put(stop)
        wait_for_queue()
        for thread in workers:
            thread.join(timeout=3)
        check(not any(thread.is_alive() for thread in workers), "all workers must exit")
        check(sorted(stopped) == [0, 1, 2], "each worker consumes its own stop item")
        print("after_three_stops: queue_join_returned=True; workers_alive=0")
    finally:
        abort.set()
        ready.abort()
        for thread in workers:
            if thread.ident is not None:
                thread.join(timeout=3)
        # Release a join monitor even if an earlier assertion interrupted the run.
        while True:
            try:
                jobs.get_nowait()
            except Empty:
                break
            else:
                jobs.task_done()
        for monitor in monitors:
            monitor.join(timeout=3)
        if any(t.is_alive() for t in workers + monitors):
            raise AssertionError("demonstration thread did not stop")
    print(f"PASS checks={checks}; all_threads_stopped=True")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B demo.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。Windows本机3个真实工作线程,单/三停止标记、任务成功失败记录、两类join与6项检查;未实现动态消费者和多生产者生产框架

运行后对照结果
after_one_stop: queue_join_returned=True; workers_alive=2
work_successes=[(2, 5), (5, 2)]; work_failures=[0]
after_three_stops: queue_join_returned=True; workers_alive=0
PASS checks=6; all_threads_stopped=True

换成 Java:BlockingQueue 不自带任务销账

Java BlockingQueue 提供放入和取出任务的操作,但没有 Python Queue.task_done 或 Queue.join 这套计数接口。本例另外为固定的三项业务任务创建 CountDownLatch(3),每项处理结束后在 finally 调用 countDown;这本业务账属于示例本身,不是 BlockingQueue 自动维护的。

除零任务也会先记录失败、再减少业务计数,所以 await 返回仍不证明所有业务成功。停止对象只负责告诉消费者退出,没有加入这次三项业务的 latch。三个消费者中只发送一个停止对象,业务 latch 虽已归零,仍会有两个线程继续等待;再发送两个停止对象并分别 Thread.join,才核实三个工作者全部退出。

保存为 BlockingQueueStopDemo.java 后编译运行。Windows 的 JDK 17.0.20.1 与 21.0.12 都通过 8 项检查。CountDownLatch 的起始值固定,也不能重置;这个例子适合已知三项任务的批次,没有实现可无限追加任务的队列计数框架。

展开收起代码 · 小实验java · 106 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

/** Three fixed consumers; completion accounting is separate from BlockingQueue. */
public final class BlockingQueueStopDemo {
    private record Job(int value, boolean stop) {}
    private static final Job STOP = new Job(0, true);
    private static int checks;

    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
        checks++;
    }

    private static void await(CountDownLatch latch, String description) throws InterruptedException {
        if (!latch.await(3, TimeUnit.SECONDS)) {
            throw new AssertionError(description + " did not finish within the guard");
        }
    }

    public static void main(String[] args) throws Exception {
        BlockingQueue<Job> jobs = new LinkedBlockingQueue<>();
        CountDownLatch ready = new CountDownLatch(3);
        CountDownLatch businessDone = new CountDownLatch(3);
        CountDownLatch firstStop = new CountDownLatch(1);
        AtomicBoolean abort = new AtomicBoolean();
        Map<Integer, Integer> successes = new ConcurrentHashMap<>();
        ConcurrentLinkedQueue<Integer> failures = new ConcurrentLinkedQueue<>();
        ConcurrentLinkedQueue<Integer> stopped = new ConcurrentLinkedQueue<>();
        ConcurrentLinkedQueue<String> workerErrors = new ConcurrentLinkedQueue<>();
        List<Thread> workers = new ArrayList<>();
        for (int index = 0; index < 3; index++) {
            final int id = index;
            workers.add(new Thread(() -> {
                ready.countDown();
                try {
                    while (!abort.get()) {
                        Job job = jobs.take();
                        if (job == STOP) {
                            stopped.add(id);
                            firstStop.countDown();
                            return;
                        }
                        try {
                            successes.put(job.value(), 10 / job.value());
                        } catch (ArithmeticException expected) {
                            failures.add(job.value());
                        } finally {
                            // This explicit latch belongs to our finite batch.
                            businessDone.countDown();
                        }
                    }
                } catch (InterruptedException interrupted) {
                    if (!abort.get()) workerErrors.add("unexpected worker interruption");
                    Thread.currentThread().interrupt();
                } catch (RuntimeException error) {
                    workerErrors.add(error.toString());
                }
            }, "queue-worker-" + id));
        }
        try {
            for (Thread worker : workers) worker.start();
            await(ready, "worker startup");
            for (int value : new int[] {2, 0, 5}) jobs.add(new Job(value, false));
            jobs.add(STOP);
            await(businessDone, "business batch");
            await(firstStop, "first stop item");
            check(stopped.size() == 1, "one stop item must stop exactly one consumer");
            workers.get(stopped.element()).join(3000);
            long aliveAfterOne = workers.stream().filter(Thread::isAlive).count();
            check(aliveAfterOne == 2, "business completion does not stop the other workers");
            check(businessDone.getCount() == 0 && jobs.isEmpty(),
                    "the finite batch is accounted for and no queue item remains");
            check(successes.equals(Map.of(2, 5, 5, 2)), "successful work must be recorded");
            check(List.copyOf(failures).equals(List.of(0)), "failed work must be recorded separately");
            System.out.println("after_one_stop: business_latch=0; workers_alive=" + aliveAfterOne);
            System.out.println("work_successes=" + new TreeMap<>(successes) + "; work_failures=" + failures);

            for (int i = 0; i < aliveAfterOne; i++) jobs.add(STOP);
            for (Thread worker : workers) worker.join(3000);
            check(workers.stream().noneMatch(Thread::isAlive), "all consumers must exit");
            check(stopped.stream().sorted().toList().equals(List.of(0, 1, 2)),
                    "each consumer must receive a stop item");
            check(workerErrors.isEmpty(), "no unexpected worker error may be hidden");
            System.out.println("after_three_stops: workers_alive=0; stopped_workers=[0, 1, 2]");
        } finally {
            abort.set(true);
            for (Thread worker : workers) worker.interrupt();
            for (Thread worker : workers) worker.join(3000);
            if (workers.stream().anyMatch(Thread::isAlive)) {
                throw new AssertionError("demonstration worker did not stop");
            }
            jobs.clear();
        }
        System.out.println("PASS checks=" + checks + "; all_threads_stopped=true");
    }
}

怎样运行:javac -encoding UTF-8 --release 17 BlockingQueueStopDemo.java java -cp . BlockingQueueStopDemo

运行后对照结果
after_one_stop: business_latch=0; workers_alive=2
work_successes={2=5, 5=2}; work_failures=[0]
after_three_stops: workers_alive=0; stopped_workers=[0, 1, 2]
PASS checks=8; all_threads_stopped=true

有专用关闭 API 时,仍要核对它承诺的事

Python 3.13 起提供 Queue.shutdown,使用相应版本时可以进一步学习它的正常与立即关闭语义。本例为了兼容 Python 3.10,采用显式标记;不要把两套协议混用后仍假设计数行为完全相同。

Java BlockingQueue 接口本身没有统一的关闭操作,官方文档也提到特殊结束对象的做法。移植时不能照搬 Python 的 task_done,因为那是具体 API;可沿用的是将输入结束、处理结果与工作者退出分开检查的思路。

记住这三点

  • 一个停止标记是一个队列项,通常只会被一个消费者取走。
  • task_done 与 queue.join 跟踪项的处理结账,业务成功要单独记录。
  • 队列结账之后,还应等待并核对工作线程已经退出。

别把例子用过头

  • 按单消费者写一个结束标记,再直接扩成多个消费者。
  • 停止标记通过 return 离开,却漏掉 task_done。
  • 看到 queue.join 返回就宣称所有任务成功或所有线程退出。
  • 生产者尚未结束时就发送标记,导致标记后还有无人处理的业务项。

自己试一小步

把示例改成四个消费者、两个生产者,怎样保证所有业务项先进入队列,再发出足够的结束标记?

  1. 先定义两个生产者怎样报告自己不再产生任务。
  2. 分别记录每一项任务的成功、失败或未完成状态。
  3. 只在约定的输入结束条件成立后安排消费者退出。
  4. 测试一个生产者提前失败的情况,并验证关闭流程不会把未完成项写成成功。

做到这些,就算完成

  • 停止标记数量与实际需要停止的消费者对应。
  • 每次 get 恰好配对一次 task_done,包括标记和失败项。
  • 结束时独立核对业务记录、队列结账和线程存活状态。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。