• 并发编程(四):互斥锁的实现——从 Runtime 到 CPU


    目录


    0. 这一篇继续回答什么?

    上一篇从语言内存模型的规则出发,看 Mutex 如何提供 Atomicity、Visibility 和 Ordering。

    这一篇继续往下看:Java、Go 和 CPython 分别如何在 Runtime 和 CPU 层实现这些保证。


    1. HotSpot 如何实现 synchronized?

    1.1 分层

    先把 Java 的实现层次固定下来:

    Java Language
    实例:synchronized
    作用:声明同步区域
    │
    ▼
    JVM Bytecode
    实例:monitorenter / monitorexit(synchronized 代码块)
    作用:表达 Monitor 的进入 / 退出
    │
    ▼
    JVM Implementation(HotSpot)
    实例:Synchronization Runtime
    作用:实现 JVM 规定的 Monitor 语义
    │
    ▼
    x86-64 Hardware
    实例:Atomic Instruction / Cache Coherence / Fence
    作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础

    1.2 一次 synchronized 的完整实现路径

    从 Java Code 经过 JVM Bytecode、HotSpot、OS Thread 到 x86-64 Hardware 的加锁、竞争、临界区和解锁主流程。

    上面的时序图只保留跨层主流程。Runtime 内部的竞争路径可以进一步展开为:

    monitorenter 先尝试 Lightweight Locking Fast Path,失败后先进行 fast-lock spinning;持续竞争时再膨胀为 ObjectMonitor,并在 ObjectMonitor 内继续 CAS、Spin 和 Park。

    1.3 Atomicity

    Atomicity 对应上面流程图里的两次原子竞争:

    Fast Path:CAS markWord lock bits [Acquire]
    Inflated Monitor:CAS _owner [Acquire]

    它们都在原子地修改锁状态。多个线程同时竞争时,只有一个线程能够成功获得锁并进入临界区。

    1.4 Visibility 和 Ordering

    Visibility 和 Ordering 对应流程图里的锁边界:

    获取锁:CAS ... [Acquire]
    释放锁:release lock state [Release]

    Release / Acquire 把前后两个临界区连接起来,使前一个持锁者在临界区中的写入,能够被后一个持锁者按照正确的顺序观察到。

    继续向下,这些保证再由 CPU 的 Cache Coherence 和内存顺序约束落实。

    所以 Java 与第一篇的硬件模型对应为:

    Atomicity
    → Atomic Instruction
    → HotSpot / x86-64: LOCK CMPXCHG
    Visibility
    → Cache Coherence
    → 典型 x86-64 CPU:MESI-family(如 MESIF / MOESI)
    Ordering
    → Fence
    → HotSpot / x86-64: LOCK ADDL $0, 0(%rsp)

    这里的 LOCK ADDL $0, 0(%rsp) 对应 HotSpot 在 Linux x86 上实现 full fence 的实际路径;并不表示每次 synchronized 都会额外执行这条指令。x86 自身的内存顺序规则以及 LOCKed RMW 也会参与建立所需的顺序约束。

    相关实现可以继续查看 OpenJDK 的 markWord.hpp、objectMonitor.inline.hpp、graphKit.cpp 和 orderAccess_linux_x86.hpp。


    2. Go Runtime 如何实现 sync.Mutex?

    2.1 分层

    Go API
    实例:sync.Mutex / Lock() / Unlock()
    作用:声明互斥边界
    │
    ▼
    Go Implementation(sync / internal/sync)
    实例:Mutex
    作用:实现 sync.Mutex 语义
    │
    ▼
    Go Runtime / Scheduler
    实例:Semaphore
    作用:处理 Goroutine 的等待 / 唤醒
    │
    ▼
    x86-64 Hardware
    实例:Atomic Instruction / Cache Coherence / Fence
    作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础

    2.2 一次 sync.Mutex 的完整实现路径

    Go 的 Lock 和 Unlock 如何经过 sync internal/sync、Go Runtime Scheduler 和 x86-64 Hardware 完成原子竞争、等待、唤醒和释放。

    上面的时序图只保留跨层主流程。sync.Mutex 内部的竞争路径可以进一步展开为:

    Lock 先尝试 Fast Path CAS,失败后进入 lockSlow,经过 Spin、状态更新和 Semaphore 等待;Unlock 原子释放锁,并在需要时唤醒等待的 Goroutine。

    2.3 Atomicity

    Atomicity 对应上面流程里的锁状态原子修改:

    获取锁:CompareAndSwapInt32(&state, 0, mutexLocked)
    释放锁:AddInt32(&state, -mutexLocked)

    多个 Goroutine 同时竞争时,只有一个能够成功把 state 从 unlocked 改成 locked,并进入临界区。

    在 amd64 上,这两类原子操作分别落到 LOCK CMPXCHGL 和 LOCK XADDL。

    2.4 Visibility 和 Ordering

    Visibility 和 Ordering 对应 Lock / Unlock 形成的同步边界。前一个持锁者完成 Unlock 后,后一个成功 Lock 的 Goroutine 能按照正确的顺序观察前一个临界区中的写入。

    继续向下,这些保证由 CPU 的 Cache Coherence、x86 内存顺序以及原子指令本身的顺序约束落实。

    所以 Go 与第一篇的硬件模型对应为:

    Atomicity
    → Atomic Instruction
    → Go / x86-64: LOCK CMPXCHGL / LOCK XADDL
    Visibility
    → Cache Coherence
    → 典型 x86-64 CPU:MESI-family(如 MESIF / MOESI)
    Ordering
    → Fence / 等价顺序约束
    → Go / x86-64: LOCK CMPXCHGL / LOCK XADDL

    Go 的这条 amd64 Mutex 路径不需要额外插入一条 MFENCE;LOCKed RMW 本身已经提供了这里需要的原子更新和顺序约束。

    实现可以参考 internal/sync/mutex.go、runtime/sema.go 和 internal/runtime/atomic/atomic_amd64.s。


    3. CPython 如何实现 threading.Lock?

    这一节只讨论当前 CPython。

    3.1 分层

    Python API
    实例:threading.Lock / acquire() / release()
    作用:声明互斥边界
    │
    ▼
    CPython Binding(_thread)
    实例:_thread.lock
    作用:将 Python Lock API 映射到 CPython 锁实现
    │
    ▼
    CPython Implementation
    实例:PyMutex
    作用:实现 Lock 语义
    │
    ▼
    x86-64 Hardware
    实例:Atomic Instruction / Cache Coherence / Fence
    作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础

    3.2 一次 threading.Lock 的完整实现路径

    Python 的 Lock acquire 和 release 如何经过 _thread、CPython PyMutex、OS Thread 和 x86-64 Hardware 完成原子竞争、等待、唤醒和释放。

    上面的时序图只保留跨层主流程。CPython 内部的竞争路径可以进一步展开为:

    acquire 先尝试 PyMutex Fast Path,失败后在可用时短暂 Spin,再进入 Parking Lot;release 根据 waiter 状态直接解锁或唤醒等待线程。

    3.3 Atomicity

    Atomicity 对应 PyMutex 对 _bits 的原子状态修改:

    获取锁:_Py_atomic_compare_exchange_uint8(..., _Py_LOCKED)
    释放锁(无 waiter):_Py_atomic_compare_exchange_uint8(..., _Py_UNLOCKED)

    存在 waiter 时,释放路径会进入 _PyParkingLot_Unpark(),并由回调原子更新 _bits。

    多个线程同时竞争时,只有一个能够成功设置 _Py_LOCKED 并进入临界区。

    3.4 Visibility 和 Ordering

    Visibility 和 Ordering 对应 acquire() / release() 形成的同步边界。当前 CPython 的默认原子 compare-exchange / store 在 GCC、Clang 实现中使用 __ATOMIC_SEQ_CST。

    继续向下,这些保证由 CPU 的 Cache Coherence、x86 内存顺序以及这些顺序一致原子操作落实。

    因此当前 CPython 与第一篇的硬件模型对应为:

    Atomicity
    → Atomic Instruction
    → CPython / x86-64: LOCK CMPXCHGB
    Visibility
    → Cache Coherence
    → 典型 x86-64 CPU:MESI-family(如 MESIF / MOESI)
    Ordering
    → Fence / 等价顺序约束
    → CPython / x86-64: SEQ_CST atomic
    (典型为 LOCK CMPXCHGB / memory XCHGB)

    这里同样不要求每次 Lock 都额外执行 MFENCE;在 x86-64 上,LOCKed RMW 和顺序一致原子操作本身已经提供相应的顺序约束。

    实现可以参考 Modules/_threadmodule.c、Python/lock.c、Python/parking_lot.c 和 Include/cpython/pyatomic_gcc.h。


    4. 三种实现的共同套路

    看完 Java、Go 和 CPython,再把具体实现拿掉,只看一把 Mutex 最终需要解决什么问题。

    4.1 谁能进入?——原子修改锁状态

    假设一把锁内部只有一个状态:

    0 = unlocked
    1 = locked

    A 和 B 不能先分别读取 0,再同时写入 1。否则两边都会认为自己获得了锁。

    因此“检查锁状态并把它改成 locked”必须是一个原子操作,例如 Compare-And-Swap:

    Compare-And-Swap(lock_state, 0, 1)

    两个执行单元同时竞争:

    CPU A CPU B
    CAS 0 -> 1 CAS 0 -> 1
    │ │
    ▼ ▼
    success failed

    只有一个竞争者能够成功修改锁状态。Mutex 再用这个很小的硬件原子操作,保护 counter++ 这样的任意临界区。

    4.2 没抢到的人怎么办?——Spin / Park / Wakeup

    CAS 失败以后,执行单元不能无休止地竞争锁状态。

    常见路径是:

    尝试原子获取锁
    │
    ├── 成功 ──> 进入临界区
    │
    └── 失败
    │
    ├── 短暂 Spin
    │ │
    │ └── 再次尝试
    │
    └── Park / 等待
    │
    └── 锁释放后被唤醒

    Spin 适合等待时间很短的情况,可以避免立即进入阻塞路径;竞争持续时则需要 Park,避免一直占用 CPU。

    4.3 抢到锁之后,为什么能看到前一个持锁者留下的数据?

    前两个问题解决了“谁能拿到锁”和“拿不到锁怎么办”。第三个共同问题是:前一个持锁者在临界区里的写入,为什么能被后一个持锁者看到?

    从三种实现的共同模型来看,关键是锁的释放和获取形成了 Release / Acquire 同步边界:

    前一个持锁者
    临界区写入
    │
    ▼
    unlock [Release]
    │
    ▼
    lock [Acquire]
    │
    ▼
    后一个持锁者
    临界区读取

    Release 约束释放锁之前的内存操作,Acquire 约束获得锁之后的内存操作。

    因此,当后一个执行单元成功获得前一个执行单元释放的同一把锁时,前一个临界区中的写入会排在后一个临界区的读取之前。对 Mutex 来说,这就是三种实现共同需要建立的内存顺序。

    具体的 Runtime 和 CPU 如何落实这个 Release / Acquire 边界,前三节已经分别展开。


    5. 下一篇:Atomic

    Mutex 的巧妙之处在于:它只需要维护一个很小的原子状态,就能为任意大小的临界区建立互斥边界。

    Atomic 则更进一步收缩了保护范围:它不再保护一整段临界区,而是直接保证一个共享变量的一次读、写或更新不可分割。

    下一篇继续看这个更小的同步单元是如何实现的。


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

  • 相关阅读:
    leetcode做题笔记154. 寻找旋转排序数组中的最小值 II
    CCC数字钥匙设计【NFC】--NFC卡相关基础知识
    LeetCode第225题—用队列实践栈
    【2022 必读书单&必看视频】计算机图形学 实时渲染 光传输模拟 动态变形 流体仿真 音视频开发 游戏引擎
    http代理有什么好处,怎么通过http代理服务安全上网呢?
    经典面试题第十二更---valueOf和toString
    react函数式组件的useEffect
    云龙开炮版飞机大战(完整版)
    时序逻辑电路
    DV SSL证书
  • 原文地址:https://www.cnblogs.com/ThinkerQAQ/p/23053554