Table of Contents
- 0. Continue from the Previous Article
- 1. Why Does the Language Need a Memory Model Layer?
- 2. How Should Programmers Understand Memory Model Rules?
- 3. Next: Mutexes
0. Continue from the Previous Article
This article shifts the perspective from hardware to the language memory model layer.
1. Why Does the Language Need a Memory Model Layer?
Source code passes through a compiler and Runtime before it finally executes on a CPU. Compilers optimize code, and processors such as x86-64 and ARM64 do not allow exactly the same memory-access orderings.
If programmers had to study every compiler, Runtime, and CPU separately, portable concurrent programming would be difficult to sustain.
We therefore also need a language memory model layer. It defines the rules programmers need to understand, while the underlying Compiler / Runtime implements those rules differently for each platform:
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 implementation
shields hardware-platform differences
│
┌────────┴────────┐
│ │
▼ ▼
x86-64 ARM64
2. How Should Programmers Understand Memory Model Rules?
Through these three dimensions:
- Atomicity
- Visibility
- Ordering
In theory — only in theory — each concurrency utility has corresponding rules at the memory model layer that describe which of these properties it provides.
This article will not go deeper yet. Instead, we will use the previous examples:
counter++
and:
counter = 1
ready = true
Use the rules at the language memory model layer to establish:
| Question | Specifically |
|---|---|
| Atomicity | Why is counter++ not atomic? |
| Visibility | After A writes counter = 1, why is B not guaranteed to observe counter = 1? |
| Ordering | When B observes ready = true, why is B not guaranteed to observe the earlier counter = 1? |
2.1 Java: Java Memory Model
Atomicity
Start with:
counter++;
Unfortunately, the JMM does not have a rule that directly states that counter++ is not atomic, so we will explain it here.
Semantically, counter++ is a compound operation. We can think of it as:
read counter
↓
counter + 1
↓
write counter
If two threads execute it concurrently:
Thread A Thread B
counter++; counter++;
these operations may interleave like this:
Thread A Thread B
read counter = 0 read counter = 0
counter + 1 counter + 1
write counter = 1 write counter = 1
Both threads execute counter++, yet the final result may still be:
expected: counter = 2
actual: counter = 1
Therefore, ordinary counter++ cannot be treated as atomic.
Visibility
Now consider:
int counter = 0;
// Thread A
counter = 1;
// Thread B
System.out.println(counter);
That is:
Thread A Thread B
counter = 1 read counter
JLS §17.4.5 gives a very direct description of happens-before:
“If one action happens-before another, then the first is visible to and ordered before the second.”
In other words, if we can establish:
Thread A Thread B
counter = 1 read counter
│ ▲
│ │
└──── happens-before ───────┘
then Thread A's write is guaranteed to be visible to Thread B's read.
But in the current code:
Thread A Thread B
counter = 1 read counter
│ ▲
│ │
└── no happens-before ──────┘
no such cross-thread happens-before relationship has been established.
Therefore, Thread B is not guaranteed to observe 1.
Ordering
Return to the second example:
// Thread A
counter = 1;
ready = true;
// Thread B
if (ready) {
System.out.println(counter);
}
That is:
Thread A Thread B
counter = 1
ready = true read ready == true
│
▼
read counter
Within Thread A, counter = 1 comes before ready = true; within Thread B, reading ready comes before reading counter. But the two threads are still missing a happens-before relationship from ready = true to read ready == true.
So the current code still does not establish a complete happens-before chain between Thread A and Thread B:
Thread A Thread B
counter = 1
ready = true read ready == true
│ │
│ ▼
│ read counter
│
└──── no cross-thread happens-before
Therefore, even if B observes:
ready == true
B is still not guaranteed to observe:
counter == 1
2.2 Go Memory Model
Atomicity
The Go Memory Model provides a direct rule for this case:
If a write to a memory location occurs concurrently with another read or write, it constitutes a Data Race unless all of those accesses are atomic accesses provided by
sync/atomic.
But:
counter++
is only an ordinary increment operation, not an atomic access provided by sync/atomic.
Therefore, if two Goroutines concurrently execute:
counter++
they perform concurrent reads and writes on the same counter, which constitutes a Data Race.
So ordinary counter++ cannot be treated as an atomic concurrent update.
Visibility
Continue with:
var counter int
// Goroutine A
counter = 1
// Goroutine B
fmt.Println(counter)
Split into two columns:
Goroutine A Goroutine B
counter = 1 read counter
This is exactly the kind of question the Go Memory Model starts from:
Under what conditions can a read in one Goroutine be guaranteed to observe a write to the same variable in another Goroutine?
In the formal rules, for an ordinary read to reliably observe a particular write, that write must be visible to the read; one of the conditions is:
“w happens before r.”
Applied to our example, if we can establish:
Goroutine A Goroutine B
counter = 1 read counter
│ ▲
│ │
└──── happens-before ───────┘
then the later read can reliably observe that write.
But in the current code:
Goroutine A Goroutine B
counter = 1 read counter
│ ▲
│ │
└── no happens-before ──────┘
no such relationship has been established, and the program contains a Data Race.
Therefore, the second Goroutine cannot rely on always observing:
counter == 1
Ordering
Now consider:
// Goroutine A
counter = 1
ready = true
// Goroutine B
if ready {
fmt.Println(counter)
}
Split into two columns:
Goroutine A Goroutine B
counter = 1
ready = true read ready == true
│
▼
read counter
This is essentially the same pattern used by the Go Memory Model under Incorrect synchronization: one Goroutine writes data and then a flag; another Goroutine observes the flag and then reads the earlier data.
The Go specification states:
“Even if this occurs, it does not imply that reads happening after r will observe writes that happened before w.”
Applied to our example:
Goroutine A Goroutine B
counter = 1
ready = true read ready == true
│
▼
read counter
no happens-before guarantee
So:
observing ready == true
does not imply:
counter must be observed as 1
This is very similar to the Java example.
Both Java and Go use happens-before to describe important Visibility and Ordering guarantees.
2.3 CPython Concurrency Semantics
Python differs from Java and Go in one important respect:
Python does not define one unified concurrency memory model for all interpreter implementations at the same level as the JMM or Go Memory Model.
So this article focuses on the most widely used implementation: CPython.
We will continue to use the same two examples:
counter += 1
and:
counter = 1
ready = True
and examine Atomicity, Visibility, and Ordering.
2.3.1 GIL Mode
Before returning to counter, first clarify one question:
What does the GIL actually protect?
The GIL is first and foremost a mechanism used by the CPython Runtime to protect Python objects and interpreter-internal state.
The official Python C API documentation states the requirement directly:
“only a thread that holds the GIL may operate on Python objects or invoke Python’s C API.”
In other words, in default GIL-enabled CPython, a thread must hold the GIL before it can operate on Python objects or call the Python C API.
The documentation then uses Reference Count to explain why such a lock is needed: if two threads increment the same object's reference count at the same time, the result may be incremented only once instead of twice.
Suppose a Python object currently has:
refcount = 10
If two threads could modify that reference count concurrently:
Thread A Thread B
read refcount = 10 read refcount = 10
refcount + 1 refcount + 1
write refcount = 11 write refcount = 11
Both threads increment the reference count once, yet the final result may be:
expected: refcount = 12
actual: refcount = 11
That would break CPython's management of Python object lifetimes.
So the GIL primarily protects:
GIL
│
▼
CPython Runtime
│
┌───────┴────────┐
▼ ▼
Python Objects Runtime State
│
├── Reference Count
└── Object Internals
There are two different layers here:
- CPython Runtime layer: how to safely access and maintain Python objects under concurrency
- Application layer: how to safely access and modify shared state under concurrency
The GIL primarily addresses the former. It does not make application code automatically thread-safe.
Atomicity
Now return to:
counter += 1
The official Python FAQ directly addresses this kind of operation.
When discussing which operations are atomic in GIL-enabled CPython, it explicitly lists:
i = i + 1
as a non-atomic operation:
“These aren’t:
i = i+1”
Therefore, even with the GIL, we cannot treat:
counter += 1
as an atomic concurrent update as a whole.
We need to distinguish:
GIL
│
└── protection for CPython Runtime / Python Objects
counter += 1
│
└── a compound state update defined by the application
Visibility
Continue with the same example:
counter = 0
# Thread A
counter = 1
# Thread B
print(counter)
Split into two columns:
Thread A Thread B
counter = 1 read counter
Here the distinction between CPython and Java / Go appears.
With Java and Go, we can continue to ask:
write
│
│ happens-before ?
▼
read
because those languages define formal Memory Models.
Python, however, does not define a corresponding happens-before model that applies uniformly to all Python implementations.
So we cannot draw the CPython code above as:
Thread A Thread B
counter = 1 read counter
│ ▲
│ │
└──── Python happens-before ┘
and then derive a conclusion from some Python Language Memory Model rule, because there is no corresponding unified model of that kind.
Conversely, because no separate rule specifies such a guarantee, we should treat this behavior as unspecified. If it is unspecified, then it does not provide a Visibility guarantee.
Ordering
Finally, consider:
# Thread A
counter = 1
ready = True
# Thread B
if ready:
print(counter)
Again, split into two columns:
Thread A Thread B
counter = 1
ready = True read ready == True
│
▼
read counter
Likewise, there is no corresponding rule that specifies such a guarantee here, so we should treat this behavior as unspecified. If it is unspecified, then it does not provide an Ordering guarantee.
2.3.2 Free-threaded Mode
Starting with Python 3.13, CPython provides free-threaded builds in which the GIL can be disabled.
In GIL-enabled CPython:
Thread A ──┐
│
├── GIL ───> Python Code
│
Thread B ──┘
In free-threaded CPython:
Thread A ─────────────> CPU Core 0
Thread B ─────────────> CPU Core 1
Multiple threads can execute Python code in parallel.
But removing the GIL does not mean CPython no longer needs to protect its objects and Runtime state.
Python's official free-threading documentation explicitly states that built-in types such as dict, list, and set use internal locks to protect against concurrent modification:
“Built-in types like
dict,list, andsetuse internal locks”
These internal locks are an implementation mechanism of free-threaded CPython. They should not be interpreted as Python the language defining a unified Memory Model.
So the change is from:
GIL-enabled
one global GIL
│
▼
protect CPython Runtime
to:
Free-threaded
finer-grained internal synchronization
│
▼
protect CPython Runtime
For application code, the same three questions still exist.
Different paths, same destination. Removing the GIL does not mean CPython no longer needs synchronization; it moves from one global lock toward finer-grained locks, atomic operations, and Runtime coordination. In this respect, its implementation approach increasingly resembles Java and Go.
2.3.3 So What Should Python Programs Rely On?
In summary, Java, Go, and CPython differ at this layer in an important way:
Java
│
▼
Java Memory Model
│
└── happens-before
Go
│
▼
Go Memory Model
│
└── happens-before
Python
│
▼
Concurrency semantics of different Interpreters
│
└── CPython
├── GIL-enabled
└── Free-threaded
The summary is simple: Python does not have one unified Memory Model, at the same level as the JMM or Go Memory Model, that applies to every interpreter implementation. Therefore, when discussing concurrency safety in Python, first identify which Interpreter is being used, and rely on explicit synchronization tools and the concurrency guarantees they provide.
3. Next: Mutexes
The article before last discussed the hardware layer; this article explains the rules at the language memory model layer.
The next article begins with the first concrete synchronization tool, Mutex, and follows it from the language memory model layer all the way down to the hardware layer.
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 (0)