并发编程(九):BlockingQueue 与 Channel——语言层的原子性、可见性与有序性

目录

  • [0. 从上一篇继续](#0. 从上一篇继续 "#0-%E4%BB%8E%E4%B8%8A%E4%B8%80%E7%AF%87%E7%BB%A7%E7%BB%AD")
  • [1. Message Passing 的发展历史](#1. Message Passing 的发展历史 "#1-message-passing-%E7%9A%84%E5%8F%91%E5%B1%95%E5%8E%86%E5%8F%B2")
    • [1.1 Dijkstra:通过共享缓冲区协作](#1.1 Dijkstra:通过共享缓冲区协作 "#11-dijkstra%E9%80%9A%E8%BF%87%E5%85%B1%E4%BA%AB%E7%BC%93%E5%86%B2%E5%8C%BA%E5%8D%8F%E4%BD%9C")
    • [1.2 RC 4000:通过系统消息接口通信](#1.2 RC 4000:通过系统消息接口通信 "#12-rc-4000%E9%80%9A%E8%BF%87%E7%B3%BB%E7%BB%9F%E6%B6%88%E6%81%AF%E6%8E%A5%E5%8F%A3%E9%80%9A%E4%BF%A1")
    • [1.3 Actor Model:通过异步消息驱动行为](#1.3 Actor Model:通过异步消息驱动行为 "#13-actor-model%E9%80%9A%E8%BF%87%E5%BC%82%E6%AD%A5%E6%B6%88%E6%81%AF%E9%A9%B1%E5%8A%A8%E8%A1%8C%E4%B8%BA")
    • [1.4 CSP:通过同步通信协调执行](#1.4 CSP:通过同步通信协调执行 "#14-csp%E9%80%9A%E8%BF%87%E5%90%8C%E6%AD%A5%E9%80%9A%E4%BF%A1%E5%8D%8F%E8%B0%83%E6%89%A7%E8%A1%8C")
    • [1.5 四种协作方式有什么区别?](#1.5 四种协作方式有什么区别? "#15-%E5%9B%9B%E7%A7%8D%E5%8D%8F%E4%BD%9C%E6%96%B9%E5%BC%8F%E6%9C%89%E4%BB%80%E4%B9%88%E5%8C%BA%E5%88%AB")
    • [1.6 回到 Message Passing](#1.6 回到 Message Passing "#16-%E5%9B%9E%E5%88%B0-message-passing")
  • [2. 三种语言的消息交接规则分别保证什么?](#2. 三种语言的消息交接规则分别保证什么? "#2-%E4%B8%89%E7%A7%8D%E8%AF%AD%E8%A8%80%E7%9A%84%E6%B6%88%E6%81%AF%E4%BA%A4%E6%8E%A5%E8%A7%84%E5%88%99%E5%88%86%E5%88%AB%E4%BF%9D%E8%AF%81%E4%BB%80%E4%B9%88")
    • [2.1 Java:BlockingQueue](#2.1 Java:BlockingQueue "#21-javablockingqueue")
      • [2.1.1 Java 有哪些 Queue?](#2.1.1 Java 有哪些 Queue? "#211-java-%E6%9C%89%E5%93%AA%E4%BA%9B-queue")
      • [2.1.2 BlockingQueue 的两条并发规则](#2.1.2 BlockingQueue 的两条并发规则 "#212-blockingqueue-%E7%9A%84%E4%B8%A4%E6%9D%A1%E5%B9%B6%E5%8F%91%E8%A7%84%E5%88%99")
      • [2.1.3 SPSC:单 Producer、单 Consumer](#2.1.3 SPSC:单 Producer、单 Consumer "#213-spsc%E5%8D%95-producer%E5%8D%95-consumer")
      • [2.1.4 MPSC:多 Producer、单 Consumer](#2.1.4 MPSC:多 Producer、单 Consumer "#214-mpsc%E5%A4%9A-producer%E5%8D%95-consumer")
      • [2.1.5 MPMC:多 Producer、多 Consumer](#2.1.5 MPMC:多 Producer、多 Consumer "#215-mpmc%E5%A4%9A-producer%E5%A4%9A-consumer")
      • [2.1.6 阻塞等待与 FIFO](#2.1.6 阻塞等待与 FIFO "#216-%E9%98%BB%E5%A1%9E%E7%AD%89%E5%BE%85%E4%B8%8E-fifo")
    • [2.2 Go:Channel](#2.2 Go:Channel "#22-gochannel")
      • [2.2.1 Go 有哪些 Channel?](#2.2.1 Go 有哪些 Channel? "#221-go-%E6%9C%89%E5%93%AA%E4%BA%9B-channel")
      • [2.2.2 Channel 的通信与同步规则](#2.2.2 Channel 的通信与同步规则 "#222-channel-%E7%9A%84%E9%80%9A%E4%BF%A1%E4%B8%8E%E5%90%8C%E6%AD%A5%E8%A7%84%E5%88%99")
      • [2.2.3 SPSC:单 Producer、单 Consumer](#2.2.3 SPSC:单 Producer、单 Consumer "#223-spsc%E5%8D%95-producer%E5%8D%95-consumer")
      • [2.2.4 MPSC:多 Producer、单 Consumer](#2.2.4 MPSC:多 Producer、单 Consumer "#224-mpsc%E5%A4%9A-producer%E5%8D%95-consumer")
      • [2.2.5 MPMC:多 Producer、多 Consumer](#2.2.5 MPMC:多 Producer、多 Consumer "#225-mpmc%E5%A4%9A-producer%E5%A4%9A-consumer")
      • [2.2.6 Buffered / Unbuffered 与 FIFO](#2.2.6 Buffered / Unbuffered 与 FIFO "#226-buffered--unbuffered-%E4%B8%8E-fifo")
    • [2.3 CPython:queue.Queue](#2.3 CPython:queue.Queue "#23-cpythonqueuequeue")
      • [2.3.1 Python 有哪些 Queue?](#2.3.1 Python 有哪些 Queue? "#231-python-%E6%9C%89%E5%93%AA%E4%BA%9B-queue")
      • [2.3.2 Queue 的线程安全与同步语义](#2.3.2 Queue 的线程安全与同步语义 "#232-queue-%E7%9A%84%E7%BA%BF%E7%A8%8B%E5%AE%89%E5%85%A8%E4%B8%8E%E5%90%8C%E6%AD%A5%E8%AF%AD%E4%B9%89")
      • [2.3.3 SPSC:单 Producer、单 Consumer](#2.3.3 SPSC:单 Producer、单 Consumer "#233-spsc%E5%8D%95-producer%E5%8D%95-consumer")
      • [2.3.4 MPSC:多 Producer、单 Consumer](#2.3.4 MPSC:多 Producer、单 Consumer "#234-mpsc%E5%A4%9A-producer%E5%8D%95-consumer")
      • [2.3.5 MPMC:多 Producer、多 Consumer](#2.3.5 MPMC:多 Producer、多 Consumer "#235-mpmc%E5%A4%9A-producer%E5%A4%9A-consumer")
      • [2.3.6 阻塞等待、FIFO 与任务完成](#2.3.6 阻塞等待、FIFO 与任务完成 "#236-%E9%98%BB%E5%A1%9E%E7%AD%89%E5%BE%85fifo-%E4%B8%8E%E4%BB%BB%E5%8A%A1%E5%AE%8C%E6%88%90")
  • [3. 下一篇:消息交接是怎么实现的?](#3. 下一篇:消息交接是怎么实现的? "#3-%E4%B8%8B%E4%B8%80%E7%AF%87%E6%B6%88%E6%81%AF%E4%BA%A4%E6%8E%A5%E6%98%AF%E6%80%8E%E4%B9%88%E5%AE%9E%E7%8E%B0%E7%9A%84")

0. 从上一篇继续

在第零篇中,我们从多个并发执行单元如何交换数据、协调工作的角度,提出了两种典型的并发协作模型:

  • Shared Memory:多个执行单元访问同一份共享状态,通过同步机制协调访问。
  • Message Passing:多个执行单元通过发送和接收消息交换数据、协调工作。

前面几篇主要沿着 Shared Memory 展开,从 Mutex、Atomic 到 ReadWriteLock,解释了这些同步机制如何提供 Atomicity、Visibility 和 Ordering。

这一篇转向 Message Passing。在进入具体语言之前,我们先回顾这一并发协作思想的发展历史。


1. Message Passing 的发展历史

1.1 Dijkstra:通过共享缓冲区协作

1968 年,Dijkstra 在《Cooperating Sequential Processes》中研究顺序进程的协作,并使用一个 Producer、一个 Consumer 的例子:双方通过共享缓冲区交换数据,依靠 Semaphore 协调执行。

当缓冲区为空时,Consumer 需要等待;如果使用有界缓冲区,缓冲区满时 Producer 也需要等待。

1.2 RC 4000:通过系统消息接口通信

1969 年,Per Brinch Hansen 为 RC 4000 计算机设计了 RC 4000 Multiprogramming System(多道程序操作系统) ,通过系统内核提供进程间消息通信机制。随后,他在 1970 年发表的《The Nucleus of a Multiprogramming System》中介绍了这一设计。

与 Dijkstra 的共享缓冲区方案不同,RC 4000 的系统核心(Nucleus)提供了明确的消息通信操作:

  • send message:向指定进程发送消息。
  • wait message:等待并接收消息。
  • send answer:向消息发送方返回回复。
  • wait answer:等待对应的回复。

系统内核负责分配消息缓冲区,并将消息放入指定接收方的队列。

把这四个操作放到一个 Producer、一个 Consumer 的例子里:

Producer 通过 send message 发送任务,Consumer 调用 wait message 取出任务;处理后,Consumer 可以通过 send answer 回复,Producer 再通过 wait answer 获取结果。

如果 Consumer 尚未准备接收,只要系统有可用的消息缓冲资源,Producer 就可以先完成发送并继续执行。

1.3 Actor Model:通过异步消息驱动行为

1973 年,Carl Hewitt、Peter Bishop 和 Richard Steiger 在《A Universal Modular ACTOR Formalism for Artificial Intelligence》中提出 Actor Model:Actor 拥有自身状态,通过异步消息与其他 Actor 协作,收到消息后按自身行为处理。

以 Producer--Consumer 为例,可以让 Buffer Actor 接收 Producer 的任务,再转交 Consumer:

这里的 Buffer Actor 和 Mailbox 只是示例设计,并非 Actor Model 要求的固定结构。

1.4 CSP:通过同步通信协调执行

1978 年,Tony Hoare 在《Communicating Sequential Processes》中提出了 CSP。

CSP 将程序组织成多个顺序执行的 Process,通过明确的输入与输出操作进行通信。其典型通信方式是同步会合(Rendezvous):发送与对应的接收操作必须配对,通信才能完成。这里的 Process 是计算模型中的执行单元,不要求对应独立的操作系统进程。

回到 Producer--Consumer,如果 Producer 要把任务交给 Consumer,两者必须在通信操作上配对:

图中的 Rendezvous 是一次逻辑通信事件,并不代表中央缓冲区。如果 Consumer 尚未准备接收,Producer 就需要等待;双方配对后,任务才完成交接。

1.5 四种协作方式有什么区别?

需要注意,这四者并不是相互替代的,而是在不同背景下探索执行单元之间的通信与协作:Dijkstra 讨论进程协作与同步,RC 4000 提供操作系统消息机制,而 Actor Model 和 CSP 是更加完整的并发计算模型。

模型 / 方案 中间机制 数据交换 主要关注点 Consumer 未准备好时
Dijkstra Shared Buffer 直接访问共享缓冲区 数据同步与进程协作 有缓冲区:未满时可以写入,满时等待,双方可以异步交接
RC 4000 系统消息队列 系统消息接口 操作系统通信机制 有缓冲区:未满时可以写入,满时等待,双方可以异步交接
Actor Model Actor 向 Actor 发送消息 Actor 的身份、状态与行为 有缓冲区:未满时可以写入,满时等待,双方可以异步交接
CSP Rendezvous Send / Receive 配对 通信事件与执行同步 无缓冲区:必须等待接收方就绪,双方同步交接

1.6 回到 Message Passing

Message Passing 的核心,是让执行单元通过显式的消息发送与接收交换数据、协调执行。 。 通信可以有缓冲,也可以无缓冲:有缓冲时,消息可以先暂存,发送方与接收方异步交接;无缓冲时,消息不能暂存,必须等待收发双方就绪后同步交接。

接下来,我们分别从 Java BlockingQueue、Go Channel 和 CPython queue.Queue 的语言规则出发,回答以下三个问题:

  • Atomicity:怎样保证单次消息交接的正确性?
  • Visibility:发送消息前准备的数据,接收方能否正确观察?
  • Ordering:发送与接收之间,能够建立怎样的同步顺序?

2. 三种语言的消息交接规则分别保证什么?

2.1 Java:BlockingQueue

2.1.1 Java 有哪些 Queue?

Java 的 Queue 是一种存放待处理元素的集合接口。根据是否支持并发访问、是否需要等待,我们可以把常见实现分成三类:

类型 代表实现 是否支持并发访问 是否提供阻塞等待操作
普通 Queue ArrayDeque、PriorityQueue 否 否
并发非阻塞 Queue ConcurrentLinkedQueue 是 否,队列为空时 poll() 立即返回 null
BlockingQueue ArrayBlockingQueue 等 是 是,例如 take() 等待元素,put() 在容量受限且已满时等待空间 e` 等 支持并发操作,并可在特定条件下等待

其中,BlockingQueue有几种代表实现:

实现 存储结构 容量 取出规则 阻塞等待条件
ArrayBlockingQueue 数组 固定,创建时指定 FIFO put() 在满时等待;take() 在空时等待
LinkedBlockingQueue 链表 创建时可指定,默认 Integer.MAX_VALUE FIFO put() 在满时等待;take() 在空时等待
SynchronousQueue 不存储元素 0 收发双方直接配对 put() 等待接收方;take() 等待发送方
PriorityBlockingQueue 堆 无界 按优先级,同优先级不保证 FIFO put() 不等待空间;take() 在空时等待
DelayQueue 优先级堆 无界 按到期时间,先到期先取出 put() 不等待空间;take() 在空或尚无到期元素时等待
LinkedTransferQueue 链表 无界 FIFO put() 不等待空间;take() 在空时等待;transfer() 等待元素被接收

这里选用最典型的实现 ArrayBlockingQueue 来讲解。

最常用的两个操作是:

  • put(e):放入元素;队列满时等待可用空间。
  • take():取出并移除元素;队列空时等待新元素。

2.1.2 BlockingQueue 的两条并发规则

JDK 25 BlockingQueue 文档 规定:

"All queuing methods achieve their effects atomically."

这条规则对应 Atomicity。每次入队或出队都是原子的,不会因并发竞争而破坏队列状态。

同一文档的 Memory Consistency Effects 还规定:

一个线程将对象放入 BlockingQueue 之前的操作,happens-before 另一个线程随后访问或移除该元素之后的操作。

这条规则同时对应 Visibility 和 Ordering。

对于 Visibility,Producer 在发送消息前完成的写入,对取得该消息的 Consumer 可见。

对于 Ordering,发送前的操作、消息交接以及接收后的操作,通过 happens-before 的传递性建立跨线程内存顺序。

继续沿用第零篇的 count++,从 SPSC 扩展到 MPSC、MPMC:

java 复制代码
class Increment {
    int delta;
}

BlockingQueue<Increment> queue = new ArrayBlockingQueue<>(2);

2.1.3 SPSC:单 Producer、单 Consumer

java 复制代码
// Producer 线程
Increment message = new Increment();
message.delta = 1;
queue.put(message);

// Consumer 线程
int count = 0;
Increment received = queue.take();
count += received.delta; // count == 1

对应的执行关系:

text 复制代码
Producer                                 Consumer

message.delta = 1
        │
        │ program order
        ▼
queue.put(message)  ─── 消息交接 ────────► queue.take() 返回 message
                                                  │
                                                  │ program order
                                                  ▼
                                         read received.delta == 1
                                         count = 1

put() 成功放入一条消息,take() 成功取出并移除它。这符合 Atomicity 的单次操作保证,但此时还没有多个 Producer 或多个 Consumer 之间的竞争。

delta 是普通字段,没有使用 volatile 或 Atomic。假设消息入队后没有其他线程继续修改它,Producer 的字段写入 happens-before Consumer 的读取,所以 Consumer 能看到 1,也符合 Visibility 和 Ordering 的保证。

2.1.4 MPSC:多 Producer、单 Consumer

java 复制代码
// Producer A、B 各自在自己的线程执行一次
Increment message = new Increment();
message.delta = 1;
queue.put(message);

// 唯一的 Consumer 线程
int count = 0;
for (int i = 0; i < 2; i++) {
    count += queue.take().delta;
}
// 两条消息均成功提交并被处理后,count == 2

一种可能的执行顺序:

text 复制代码
Producer A              Producer B              Consumer

put(A: +1)
                        put(B: +1)
                                                take() -> +1
                                                count: 0 -> 1
                                                take() -> +1
                                                count: 1 -> 2

Atomicity :两个 Producer 同时入队,也不会互相覆盖已成功提交的消息。只要两次 put() 均成功,Consumer 就能取出两条消息。

Visibility / Ordering :对 Producer A、B 各自发送的消息,前述 happens-before 规则分别成立;但两次并发 put() 谁先成功,并没有预定顺序。

这里 count += delta 并非原子指令,只是 count 始终由同一个 Consumer 修改,因此不会发生两个 Consumer 之间的丢失更新。

2.1.5 MPMC:多 Producer、多 Consumer

继续增加一个 Consumer。两个 Producer 仍然分别发送 +1;现在由 Consumer A、B 并发调用 take():

java 复制代码
// Producer A、B:各发送一个独立的 Increment 对象
Increment message = new Increment();
message.delta = 1;
queue.put(message);

// Consumer A、B:各执行一次
Increment received = queue.take();
int delta = received.delta;

例如,两条消息分别由两个 Consumer 取走:

text 复制代码
Producer A        Producer B        Consumer A        Consumer B

put(A: +1)
                  put(B: +1)
                                    take() -> A
                                                      take() -> B

Atomicity :同一条消息只会被一次成功的 take() 移除,不会被两个 Consumer 重复取走。图中 A、B 只是区分两次发送的消息对象,并不表示固定分配给哪个 Consumer。

Visibility / Ordering:无论哪个 Consumer 取得消息,都能依赖该消息对应 Producer 的入队前写入与接收后读取之间的 happens-before 关系。但消息出队的先后,并不保证两个 Consumer 业务处理完成的先后。

这里还存在一个边界:如果两个 Consumer 取得消息后仍直接修改同一个普通 count:

java 复制代码
// 反例:两个 Consumer 共享同一个 Counter 对象
class Counter { int count; }
Counter sharedCounter = new Counter(); // 先创建,交给两个 Consumer 共用

// Consumer A、B 各在自己的线程中执行一次
int delta = queue.take().delta;
sharedCounter.count += delta;

两条消息虽然各自被安全取出,但 count 仍可能因并发更新而只得到 1。BlockingQueue 保证的是消息交接的原子性,而不是任意后续业务操作的原子性。

2.1.6 阻塞等待与 FIFO

以上三个场景还共享 ArrayBlockingQueue 的两个具体行为:

  • 阻塞等待 :队列为空时,take() 等待;容量为 2 的队列满时,put() 等待可用空间。
  • FIFO:元素按照实际入队的顺序出队。但多个 Producer 的竞争入队顺序不预先确定,也不能据此推断多个 Consumer 的业务完成顺序。

2.2 Go:Channel

与 Java 不同,Go 没有提供一组与 BlockingQueue 对应的标准并发队列实现,而是直接在语言层提供了 Channel,用于 Goroutine 之间的数据通信。

2.2.1 Go 有哪些 Channel?

根据 Go Language Specification,Channel 可以按照缓冲容量分为两种:

类型 创建方式 主要特点
Unbuffered Channel make(chan int) 不存储消息,发送与接收需要配对
Buffered Channel make(chan int, 2) 具有固定容量的缓冲区,允许发送者先提交消息

Channel 还可以限制通信方向:

类型 声明 允许的操作
双向 Channel chan T 发送和接收
Send-only Channel chan<- T 只能发送
Receive-only Channel <-chan T 只能接收

方向限制与是否有缓冲是两个独立的维度。例如,一个 Buffered Channel 也可以通过 chan<- T 类型交给只负责发送消息的函数。

这里选用典型的 Buffered Channel 来讲解。

初始化一个容量为 2 的 Buffered Channel:

go 复制代码
type Increment struct {
    delta int
}

jobs := make(chan Increment, 2)

其中两个最重要的操作是:

  • ch <- value:发送消息;缓冲区满时等待可用空间。
  • value := <-ch:接收消息;缓冲区空时等待新消息。

2.2.2 Channel 的通信与同步规则

Go Language Specification 规定,同一个 Channel 可以被任意数量的 Goroutine 发送、接收,无需额外同步。

这条规则对应 Atomicity 。并发 Send / Receive 可以安全交接消息:每次成功的接收对应一条发送的消息,不会让多个 Consumer 重复取得同一条消息。 Go Memory Model --- Channel Communication 还规定:

"A send on a channel is synchronized before the completion of the corresponding receive from that channel."

这条规则同时对应 Visibility 和 Ordering。

对于 Visibility,Producer 在发送消息前完成的写入,对完成对应接收的 Consumer 可见。

对于 Ordering,发送前的操作、Channel 的 Send / Receive,以及接收后的操作,通过 synchronized-before 和程序顺序建立 happens-before 关系。

下面继续用第零篇的 count++,从 SPSC 扩展到 MPSC、MPMC。

2.2.3 SPSC:单 Producer、单 Consumer

go 复制代码
// Producer Goroutine
message := Increment{delta: 1}
jobs <- message

// Consumer Goroutine
count := 0
received := <-jobs
count += received.delta // count == 1

对应的执行关系:

text 复制代码
Producer                                 Consumer

message.delta = 1
        │
        │ sequenced-before
        ▼
jobs <- message  ─── 消息交接 ───────────► received := <-jobs 完成
                                                  │
                                                  │ sequenced-before
                                                  ▼
                                         read received.delta == 1
                                         count = 1

一次成功的 Send 与 Receive 完成消息交接。此时还没有多个 Producer 或 Consumer 之间的竞争。

delta 是普通字段。Go Channel 会传递 Increment 的值;发送前的写入 synchronized-before 对应接收完成,因此 Consumer 能读取到 1,满足 Visibility 和 Ordering。

2.2.4 MPSC:多 Producer、单 Consumer

go 复制代码
// Producer A、B 各自在自己的 Goroutine 执行一次
jobs <- Increment{delta: 1}

// 唯一的 Consumer Goroutine
count := 0
for i := 0; i < 2; i++ {
    received := <-jobs
    count += received.delta
}
// 两条消息均成功发送并被处理后,count == 2

一种可能的执行顺序:

text 复制代码
Producer A              Producer B              Consumer

send(A: +1)
                        send(B: +1)
                                                receive -> +1
                                                count: 0 -> 1
                                                receive -> +1
                                                count: 1 -> 2

Atomicity:两个 Producer 并发 Send 不会互相覆盖已成功发送的消息;两次发送各对应一次接收。

Visibility / Ordering:每条消息的发送与对应接收之间分别建立同步关系;两个 Producer 谁先发送成功并没有预定顺序。

2.2.5 MPMC:多 Producer、多 Consumer

go 复制代码
// Producer A、B 各自在自己的 Goroutine 执行一次
jobs <- Increment{delta: 1}

// Consumer A、B 各自在自己的 Goroutine 执行一次
received := <-jobs
delta := received.delta

例如,两条消息分别被两个 Consumer 接收:

text 复制代码
Producer A        Producer B        Consumer A        Consumer B

send(A: +1)
                  send(B: +1)
                                    receive -> A
                                                      receive -> B

Atomicity:同一次发送的消息只由一个对应的 Receive 取得,不会被两个 Consumer 重复接收。图中的 A、B 仅用来区分消息,不代表固定的消费者分配规则。

Visibility / Ordering:每个 Consumer 都能依赖所接收消息的 Send / Receive 同步关系,但接收消息的顺序并不保证处理完成的顺序。

如果两个 Consumer 接收后仍修改同一个共享 count:

go 复制代码
// 反例:两个 Consumer 共享同一个普通计数器
received := <-jobs
sharedCounter.count += received.delta

即使两个 Receive 都正确取得消息,sharedCounter.count 仍然存在 Data Race,可能发生丢失更新。Channel 保障消息交接,不会自动保护接收后的共享状态。

2.2.6 Buffered / Unbuffered 与 FIFO

前面的例子使用容量为 2 的 Buffered Channel:

  • 等待:缓冲区空时 Receive 等待;缓冲区满时 Send 等待。
  • FIFO:同一个 Channel 按消息实际发送的顺序交接,但并发 Producer 的发送先后并不预先确定,也不保证多个 Consumer 的业务完成顺序。

Go 还支持 Unbuffered Channel:

go 复制代码
jobs := make(chan Increment)

它不存储消息,Send 和 Receive 必须配对,Send 才能完成。Go Memory Model 还规定:Unbuffered Channel 的对应 Receive synchronized-before Send 完成。这项额外保证不适用于一般的 Buffered Channel 发送完成时刻。

Channel 如何让等待中的 Goroutine 挂起,并在条件满足时恢复执行,是下一篇要回答的问题。


2.3 CPython:queue.Queue

与 Go 不同,Python 没有在语言语法层提供 Channel,而是通过标准库提供不同执行模型下的队列机制。

2.3.1 Python 有哪些 Queue?

Python 为不同的执行单元提供了不同的队列:

模块 代表类型 通信双方 等待方式
queue Queue、SimpleQueue 等 同一进程内的线程 阻塞当前线程
asyncio asyncio.Queue 同一事件循环内的异步 Task 挂起当前 Task,让其他 Task 继续执行
multiprocessing multiprocessing.Queue 不同进程 阻塞调用方

其中,queue 提供线程安全队列;asyncio.Queue 面向异步 Task,不提供线程安全保证;multiprocessing.Queue 面向多进程通信,传递的对象需要经过序列化。

这里讨论 CPython 的线程,因此重点介绍标准库 queue 模块。

根据 Python queue 官方文档,它提供以下几种代表实现:

实现 容量 取出规则 默认阻塞等待条件 任务完成跟踪
queue.Queue 可配置,默认无界 FIFO,先进先出 put() 在满时等待;get() 在空时等待 支持
queue.LifoQueue 可配置,默认无界 LIFO,后进先出 put() 在满时等待;get() 在空时等待 支持
queue.PriorityQueue 可配置,默认无界 按优先级,值小的先取出 put() 在满时等待;get() 在空时等待 支持
queue.SimpleQueue 无界 FIFO,先进先出 put() 不等待空间;get() 在空时等待 不支持

任务完成跟踪指通过 task_done() 标记任务处理完成,通过 join() 等待所有入队任务处理完成。

注意:Python 的 maxsize <= 0 表示缓冲区容量不设上限;Go 的 Channel 容量为 0 则表示没有缓冲区。

这里选用典型的实现 queue.Queue 来讲解。其中两个最重要的操作是:

操作 含义 默认等待条件
put(item) 放入元素 队列满时,等待可用空间
get() 取出并移除元素 队列空时,等待新元素

两者默认阻塞等待,也可以通过 block=False 立即尝试,或通过 timeout 限制等待时间;未能放入或取出元素时,分别抛出 queue.Full 或 queue.Empty。

2.3.2 Queue 的线程安全与同步语义

Python queue 官方文档 规定:

"The Queue class in this module implements all the required locking semantics."

这条契约对应 Atomicity :queue.Queue 支持多个线程并发执行 put() 和 get(),使用内部锁保证队列操作的线程安全;同一条消息不会被两个 Consumer 重复取出。

对于 Visibility 和 Ordering ,Producer 在 put() 之前准备消息,Consumer 通过 get() 取得该消息,再读取其字段。在 CPython 中,这一交接依靠 queue.Queue 内部的锁与条件变量完成线程同步,使 Consumer 能读取交接前准备好的数据。

。

继续沿用第零篇的 count++,从 SPSC 扩展到 MPSC、MPMC:

python 复制代码
from dataclasses import dataclass
from queue import Queue

@dataclass
class Increment:
    delta: int

jobs = Queue(maxsize=2)

2.3.3 SPSC:单 Producer、单 Consumer

Producer 发送一条 +1 消息,唯一的 Consumer 接收后更新 count:

python 复制代码
# Producer Thread
message = Increment(delta=1)
jobs.put(message)

# Consumer Thread
count = 0
received = jobs.get()
count += received.delta  # count == 1

对应的执行关系:

text 复制代码
Producer                                 Consumer

message.delta = 1
        │
        ▼
jobs.put(message) ─── Queue 内部同步 ───► jobs.get() 返回 message
                                                  │
                                                  ▼
                                         read received.delta == 1
                                         count = 1

put() 成功放入一条消息,get() 取出并移除它。只有一个 Producer 和一个 Consumer 时,还没有同侧的并发竞争。

delta 是普通字段。假设入队后不再有线程并发修改这个消息,Consumer 通过 Queue 完成交接后可以读取到 1;这一可见性来自 Queue 的同步,而不是 GIL 自动保护了所有共享数据。

与第零篇通过 ready 协调读取不同,这里 get() 会在队列为空时等待消息,无需额外共享 ready 标记。

2.3.4 MPSC:多 Producer、单 Consumer

增加一个 Producer。两个 Producer 各发送一条 +1 消息,唯一的 Consumer 顺序累加:

python 复制代码
# Producer A、B 各自在自己的 Thread 执行一次
jobs.put(Increment(delta=1))

# 唯一的 Consumer Thread
count = 0
for _ in range(2):
    received = jobs.get()
    count += received.delta
# 两条消息均成功提交并被处理后,count == 2

一种可能的执行顺序:

text 复制代码
Producer A              Producer B              Consumer

put(A: +1)
                        put(B: +1)
                                                get() -> +1
                                                count: 0 -> 1
                                                get() -> +1
                                                count: 1 -> 2

Atomicity :两个 Producer 并发调用 put() 不会覆盖彼此已成功入队的消息;单个 Consumer 可以依次取出两条消息。

Visibility / Ordering:每条消息都通过 Queue 完成交接与同步;两个 Producer 谁先成功入队没有预定顺序。

count += received.delta 是普通复合操作,但这里仅由单个 Consumer 修改 count,不涉及多个 Consumer 对同一计数器的竞争。

2.3.5 MPMC:多 Producer、多 Consumer

再增加一个 Consumer。两个 Producer 各提交一条 +1,两个 Consumer 并发调用 get():

python 复制代码
# Producer A、B 各自在自己的 Thread 执行一次
jobs.put(Increment(delta=1))

# Consumer A、B 各自在自己的 Thread 执行一次
received = jobs.get()
delta = received.delta

例如,两条消息分别由两个 Consumer 取得:

text 复制代码
Producer A        Producer B        Consumer A        Consumer B

put(A: +1)
                  put(B: +1)
                                    get() -> A
                                                      get() -> B

Atomicity :成功的 get() 会从队列移除一个元素;同一条消息不会被两个 Consumer 同时取出。图中的 A、B 仅用于区分消息,不表示预先指定哪个 Consumer 接收。

Visibility / Ordering:取得消息的 Consumer 可以依赖 Queue 的线程同步读取入队前准备的数据;但出队顺序不等于两个 Consumer 的业务完成顺序。

如果两个 Consumer 之后仍然修改同一个共享计数器:

python 复制代码
# 反例:两个 Consumer 共用 counter
counter = {"value": 0}  # 在创建两个 Consumer 之前共享

# Consumer A、B 各自执行一次
received = jobs.get()
counter["value"] += received.delta

队列安全取出两条消息,并不能保证两次 counter["value"] += 1 不发生丢失更新。不能依赖 GIL 把这个复合操作当作 Python 的原子性保证,Free-threaded CPython 更不能这样假设。

2.3.6 阻塞等待、FIFO 与任务完成

前面的三个场景还共享 queue.Queue 的具体行为:

  • 阻塞等待 :容量为 2 的队列满时,put() 默认等待;空队列上的 get() 默认等待。maxsize=0 表示不限制容量,而不是 Go 式无缓冲通信。
  • FIFO :queue.Queue 按实际入队顺序取出元素;并发 Producer 谁先入队无法预先确定,也不保证多个 Consumer 按出队顺序完成处理。
  • 任务完成 :get() 只表示取出消息,task_done() 用于报告任务处理完成,join() 等待未完成任务计数归零。取出消息和处理完成不是一回事。

Queue 底层怎样通过 Lock 与 Condition 协调等待线程,是下一篇讨论的问题。


3. 下一篇:消息交接是怎么实现的?

这一篇停在语言层:Java BlockingQueue 和 Go Channel 通过明确的通信规则保证消息交接及其内存顺序;CPython queue.Queue 则依靠标准库的线程同步机制完成安全交接。

下一篇会把这些规则转换成实现层需要解决的问题,分别沿着 Java ArrayBlockingQueue、Go Channel 和 CPython queue.Queue,从同步原语、Runtime 一直看到 OS 与 CPU。


本文首发于 ThinkerQAQ 的个人博客,由作者本人同步发布。原文可能持续修订,最新版本请以个人博客为准。

相关推荐
盛世宏博智慧档案1 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
兔子零10241 小时前
FastAPI 零基础入门教程:写过 Node.js,就能轻松学会 Python 后端开发(一)
前端·后端·python
huaweichenai1 小时前
spring boot实现base64上传
java·spring boot·后端
花间相见2 小时前
【计算基础|网络16】【工程实战】Nginx 部署与反向代理
后端
点纭2 小时前
spring ai1.0实战:ChatClient(2)
java·后端·spring
Gopher_HBo2 小时前
Resolver 与 Balancer —— 连接管理三层联动
后端
yunwei372 小时前
eBPF 实例教程:BPF Arena 零拷贝共享内存
linux·后端·性能优化
UrbanJazzerati2 小时前
AutoHotkey v2 零基础快速入门 Part 1
后端
geovindu2 小时前
rust: pool Pattern
开发语言·后端·设计模式·rust·对象池简写模式