Skip to content
Chapter 4 · Linear Structures

Concurrency for Data Structures

Locks, atomics, and what "thread-safe" costs you — with the data race and its fix animated step by step.

Every data structure in this book so far has assumed one thread. That assumption is a luxury. The moment two threads touch the same structure, code that was obviously correct becomes subtly, intermittently wrong — and the bug only shows up on the customer’s 64-core machine, at 3 a.m., once every ten million requests.

There is one skill underneath all of concurrency, and it is worth stating before anything else: a concurrent operation is correct only if every possible interleaving of it with other threads preserves the structure’s invariants. Locks, atomics, memory ordering, lock-free tricks — all of it exists to make that one sentence true. We stay in shared-memory territory: multiple threads, one address space, locks as the default, lock-free as a preview.

Why one thread was a luxury

In single-threaded code you reason in program order: each statement finishes before the next starts. counter++ is one thing that happens. Under concurrency that intuition is a lie, because counter++ is really three machine steps — load, add, store — and the scheduler can suspend a thread between any two of them. Watch two threads each run counter++ once, interleaved so that one increment is silently lost:

Nothing is broken in either thread’s code. The bug lives in the gap between their steps: Thread B read the counter before Thread A stored its result, so B computed from a stale value and its write clobbered A’s. This is a lost update, the most common shape of a data race — two threads touch the same location, at least one writes, and nothing synchronizes them. In C++ a data race is not “you get a stale value”; it is undefined behavior, and the standard makes no promises at all.

The same gap corrupts pointer surgery. Between these two lines the list is in a state its invariant forbids — new_node points into the chain, but nothing points at new_node yet:

void insertAfter(Node* prev, Node* new_node) {
    new_node->next = prev->next;   // step 1
    prev->next = new_node;         // step 2  ← a reader between 1 and 2 sees a half-linked list
}

Making incorrect code faster with threads only lets it be wrong in parallel. Correctness first.

What a lock actually promises

The fix is atomicity: make the whole load-modify-store sequence indivisible, so no other thread can observe it half-done. A critical section is the region that touches shared state; mutual exclusion is the guarantee that only one thread is inside it at a time; a mutex is the tool that provides it. In C++ you rarely lock by hand — an RAII guard ties the lock to a scope, so it is released even if the body throws:

std::mutex mtx;
long counter = 0;

void safeIncrement() {
    std::lock_guard<std::mutex> lock(mtx);  // acquire
    ++counter;                              // critical section: one thread only
}                                           // release on scope exit

Run the same two threads again, now each taking the lock before it touches the counter. Whoever loses the race blocks until the winner is completely done, so no interleaving can slip between load and store:

A lock gives you two things. The obvious one is mutual exclusion. The one people forget is visibility via happens-before: every write a thread makes before it unlocks is guaranteed visible to the next thread that locks the same mutex. That second guarantee is what makes the data you protected actually readable by the next thread — and it is why “just mark it volatile” is not a substitute for a lock. What a lock does not do is make your logic correct: it won’t save you from a check-then-act race across two separate acquisitions, and it can’t tell you which state needs protecting. That judgment is yours.

Visibility is a separate problem from atomicity

Atomicity is about indivisibility; visibility is about whether a write ever reaches another thread’s eyes. They are independent, and this snippet — correct-looking, and broken — shows why:

bool ready = false;
int  data  = 0;

void producer() { data = 42; ready = true; }       // Thread 1
void consumer() { while (!ready) {} use(data); }   // Thread 2 — may read data == 0

Every write here is a single-word store, so each is atomic. Yet the consumer can spin out of the loop and still read data == 0, because nothing establishes happens-before between the threads. The compiler may reorder the two independent stores, the CPU may commit them out of order, and the consumer’s core may hold a stale cached data. “Works on my machine” means your machine’s memory model happened to hide the bug; a weaker-ordered CPU (ARM, POWER) will not. The fix is any real synchronization — a mutex around both, or making ready a std::atomic<bool>.

A hardware footnote — false sharing. Cache coherence operates on whole cache lines (~64 bytes), not individual variables. If two threads hammer two different atomics that happen to sit in the same line, the coherence protocol bounces that line between cores on every write, and your “independent” counters run an order of magnitude slower than they should. No logical conflict, pure hardware contention — this is why giving each thread its own counter sometimes buys no speedup at all. Pad to a line (alignas(64)) and the contention vanishes.

Deadlock

