Concurrency Programming (2): Language Memory Models — Rules Programmers Can Rely On
Moves from hardware memory models back to the language layer: why languages need their own concurrency semantics, and what Java, Go, and CPython guarantee to concurrent programs.

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
The previous article on runtime scheduling explained how execution units get CPU time. Here, we turn to the language rules governing concurrent memory accesses.
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
abstracts away hardware differences
│
┌────────┴────────┐
│ │
▼ ▼
x86-64 ARM64
2. How Should Programmers Understand Memory Model Rules?
There are three dimensions to look at:
- Atomicity
- Visibility
- Ordering
In theory, each concurrency primitive has corresponding memory-model rules that describe which of these properties it provides.
We will not go deeper here. Instead, we will reuse the same examples from earlier:
count++
and:
counter = 1
ready = true
We will use the language memory model to answer three questions:
| Question | Specifically |
|---|---|
| Atomicity | Why is count++ 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 Memory Model
Atomicity
Suppose a process contains this variable:
count = 0
Now there are two execution units:
Thread A Thread B
count++ count++
If A and B each execute once, is the final result guaranteed to be:
count = 2
No.
Logically, count++ can be broken down into three steps:
read count
compute count + 1
write count back
So the following interleaving is possible:
initial: count = 0
Thread A Thread B
read count -> 0
compute count + 1 -> 1
read count -> 0
compute count + 1 -> 1
write count -> 1
write count -> 1
The final value becomes:
count = 1
Both threads complete one +1, but because they read the same old value 0, both compute 1, and one of the updates is overwritten.
Therefore, ordinary count++ 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
In Java, Visibility is commonly expressed in terms of happens-before: whether a write is guaranteed to be observed by another thread depends on whether a happens-before relationship exists between the write and the read.
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
In Java, Ordering can also be expressed in terms of happens-before. If one action happens-before another, then the first action—and memory writes that precede it in the same thread—are ordered before the second action. Through the transitivity of happens-before, those earlier writes are also visible to reads that follow the second action.
Applied to this example: 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 the cross-thread 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
In Go, Atomicity can be approached directly through the Go Memory Model’s definition of a data race. The model states:
“A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the
sync/atomicpackage.”
This gives us the distinction we need: ordinary concurrent reads and writes can form a data race, while accesses performed through sync/atomic are treated as atomic accesses.
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
In Go, Visibility can also be expressed in terms of happens-before. For an ordinary read r to observe a write w, the Go Memory Model requires w to be visible to r; the first condition 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
In Go, Ordering can likewise be expressed in terms of happens-before. 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 Memory Model 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
Earlier, we 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.
Discussion
Sign in with GitHub to comment. Discussions are stored as GitHub Issues.View on GitHub