DEV Community

Cover image for Concurrency Programming (4): Mutex Implementation — From Runtime to CPU
ThinkerQAQ
ThinkerQAQ

Posted on Originally published at thinkerqaq.github.io

Concurrency Programming (4): Mutex Implementation — From Runtime to CPU

Table of Contents


0. What Does This Article Continue to Answer?

The previous article started from the rules of the language memory model and looked at how a Mutex provides Atomicity, Visibility, and Ordering.

This article continues downward: how Java, Go, and CPython implement those guarantees at the Runtime and CPU layers.


1. How Does HotSpot Implement synchronized?

1.1 Layers

First, fix the implementation layers for Java:

Java Language
Example: synchronized
Role: declare a synchronized region
        │
        ▼
JVM Bytecode
Example: monitorenter / monitorexit (synchronized blocks)
Role: express entering / exiting a Monitor
        │
        ▼
JVM Implementation (HotSpot)
Example: Synchronization Runtime
Role: implement the Monitor semantics required by the JVM
        │
        ▼
x86-64 Hardware
Example: Atomic Instruction / Cache Coherence / Fence
Role: provide the hardware foundation for Atomicity, Visibility, and Ordering
Enter fullscreen mode Exit fullscreen mode

1.2 The Complete Path of One synchronized Operation

The main lock, contention, critical-section, and unlock path from Java Code through JVM Bytecode, HotSpot, OS Thread, and x86-64 Hardware.

The sequence diagram above keeps only the main cross-layer path. The contention path inside the Runtime can be expanded further:

monitorenter first tries the Lightweight Locking fast path. On failure it performs fast-lock spinning before inflating to ObjectMonitor, where contention continues through CAS, Spin, and Park.

1.3 Atomicity

Atomicity corresponds to the two atomic competitions in the flowchart above:

Fast Path: CAS markWord lock bits [Acquire]
Inflated Monitor: CAS _owner [Acquire]
Enter fullscreen mode Exit fullscreen mode

Both atomically modify lock state. When multiple threads compete at the same time, only one thread can successfully acquire the lock and enter the critical section.

1.4 Visibility and Ordering

Visibility and Ordering correspond to the lock boundaries in the flowchart:

Acquire lock: CAS ... [Acquire]
Release lock: release lock state [Release]
Enter fullscreen mode Exit fullscreen mode

Release / Acquire connects the two critical sections so that writes from the previous lock holder can be observed by the next lock holder in the correct order.

Going further down, these guarantees are implemented through CPU Cache Coherence and memory-ordering constraints.

So Java maps back to the hardware model from the first article as follows:

Atomicity
  → Atomic Instruction
  → HotSpot / x86-64: LOCK CMPXCHG

Visibility
  → Cache Coherence
  → Typical x86-64 CPUs: MESI-family (e.g. MESIF / MOESI)

Ordering
  → Fence
  → HotSpot / x86-64: LOCK ADDL $0, 0(%rsp)
Enter fullscreen mode Exit fullscreen mode

LOCK ADDL $0, 0(%rsp) is the actual full-fence path used by HotSpot on Linux x86. This does not mean every synchronized operation executes an extra copy of that instruction; x86 ordering rules and LOCKed RMW operations also participate in establishing the required ordering.

For implementation details, see OpenJDK's markWord.hpp, objectMonitor.inline.hpp, graphKit.cpp, and orderAccess_linux_x86.hpp.


2. How Does the Go Runtime Implement sync.Mutex?

2.1 Layers

Go API
Example: sync.Mutex / Lock() / Unlock()
Role: declare a mutual-exclusion boundary
        │
        ▼
Go Implementation (sync / internal/sync)
Example: Mutex
Role: implement sync.Mutex semantics
        │
        ▼
Go Runtime / Scheduler
Example: Semaphore
Role: handle Goroutine waiting / wakeup
        │
        ▼
x86-64 Hardware
Example: Atomic Instruction / Cache Coherence / Fence
Role: provide the hardware foundation for Atomicity, Visibility, and Ordering
Enter fullscreen mode Exit fullscreen mode