A deadlock is not a slow program; it is a stopped one. Threads block forever, each holding a resource the next one needs. It is a correctness bug, and because it depends on rare interleavings, it sails through testing and surfaces in production. The canonical trigger is two threads locking the same pair in opposite orders:

// Thread A: lock(from) then lock(to)
// Thread B: lock(to)   then lock(from)
// A holds from and waits for to; B holds to and waits for from. Frozen.

Deadlock needs four conditions at once — mutual exclusion, hold-and-wait, no preemption, and circular wait — so breaking any one prevents it. In practice you break circular wait with a consistent lock order. For a fixed pair, std::scoped_lock acquires both with a deadlock-free algorithm; for any two of N objects, impose a total order (a stable proxy like the address works) and always lock in it:

void transfer(Account& from, Account& to, long amount) {
    std::scoped_lock lock(from.mtx, to.mtx);   // both at once, deadlock-free (C++17)
    from.balance -= amount;
    to.balance   += amount;
}

Two habits prevent most deadlocks regardless of strategy: hold locks for the shortest possible scope, and never call unknown code — a callback, a virtual method — while holding a lock, because you don’t know what it will try to lock.

Lock granularity

Once locking is correct, the question is how much one lock protects. Coarse-grained locking puts one mutex around the whole structure: trivially correct, and trivially serial — every operation waits for every other, so throughput flatlines no matter how many cores you add. Fine-grained locking splits the structure so independent parts have independent locks — one lock per hash bucket, say, so operations on different buckets proceed in parallel:

class FineGrainedHashTable {
    std::vector<Bucket>     buckets;
    std::vector<std::mutex> bucket_locks;    // one lock per bucket
public:
    void insert(int key, int value) {
        size_t b = hash(key) % buckets.size();
        std::lock_guard<std::mutex> lock(bucket_locks[b]);
        buckets[b].insert(key, value);
    }
};

You buy scalability and pay in complexity: more memory for locks, real deadlock risk once an operation needs two of them, and structure-wide operations like resizing that must suddenly coordinate every lock. The guidance is unglamorous and correct: start coarse, measure, and refine to fine-grained only where a profiler shows contention. Fine-grained locking you didn’t need is just deadlock risk you didn’t need.

A preview of lock-free

Lock-free algorithms drop locks entirely, building operations directly on atomic hardware instructions so no thread can block another by holding a lock. The foundation is compare-and-swap (CAS): atomically, “if this location still holds the value I expect, replace it; otherwise tell me it changed.” CAS turns a read-modify-write into a retry loop — read, compute, CAS; if someone got there first, loop with the fresh value.

The one lock-free pattern you can and should use freely is the atomic counter — no pointer to reclaim, no way to go wrong:

std::atomic<long> count{0};
count.fetch_add(1, std::memory_order_relaxed);   // atomic, wait-free, cheap

A lock-free stack looks just as short — point a new node at the head, then CAS the head — but the delete on pop is where it stops being easy. The ABA problem: between your read of the head and your CAS, another thread pops A, pops B, and pushes A back; your CAS sees A and succeeds, but the list underneath has changed. And memory reclamation: another thread may still be dereferencing the node you just freed. Real lock-free structures solve these with hazard pointers or epoch-based reclamation — each a small research project. The takeaway is blunt: do not ship a hand-rolled lock-free structure. Use Intel TBB, Folly, or Boost.Lockfree; this section exists so you can reason about them, not reinvent them.

Where this actually lives

Interview checklist

Takeaways

  • Concurrency adds interleavings, not just parallelism. An operation is correct only if every interleaving preserves the structure’s invariants — so the first question about any shared operation is “which invariant does it touch, and can any interleaving catch it broken?”
  • A data race is unsynchronized shared access with a write; the usual shape is a lost update from a non-atomic read-modify-store. Make the whole sequence atomic — a mutex, or std::atomic for a single word.
  • A lock buys mutual exclusion and visibility. Visibility is a separate problem from atomicity — unsynchronized writes may never become visible, or become visible out of order, on real hardware.
  • Deadlock is a correctness bug; break circular wait with a consistent lock order. Lock granularity is a performance knob: start coarse, refine only at measured contention.
  • Prefer proven libraries — std::mutex, std::shared_mutex, std::atomic, concurrent containers — over hand-rolled synchronization, and never ship a hand-rolled lock-free structure.

Next, the memory hierarchy — where these threads, locks, and cache lines meet the physical machine, and the 100× that Big-O cannot see becomes something you can measure.