并发编程(二):语言内存模型——程序员可以依赖的规则

并发编程(二):语言内存模型——程序员可以依赖的规则

本文首发于 ThinkerQAQ 的个人博客,由作者本人同步发布。

目录


0. 从上一篇继续

上一篇讨论的是硬件层如何提供原子性、可见性和有序性的基础能力。

这一篇把视角切换到语言层:面对不同的硬件平台,编程语言需要向程序员提供统一的并发语义。依旧是从下面三个方面来讨论:

  • Atomicity:哪些操作可以被视为不可分割;
  • Visibility:一个线程的写入,什么时候保证能被另一个线程看到;
  • Ordering:多个操作之间的先后关系,什么时候保证能被其他线程按照这个顺序观察到。

其中,在 Java 和 Go 的内存模型中,可见性和有序性的一个核心抽象,就是我们常说的 happens-before


1. 为什么语言还需要自己的并发语义?

源代码需要经过编译器和 Runtime,最终才能在 CPU 上执行。编译器会优化代码,x86-64、ARM64 等处理器允许的内存访问顺序也不完全相同。

如果程序员必须分别研究每一种编译器、Runtime 和 CPU,跨平台并发编程就很难成立。

因此还需要一层语言规则:

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. 语言内存模型需要回答什么?

继续使用前面的两个例子:

counter++

以及:

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

先看:

int counter = 0;

// Thread A
counter++;

// Thread B
counter++;

即:

Thread A                    Thread B

counter++;                  counter++;

counter++ 是一次复合更新。从并发执行的角度,可以理解为:

read counter
     ↓
counter + 1
     ↓
write counter

两个线程之间可能发生这样的交错:

Thread A                    Thread B

read counter = 0            read counter = 0

counter + 1                 counter + 1

write counter = 1           write counter = 1

两个线程都执行了一次 counter++,最终却可能得到:

expected: counter = 2
actual:   counter = 1

这里,JMM 并没有明确说“counter++ 不是原子的”,但是我们仍需知道:counter++ 是由多个动作组成的复合更新,因此不能把整个操作直接视为一个不可分割的并发操作。


Visibility

再看:

int counter = 0;

// Thread A
counter = 1;

// Thread B
System.out.println(counter);

即:

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.”

也就是说,如果能够建立:

Thread A                    Thread B

counter = 1                 read counter
     │                           ▲
     │                           │
     └──── happens-before ───────┘

那么 Thread A 的写入被保证对 Thread B 的读取可见。

而现在这段代码中:

Thread A                    Thread B

counter = 1                 read counter
     │                           ▲
     │                           │
     └── no happens-before ──────┘

没有建立这样的跨线程 happens-before 关系。

因此:

Thread B 可能看到 1

但程序不能依赖:

Thread B 一定看到 1

这就是 JMM 在 Visibility 上给程序员提供的判断方式。


Ordering

再回到第二个例子:

// Thread A
counter = 1;
ready = true;

// Thread B
if (ready) {
    System.out.println(counter);
}

即:

Thread A                    Thread B

counter = 1
ready = true                read ready == true
                                  │
                                  ▼
                             read counter

这里关心的是:

Thread B 已经看到 ready == true,是否也意味着它一定能够看到前面的 counter = 1

前面引用的 happens-before 定义其实已经同时包含了两个部分:

visible to
+
ordered before

也就是:

Visibility
    ↓
前一个操作的结果是否被保证对后一个操作可见

Ordering
    ↓
两个操作之间是否存在程序可以依赖的先后关系

但当前代码中,Thread A 和 Thread B 之间仍然没有建立对应的 happens-before 保证:

Thread A                    Thread B

counter = 1
ready = true                read ready == true
     │                            │
     │                            ▼
     │                       read counter
     │
     └──── no cross-thread happens-before

因此,仅仅观察到:

ready == true

不能让程序进一步依赖:

counter 一定等于 1

至于具体怎样建立这样的 happens-before,留到后面介绍具体同步工具时再讨论。


