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.

EnglishPublished Updated 10/08/20267 min read
Concurrency Programming (2): Language Memory Models — Rules Programmers Can Rely On

Table of Contents


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/atomic package.”

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, and set use 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