2.2 The Complete Path of One sync.Mutex Operation

How Lock and Unlock move through sync internal/sync, the Go Runtime Scheduler, and x86-64 Hardware for atomic competition, waiting, wakeup, and release.

The sequence diagram above keeps only the main cross-layer path. The contention path inside sync.Mutex can be expanded further:

Lock first tries the fast-path CAS. On failure it enters lockSlow, may spin, update state, and wait on the runtime semaphore. Unlock atomically releases the lock and wakes a waiting Goroutine when needed.

2.3 Atomicity

Atomicity corresponds to the atomic lock-state modifications in the flow above:

Acquire lock: CompareAndSwapInt32(&state, 0, mutexLocked)
Release lock: AddInt32(&state, -mutexLocked)
Enter fullscreen mode Exit fullscreen mode

When multiple Goroutines compete at the same time, only one can successfully change state from unlocked to locked and enter the critical section.

On amd64, these two classes of atomic operation map to LOCK CMPXCHGL and LOCK XADDL.

2.4 Visibility and Ordering

Visibility and Ordering correspond to the synchronization boundary formed by Lock / Unlock. After one holder completes Unlock, a later Goroutine that successfully executes Lock can observe writes from the previous critical section in the required order.

Going further down, these guarantees are implemented through CPU Cache Coherence, x86 memory ordering, and the ordering constraints of the atomic instructions themselves.

So Go maps back to the hardware model from the first article as follows:

Atomicity
  → Atomic Instruction
  → Go / x86-64: LOCK CMPXCHGL / LOCK XADDL

Visibility
  → Cache Coherence
  → Typical x86-64 CPUs: MESI-family (e.g. MESIF / MOESI)

Ordering
  → Fence / equivalent ordering constraint
  → Go / x86-64: LOCK CMPXCHGL / LOCK XADDL
Enter fullscreen mode Exit fullscreen mode

This amd64 Mutex path does not need an additional MFENCE; the LOCKed RMW operations already provide the atomic update and ordering constraints required here.

For implementation details, see internal/sync/mutex.go, runtime/sema.go, and internal/runtime/atomic/atomic_amd64.s.


3. How Does CPython Implement threading.Lock?

This section discusses current CPython only.

3.1 Layers

Python API
Example: threading.Lock / acquire() / release()
Role: declare a mutual-exclusion boundary
        │
        ▼
CPython Binding (_thread)
Example: _thread.lock
Role: map the Python Lock API to CPython's lock implementation
        │
        ▼
CPython Implementation
Example: PyMutex
Role: implement Lock semantics
        │
        ▼
x86-64 Hardware
Example: Atomic Instruction / Cache Coherence / Fence
Role: provide the hardware foundation for Atomicity, Visibility, and Ordering
Enter fullscreen mode Exit fullscreen mode

3.2 The Complete Path of One threading.Lock Operation

How Python Lock acquire and release move through _thread, CPython PyMutex, OS Thread, and x86-64 Hardware for atomic competition, waiting, wakeup, and release.

The sequence diagram above keeps only the main cross-layer path. CPython's internal contention path can be expanded further:

acquire first tries the PyMutex fast path. On failure it may briefly spin when supported, then enters the Parking Lot. release either unlocks directly or wakes a waiting thread according to waiter state.

3.3 Atomicity

Atomicity corresponds to the atomic state changes that PyMutex performs on _bits:

Acquire lock: _Py_atomic_compare_exchange_uint8(..., _Py_LOCKED)
Release lock (no waiter): _Py_atomic_compare_exchange_uint8(..., _Py_UNLOCKED)
Enter fullscreen mode Exit fullscreen mode

When waiters exist, the release path enters _PyParkingLot_Unpark(), whose callback atomically updates _bits.

When multiple threads compete at the same time, only one can successfully set _Py_LOCKED and enter the critical section.

3.4 Visibility and Ordering

Visibility and Ordering correspond to the synchronization boundary formed by acquire() / release(). In current CPython, the default atomic compare-exchange and store operations use __ATOMIC_SEQ_CST in the GCC / Clang implementation.

