码农高天
用内置列表替代队列会在多线程下引发CPU空轮询与竞态崩溃,开发者必须依托原生队列的线程安全特性与task_done计数协同,才能实现无死锁的动态负载均衡与平稳退出。
AI-assisted summary of 【python】queue是个啥?能干啥?啥是任务分配? — original source. Check the original for full context.
Source published:
Key ideas
- Queue(队列)通过先进先出(FIFO)机制确保任务调度的公平性与有序性,其基础操作 put 和 get 的时间复杂度均为 O(1)。 (0:00)
- 相较于标准 Queue,使用内置 list 模拟队列存在 list.pop(0) 带来的 O(n) 内存挪动开销,且在多线程下极易引发竞态条件导致程序崩溃。 (1:45)
- 在生产者-消费者模型中,基于队列的“领任务”模式能够根据任务实际耗时自然实现工作线程间的动态负载均衡。 (4:10)
- 标准 Queue 具备原生阻塞挂起特性,结合 queue.task_done() 与 queue.join() 可以精确实现多线程任务的协同完成与优雅退出。 (7:44)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2024 年 11 月 3 日发布。视频简介指出,本期内容深入探讨了 Queue(队列)数据结构及其在 Python 中的应用场景,并系统讲解了 Producer-Consumer(生产者-消费者)模型、使用队列的核心原因及其架构优势。
## TL;DR 视频从生活中的排队买票隐喻切入,解析了先进先出(FIFO)队列的基础特性,并通过二叉树广度优先搜索(BFS)阐明其保障时序的核心价值。高天从 O(n) 出队耗时、CPU 空轮询损耗以及多线程竞态条件(Race Condition)三个维度,透彻解释了为何不能用 Python 内置的 list 代替队列。随后,视频重点演示了生产者-消费者模型如何借助阻塞队列实现天然的动态负载均衡与解耦,并针对多线程退出的工程陷阱,深度剖析了 queue.task_done() 与 queue.join() 的协同计数机制。
## Atmosphere & style 讲解风格幽默平实且极接地气,高天善用“人体大肠”、“窗口排队”、“各科老师留作业”等生活隐喻化解复杂的并发逻辑。视频推进节奏紧凑、直面工程痛点,从简单的功能调用逐步深入到多线程并发陷阱与底层原语,兼具轻松生动的视听趣味与严谨硬核的编码实战感。
## Bulletin - [00:00](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=0s) Queue(队列)通过先进先出(FIFO)机制确保任务调度的公平性与有序性,其基础操作 put 和 get 的时间复杂度均为 O(1)。 - [01:45](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=105s) 相较于标准 Queue,使用内置 list 模拟队列存在 list.pop(0) 带来的 O(n) 内存挪动开销,且在多线程下极易引发竞态条件导致程序崩溃。 - [04:10](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=250s) 在生产者-消费者模型中,基于队列的“领任务”模式能够根据任务实际耗时自然实现工作线程间的动态负载均衡。 - [07:44](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=464s) 标准 Queue 具备原生阻塞挂起特性,结合 queue.task_done() 与 queue.join() 可以精确实现多线程任务的协同完成与优雅退出。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=0s) 队列(Queue)最基础的形式是先进先出(FIFO)的线性结构,核心功能仅包含 put(放)与 get(拿)。 - [00:00](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=0s) 生活中的排队隐喻说明了队列能避免无序争抢、防止不公平现象,并支持根据负载灵活增减服务窗口。 - [01:45](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=105s) 在二叉树广度优先搜索(BFS)等经典算法中,队列的有序性是确保层序遍历逻辑正确、不退化为深度优先搜索(DFS)的基石。 - [01:45](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=105s) 不能用 Python 的内置 list 代替标准 Queue,因为 list.pop(0) 具有 O(n) 的时间复杂度,存在明显的性能缺陷。 - [04:10](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=250s) 生产者-消费者模型通过队列实现生产方与消费方的完全解耦,使双方无需关心对方的具体身份或数量匹配。 - [04:10](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=250s) 多线程消费者并发执行时只能保证“开始执行”的先后顺序,任务完成顺序取决于具体计算时长,从而自然实现了动态负载均衡。 - [06:05](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=365s) Queue 的 get() 默认具有阻塞特性,队列为空时消费者线程会自动挂起,避免了 list 空轮询造成的 CPU 资源浪费。 - [06:05](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=365s) 多线程下使用 list 的 if-pop 模式存在竞态条件(Race Condition),线程上下文切换极易引发 IndexError,而 Queue 自带线程安全保证。 - [07:44](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=464s) 将消费者简单设为守护线程(daemon=True)会导致主线程执行完后未完成的任务被直接强制杀死。 - [07:44](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=464s) 仅检测队列为空无法判断所有任务是否执行完毕,因为任务被消费者认领出队后仍可能处于耗时计算中。 - [07:44](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=464s) Python 的 Queue 提供了内置任务计数机制:put() 会自增计数,消费者处理完毕调用 task_done() 递减计数,主线程调用 join() 阻塞等待归零以实现安全退出。 - [07:44](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=464s) 标准 FIFO 队列是并发编程的核心基石,其机制可扩展延伸至 LifoQueue、PriorityQueue 及非阻塞调用等高级变体。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=0s) 队列的基本概念与生活中的排队隐喻
队列(Queue)是一种非常简单的线性有序数据结构。通常情况下,人们聊到 Queue 时指的都是先进先出(FIFO, First In, First Out)模型,即最先放进去的数据最先被拿出来;而在广义层面上,它也可以指能够逐个存入数据、并按照特定顺序输出的结构(例如 PriorityQueue 优先队列)。一个最基础的 Queue 核心只需要支持两个功能:“放”(put)与“拿”(get)。以 Python 内置库的队列为例,将 0 到 9 依序放进队列后,在队列非空的情况下逐个取出并打印,输出的顺序必然是 0 到 9。高天形象地打了一个生动的比方:“大家可以把它想象成人的大肠儿,有一边进东西,有点出东西。”
Queue 最常见的现实映射是有序安排任务。如果火车站或迪士尼的售票窗口没有队伍,人们来一个人就直接冲到窗口抢着买票,就会产生诸多混乱:售票窗口挤作一团导致整体效率大幅下降,而身材瘦小的人可能一辈子都买不到票,因为全凭谁强壮、谁嗓门大谁先买。因此人类发明了排队: - 大家按照到达的先后顺序排成一队,依次走到窗口办理; - 如果队伍排得过长,管理员可以多开几个售票窗口; - 如果队伍没人了,可以适当减少开放的窗口数量; - 这彻底避免了“来得早却始终买不到票”的不公平情况。
```mermaid flowchart LR accTitle: 队列基础 FIFO 模型 accDescr: 展示元素从队尾进入、在队列中按序排列、并从队头取出的先进先出流程
subgraph Queue["Queue (FIFO 队列)"] direction LR InTail["队尾 (Put)"] Node0["0"] Node1["1"] Node9["... 9"] OutHead["队头 (Get)"] InTail --> Node9 --> Node1 --> Node0 --> OutHead end
Producer["数据生产者"] -->|"put(item)"| InTail OutHead -->|"get() -> 0, 1,... 9"| Consumer["数据消费者"] ```
---
[01:45](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=105s) 算法应用与为什么不用内置 List
在编程实践中,队列的有序性往往是算法正确性的基石。以广度优先搜索(BFS)遍历二叉树为例:二叉树的每个节点包含数据,并分别拥有左、右子节点。实现宽度优先遍历时,最标准的做法是维护一个记录“接下来该搜索谁”的队列。每访问一个节点,先处理自己,再将其左孩子、右孩子按顺序入队,这样便天然保证了逐层向外的遍历顺序。高天指出,如果没有队列来强制保障有序性,逻辑就会失控甚至沦为深度优先搜索(DFS):“这根节点说我完事了,左孩子你先来,结果左孩子说我还有孩子呢,我孩子得来啊,这就变成深度搜索了。”凭借 FIFO 的保证,节点最终才能严格按照 1、2、3、4、5 这种层序宽度被依次访问。
不少人会疑惑,Python 中拥有极其灵活的内置 `list`,既然 `list` 能取出任意索引的数据,为何不直接用 `list.pop(0)`?高天解释说,用 `list` 在逻辑层面看似能跑通,但存在两个严重缺陷: 1. **时间复杂度差异(性能问题)**:对于标准 Queue 来说,`put` 和 `get` 操作通常都是 $O(1)$ 常数时间的。而 Python 的 `list` 在内存中是连续数组,执行 `list.pop(0)` 时需要将首个元素弹出,并把后续所有元素全部向前挪动一位,因而其时间复杂度高达 $O(n)$。 2. **多线程下的阻塞与线程安全机制缺失**:`list` 无法原生支持线程间的挂起等待与安全并发。
---
[04:10](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=250s) 生产者-消费者模型与负载均衡
谈到队列的高级用途,核心便是“生产者-消费者模型”(Producer-Consumer Model)。该模型的逻辑非常质朴:现实中存在任务的产生者(Producer),也存在任务的完成者(Consumer)。高天举例说,在学校里,化学老师、数学老师和物理老师留家庭作业,他们就是 Producer;布置下来的作业由你负责数学、同桌负责物理和化学,你们把作业凑齐交付,你们就是 Consumer。在实际系统架构中,生产者与消费者的数量比例千差万别,可能是 100 比 1,也可能是 1 比 100。引入队列后,任务两端完全实现了解耦:生产方只管往队列里塞任务,无需寻找由哪个具体的消费者干活;消费方只管从队列里拿任务,不必关心任务出自何人之手。
```mermaid flowchart TD accTitle: 生产者消费者与负载均衡架构 accDescr: 多个生产者向队列投递任务,由阻塞队列统一缓冲,多个工作线程动态争抢任务以实现负载均衡
subgraph Producers["生产者 (Producers)"] P1["Producer 1 (如: 数学老师)"] P2["Producer 2 (如: 物理老师)"] end
subgraph Buffer["线程安全队列 (Queue)"] Q["FIFO Task Queue<br/>(支持阻塞 get 与线程安全)"] end
subgraph Consumers["消费者 (Consumers)"] C1["Consumer 1 (工作线程 A)"] C2["Consumer 2 (工作线程 B)"] end
P1 -->|"put(task)"| Q P2 -->|"put(task)"| Q Q -->|"阻塞抢占 get()"| C1 Q -->|"阻塞抢占 get()"| C2 ```
当设定一个生产者往队列填充数字 0 到 9、由两个消费者线程并发读取并打印时,输出的数字不再是绝对递增的。这是因为队列只能保证任务“被开始执行”的先后顺序,无法左右任务“被执行完成”的时长。如果把 10 个任务机械地按数量各分 5 个给两个人,极易发生一人分到简单任务早早收工、另一人面对极其复杂的任务苦不堪言的局面。而让消费者从队列中“做完一个再领下一个”,便自然达成了动态负载均衡(Load Balancing)。
---
[06:05](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=365s) 阻塞特性与 List 轮询的线程安全隐患
Queue 的一个杀手级特性是其 `get()` 方法默认属于**阻塞操作(Blocking Operation)**。当队列为空时,调用 `get()` 的线程会自动挂起停滞,直到队列中有新元素被写入才被唤醒,“有任务就做,没有任务就歇着”。如果试图用 `list` 模拟这一流程,通常会写一个 `while True` 配合 `if my_list: my_list.pop(0)`。这种做法问题重重: - **空轮询浪费 CPU**:如果列表为空,CPU 会陷入无意义的飞速空转。 - **`time.sleep()` 难以量化**:休眠时间设短了依然白白浪费 CPU 计算资源;休眠时间设长了,突发的大量任务产生堆积,系统响应迟钝,远不如操作系统底层的事件阻塞与条件变量灵活。 - **缺乏线程安全(Race Condition)**:在单线程环境下,先判断 `if my_list:` 再执行 `my_list.pop(0)` 没有任何逻辑瑕疵。但在多线程并发环境下,线程 A 执行完非空判断后发生上下文切换,线程 B 也恰好通过了非空判断。此时如果列表中仅剩 1 个元素,两线程前后脚抢着执行 `pop(0)`,必定会导致后执行的线程在空列表上执行弹出操作,直接抛出 `IndexError` 异常崩掉程序。若要在 `list` 上解决这个问题,必须手动额外加锁(Locking),代码复杂度和心智负担倍增。
---
[07:44](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=464s) 线程退出陷阱与 `task_done` 协同机制
在多线程中使用队列常会遭遇控制退出的难题。直接运行两个 Consumer 线程时,主程序往往会卡住无法退出,必须手动按 `Ctrl+C` 强行终止。原因在于 Producer 投递完任务后函数已退出,不再产生新数据,但两个 Consumer 线程仍在空队列上无限阻塞等待,且 Python 默认主进程会等待所有非守护线程执行完毕才退出。
有开发者可能会尝试将线程的属性配置为 `daemon=True`(守护线程)。“Daemon 等于 True 的意思是只要主线程结束了我就结束。”然而一旦加上守护线程属性,程序运行后屏幕上可能空空如也:Producer 闪电般执行完毕后主线程随即终结,并强制将两个未及执行的子线程一并杀死。此外,仅在主线程监测“队列是否为空”也是错误的——当消费者线程把最后一个任务从队列拿出来时,队列瞬间变成了空,但该任务很可能还在被密集计算、尚未真正执行完成。“Queue 里面空只代表所有的任务都被认领了,不代表所有的任务都被完成了。”
针对这个痛点,Python 的 `queue.Queue` 提供了内置的协同计数机制: - 每当调用 `queue.put()` 往队列放入一个任务时,队列内部的计数器就会自增 1; - 消费者完整处理完一项任务后,必须显式调用一次 `queue.task_done()`,通知队列内部计数器减 1; - 主线程通过调用 `queue.join()` 实施阻塞,程序将静止停在这一行,直到所有被 `put()` 的任务都被消费者调用了对应的 `task_done()`、未完成计数归零后,`join()` 才会顺利放行并平稳退出。
高天总结道,这就是 Python 最经典标准的生产者-消费者模型。除基础 Queue 之外,Python 还提供了带超时的非阻塞 `put_nowait()` / `get_nowait()`、后进先出的 `LifoQueue`(栈行为)以及按优先级提取的 `PriorityQueue`,掌握最底层的 FIFO 队列与并发配合是理解其他所有高级变体的基石。
## Notable quotes > [07:44](https://www.youtube.com/watch?v=Qsa3xZgDUh4&t=464s) Queue 里面空只代表所有的任务都被认领了,不代表所有的任务都被完成了。 — 高天
## Mentioned 高天, Python, Queue, FIFO, PriorityQueue, LifoQueue, 生产者-消费者模型, 广度优先搜索, 深度优先搜索
## Video description 这期视频聊聊queue这个数据结构和python上的应用场景。也讨论了一下producer-consumer模型。尽可能细致地给大家讲解了一下为什么要用queue,以及它有什么好处。
[Source](https://www.youtube.com/watch?v=Qsa3xZgDUh4)