ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

并发编程(七):volatile——从语言规则到 CPU

并发编程(七):volatile——从语言规则到 CPU 目录0. 这一篇继续回答什么1. Java volatile 在语言层保证什么1.1 Visibility 和 Ordering1.2 volatile 不能保证复合操作的 Atomicity2. HotSpot 如何实现 volatile2.1 分层2.2 一次 volatile write / read 的完整实现路径2.3 Runtime / Language Implementation 层的两种保证2.3.1 Visibility2.3.2 Ordering2.4 Hardware 层的两种保证3. Go 和 CPython 为什么没有 Java volatile3.1 Go这是语言设计选择3.2 CPython同步一直由 Runtime 承担4. volatile、Atomic 和 Mutex 的关系5. 下一篇从 Mutex 到读写锁0. 这一篇继续回答什么前两篇从语言规则一路看到 CPU解释了 Atomic 如何提供 Atomicity、Visibility 和 Ordering。这一篇继续沿用同一条思路只是对象换成volatile。volatile不解决counter这样的复合更新。它更适合下面这种发布 / 观察关系Thread A Thread B counter 1 if (ready) { ready true print(counter) }我们希望保证如果 Thread B 已经读到ready true那么随后读取counter时必须看到1。这一篇先从 Java 的语言规则解释为什么成立再继续往下看 HotSpot 和 CPU 如何实现。1. Java volatile 在语言层保证什么1.1 Visibility 和 Ordering把ready声明为volatileint counter 0; volatile boolean ready false; // Thread A counter 1; ready true; // Thread B if (ready) { System.out.println(counter); }只有ready是volatilecounter仍然是普通变量。JLS §17.4.4 规定“A write to a volatile variable v synchronizes-with all subsequent reads of v by any thread.”因此如果 Thread B 的 volatile read 观察到了 Thread A 写入的ready true就有Thread A Thread B counter 1 │ │ program order ▼ volatile ready true ── synchronizes-with ──► read volatile ready true │ │ program order ▼ read counter再根据 happens-before 的传递性write counter ↓ happens-before write volatile ready ↓ synchronizes-with read volatile ready ↓ happens-before read counter所以当 B 已经读到ready true时前面的counter 1对 B 可见并且不能再出现ready true counter 0这里同时得到两个保证VisibilityA 在 volatile write 之前完成的写入对随后观察到该 volatile write 的 B 可见Ordering这些操作必须按照 happens-before 关系被观察不能得到违反该顺序的结果。1.2 volatile 不能保证复合操作的 Atomicity例如volatile int counter 0; counter;counter仍然是一个 Read-Modify-Writevolatile read ↓ add ↓ volatile writevolatile可以约束这次读和写的可见性与顺序但不能把整段read → add → write合成一次不可分割的更新。多个线程同时执行时仍然可能发生丢失更新。如果需要原子的 RMW应该使用AtomicInteger、CAS 或 Mutex。2. HotSpot 如何实现 volatile2.1 分层和前面的 Mutex、Atomic 一样先把实现层次固定下来Java Source Code 实例volatile boolean ready 作用声明具有 volatile 内存语义的字段 │ ▼ JVM Bytecode 实例getfield / putfield或 getstatic / putstatic 字段元数据ACC_VOLATILE 作用表达字段访问volatile 属性来自字段元数据 │ ▼ JVM ImplementationHotSpot 实例field-is_volatile() / MO_SEQ_CST / MemBar 作用保留并实现 volatile 的内存顺序语义 │ ▼ x86-64 Hardware 实例Load / Store / Cache Coherence / Fence 作用提供 Visibility 与 Ordering 的硬件基础这里和synchronized、AtomicInteger都不一样synchronized → 有 monitorenter / monitorexit 专用字节码 AtomicInteger → 普通 invokevirtual → 没有 Atomic 专用字节码 volatile → 仍然是 getfield / putfield → 没有 volatile load / store 专用字节码 → volatile 属性记录在字段的 ACC_VOLATILE 元数据中2.2 一次 volatile write / read 的完整实现路径继续使用同一个counter / ready例子主流程里最重要的不是某一条固定机器指令而是 HotSpot 必须识别ready是 volatile 字段并把它的内存语义一直保留到目标架构。2.3 Runtime / Language Implementation 层的两种保证2.3.1 VisibilityHotSpot 在解析字段访问时会区分普通字段和 volatile 字段。当前 C2 路径中volatile 字段访问会带上MO_SEQ_CST一类的内存顺序信息而普通字段使用普通的无序访问语义。可以抽象成普通字段 → ordinary load / store volatile 字段 → volatile load / store → 保留跨线程可观察的内存语义也就是说HotSpot 不能把 volatile 访问当作普通字段访问随意优化掉、合并或跨越必要的同步边界。2.3.2 OrderingHotSpot 还需要限制 volatile 前后的内存操作重排。C2 的模型中可以看到典型的volatile store ← MemBarRelease 等顺序约束 volatile load → MemBarAcquire 等顺序约束对于 volatile store → volatile load 这类还需要更强顺序的场景还要建立相应的 StoreLoad / volatile barrier 约束。这些是 HotSpot 的中间表示和编译约束经过优化后不代表每个节点都会一一对应成独立的 CPU fence。相关实现可以查看 OpenJDK 的parse3.cpp、memnode.hpp和templateTable_x86.cpp。2.4 Hardware 层的两种保证继续向下最终还是回到第一篇的两类硬件能力Visibility → Cache Coherence → 让其他 CPU Core 不再长期使用已经失效的旧 Cache Line Ordering → CPU Memory Ordering Fence / 等价顺序约束 → 保证 volatile 边界要求的内存访问顺序在 x86-64 上内存模型本身已经提供较强的顺序约束所以很多 Acquire / Release 约束不需要额外生成一条硬件 fence。真正需要 StoreLoad fence 时HotSpot 的 Linux x86 路径可以使用lock; addl $0, 0(%rsp)来建立 full fence 效果。相关实现可以继续查看 OpenJDK 的orderAccess_linux_x86.hpp。3. Go 和 CPython 为什么没有 Java volatileGo 和 Python 都没有 Java 这种字段级volatile机制。原因不同分别来看。3.1 Go这是语言设计选择Effective Go 用一句话概括 Go 的并发设计“Do not communicate by sharing memory; instead, share memory by communicating.”这句话反映的是 Go 的设计取向并发同步应该通过显式的并发原语表达而不是通过字段修饰符隐式表达。因此Go 没有像 Java 那样提供字段级volatile。同类问题交给 Channel、Mutex 和 Atomic 处理。对于和 Javavolatile最接近的场景Go 使用sync/atomic。The Go Memory Model 明确写道“This definition provides the same semantics as Cs sequentially consistent atomics and Javas volatile variables.”还是用前面的counter / ready例子var counter int var ready atomic.Bool // Goroutine A counter 1 ready.Store(true) // Goroutine B if ready.Load() { fmt.Println(counter) }如果ready.Load()观察到ready.Store(true)那么两个 Atomic 操作之间建立 synchronized-beforeGoroutine A Goroutine B counter 1 │ │ sequenced-before ▼ ready.Store(true) ── synchronized-before ──► ready.Load() true │ │ sequenced-before ▼ read counter因此当 B 读到ready true时前面的counter 1对 B 可见。3.2 CPython同步一直由 Runtime 承担Python 没有 Javavolatile核心原因不在语法而在同步职责一直放在 CPython Runtime。主要有两点。1. 传统 CPython 通过 GIL 覆盖了大量 Visibility / Ordering 问题CPython C API 文档写道“only a thread that holds the GIL may operate on Python objects or invoke Python’s C API.”传统 CPython 中大量 Python 对象操作都经过 GIL 串行化。GIL 的释放与重新获取也是线程同步边界因此它在实际效果上覆盖了很多volatile用来解决的 Visibility 和 Ordering 问题。但两者不是同一个东西Java volatile → 字段级语言语义 CPython GIL → Runtime 级同步机制2. Free-threaded CPython 仍然选择 Runtime Lock AtomicGIL 可以关闭以后CPython 也没有新增字段级volatile。PEP 703 的方案是“This PEP proposes using per-object locks...”同时Runtime 内部还会使用 Atomic 操作。所以路线只是从GIL变成Per-object Lock Atomic同步仍然留在 Runtime没有上移成 Python 的字段级语言语义。应用层需要发布 / 观察时使用同步对象。Python 的threading.Event文档写道“one thread signals an event and other threads wait for it.”还是套回前面的counter / readycounter 0 ready threading.Event() # Thread A counter 1 ready.set() # Thread B ready.wait() print(counter)这里Event承担了ready的同步职责Thread A Thread B counter 1 │ ▼ ready.set() ───────────────► ready.wait() returns │ ▼ read counter所以 Python 应用层使用 Event / Lock底层的 Atomic 和 Memory Ordering 留在 CPython Runtime。4. volatile、Atomic 和 Mutex 的关系把前面几篇放在一起三者解决的问题并不相同volatileAtomicMutex核心问题发布 / 观察共享状态一次共享状态操作一段临界区Atomicity不保证复合 RMW保证单次 Atomic 操作保证临界区互斥Visibility是是是Ordering是是是典型场景初始化完成 / 配置加载 / 停止标记 / 状态发布计数 / Add / CAS / Swap多步逻辑 / 多字段整体更新可以把关系简化为volatile → 让一次发布与后续观察建立 Visibility / Ordering Atomic → 让一次共享状态操作本身不可分割 → 同时提供相应的 Visibility / Ordering Mutex → 用 Atomic 等底层能力竞争锁状态 → 再保护一整段临界区它们不是“强弱不同的同一种工具”而是在解决不同粒度的问题。5. 下一篇从 Mutex 到读写锁Mutex 同一时刻只允许一个执行单元进入临界区。如果大量操作只是读取共享状态让读者之间也互斥就没有必要。下一篇继续看读写锁如何让多个读者同时进入同时仍然保证写入互斥。本文首发于 ThinkerQAQ 的个人博客由作者本人同步发布。原文可能持续修订最新版本请以个人博客为准。
返回列表