DEV Community

Cover image for Language Memory Models — Rules Programmers Can Rely On-Concurrency Programming
ThinkerQAQ
ThinkerQAQ

Posted on Originally published at thinkerqaq.github.io

Language Memory Models — Rules Programmers Can Rely On-Concurrency Programming

Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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++
Enter fullscreen mode Exit fullscreen mode

and:

counter = 1
ready = true
Enter fullscreen mode Exit fullscreen mode

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++;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If two threads execute it concurrently:

Thread A                    Thread B

counter++;                  counter++;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Both threads execute counter++, yet the final result may still be:

expected: counter = 2
actual:   counter = 1
Enter fullscreen mode Exit fullscreen mode

Therefore, ordinary counter++ cannot be treated as atomic.


Visibility

Now consider:

int counter = 0;

// Thread A
counter = 1;

// Thread B
System.out.println(counter);
Enter fullscreen mode Exit fullscreen mode

That is:

Thread A                    Thread B

counter = 1                 read counter
Enter fullscreen mode Exit fullscreen mode

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 ───────┘
Enter fullscreen mode Exit fullscreen mode

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 ──────┘
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

That is:

Thread A                    Thread B

counter = 1
ready = true                read ready == true
                                  │
                                  ▼
                             read counter
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Therefore, even if B observes:

ready == true
Enter fullscreen mode Exit fullscreen mode

B is still not guaranteed to observe:

counter == 1
Enter fullscreen mode Exit fullscreen mode

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++
Enter fullscreen mode Exit fullscreen mode

is only an ordinary increment operation, not an atomic access provided by sync/atomic.

Therefore, if two Goroutines concurrently execute:

counter++
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Split into two columns:

Goroutine A                 Goroutine B

counter = 1                 read counter
Enter fullscreen mode Exit fullscreen mode

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 ───────┘
Enter fullscreen mode Exit fullscreen mode

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 ──────┘
Enter fullscreen mode Exit fullscreen mode

no such relationship has been established, and the program contains a Data Race.

Therefore, the second Goroutine cannot rely on always observing:

counter == 1
Enter fullscreen mode Exit fullscreen mode

Ordering

Now consider:

// Goroutine A
counter = 1
ready = true

// Goroutine B
if ready {
    fmt.Println(counter)
}
Enter fullscreen mode Exit fullscreen mode

Split into two columns:

Goroutine A                 Goroutine B

counter = 1
ready = true                read ready == true
                                  │
                                  ▼
                             read counter
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

So:

observing ready == true
Enter fullscreen mode Exit fullscreen mode

does not imply:

counter must be observed as 1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and:

counter = 1
ready = True
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Both threads increment the reference count once, yet the final result may be:

expected: refcount = 12
actual:   refcount = 11
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

as a non-atomic operation:

“These aren’t: i = i+1

Therefore, even with the GIL, we cannot treat:

counter += 1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Visibility

Continue with the same example:

counter = 0

# Thread A
counter = 1

# Thread B
print(counter)
Enter fullscreen mode Exit fullscreen mode

Split into two columns:

Thread A                    Thread B

counter = 1                 read counter
Enter fullscreen mode Exit fullscreen mode

Here the distinction between CPython and Java / Go appears.

With Java and Go, we can continue to ask:

write
 │
 │ happens-before ?
 ▼
read
Enter fullscreen mode Exit fullscreen mode

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 ┘
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Again, split into two columns:

Thread A                    Thread B

counter = 1
ready = True                read ready == True
                                  │
                                  ▼
                             read counter
Enter fullscreen mode Exit fullscreen mode

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 ──┘
Enter fullscreen mode Exit fullscreen mode

In free-threaded CPython:

Thread A ─────────────> CPU Core 0

Thread B ─────────────> CPU Core 1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

to:

Free-threaded

finer-grained internal synchronization
      │
      ▼
protect CPython Runtime
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)