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


    目录


    0. 从上一篇继续

    这一篇把视角从硬件切换到语言内存模型层。


    1. 为什么语言需要内存模型层?

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

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

    因此还需要一层语言内存模型层,由它定义规则,程序员只需要了解这些规则,然后底层实现则有Compiler / Runtime根据不同平台去实现

    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

    2. 程序员需要怎么了解内存模型的规则?

    其实就是通过这三个来了解:

    • Atomicity
    • Visibility
    • Ordering

    理论上,只是理论上,每个并发工具类,在内存模型层都有相应的规则,说明这个工具类都满足了以上的某几个特性。

    这一篇不深入,而是用之前的例子:

    counter++

    以及:

    counter = 1
    ready = true

    从语言内存模型层的规则来证明:

    问题 具体来说
    Atomicity counter++ 为什么不是原子的?
    Visibility A 写入 counter = 1 后,B 为啥不一定能看到 counter =1 ?
    Ordering 当 B 看到 ready = true 时,为什么不一定能看到前面的 counter = 1?

    2.1 Java:Java Memory Model

    Atomicity

    先看:

    counter++;

    很遗憾,JMM并没有规则,说明counter++不是原子的,所以这里就代为解释一把:

    从语义上,counter++ 是一次复合操作。可以理解为:

    read counter
    ↓
    counter + 1
    ↓
    write counter

    如果两个线程并发执行它:

    Thread A Thread B
    counter++; 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

    因此普通的 counter++ 不能被视为原子的。


    Visibility

    再看:

    int counter = 0;
    // Thread A
    counter = 1;
    // Thread B
    System.out.println(counter);

    即:

    Thread A Thread B
    counter = 1 read counter

    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


    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 A 内部,counter = 1 先于 ready = true;在 Thread B 内部,读取 ready 又先于读取 counter。但两个线程之间,还缺少从 ready = true 到 read ready == true 的 happens-before 关系。

    因此,当前代码中 Thread A 和 Thread B 之间仍然没有建立完整的 happens-before 保证:

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

    因此,B即使观察到:

    ready == true

    也不一定能看到

    counter == 1

    2.2 Go Memory Model

    Atomicity

    Go Memory Model 对这里有直接的规则:

    如果对同一个内存位置的写,与另一个读或写并发发生,除非这些访问都是 sync/atomic 提供的原子访问,否则就构成 Data Race。

    而:

    counter++

    只是普通的增量操作,并不是 sync/atomic 提供的原子访问。

    因此,两个 Goroutine 并发执行:

    counter++

    会对同一个 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 来描述重要的Visibility和Ordering。


    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 Runtime 层面:如何并发安全地访问和维护 Python 对象
    • 应用程序层面:如何并发安全地访问和修改共享状态

    GIL 主要解决前者,并不等价于应用程序自动获得线程安全。


    Atomicity

    现在回到:

    counter += 1

    Python 官方 FAQ 对这一类操作有直接说明。

    在讨论 GIL-enabled CPython 中哪些操作具有原子性时,官方明确把:

    i = i + 1

    列为非原子操作:

    “These aren’t: i = i+1”

    因此,即使存在 GIL,也不能把:

    counter += 1

    视为一个整体原子的并发更新。

    这里需要区分:

    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 得出结论——因为不存在这样一套对应的正式模型。

    但也可以反向说明,他没有单独说明这种规则说明的话,那我们就应该把这种操作看作是不确定的,既然不确定,那就是不满足 Visibility 的


    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

    同样,这里没有对应的规则说明,那我们就应该把这种操作看作是不确定的,既然不确定,那就是不满足 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 文档明确说明,dict、list、set 等内置类型会使用内部锁来保护并发修改:

    “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

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

    殊途同归。去掉 GIL 之后,CPython 并不是不再需要同步,而是从一把全局锁,走向更细粒度的锁、原子操作和 Runtime 协调;在这一点上,它最终也越来越接近 Java 和 Go 的实现思路。


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

    上上篇讨论了硬件层面,这一篇讨论语言内存模型的规则解释

    下一篇开始讨论第一个具体同步工具:Mutex,从语言内存模型层一直追到硬件层


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

  • 相关阅读:
    基于PyTorch使用LSTM实现新闻文本分类任务
    C++ 信奥编程 1132:石头剪子布
    微信小程序+echart实现点亮旅游地图
    金九银十,阿里高级测开给面试者的十大建议
    使用Git从其他分支merge个别文件
    Databend 源码阅读系列(一): 开篇
    大数据架构设计(四十五)
    深入理解JVM虚拟机第十一篇:详细介绍JVM中运行时数据区
    串口触摸屏的键盘控制
    LeetCode 刷题系列 -- 98. 验证二叉搜索树
  • 原文地址:https://www.cnblogs.com/ThinkerQAQ/p/23002812