Execution & Concurrency Models
When you write a single-threaded program, the behavior is predictable: instructions are executed in the order they appear in your code. But in a multi-threaded, multi-core world, this simple assumption breaks down.
A memory model is a set of rules that defines a contract between the hardware and the programming language. It specifies the guarantees a language provides about how memory operations (reads and writes) from different threads will appear to interact with each other.
Understanding the memory model is crucial for writing correct concurrent programs because it answers the fundamental question: "If thread A writes to a shared variable, when is that write guaranteed to be visible to thread B?"
Without these guarantees, concurrent programming would be nearly impossible.
Modern computer systems perform a host of aggressive optimizations to run code faster. These optimizations are perfectly safe in a single-threaded world but can cause chaos in a concurrent one.
x = 1; y = 2; might be reordered to y = 2; x = 1;.The Problem: These optimizations mean that what you write in your code is not necessarily the order in which things happen in memory.
Imagine two threads running on different cores.
Shared Variables (in main memory):
data = 0
ready = false
| Thread A (Producer) | Thread B (Consumer) |
|---|---|
data = 42; ready = true; | while (!ready) { // spin wait } print(data); |
Intended Outcome: Thread B should print 42.
What can go wrong?
ready = true before it sets data = 42.data = 42 to its local cache, but the value might not be flushed to main memory immediately. Meanwhile, the value of ready might be set to true in main memory, and Thread B reads it.In either case, Thread B could see ready as true, exit its loop, and print data, which might still be 0. This is a data race.
A memory model introduces specific concepts that force the compiler and hardware to behave in a more predictable way, but only when you ask for it. These mechanisms create "happens-before" relationships.
A "happens-before" relationship is a guarantee: if action X happens-before action Y, then the results of X are guaranteed to be visible to Y, and X will be ordered before Y.
The most common way to establish a happens-before relationship is through synchronization.
Rule: A lock release on a variable happens-before every subsequent lock acquire on that same variable.
When a thread acquires a lock, it invalidates its local cache, forcing it to re-read from main memory. When it releases a lock, it flushes all the changes from its local cache back to main memory.
Revisiting the example with locks:
| Thread A (Producer) | Thread B (Consumer) |
|---|---|
lock.acquire()data = 42; ready = true;lock.release() | lock.acquire()if (ready) {print(data); }lock.release() |
Now, if Thread B acquires the lock after Thread A has released it, it is guaranteed to see all the changes A made inside the locked section, including data = 42.
Locks are powerful but can be heavy. Sometimes you just need to ensure that reads and writes to a single variable are atomic and visible across threads, without the mutual exclusion of a lock. This is what the volatile keyword is for in languages like Java and C#.
A volatile variable has two key properties:
Revisiting the example with volatile:
Let's declare ready as volatile.
data = 0 volatile ready = false
| Thread A (Producer) | Thread B (Consumer) |
|---|---|
data = 42; ready = true; | while (!ready) { // spin wait } print(data); |
Because ready is volatile, the write ready = true cannot be reordered before the write data = 42. Furthermore, when Thread B reads ready as true, it is guaranteed to see the write to data that happened before it. This fixes the data race.
Important: volatile does not provide mutual exclusion. If you have a complex operation that needs to be atomic (like count++, which is actually a read, a modify, and a write), you still need a lock. volatile is for simple, atomic assignments.
i++.