95

“Thread-safe” is not a property: reason in happens-before edges and atomic steps

#
  • heuristic
  • established
  • fragment

Scope. What a data race is, what a happens-before edge guarantees, and what each primitive promises are language and library specifications (Go memory model, JLS chapter 17, the Rustonomicon, the ConcurrentHashMap and CPython documentation). The decision rule — name the invariant and give its whole read-and-write one atomic step — is engineering judgment built on them. The Python comment describes CPython: the free-threaded documentation calls the internal per-object locks a description of the current implementation, not a guarantee. The fragments were compiled and run locally (JDK 21, Go 1.27.1 with -race, Rust 1.98.1, CPython 3.14.7 with and without the GIL); the racy variants lost updates or loaded twice in those runs, which shows possibility, not frequency.

Versions. Java: ConcurrentHashMap.computeIfAbsent and LongAdder exist since Java 8; JLS chapter 17 is cited in its SE 27 edition. Go: atomic.Int64 needs Go 1.19+. Rust: AtomicU64::try_update is stable since 1.95 (the older fetch_update is the same operation; the 1.98 standard library source already carries its deprecation, effective 1.99, in favour of try_update). Python: the free-threaded build exists since 3.13; the fragment runs unchanged on both builds.

Primary sources: The Go Memory Model (version of June 6, 2022), The Java Language Specification, Java SE 27 Edition — Chapter 17, Threads and Locks, The Rustonomicon — Data Races and Race Conditions

References: The Rustonomicon — Send and Sync, PEP 703 — Making the Global Interpreter Lock Optional in CPython, Python documentation — Python support for free threading, Java SE 27 API — ConcurrentHashMap (computeIfAbsent), Go documentation — Data Race Detector, Announcing Rust 1.95.0 (atomic update / try_update stabilized)

Other claims on this card, typed separately:

Shared-memory correctness is two separate questions. Visibility: is there a happens-before edge (a lock, a volatile write, an atomic operation, a channel send) from the write to the read, or may the reader see a stale or half-built value? Atomicity: does every invariant-bearing sequence — read-modify-write, check-then-act — run as one step? A “thread-safe” collection answers the first question for each call and neither question for a pair of calls; freedom from data races, which the Go memory model, the JLS and the Rustonomicon each define, is necessary and not sufficient.

Code example (fragment)
fragment
Java 8+
final class SessionRegistry {
    private final ConcurrentMap<String, Session> sessions = new ConcurrentHashMap<>();
    private final Function<String, Session> loader;
    private volatile long hitsRacy;              // every write is visible to later reads...
    private final LongAdder hits = new LongAdder();

    SessionRegistry(Function<String, Session> loader) { this.loader = loader; }

    Session getRacy(String id) {
        hitsRacy++;                              // BUG: ...but read-add-write is three steps
        Session s = sessions.get(id);            // each call is thread-safe, the pair is not:
        if (s == null) {                         // two threads both miss, both load,
            s = loader.apply(id);                // and hand out different sessions
            sessions.put(id, s);
        }
        return s;
    }

    Session get(String id) {
        hits.increment();
        // FIX: check-then-act is one atomic map operation; concurrent callers for an
        // absent key wait for a single load. The loader must be short and must not
        // touch this map.
        return sessions.computeIfAbsent(id, loader);
    }
}
Go 1.19+
type Registry struct {
    mu       sync.Mutex
    sessions map[string]*Session // guarded by mu
    hits     atomic.Int64
    load     func(string) *Session
}

// BUG: no happens-before edge between goroutines touching the map. That is a
// data race (go test -race reports it; the runtime may abort with "concurrent
// map writes"), and even a race-free map would let two goroutines both miss.
func (r *Registry) GetRacy(id string) *Session {
    s, ok := r.sessions[id]
    if !ok {
        s = r.load(id)
        r.sessions[id] = s
    }
    return s
}

