DEV Community

Cover image for Concurrency Programming (5): Atomics — Atomicity, Visibility, and Ordering at the Language Level
ThinkerQAQ
ThinkerQAQ

Posted on Originally published at thinkerqaq.github.io

Concurrency Programming (5): Atomics — Atomicity, Visibility, and Ordering at the Language Level

Table of Contents


0. Continue from the Previous Article

This article discusses Atomics directly from the rules defined by language memory models and public APIs.


1. What Guarantees Do the Atomic Rules in Each Language Provide?

1.1 Java: AtomicInteger

1.1.1 Atomicity

AtomicInteger.incrementAndGet() states:

“Atomically increments the current value, with memory effects as specified by VarHandle.getAndAdd.”

This means that incrementAndGet() is one atomic increment. Reading the old value, adding one, and writing the result back are not three independently interleavable steps; together they form one Atomic RMW operation.

AtomicInteger counter = new AtomicInteger(0);

// Thread A
counter.incrementAndGet();

// Thread B
counter.incrementAndGet();
Enter fullscreen mode Exit fullscreen mode

The two updates apply to the same value in sequence:

Thread A                    Thread B

incrementAndGet()           incrementAndGet()
        │                           │
        ▼                           ▼
      0 → 1                       1 → 2
Enter fullscreen mode Exit fullscreen mode

A lost update like the one possible with a plain counter++ therefore does not occur.

1.1.2 Visibility

incrementAndGet() gets its memory effects from VarHandle.getAndAdd. VarHandle.getAndAdd describes its write side as:

“with the memory semantics of setVolatile(Object...)”

AtomicInteger.get() uses VarHandle.getVolatile read semantics.

JLS §17.4.4 Synchronization Order says that a volatile write:

“synchronizes-with all subsequent reads of v by any thread”

So the volatile write performed by the Atomic update can establish a synchronizes-with relationship with a later volatile read in another thread.

AtomicInteger counter = new AtomicInteger(0);
boolean ready = false;

// Thread A
ready = true;                 // A1
counter.incrementAndGet();    // A2

// Thread B
if (counter.get() == 1) {     // B1
    System.out.println(ready); // B2
}
Enter fullscreen mode Exit fullscreen mode

Assume B's counter.get() reads the 1 produced by A's update:

Thread A                              Thread B

A1  ready = true
        │
        │ program order
        ▼
A2  counter.incrementAndGet()
        │
        │ synchronizes-with
        │ JLS §17.4.4
        └──────────────────────────► B1  counter.get() == 1
                                        │
                                        │ program order
                                        ▼
                                    B2  read ready
Enter fullscreen mode Exit fullscreen mode

A's earlier ready = true write is therefore visible to B's later read. That is the Visibility guarantee in this example.

1.1.3 Ordering

JLS §17.4.5 Happens-before Order states:

“If one action happens-before another, then the first is visible to and ordered before the second.”

So happens-before describes not only visibility but also the required ordering between the actions.

A1 ready = true
    ↓ program order
A2 counter.incrementAndGet()
    ↓ synchronizes-with
B1 counter.get() == 1
    ↓ program order
B2 read ready
Enter fullscreen mode Exit fullscreen mode

Therefore:

A1 happens-before A2
A2 happens-before B1
B1 happens-before B2

        ↓ transitivity

A1 happens-before B2
Enter fullscreen mode Exit fullscreen mode

Once B has observed A's update through counter.get(), it cannot still observe:

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

That is the Ordering guarantee.

1.2 Go: sync/atomic

1.2.1 Atomicity

atomic.Int64.Add states:

“Add atomically adds delta to x and returns the new value.”

Add(1) therefore performs the read, increment, and write-back as one atomic update.

var counter atomic.Int64

// Goroutine A
counter.Add(1)

// Goroutine B
counter.Add(1)
Enter fullscreen mode Exit fullscreen mode

The two updates do not lose one another:

Goroutine A                 Goroutine B

Add(1)                      Add(1)
  │                           │
  ▼                           ▼
0 → 1                       1 → 2
Enter fullscreen mode Exit fullscreen mode

That is Atomicity.

1.2.2 Visibility

The Go Memory Model - Atomic Values states:

“If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B.”

So if B's Atomic operation observes A's Atomic update, the two operations have a synchronized-before relationship.

var counter atomic.Int64
var ready bool

// Goroutine A
ready = true          // A1
counter.Add(1)        // A2

// Goroutine B
if counter.Load() == 1 { // B1
    fmt.Println(ready)    // B2
}
Enter fullscreen mode Exit fullscreen mode

Assume B's counter.Load() observes the 1 produced by A's Add(1):

Goroutine A                           Goroutine B

A1  ready = true
        │
        │ sequenced-before
        ▼
A2  counter.Add(1)
        │
        │ synchronized-before
        │ Go Memory Model
        └──────────────────────────► B1  counter.Load() == 1
                                        │
                                        │ sequenced-before
                                        ▼
                                    B2  read ready
Enter fullscreen mode Exit fullscreen mode

A's earlier ready = true write is therefore visible to B's later read. That is Visibility.

1.2.3 Ordering

The same Atomic Values section says that Atomic operations behave as though they were in:

“some sequentially consistent order”

The Go Memory Model defines happens-before from the transitive closure of sequenced-before and synchronized-before.

A1 ready = true
    ↓ sequenced-before
A2 counter.Add(1)
    ↓ synchronized-before
B1 counter.Load() == 1
    ↓ sequenced-before
B2 read ready
Enter fullscreen mode Exit fullscreen mode

Therefore:

A1 happens-before A2
A2 happens-before B1
B1 happens-before B2

        ↓ transitivity

A1 happens-before B2
Enter fullscreen mode Exit fullscreen mode

After B has observed counter == 1, it cannot treat A's earlier ready = true as if it had not happened. That is Ordering.

1.3 CPython: No Symmetric Application-level Atomic API

Python's standard library currently has no general integer Atomic API symmetric with Java's AtomicInteger or Go's atomic.Int64.

Application code therefore has no corresponding set of public Atomic rules to cite. An ordinary counter += 1 is not a language-defined Atomic API. The existence of the GIL in a particular CPython configuration, or the use of _Py_atomic_* inside the Runtime, does not turn it into a cross-implementation guarantee of Atomicity, Visibility, or Ordering for application code.

CPython itself needs Atomics for shared Runtime state. How _Py_atomic_* reaches the compiler and CPU is an implementation question for the next article, not part of Python's public application-level Atomic semantics.


2. Next: How Are Atomics Implemented?

This article stops at the language level: Java and Go define Atomicity, Visibility, and Ordering through public Atomic APIs and their memory models, while Python application code has no symmetric general-purpose Atomic API.

The next article turns those language-level rules into implementation-level problems by following Java AtomicInteger, Go sync/atomic, and CPython's internal _Py_atomic_* path from the Runtime down to the CPU.


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)