2.2 Go Memory Model

Go 同样定义了自己的 Memory Model,用来规定一个 Goroutine 的内存操作在什么条件下能够被另一个 Goroutine 可靠地观察到。

仍然使用完全相同的三个问题。

Atomicity

先看:

var counter int

// Goroutine A
counter++

// Goroutine B
counter++

从并发执行的角度,同样可以理解为:

read counter
     ↓
counter + 1
     ↓
write counter

两个 Goroutine 可能发生这样的交错:

Goroutine A                 Goroutine B

read counter = 0            read counter = 0

counter + 1                 counter + 1

write counter = 1           write counter = 1

因此:

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。

套回这个例子:

Goroutine A                 Goroutine B

read counter                read counter
     │                           │
write counter               write counter

两个 Goroutine 都访问同一个 counter,其中包含写操作,而且这些访问没有被同步,因此这里存在 Data Race。

所以:

counter++

不能被视为一个不可分割的并发更新。


Visibility

继续看:

var counter int

// Goroutine A
counter = 1

// Goroutine B
fmt.Println(counter)

拆成两列:

Goroutine A                 Goroutine B

counter = 1                 read counter

Go Memory Model 开篇讨论的就是:

一个 Goroutine 的读取,在什么条件下能够保证观察到另一个 Goroutine 对同一变量的写入?

在正式规则中,一个普通读取能够可靠观察某个写入,需要这个写入对读取是 visible 的;其中一个条件就是:

“w happens before r.”

套回我们的例子,如果能够建立:

Goroutine A                 Goroutine B

counter = 1                 read counter
     │                           ▲
     │                           │
     └──── happens-before ───────┘

那么这个写入可以被后面的读取可靠观察到。

而当前代码中:

Goroutine A                 Goroutine B

counter = 1                 read counter
     │                           ▲
     │                           │
     └── no happens-before ──────┘

两个 Goroutine 之间没有建立这样的关系,并且存在 Data Race。

因此不能依赖第二个 Goroutine 一定看到:

counter == 1

Ordering

再看:

// Goroutine A
counter = 1
ready = true

// Goroutine B
if ready {
    fmt.Println(counter)
}

拆成双列:

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.”

套回我们的例子:

Goroutine A                 Goroutine B

counter = 1
ready = true                read ready == true
                                  │
                                  ▼
                             read counter

        没有 happens-before 保证

所以:

看到 ready == true

并不能推出:

一定看到 counter == 1

这一点和 Java 的例子非常接近。

Java 和 Go 都使用 happens-before 来描述重要的可见性和顺序保证,但具体规则仍然由各自的 Memory Model 定义。


2.3 CPython 的并发语义

Python 和 Java、Go 有一个重要区别:

Python 没有一套适用于所有解释器实现、与 JMM 或 Go Memory Model 同等级的统一并发内存模型。

因此这里讨论最常用的实现:CPython

仍然使用同样的两个例子:

counter += 1

以及:

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 对象当前:

refcount = 10

如果两个线程可以同时修改这个引用计数:

Thread A                    Thread B

read refcount = 10          read refcount = 10

refcount + 1                refcount + 1

write refcount = 11         write refcount = 11

两个线程都增加了一次引用,但最终:

expected: refcount = 12
actual:   refcount = 11

这会破坏 CPython 对 Python 对象生命周期的管理。

因此 GIL 首先保护的是:

             GIL
              │
              ▼
      CPython Runtime
              │
      ┌───────┴────────┐
      ▼                ▼
Python Objects     Runtime State
      │
      ├── Reference Count
      └── Object Internals

它解决的是:

CPython 自己如何安全地操作 Python 对象

而不是:

应用程序中的所有共享状态
自动获得线程安全

Atomicity

现在回到:

counter += 1

从应用程序的角度,它仍然是一次 Read-Modify-Write:

read counter
     ↓
counter + 1
     ↓
write counter

也就是概念上的:

Thread A                    Thread B

read counter                read counter

counter + 1                 counter + 1

write counter               write counter

