3.1 Shared Memory Concurrency: Locks, Atomic Operations, and Invariants
An inventory service can return success for every request and still oversell. Even if the stored count stays nonnegative, two successful reservations for the last item violate the business rule. A clean error log does not create a second item.
Concurrency correctness requires that a system maintain its invariants even when threads operate in arbitrary interleaving. Simply using thread-safe containers is not sufficient to guarantee this. In this lesson, we start from this principle and explore locks, atomic operations, and the concept of state ownership.
What You Should Be Able to Answer
- Why atomic read-write safety does not guarantee the safety of a group of operations;
- What problems are solved by mutual exclusion, visibility, and atomicity respectively;
- When to use locks, atomic variables, or thread confinement;
- Why the GIL,
volatile, and thread-safe collections are not universal safeguards.
Seeing Race Conditions Through Interleaved Execution
The following check and decrement work in a single-threaded program:
final class Inventory {
private int stock = 1;
boolean reserveOne() {
if (stock <= 0) {
return false;
}
stock--;
return true;
}
}Two threads might interleave like this:
Thread A: passes the check at 1 and reads 1 for the decrement
Thread B: passes the check at 1 and reads 1 for the decrement
Thread A: writes 0, returns success
Thread B: writes 0, returns successThe final value didn't become negative, but the system ended up selling the same item twice. Race conditions cause results to depend on unconstrained execution order, and this doesn't always manifest as random values.
First, write down the invariants that need to be preserved:
stock >= 0
Number of successful one-item reservations <= Initial stock + Total units restockedThe lock's boundary should cover the entire state transition from check to modification:
final class Inventory {
private int stock = 1;
synchronized boolean reserveOne() {
if (stock <= 0) return false;
stock--;
return true;
}
}Here, "check inventory and deduct" must be treated as a single atomic unit; atomizing stock-- individually would still leave a race condition.
Locks Enforce Two Constraints Simultaneously
Mutual exclusion locks typically provide both:
- Mutual exclusion: Only one execution thread can enter the critical section at a time;
- Memory ordering: Writes performed before the lock is released are visible to subsequent threads that acquire the same lock, in accordance with the language's memory model.
A lock protects shared state through an access protocol. Keep the lock and the data in the same abstraction so callers cannot bypass that protocol:
final class Account {
private final Object lock = new Object();
private long balanceInCents;
Account(long initialBalance) {
if (initialBalance < 0) throw new IllegalArgumentException();
balanceInCents = initialBalance;
}
boolean debit(long amount) {
if (amount <= 0) throw new IllegalArgumentException();
synchronized (lock) {
if (balanceInCents < amount) return false;
balanceInCents -= amount;
return true;
}
}
}Common failure modes include:
- Using different locks to protect the same shared state;
- Performing network requests or disk I/O while holding a lock;
- Multiple locks lacking a fixed acquisition order, leading to circular wait conditions;
- Returning mutable internal objects, allowing callers to modify them outside the lock scope;
- Making the critical section too large, resulting in correct behavior but degraded throughput and increased tail latency.
Atomic Variables Are Suitable for Single-Point State Transitions
Counters, sequence numbers, and simple state machines can use atomic variables:
import java.util.concurrent.atomic.AtomicInteger;
final class Quota {
private final AtomicInteger remaining = new AtomicInteger(100);
boolean tryAcquire() {
while (true) {
int current = remaining.get();
if (current == 0) return false;
if (remaining.compareAndSet(current, current - 1)) {
return true;
}
}
}
}compareAndSet expresses the idea that a new value is only committed if the current value still matches the one observed previously; otherwise, the operation re-reads and retries. It doesn't eliminate competition, it transforms conflicts into explicit retries.
The boundaries of atomic variables are clear: when invariants span multiple variables, using separate atomic types is often insufficient. For example, a transaction that deducts from account A and credits account B cannot be reliably achieved with two independent AtomicLong operations alone. In such cases, either a single lock must cover the combined operation, or the state must be consolidated into a single immutable value that can be atomically replaced, or consistency must be delegated to a database transaction.
Lock-free algorithms also need to address issues like ABA problems, retry starvation, and memory reclamation. Unless performance measurements clearly show that locking is a bottleneck, do not implement complex algorithms simply because "lock-free sounds faster."
Reduce Shared State First
Sharing memory doesn't require sharing all objects. A more robust approach typically follows this priority:
- Share deeply immutable data through an appropriate publication mechanism;
- Local variables and request context are owned by individual tasks;
- Data or ownership is transferred via queues;
- For a small amount of truly collaborative state, use locks or atomic operations.
Rust uses ownership and constraints like Send and Sync to catch certain cross-thread errors at compile time. However, once Arc<Mutex<T>> is used, developers must still design lock granularity, invariants, and failure handling strategies. The type system reduces the space of possible errors, but it cannot replace thoughtful concurrent design.
Don't Mistake Runtime Features for Business Locks
In traditional CPython builds, the Global Interpreter Lock (GIL) limits the number of threads that can execute Python bytecode simultaneously. However, the interpreter switches between threads and releases the GIL during blocking I/O operations. As a result, shared business state still requires locks or queues for synchronization. Starting with Python 3.13, CPython offers an optional free-threaded build, meaning code should no longer assume that the GIL is always present.
Similarly:
- Java's
volatileprimarily ensures visibility and ordering, but does not turn arbitrary "read-modify-write" operations into atomic actions; - Thread-safe collections only guarantee the atomicity of their individually promised operations;
containsfollowed byaddmay still result in race conditions; - A single database update is atomic, but this does not imply that business invariants across multiple records are automatically preserved.
Bring the Task Lifecycle into Correctness
Shared state correctness is only the first layer. Threads or tasks must also answer:
- Who started it, and who waits for it to complete?
- If a request times out, does background work continue modifying state?
- How is a cancellation signal propagated?
- Who observes exceptions, or do they silently disappear?
- How are resources released when a service shuts down?
Unowned background threads can extend a local failure across the process. Lesson 3.3 uses structured concurrency to give child tasks a defined lifetime.
Quick Reference
| Scenario | Preferred Tool | Reason |
|---|---|---|
| Monotonic counting, simple flags | Atomic variables | State transitions are small and independent |
| Maintaining invariants across multiple fields | Locks or transactions | Requires atomic commitment across the whole operation |
| Read-heavy, immutable snapshots | Atomic reference replacement | Read paths are simple and predictable |
| Temporary state within a request | Thread- or task-local scope | Eliminates shared state at the source |
| Producer-consumer patterns | Bounded queue or Channel | Simultaneously expresses handoff and backpressure |
Run a Concurrent Check
Save the synchronized Inventory above as Inventory.java and the Quota example, including its import, as Quota.java. Save the following as InventoryCheck.java. With JDK 17 or later, run javac Inventory.java Quota.java InventoryCheck.java, then java InventoryCheck.
import java.util.concurrent.atomic.AtomicInteger;
class InventoryCheck {
public static void main(String[] args) throws InterruptedException {
Inventory inventory = new Inventory();
Quota quota = new Quota();
AtomicInteger reserved = new AtomicInteger();
AtomicInteger acquired = new AtomicInteger();
Thread[] workers = new Thread[16];
for (int i = 0; i < workers.length; i++) {
workers[i] = new Thread(() -> {
for (int j = 0; j < 20; j++) {
if (inventory.reserveOne()) reserved.incrementAndGet();
if (quota.tryAcquire()) acquired.incrementAndGet();
}
});
workers[i].start();
}
for (Thread worker : workers) worker.join();
System.out.println(reserved.get());
System.out.println(acquired.get());
}
}The outputs must be 1 and 100 after 320 attempts. All workers share the same two objects, and every worker is joined before the totals are read. Using the unsynchronized Inventory may violate the first count, but a particular run may also pass. The synchronized argument comes from the shared monitor and its protected state transition; repeated successful runs alone are not a proof. These examples protect one process and do not provide persistence or cross-instance coordination.
Completion Check
Write at least two invariants for the workflow "reserve seat, deduct balance, record order," and then answer:
- Which states must be updated within the same atomic boundary?
- Where does this boundary reside, process lock, database transaction, or message processor?
- How can we avoid duplicate deductions when timeouts or retries occur?
If the answer is simply "add a lock," the design is incomplete.