All posts
Software Engineering

Concurrency Deep Dive: Race Conditions, Locks, and Lock-Free Patterns

Mental models for concurrency: data races, memory ordering, mutexes versus lock-free structures, and how to reason about correctness.

SKSushan Khadka
February 20, 2026 (6mo ago)5 min read
Concurrency Deep Dive: Race Conditions, Locks, and Lock-Free Patterns — article by Sushan Khadka (namelessnerd)

Two threads, one counter, a result that's wrong only some of the time. Concurrency bugs don't crash loudly — they corrupt quietly under load, then vanish the moment you attach a debugger.

Concurrency Deep Dive: Race Conditions, Locks, and Lock-Free Patterns - diagram by Sushan Khadka (namelessnerd)

The lost update

counter++ looks atomic. It isn't. It's three steps — read, add, write — and two threads can interleave them so both read the same value and one increment evaporates.

// no synchronization — broken on purpose
long counter = 0;
void worker() {
    for (int i = 0; i < 1000000; i++)
        counter++;   // read, +1, write — not atomic
}

Run this on two threads and you will not get 2,000,000. You will get something less, and something different each run. This is the lost-update race, and it's the root of most "the numbers don't add up" bugs. Atomicity is a property of machine operations, not source-line count — one line of C can compile to three instructions, and the gaps between them are where the other thread sneaks in.

Concurrency is not parallelism

A quick distinction that clears up a lot of confusion. Concurrency is about structure — dealing with many things at once, interleaving tasks that may or may not run simultaneously. Parallelism is about execution — actually running things at the same instant on multiple cores. You can have concurrency on a single core (the OS time-slices threads) and the bugs still appear, because the interleaving is what matters, not the simultaneity. This is why concurrency bugs show up even on hardware that can only truly run one thread at a time.

The invisible enemy: memory ordering

Here is the part that ambushes people who think a mutex is the only concern. Modern CPUs and compilers reorder memory operations for speed. A write in your source code may become visible to another thread later than a write that came after it, because the hardware buffered it or the compiler moved it. Each thread sees its own actions in order; other threads may not.

// thread A            // thread B
data = 42;             while (!ready) {}
ready = true;          use(data);   // may see ready=true but data=0!

Without a memory barrier, thread B can observe ready == true while still seeing the stale data == 0, because the two writes in thread A reached B's core out of order. This is why you cannot reason about concurrency by just reading the source top to bottom — you need the language's memory model to tell you what orderings are guaranteed. Atomics and locks don't just prevent lost updates; they insert the barriers that force a consistent order.

Mutex vs CAS

You fix the lost update by forcing order. A mutex serializes access: one thread holds the lock, everyone else waits. Simple, correct, and it costs you contention — threads block, and a thread that sleeps holding the lock stalls everyone behind it.

The lock-free alternative is compare-and-swap (CAS): read the value, compute the new one, and atomically write only if nothing changed underneath you. If it did, loop and retry. No blocking, no held locks — just optimistic retries on a hardware-atomic primitive.

long old, next;
do {
    old  = atomic_load(&counter);
    next = old + 1;
} while (!atomic_compare_exchange_weak(&counter, &old, next));

If another thread bumped the counter between the load and the swap, the exchange fails, old is refreshed, and you try again. Under contention this spins; under low contention it is nearly free.

The ABA gotcha

CAS checks equality, not history — a value can go A to B and back to A, and CAS thinks nothing happened when in fact a lot did. Imagine a lock-free stack where a node is popped, freed, and a new node reusing the same address is pushed. Your CAS sees the same pointer and proceeds, corrupting the structure. The standard fix is a version tag (or "stamp") bumped on every write, so A-then-A reads as two distinct states — the pointer may match but the version won't.

Deadlock: the other failure mode

Locks trade races for a new hazard. Two threads that each hold a lock the other needs will wait forever — a deadlock. The classic recipe is inconsistent lock ordering: thread A grabs lock 1 then 2, thread B grabs lock 2 then 1, and they meet in the middle. The discipline that prevents it is boring and absolute: always acquire locks in a globally consistent order, keep critical sections short, and avoid calling unknown code while holding a lock. When a fixed order is impossible, use a timeout-and-retry (try_lock) so a stuck thread backs off instead of hanging.

Lock-free is not always worth it

Lock-free is harder to write, harder to prove, and only faster under real contention. Under light load a plain mutex is often quicker and always more readable, and modern mutexes are highly optimized — an uncontended lock is nearly free. Reach for lock-free when profiling shows lock contention is the actual bottleneck — not because it sounds impressive in a design doc.

Better still, avoid shared mutable state where you can. Immutable data needs no synchronization. Message passing (channels, actors) confines state to one owner and sidesteps the whole problem. The fastest lock is the one you never take, and the safest race is the one that is structurally impossible.

The takeaway

Read more posts