并发编程(十):BlockingQueue 与 Channel 的实现——从 Runtime 到 CPU
目录
- 0. 这一篇继续回答什么?
- 1. JDK 如何实现 ArrayBlockingQueue?
- 2. Go Runtime 如何实现 Channel?
- 3. CPython 如何实现 queue.Queue?
- 4. 三种实现的共同套路
- 5. 下一篇:两种协作模型的关系
0. 这一篇继续回答什么?
上一篇从语言和标准库规则出发,看 BlockingQueue、Channel 和 queue.Queue 如何提供 Atomicity、Visibility 和 Ordering。
这一篇继续往下看:Java、Go 和 CPython 分别如何在 Runtime、OS Thread 和 CPU 层实现这些保证。
1. JDK 如何实现 ArrayBlockingQueue?
1.1 分层
Java Source Code
实例:BlockingQueue.put() / take()
作用:声明消息交接与阻塞等待
│
▼
JDK Class Library
实例:ArrayBlockingQueue / ReentrantLock / AQS / ConditionObject
作用:维护队列状态、互斥锁和条件等待队列
│
▼
JVM Implementation(HotSpot)
实例:Unsafe.park / HotSpot Parker、Continuation
作用:提供底层线程等待与虚拟线程执行机制
│
▼
OS Thread / Thread Parking
实例:Platform Thread / Parker;Virtual Thread / Carrier
作用:竞争或等待时暂停、恢复相应执行单元
│
▼
x86-64 Hardware
实例:Atomic Instruction / Cache Coherence / Memory Ordering
作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础
1.2 一次 put / take 的完整实现路径

JDK 内部的核心流程:

1.3 Runtime / Language Implementation 层的三种保证
1.3.1 Atomicity
这里的 Atomicity 指一次入队或出队对队列状态的修改不会与其他线程的操作交错。
看 1.2 节的第二张图——「ArrayBlockingQueue put / take 内部路径」。put 和 take 都先调用 lockInterruptibly(),拿到锁后才执行 enqueue(e) 或 dequeue()。数组、索引和 count 的更新都在锁的保护下完成,其他线程无法在中途修改队列。
1.3.2 Visibility 和 Ordering
这里的 Visibility 指Consumer 能看到 Producer 入队前写入的数据;Ordering 指Producer 的这些写入先于 Consumer 的读取生效。
看 1.2 节的第一张图——「ArrayBlockingQueue 跨层实现路径」。Producer 先写入 message.delta = 1,再调用 jobs.put(message) 并释放锁(Release)。Consumer 随后获取同一把锁(Acquire),从 take() 取出消息后就能读到 delta == 1。因为前一次解锁 happens-before 随后的加锁,Producer 的写入对 Consumer 可见。notEmpty.signal() 则负责通知正在等待的 Consumer。
1.4 Hardware 层的三种保证
这些保证最终通过原子指令、Cache Coherence 和内存顺序约束落实:
Atomicity
→ Atomic Instruction
→ 锁状态竞争:LOCK CMPXCHG 等原子操作
Visibility
→ Cache Coherence
→ 典型 x86-64 CPU:MESI-family(如 MESIF / MOESI)
Ordering
→ Acquire / Release 与必要的 Memory Ordering
→ x86-64 内存顺序约束 + LOCKed RMW / 编译器约束
相关实现:ArrayBlockingQueue.java、AbstractQueuedSynchronizer.java、ReentrantLock.java。
2. Go Runtime 如何实现 Channel?
2.1 分层
Go Source Code
实例:jobs <- message / message := <-jobs
作用:声明 Send / Receive 及同步关系
│
▼
Go Implementation
实例:runtime/chan.go、chansend / chanrecv、hchan、sudog
作用:管理缓冲区、等待队列及消息配对
│
▼
Go Runtime / Scheduler(G / M / P)
实例:gopark / goready
作用:暂停等待的 G,将被唤醒的 G 置为可运行状态
│
▼
OS Thread / Thread Parking
实例:承载 G 的 M、内部锁竞争时的线程级等待
作用:运行 Goroutine;必要时暂停或唤醒 M
│
▼
x86-64 Hardware
实例:Atomic Instruction / Cache Coherence / Memory Ordering
作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础
2.2 一次 Channel 通信的完整实现路径
先看「Go Channel 跨层实现路径」时序图,以无缓冲 Channel 为例:

Channel 的核心通信流程:

2.3 Runtime / Language Implementation 层的三种保证
2.3.1 Atomicity
这里的 Atomicity 指一次 Channel 收发涉及的状态修改和消息配对,不会与其他 Goroutine 的操作交错。
看 2.2 节的第二张图——「Channel 的核心通信流程」。Runtime 先获取 hchan.lock:有缓冲时,修改 buf、sendx、recvx 和 qcount;无缓冲时,从 recvq 或 sendq 找到等待者并完成消息交接。如果暂时无法通信,Goroutine 会通过 gopark 等待并释放锁,等另一方完成交接后再由 goready 唤醒。
2.3.2 Visibility 和 Ordering
这里的 Visibility 指Consumer 完成接收后,能看到 Producer 发送前写入的数据;Ordering 指这些写入先于 Consumer 接收后的读取生效。
看 2.2 节的第一张图——「Go Channel 跨层实现路径」。Producer 先写入 message.delta = 1,再通过无缓冲 Channel 发送;sendDirect 将消息交给等待中的 Consumer,Consumer 完成接收后就能读到 delta == 1。Go 内存模型规定,一次发送 synchronized-before 对应接收操作完成。有缓冲 Channel 虽然先存储消息,也遵循这条规则。
2.4 Hardware 层的三种保证
Channel 的锁最终也要靠底层原子指令和内存顺序约束来实现:
Atomicity
→ Atomic Instruction
→ Runtime 内部锁的 CAS / Exchange 等原子操作
Visibility
→ Cache Coherence
→ 典型 x86-64 CPU:MESI-family(如 MESIF / MOESI)
Ordering
→ Acquire / Release 与必要的 Memory Ordering
→ 编译器约束 + x86-64 内存顺序 / LOCKed RMW
源码:runtime/chan.go、runtime/proc.go、internal/runtime/atomic/atomic_amd64.s。
3. CPython 如何实现 queue.Queue?
3.1 分层
Python Source Code
实例:queue.Queue.put() / get()
作用:声明消息入队、出队及等待条件
│
▼
CPython Standard Library
实例:queue.Queue / threading.Condition / collections.deque
作用:维护队列状态、检查条件并通知等待者
│
▼
CPython Implementation
实例:_thread.lock / PyMutex / Parking Lot
作用:实现底层锁的原子状态与线程等待
│
▼
OS Thread / Thread Parking
实例:Native Thread / Semaphore 等平台等待原语
作用:锁竞争或条件等待时阻塞与恢复线程
│
▼
x86-64 Hardware
实例:Atomic Instruction / Cache Coherence / Memory Ordering
作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础
3.2 一次 put / get 的完整实现路径
先看「CPython queue.Queue 跨层实现路径」时序图,以 Consumer 等待空队列为例:

再看「CPython Queue put / get 核心流程」:

3.3 Runtime / Language Implementation 层的三种保证
3.3.1 Atomicity
这里的 Atomicity 指一次 put 或 get 中的队列检查与修改,不会与其他线程的队列操作交错。
看 3.2 节的第二张图——「CPython Queue put / get 核心流程」。put 和 get 都先获取 Queue.mutex,检查队列状态后,再执行 deque.append(item) 或 deque.popleft()。队列满或空时,Condition.wait() 会释放锁等待;收到通知后,线程重新获取锁并检查条件,才能继续操作。
3.3.2 Visibility 和 Ordering
这里的 Visibility 指Consumer 能看到 Producer 入队前写入的消息内容;Ordering 指这些写入先于 Consumer 的读取生效。
看 3.2 节的第一张图——「CPython queue.Queue 跨层实现路径」。Producer 先写入 message.delta = 1,再调用 put(message) 入队并释放 Queue.mutex(Release)。Consumer 收到通知、重新获取同一把锁(Acquire)后,从 get() 取出消息就能读到 delta == 1。这里由锁的释放和获取保证同步;not_empty.notify() 负责通知等待的线程。
3.4 Hardware 层的三种保证
再往下看,Queue.mutex 最终由 _thread.lock 实现。底层通过 PyMutex 的原子操作竞争锁;抢不到锁时,再借助 Parking Lot 和系统等待机制让线程暂停:
Atomicity
→ Atomic Instruction
→ PyMutex 锁状态的 CAS / Exchange
Visibility
→ Cache Coherence
→ 典型 x86-64 CPU:MESI-family(如 MESIF / MOESI)
Ordering
→ Acquire / Release 与必要的 Memory Ordering
→ 编译器原子内存顺序 + CPU 顺序约束
源码:Lib/queue.py、Lib/threading.py、Python/lock.c、Python/parking_lot.c。
4. 三种实现的共同套路
以容量为 2 的 Queue 为例,两个 Producer 和两个 Consumer 同时操作,需要解决三个问题:
| 问题 | 如果不解决 | 对应机制 |
|---|---|---|
| 多个 Producer / Consumer 如何安全交接消息? | 消息可能被覆盖,或被重复取出 | Mutex / Atomicity |
| 暂时无法交接消息时怎么办? | 队列空了无法取、满了无法放,等待还可能白白占用 CPU | Condition / Park / Wakeup |
| Consumer 为什么能读到 Producer 写入的数据? | 消息虽然交接成功,读取到的内容却未必正确 | Release / Acquire、Visibility / Ordering |
4.1 谁能入队或出队?——Mutex / Atomicity
初始时,Queue 有两个空位,下一次写入位置为 tail = 0。如果两个 Producer 同时读取这个位置:
Producer A Producer B
读取 tail = 0 读取 tail = 0
写入 buffer[0] = A 写入 buffer[0] = B
结果:两个 Producer 写入同一槽位,其中一条消息被覆盖
问题在于,读取写入位置与更新队列状态之间,其他 Producer 也可能插进来。
给 Queue 加上一把 Mutex,假设 Producer A 先获得锁:
Queue 初始:[空,空],tail = 0
Producer A Producer B
Lock 成功 尝试 Lock → 等待
读取 tail = 0
写入槽位 0:A
更新 tail = 1
Unlock ──────────────────→ Lock 成功
重新读取 tail = 1
写入槽位 1:B
更新 tail = 0
Unlock
最终 Queue:[A,B]
Producer B 必须等 A 释放锁后,才能读取更新后的 tail,因此不会再写入槽位 0。谁先获得锁并不确定,但两次成功入队不会相互覆盖。
两个 Consumer 并发出队也是同一个道理:检查队列状态、取出消息和更新状态必须在同一临界区内完成,才能避免重复取出。Mutex 底层通过 Atomic 操作竞争锁状态,从而保证 Queue 操作的 Atomicity。
4.2 Queue 为空或已满怎么办?——Condition / Park / Wakeup
互斥保证了交接时不发生冲突,却不能解决队列为空或已满时的等待。
Queue 为空 → Consumer 等待 notEmpty
Queue 已满 → Producer 等待 notFull
以 Consumer 先遇到空队列为例:
Consumer Producer
Lock → 发现 Queue 为空
登记 notEmpty 等待
Unlock → Park
Lock → 入队
signal(notEmpty)
Unlock
Wakeup → Lock
重新检查 Queue 状态
取出消息 → Unlock
Producer 遇到满队列时则反过来:等待 notFull,由 Consumer 出队后通知。
等待必须释放锁,否则另一方无法改变 Queue 的状态;唤醒后必须重新检查条件,因为其他线程可能先一步完成交接。登记等待与释放锁还必须正确协调,避免丢失通知。
4.3 交接完成后,为什么能读到消息内容?——Release / Acquire
前两个问题保证了交接不会冲突,也不会因为空满而一直占住锁。但 Consumer 还需要看到 Producer 发送前写入的数据。
Producer Consumer
写入 message.delta = 1
↓
发布消息 [Release] ─── 同步交接 ───▶ 接收消息 [Acquire]
↓
读取 delta == 1
对于使用 Mutex 的 Queue,Producer 释放锁(Release)happens-before Consumer 随后获取同一把锁(Acquire)。因此,Consumer 能看到 Producer 在发送消息前的写入。
Go 的无缓冲 Channel 则不需要消息缓冲区,而是直接配对发送者与接收者,并保证发送前的写入对接收方可见。
加上保存消息的缓冲区和队列状态,这三个机制就构成了一个有界阻塞 Queue 的核心:
消息缓冲区 + 队列状态
+
Mutex:保证一次交接不可被打断
+
Condition:空满时等待与唤醒
+
Release / Acquire:保证消息可见、读写有序
↓
Blocking Queue
5. 下一篇:两种协作模型的关系
下一篇将讨论 Shared Memory 与 Message Passing 的关系,以及它们在实际程序中如何组合使用。第十二篇则作为终章,回顾和总结整个并发编程系列。
本文首发于 ThinkerQAQ 的个人博客,由作者本人同步发布。原文可能持续修订,最新版本请以个人博客为准。

浙公网安备 33010602011771号