Going further down, these guarantees are implemented through CPU Cache Coherence, x86 memory ordering, and those sequentially consistent atomic operations.

So current CPython maps back to the hardware model from the first article as follows:

Atomicity
  → Atomic Instruction
  → CPython / x86-64: LOCK CMPXCHGB

Visibility
  → Cache Coherence
  → Typical x86-64 CPUs: MESI-family (e.g. MESIF / MOESI)

Ordering
  → Fence / equivalent ordering constraint
  → CPython / x86-64: SEQ_CST atomic
    (typically LOCK CMPXCHGB / memory XCHGB)
Enter fullscreen mode Exit fullscreen mode

This likewise does not require every Lock operation to execute an additional MFENCE; on x86-64, LOCKed RMW and sequentially consistent atomic operations themselves provide the corresponding ordering constraints.

For implementation details, see Modules/_threadmodule.c, Python/lock.c, Python/parking_lot.c, and Include/cpython/pyatomic_gcc.h.


4. The Common Pattern Across the Three Implementations

After looking at Java, Go, and CPython, remove the implementation-specific details and focus only on the problems that a Mutex ultimately needs to solve.

4.1 Who Gets In? — Atomically Modify Lock State

Suppose a lock has only one internal state:

0 = unlocked
1 = locked
Enter fullscreen mode Exit fullscreen mode

A and B must not both read 0 first and then both write 1. Otherwise, both would believe they acquired the lock.

Therefore, "check the lock state and change it to locked" must be an atomic operation, such as Compare-And-Swap:

Compare-And-Swap(lock_state, 0, 1)
Enter fullscreen mode Exit fullscreen mode

When two execution units compete:

CPU A                         CPU B

CAS 0 -> 1                   CAS 0 -> 1
    │                             │
    ▼                             ▼
  success                       failed
Enter fullscreen mode Exit fullscreen mode

Only one competitor can successfully modify the lock state. A Mutex then uses this small hardware atomic operation to protect an arbitrary critical section such as counter++.

4.2 What Happens If You Don't Get the Lock? — Spin / Park / Wakeup

After CAS fails, an execution unit cannot compete for the lock state forever.

A common path is:

try atomic lock acquisition
        │
        ├── success ──> enter critical section
        │
        └── failure
              │
              ├── Spin briefly
              │      │
              │      └── retry
              │
              └── Park / wait
                         │
                         └── wake after lock release
Enter fullscreen mode Exit fullscreen mode

Spin is suitable when the wait is expected to be very short because it avoids immediately entering a blocking path. If contention persists, Park avoids continuously occupying the CPU.

4.3 After Acquiring the Lock, Why Can You See the Data Left by the Previous Holder?

The first two questions answer "who gets the lock?" and "what happens if you don't get it?" The third common question is: why can writes performed by the previous lock holder inside the critical section be seen by the next lock holder?

Across the three implementations, the key common model is that lock release and lock acquisition form a Release / Acquire synchronization boundary:

previous lock holder
critical-section writes
    │
    ▼
unlock [Release]
    │
    ▼
lock [Acquire]
    │
    ▼
next lock holder
critical-section reads
Enter fullscreen mode Exit fullscreen mode

Release constrains memory operations before the lock is released, while Acquire constrains memory operations after the lock is acquired.

Therefore, when the next execution unit successfully acquires the same lock released by the previous execution unit, writes in the earlier critical section are ordered before reads in the later critical section. For a Mutex, this is the memory ordering that all three implementations need to establish.

The first three sections already showed how each Runtime and CPU implementation realizes this Release / Acquire boundary.


5. Next: Atomic

The cleverness of a Mutex is that it only needs to maintain a very small atomic state to establish a mutual-exclusion boundary around a critical section of arbitrary size.

Atomic narrows the protected scope further: instead of protecting an entire critical section, it directly guarantees that a single read, write, or update of a shared variable is indivisible.

The next article continues with how this smaller synchronization unit is implemented.


This article was first published on ThinkerQAQ's personal blog and syndicated here by the author. The original article may be revised over time; please refer to the personal blog for the latest version.

Top comments (1)