并发编程(二):语言内存模型------程序员可以依赖的规则
本文首发于 ThinkerQAQ 的个人博客,由作者本人同步发布。
目录
-
[0. 从上一篇继续](#0. 从上一篇继续)
-
[1. 为什么语言还需要自己的并发语义?](#1. 为什么语言还需要自己的并发语义?)
-
[2. 语言内存模型需要回答什么?](#2. 语言内存模型需要回答什么?)
-
[2.1 Java Memory Model](#2.1 Java Memory Model)
-
[2.2 Go Memory Model](#2.2 Go Memory Model)
-
[2.3 CPython 的并发语义](#2.3 CPython 的并发语义)
- [2.3.1 GIL 模式](#2.3.1 GIL 模式)
- [2.3.2 Free-threaded 模式](#2.3.2 Free-threaded 模式)
- [2.3.3 那 Python 程序应该依赖什么?](#2.3.3 那 Python 程序应该依赖什么?)
-
-
[3. 下一篇:互斥锁](#3. 下一篇:互斥锁)
0. 从上一篇继续
上一篇讨论的是硬件层如何提供原子性、可见性和有序性的基础能力。
这一篇把视角切换到语言层:面对不同的硬件平台,编程语言需要向程序员提供统一的并发语义。依旧是从下面三个方面来讨论:
- Atomicity:哪些操作可以被视为不可分割;
- Visibility:一个线程的写入,什么时候保证能被另一个线程看到;
- Ordering:多个操作之间的先后关系,什么时候保证能被其他线程按照这个顺序观察到。
其中,在 Java 和 Go 的内存模型中,可见性和有序性的一个核心抽象,就是我们常说的 happens-before。
1. 为什么语言还需要自己的并发语义?
源代码需要经过编译器和 Runtime,最终才能在 CPU 上执行。编译器会优化代码,x86-64、ARM64 等处理器允许的内存访问顺序也不完全相同。
如果程序员必须分别研究每一种编译器、Runtime 和 CPU,跨平台并发编程就很难成立。
因此还需要一层语言规则:
text
Java Go Python
│ │ │
▼ ▼ ▼
synchronized / volatile / AtomicInteger
sync.Mutex / sync/atomic / channel
threading.Lock / queue.Queue
│ │ │
▼ ▼ ▼
Java Memory Model Go Memory Model CPython Concurrency Semantics
│ │ │
└─────────────────────────────────────┼─────────────────────────────────────┘
│
Compiler / Runtime 实现
屏蔽不同硬件平台的差异
│
┌────────┴────────┐
│ │
▼ ▼
x86-64 ARM64
语言内存模型定义程序员可以依赖的行为,编译器和 Runtime 再针对不同硬件平台实现这些保证。
因此,无论程序最终运行在 x86-64 还是 ARM64 上,程序员都不需要分别分析底层应该生成哪些 CPU 指令,而是按照语言定义的并发规则判断程序行为。
2. 语言内存模型需要回答什么?
继续使用前面的两个例子:
text
counter++
以及:
text
counter = 1
ready = true
语言层需要回答:
| 问题 | 具体来说 |
|---|---|
| Atomicity | counter++ 是否可以被视为不可分割? |
| Visibility | A 写入 counter = 1 后,B 在什么条件下能够可靠地看到它? |
| Ordering | 当 B 看到 ready = true 时,是否也必须看到前面的 counter = 1? |
语言内存模型不要求程序员直接分析 Cache、Store Buffer 或 Fence。它定义源代码层允许出现哪些结果,而底层实现负责满足这些规则。
2.1 Java Memory Model
Java Memory Model(JMM)定义 Java 多线程程序允许观察到哪些内存行为。
仍然从前面的三个问题来看。
Atomicity
先看:
java
int counter = 0;
// Thread A
counter++;
// Thread B
counter++;
即:
text
Thread A Thread B
counter++; counter++;
counter++ 是一次复合更新。从并发执行的角度,可以理解为:
text
read counter
↓
counter + 1
↓
write counter
两个线程之间可能发生这样的交错:
text
Thread A Thread B
read counter = 0 read counter = 0
counter + 1 counter + 1
write counter = 1 write counter = 1
两个线程都执行了一次 counter++,最终却可能得到:
text
expected: counter = 2
actual: counter = 1
这里,JMM 并没有明确说"counter++ 不是原子的",但是我们仍需知道:counter++ 是由多个动作组成的复合更新,因此不能把整个操作直接视为一个不可分割的并发操作。
Visibility
再看:
java
int counter = 0;
// Thread A
counter = 1;
// Thread B
System.out.println(counter);
即:
text
Thread A Thread B
counter = 1 read counter
这里真正关心的是:
Thread A 写入
counter = 1后,Thread B 是否被保证能够看到1?
JLS §17.4.5 对 happens-before 有一个非常直接的描述:
"If one action happens-before another, then the first is visible to and ordered before the second."
也就是说,如果能够建立:
text
Thread A Thread B
counter = 1 read counter
│ ▲
│ │
└──── happens-before ───────┘
那么 Thread A 的写入被保证对 Thread B 的读取可见。
而现在这段代码中:
text
Thread A Thread B
counter = 1 read counter
│ ▲
│ │
└── no happens-before ──────┘
没有建立这样的跨线程 happens-before 关系。
因此:
text
Thread B 可能看到 1
但程序不能依赖:
text
Thread B 一定看到 1
这就是 JMM 在 Visibility 上给程序员提供的判断方式。
Ordering
再回到第二个例子:
java
// Thread A
counter = 1;
ready = true;
// Thread B
if (ready) {
System.out.println(counter);
}
即:
text
Thread A Thread B
counter = 1
ready = true read ready == true
│
▼
read counter
这里关心的是:
Thread B 已经看到
ready == true,是否也意味着它一定能够看到前面的counter = 1?
前面引用的 happens-before 定义其实已经同时包含了两个部分:
text
visible to
+
ordered before
也就是:
text
Visibility
↓
前一个操作的结果是否被保证对后一个操作可见
Ordering
↓
两个操作之间是否存在程序可以依赖的先后关系
但当前代码中,Thread A 和 Thread B 之间仍然没有建立对应的 happens-before 保证:
text
Thread A Thread B
counter = 1
ready = true read ready == true
│ │
│ ▼
│ read counter
│
└──── no cross-thread happens-before
因此,仅仅观察到:
text
ready == true
不能让程序进一步依赖:
text
counter 一定等于 1
至于具体怎样建立这样的 happens-before,留到后面介绍具体同步工具时再讨论。
2.2 Go Memory Model
Go 同样定义了自己的 Memory Model,用来规定一个 Goroutine 的内存操作在什么条件下能够被另一个 Goroutine 可靠地观察到。
仍然使用完全相同的三个问题。
Atomicity
先看:
go
var counter int
// Goroutine A
counter++
// Goroutine B
counter++
从并发执行的角度,同样可以理解为:
text
read counter
↓
counter + 1
↓
write counter
两个 Goroutine 可能发生这样的交错:
text
Goroutine A Goroutine B
read counter = 0 read counter = 0
counter + 1 counter + 1
write counter = 1 write counter = 1
因此:
text
expected: counter = 2
actual: counter = 1
Go Memory Model 的 Informal Overview 对 Data Race 有一个非常直接的定义:
"A data race is defined as a write to a memory location happening concurrently with another read or write to that same location"
也就是说,当一个 Goroutine 对某个内存位置进行写操作,同时另一个 Goroutine 对同一个内存位置进行读或写,并且这些访问没有通过 sync/atomic 提供的原子操作完成时,就存在 Data Race。
套回这个例子:
text
Goroutine A Goroutine B
read counter read counter
│ │
write counter write counter
两个 Goroutine 都访问同一个 counter,其中包含写操作,而且这些访问没有被同步,因此这里存在 Data Race。
所以:
go
counter++
不能被视为一个不可分割的并发更新。
Visibility
继续看:
go
var counter int
// Goroutine A
counter = 1
// Goroutine B
fmt.Println(counter)
拆成两列:
text
Goroutine A Goroutine B
counter = 1 read counter
Go Memory Model 开篇讨论的就是:
一个 Goroutine 的读取,在什么条件下能够保证观察到另一个 Goroutine 对同一变量的写入?
在正式规则中,一个普通读取能够可靠观察某个写入,需要这个写入对读取是 visible 的;其中一个条件就是:
"w happens before r."
套回我们的例子,如果能够建立:
text
Goroutine A Goroutine B
counter = 1 read counter
│ ▲
│ │
└──── happens-before ───────┘
那么这个写入可以被后面的读取可靠观察到。
而当前代码中:
text
Goroutine A Goroutine B
counter = 1 read counter
│ ▲
│ │
└── no happens-before ──────┘
两个 Goroutine 之间没有建立这样的关系,并且存在 Data Race。
因此不能依赖第二个 Goroutine 一定看到:
text
counter == 1
Ordering
再看:
go
// Goroutine A
counter = 1
ready = true
// Goroutine B
if ready {
fmt.Println(counter)
}
拆成双列:
text
Goroutine A Goroutine B
counter = 1
ready = true read ready == true
│
▼
read counter
这个例子和 Go Memory Model 在 Incorrect synchronization 中给出的例子本质上相同:一个 Goroutine 先写数据,再写一个标志;另一个 Goroutine 观察到标志以后,再读取前面的数据。
Go 官方明确指出:
"Even if this occurs, it does not imply that reads happening after r will observe writes that happened before w."
套回我们的例子:
text
Goroutine A Goroutine B
counter = 1
ready = true read ready == true
│
▼
read counter
没有 happens-before 保证
所以:
text
看到 ready == true
并不能推出:
text
一定看到 counter == 1
这一点和 Java 的例子非常接近。
Java 和 Go 都使用 happens-before 来描述重要的可见性和顺序保证,但具体规则仍然由各自的 Memory Model 定义。
2.3 CPython 的并发语义
Python 和 Java、Go 有一个重要区别:
Python 没有一套适用于所有解释器实现、与 JMM 或 Go Memory Model 同等级的统一并发内存模型。
因此这里讨论最常用的实现:CPython。
仍然使用同样的两个例子:
python
counter += 1
以及:
python
counter = 1
ready = True
来看 Atomicity、Visibility 和 Ordering。
2.3.1 GIL 模式
在继续讨论 counter 之前,我们先弄清楚:
GIL 到底保护了什么?
GIL 首先是 CPython Runtime 用来保护 Python 对象和解释器内部状态的机制。
Python 官方 C API 文档对 GIL 的要求说得很直接:
"only a thread that holds the GIL may operate on Python objects or invoke Python's C API."
也就是说,在默认的 GIL-enabled CPython 中,线程必须先持有 GIL,才能操作 Python 对象或调用 Python C API。
官方文档紧接着用 Reference Count 说明为什么需要这把锁:如果两个线程同时增加同一个对象的引用计数,最终可能只增加一次,而不是两次。
假设某个 Python 对象当前:
text
refcount = 10
如果两个线程可以同时修改这个引用计数:
text
Thread A Thread B
read refcount = 10 read refcount = 10
refcount + 1 refcount + 1
write refcount = 11 write refcount = 11
两个线程都增加了一次引用,但最终:
text
expected: refcount = 12
actual: refcount = 11
这会破坏 CPython 对 Python 对象生命周期的管理。
因此 GIL 首先保护的是:
text
GIL
│
▼
CPython Runtime
│
┌───────┴────────┐
▼ ▼
Python Objects Runtime State
│
├── Reference Count
└── Object Internals
它解决的是:
text
CPython 自己如何安全地操作 Python 对象
而不是:
text
应用程序中的所有共享状态
自动获得线程安全
Atomicity
现在回到:
python
counter += 1
从应用程序的角度,它仍然是一次 Read-Modify-Write:
text
read counter
↓
counter + 1
↓
write counter
也就是概念上的:
text
Thread A Thread B
read counter read counter
counter + 1 counter + 1
write counter write counter
Python 官方 FAQ 在讨论 GIL-enabled CPython 中哪些操作具有原子性时,把与它同类的复合更新:
python
i = i + 1
明确列在非原子操作一类:
"These aren't:
i = i+1"
因此,即使存在 GIL,也不能把一次"读取 → 修改 → 写回"的复合更新直接视为应用程序可以依赖的整体原子操作。
这里需要区分:
text
GIL
│
└── CPython Runtime / Python Object 的保护
counter += 1
│
└── 应用程序定义的一次复合状态更新
Visibility
继续使用同样的例子:
python
counter = 0
# Thread A
counter = 1
# Thread B
print(counter)
拆成双列:
text
Thread A Thread B
counter = 1 read counter
到了这里,CPython 和 Java / Go 的区别就出现了。
Java 和 Go 可以继续问:
text
write
│
│ happens-before ?
▼
read
因为它们有正式定义的 Memory Model。
但是 Python 没有一套对应的、适用于所有 Python 实现的 happens-before 规则。
所以不能把上面的 CPython 代码画成:
text
Thread A Thread B
counter = 1 read counter
│ ▲
│ │
└──── Python happens-before ┘
然后引用某条 Python Language Memory Model 得出结论------因为不存在这样一套对应的正式模型。
Ordering
最后看:
python
# Thread A
counter = 1
ready = True
# Thread B
if ready:
print(counter)
同样拆成:
text
Thread A Thread B
counter = 1
ready = True read ready == True
│
▼
read counter
在 Java / Go 中,我们可以根据各自 Memory Model 判断这里有没有建立 happens-before。
而在 Python 中,同样由于没有一套对应的、适用于所有 Python 实现的 happens-before 规则,
所以如果程序需要明确的跨线程保证,应该使用明确的同步工具来表达这种关系。
至于这些同步工具到底怎样提供 Atomicity、Visibility 和 Ordering,后面的文章再继续讨论。
2.3.2 Free-threaded 模式
从 Python 3.13 开始,CPython 提供可以禁用 GIL 的 Free-threaded 构建。
GIL-enabled CPython 中:
text
Thread A ──┐
│
├── GIL ───> Python Code
│
Thread B ──┘
而在 Free-threaded CPython 中:
text
Thread A ─────────────> CPU Core 0
Thread B ─────────────> CPU Core 1
多个线程可以真正并行执行 Python 代码。
但是,去掉 GIL 并不意味着 CPython 不再需要保护自己的对象和 Runtime 状态。
Python 官方的 Free-threading 文档明确说明,dict、list、set 等内置类型会使用内部锁来保护并发修改:
"Built-in types like
dict,list, andsetuse internal locks"
这些内部锁属于 Free-threaded CPython 的实现机制,不应被理解成 Python 语言层定义了一套统一的 Memory Model。
因此变化的是:
text
GIL-enabled
一把全局的 GIL
│
▼
保护 CPython Runtime
变成:
text
Free-threaded
更细粒度的内部同步
│
▼
保护 CPython Runtime
对于应用程序来说,前面的三个问题仍然存在:
text
Atomicity
counter += 1
是否能被当成不可分割的共享状态更新?
text
Visibility
一个线程的写入,
什么时候能被另一个线程可靠观察?
text
Ordering
两个线程之间,
哪些操作顺序是程序可以依赖的?
Free-threaded 改变的是 CPython Runtime 如何实现线程安全,并没有让这些并发问题消失。
2.3.3 那 Python 程序应该依赖什么?
综上,Java、Go 和 CPython 在这一层存在一个重要区别:
text
Java
│
▼
Java Memory Model
│
└── happens-before
Go
│
▼
Go Memory Model
│
└── happens-before
Python
│
▼
不同 Interpreter 的并发语义
│
└── CPython
├── GIL-enabled
└── Free-threaded
总结就是一句话:Python 没有一套适用于所有解释器实现、与 JMM 或 Go Memory Model 同等级的统一 Memory Model。因此,在 Python 中讨论并发安全,需要先明确使用的是哪一种 Interpreter,并依赖明确的同步工具及其提供的并发保证。
3. 下一篇:互斥锁
这一篇仍然只是从语言层回答三个问题:
text
Atomicity
Visibility
Ordering
下一篇开始讨论第一个具体同步工具:Mutex。
继续沿着完全相同的三个方向:
text
Atomicity
↓
一把锁为什么能够让临界区表现为不可分割?
Visibility
↓
为什么前一个临界区的写入,
能够被后一个进入临界区的线程看到?
Ordering
↓
锁如何约束同步边界两侧的操作顺序?
然后分别看 Java、Go 和 CPython 的锁如何提供这些保证。
再继续向下,从 Runtime 追到 CPU。
References
- Java Language Specification §17.4 --- Memory Model
- Java Language Specification §17.4.5 --- Happens-before Order
- The Go Memory Model
- Python C API --- Thread states and the global interpreter lock
- Python FAQ --- What kinds of global value mutation are thread-safe?
- Python support for free threading
- PEP 703 --- Making the Global Interpreter Lock Optional in CPython