// FIX: one critical section spans the check and the act.
func (r *Registry) Get(id string) *Session {
    r.hits.Add(1) // a lone counter needs no lock, only an atomic step
    r.mu.Lock()
    defer r.mu.Unlock()
    s, ok := r.sessions[id]
    if !ok {
        s = r.load(id)
        r.sessions[id] = s
    }
    return s
}
Rust 1.95+
// Compiles: safe Rust rules out the data race, not the lost update.
fn withdraw_racy(balance: &Mutex<u64>, amount: u64) -> bool {
    let current = *balance.lock().unwrap(); // guard is dropped right here
    if current < amount {
        return false;
    }
    *balance.lock().unwrap() = current - amount; // BUG: writes back a stale read
    true
}

// FIX: one guard spans the check and the act.
fn withdraw(balance: &Mutex<u64>, amount: u64) -> bool {
    let mut guard = balance.lock().unwrap();
    if *guard < amount {
        return false;
    }
    *guard -= amount;
    true
}

// Lock-free: compare-and-swap in a loop; the closure re-runs on a lost race,
// so it must be pure. A plain number has no ABA problem; a recycled pointer does.
fn withdraw_cas(balance: &AtomicU64, amount: u64) -> bool {
    balance
        .try_update(Ordering::SeqCst, Ordering::SeqCst, |cur| {
            cur.checked_sub(amount)
        })
        .is_ok()
}
Python 3.13+ (GIL or free-threaded)
import threading


class Registry:
    def __init__(self, load):
        self._load = load
        self._sessions = {}
        self._hits = 0
        self._lock = threading.Lock()

    def get_racy(self, sid):
        self._hits += 1  # BUG: load, add, store; no build promises they stay together
        if sid not in self._sessions:  # check ...
            self._sessions[sid] = self._load(sid)  # ... act: two threads both load
        return self._sessions[sid]

    def get(self, sid):
        # FIX: one lock spans the counter and the check-then-act. The free-threaded
        # build locks each dict operation internally; that protects the dict,
        # never a sequence of operations on it.
        with self._lock:
            self._hits += 1
            session = self._sessions.get(sid)
            if session is None:
                session = self._sessions[sid] = self._load(sid)
            return session

Visibility. Without a happens-before edge the compiler and the CPU may reorder or cache, and the JLS gives the canonical surprise: a reader that sees a non-null reference is guaranteed the constructor’s value only for final fields; a plain field may still read as zero. Hence safe publication: make the object immutable (6), or hand the reference over through a synchronized channel — a lock, a volatile or atomic reference, a concurrent collection, sync.Once — and never let this escape from a constructor. Double-checked initialization on a plain flag is broken in Go for the same reason: seeing done == true says nothing about seeing the value it guards. In Go a race on a map, slice, string or interface can corrupt memory, not merely lose an update; in Java the damage stays within the language’s memory safety but the observed behaviour can be arbitrarily strange. Run go test -race in CI — it finds races that executions actually hit, not all of them.

Atomicity. The Rust pane compiles: Send/Sync and the borrow checker exclude data races, and the Rustonomicon states plainly that general race conditions remain the programmer’s problem. The Python pane is the same bug on both builds: the GIL never promised that += or check-then-act are atomic, it only made the interleaving rare, and PEP 703’s per-object locks protect one container operation, not a sequence. The decision rule: name the invariant, then give its whole read-and-write one critical section, one atomic primitive (computeIfAbsent, compare-and-swap), or one owner thread that the others message. This is write skew (90) inside a process; for a check-then-act across processes see 66, and for many callers loading the same key see 71.

The primitives carry their own traps. A CAS loop re-runs its update function after every lost race, so the function must be pure; and CAS compares values, not histories — a pointer freed and reused between the read and the swap passes the comparison (ABA), which is why lock-free structures over recycled memory need hazard pointers, epochs or tagged versions. Locks trade that for deadlock: whenever code holds two locks, acquire them in one global order, and never call unknown code (a callback, a loader, a logger that blocks) while holding one — the computeIfAbsent contract forbids exactly that for its mapping function.

Last technical review: 2026-08-04 · Reference · 112 principles