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.
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);
}
}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
}// 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()
}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 sessionVisibility. 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.