Python 官方 FAQ 在讨论 GIL-enabled CPython 中哪些操作具有原子性时,把与它同类的复合更新:

i = i + 1

明确列在非原子操作一类:

“These aren’t: i = i+1

因此,即使存在 GIL,也不能把一次“读取 → 修改 → 写回”的复合更新直接视为应用程序可以依赖的整体原子操作。

这里需要区分:

GIL
 │
 └── CPython Runtime / Python Object 的保护

counter += 1
 │
 └── 应用程序定义的一次复合状态更新

Visibility

继续使用同样的例子:

counter = 0

# Thread A
counter = 1

# Thread B
print(counter)

拆成双列:

Thread A                    Thread B

counter = 1                 read counter

到了这里,CPython 和 Java / Go 的区别就出现了。

Java 和 Go 可以继续问:

write
 │
 │ happens-before ?
 ▼
read

因为它们有正式定义的 Memory Model。

但是 Python 没有一套对应的、适用于所有 Python 实现的 happens-before 规则。

所以不能把上面的 CPython 代码画成:

Thread A                    Thread B

counter = 1                 read counter
     │                           ▲
     │                           │
     └──── Python happens-before ┘

然后引用某条 Python Language Memory Model 得出结论——因为不存在这样一套对应的正式模型。


Ordering

最后看:

# Thread A
counter = 1
ready = True

# Thread B
if ready:
    print(counter)

同样拆成:

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 中:

Thread A ──┐
           │
           ├── GIL ───> Python Code
           │
Thread B ──┘

而在 Free-threaded CPython 中:

Thread A ─────────────> CPU Core 0

Thread B ─────────────> CPU Core 1

多个线程可以真正并行执行 Python 代码。

但是,去掉 GIL 并不意味着 CPython 不再需要保护自己的对象和 Runtime 状态。

Python 官方的 Free-threading 文档明确说明,dictlistset 等内置类型会使用内部锁来保护并发修改:

“Built-in types like dict, list, and set use internal locks”

这些内部锁属于 Free-threaded CPython 的实现机制,不应被理解成 Python 语言层定义了一套统一的 Memory Model。

因此变化的是:

GIL-enabled

一把全局的 GIL
      │
      ▼
保护 CPython Runtime

变成:

Free-threaded

更细粒度的内部同步
      │
      ▼
保护 CPython Runtime

对于应用程序来说,前面的三个问题仍然存在:

Atomicity

counter += 1
是否能被当成不可分割的共享状态更新?
Visibility

一个线程的写入,
什么时候能被另一个线程可靠观察?
Ordering

两个线程之间,
哪些操作顺序是程序可以依赖的?

Free-threaded 改变的是 CPython Runtime 如何实现线程安全,并没有让这些并发问题消失。


2.3.3 那 Python 程序应该依赖什么?

综上,Java、Go 和 CPython 在这一层存在一个重要区别:

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. 下一篇:互斥锁

这一篇仍然只是从语言层回答三个问题:

Atomicity
Visibility
Ordering

下一篇开始讨论第一个具体同步工具:Mutex。

继续沿着完全相同的三个方向:

Atomicity
    ↓
一把锁为什么能够让临界区表现为不可分割?

Visibility
    ↓
为什么前一个临界区的写入,
能够被后一个进入临界区的线程看到?

Ordering
    ↓
锁如何约束同步边界两侧的操作顺序?

然后分别看 Java、Go 和 CPython 的锁如何提供这些保证。

再继续向下,从 Runtime 追到 CPU。


References

  1. Java Language Specification §17.4 — Memory Model
  2. Java Language Specification §17.4.5 — Happens-before Order
  3. The Go Memory Model
  4. Python C API — Thread states and the global interpreter lock
  5. Python FAQ — What kinds of global value mutation are thread-safe?
  6. Python support for free threading
  7. PEP 703 — Making the Global Interpreter Lock Optional in CPython
posted @ 2026-09-17 18:01  ThinkerQAQ  阅读(21)  评论(0)    收藏  举报