Concurrent code fails differently from everything else you write. It passes review, passes tests, runs for a year, then returns a wrong answer under load — the defect was never a bad line, it was a missing ordering rule in the design. This guide is about that shape: what a memory model promises, what a lock buys that an atomic does not, why deadlock always looks the same, and why the tools that catch these defects only catch the ones you ran. Concurrency semantics are language-specific, so every claim names its model.
Each chapter opens with the short version. Tap one to read the detail.
Concurrency is a structure; parallelism is a resource
~2 min
Threads exist so a program can use several cores, but nothing in a concurrency toolkit is about speed: mutexes, condition variables, semaphores and latches all exist to order and exclude. Extra cores do not create the hard problems.
Threads let a program execute across several processor cores. That is the parallelism half, and the easy half: a resource question answered by hardware and a scheduler.
Now look at what a standard concurrency library contains and the other half appears — mutual exclusion, so threads cannot reach a shared resource at the same moment; condition variables, always paired with a mutex; semaphores, capping how many callers touch a resource at once; latches and barriers; cooperative cancellation.
Suppose ten workers must all finish before a report runs. A latch fits, because it opens once at zero and can never be reset. If instead they must regroup at the end of every round, that is a barrier, which re-arms. Neither makes the work faster; both decide what may happen before what.
The beginner's mistake is reading concurrency as a performance technique that occasionally misbehaves, and reasoning about it in terms of speed and luck. It is a correctness discipline that also lets you use more cores — which is why "we only run on one core" was never a defense.
Rule: before adding a thread, say whether you want more work done at once or less waiting; the answer picks different tools.
A data race is a missing rule, not bad luck
~2 min
Go and Java define a data race identically: two accesses to one location, at least one a write, with nothing ordering them. Neither mentions clocks, cores or probability — a race is a design defect, not an accident you test away.
Go defines a data race as a write to a memory location happening concurrently with another read or write to it, unless every access is atomic. Java says two conflicting accesses — at least one a write — race when no happens-before relationship orders them. Notice what is absent from both: timing, core count, rarity.
The unit that races is a memory location, not a variable or object: in C++, one scalar object that is not a bit-field, or the longest run of adjacent bit-fields. A struct is not the unit; its fields are.
Keep that separate from a race condition, a wrong answer produced by ordering: checking a map for a key and then putting a value races even when both calls are thread-safe, which is why concurrent maps ship a single putIfAbsent.
Nor is the damage merely a stale value: a Go read wider than one machine word may observe an inconsistent multiword value, which on a slice or interface can corrupt memory. And you cannot test your way out — a race detector instruments real execution, so it reports races only on paths that ran.
Rule: fix a race by naming the rule that orders the two accesses — if a sleep makes it disappear, you changed the odds, not the ordering.
Atomicity, visibility and ordering are three different promises
~2 min
Java's volatile buys visibility and ordering; it does not make a read-then-write indivisible. The identically spelled C++ keyword buys neither — it is for memory-mapped hardware, not threads. Name the guarantee you need before grabbing a keyword.
The Java specification settles visibility with a worked example: a loop spinning on a plain boolean field, sleeping a second each pass, may never finish.
while (!stopped) // plain, non-volatile field
Thread.sleep(1000); // permitted to loop forever
Neither Thread.sleep nor Thread.yield carries synchronization semantics, so the compiler may read the field once into a register and reuse it. No unlucky timing is involved; no second thread even has to run. Marking the field volatile fixes it: a write to a volatile field happens-before every later read of that field.
But visibility is not atomicity. That edge orders a write against a read; it does not fuse a read and a following write into one indivisible step — a separate property, the operation C++ calls a read modify write and pointedly describes as one atomic operation, not several. So count++ on a volatile field is still two operations and still loses updates.
The divergence that catches people moving between languages: C++ volatile accesses are not atomic, establish no inter-thread synchronization, and do not order surrounding memory.
Rule: name which of atomicity, visibility and ordering you need before reaching for a keyword — most of these bugs are buying one and assuming all three.
Happens-before, and the deal it offers you
~2 min
A memory model does not say what will happen; it says which writes a read may observe. Go and Java offer one bargain: a race-free program can be reasoned about as a plain interleaving. Race, and the model owes you nothing.
Happens-before is a partial order built from two ingredients, closed transitively: the order of one thread's own operations, and the edges created by synchronization. A read must observe a write that happens-before it and that no intervening write has replaced; anything the order does not relate is unconstrained.
Both specifications state the payoff outright: a program with no data races executes with sequential consistency — the behavior you would get if every thread were multiplexed onto a single processor. That is what buys the right to reason by interleaving, the only way people reason about this at all.
Go's own edges show the asymmetry. The go statement is ordered before the new goroutine's first instruction, so anything set before launching it is visible inside. Its exit is ordered with nothing at all: read a variable it wrote without joining through a channel or a wait group and the model promises nothing.
The strongest thing you can buy back is a single total order: C++'s sequentially consistent atomics, which are the default there, and the only kind Go offers.
The misconception to drop is timeliness. Neither model promises a write arrives soon — with no edge, a read may keep returning an older value indefinitely.
Rule: for every cross-thread read, name the synchronizing operation that ordered the write ahead of it; no nameable operation means no edge and no promise.
Where edges come from: release, acquire, and the object they belong to
~2 min
Every cross-thread edge is a release paired with an acquire that actually reads what the release wrote, and it belongs to one object. A different mutex, channel or volatile field is a different edge — so "but it is locked" answers nothing.
A release publishes everything the thread wrote before it; an acquire that reads the released value receives all of it. The clause people skip is the second: it holds only if the acquiring load actually returns what the release stored, or a later value in the same release sequence. A release nobody reads orders nothing — and taking a mutex counts as an acquire, releasing it as a release.
The edge belongs to one object, and each language says which. In Java, unlocking a monitor happens-before every later lock of that same monitor, and a volatile write happens-before every later read of that same field; a successful join brackets the joined thread's work the same way. In Go, a send is ordered before the matching receive completes; on an unbuffered channel the receive is also ordered before the send completes.
var a string
c := make(chan int) // unbuffered
go func() { a = "ready"; <-c }()
c <- 0 // returns only after that receive
Give that channel a buffer and the memory model's own version loses its guarantee.
Relaxed atomics are the other trap: explicitly not synchronization operations, promising only atomicity and a consistent order of writes to one variable. And on strongly ordered hardware such as x86, release and acquire need no extra instructions — so a wrong ordering argument passes on your laptop and fails on weakly ordered processors.
Rule: point at the exact object carrying the edge, and confirm the reader observes what the writer published.
One lock per invariant, not one per field
~2 min
A lock supplies exclusion and an ordering edge, but only against threads taking that same lock. If one rule spans two fields, one lock must span both. Semaphores, latches and barriers bound or rendezvous rather than protect.
A lock does two jobs. It excludes, so there is one holder at a time. And it publishes: acquiring is an acquire and releasing is a release, so everything the previous holder did becomes visible to the next.
Both jobs are scoped to that one lock object. Java states the rule for a specific monitor — unlocking m orders before later locks of m — and says nothing about any other monitor. So when an invariant relates a balance to a transaction log, guarding each field with its own lock guards neither: two threads sit inside the object at once, each holding a lock the other never asked for.
Beyond the built-in monitor sits a lock framework trading syntax for flexibility: locking that is not block-structured at all, plus a read-write pair admitting many readers or one writer.
The coordination primitives get confused with locks constantly. A counting semaphore caps how many callers may use a resource, but it bounds concurrency rather than defining a critical section over your data. A latch opens once and cannot be reset; a barrier re-arms.
Rule: draw the invariant first, then pick a lock covering all of it; locking per field means you chose the lock before you knew the rule.
Deadlock is a cycle, and cycles are preventable
~2 min
Deadlock is not bad luck: it is a cycle of threads each holding one lock and waiting for the next. Two remedies exist, both boring — take every multi-lock set in one stable order, or take the whole set atomically.
The canonical shape is two threads and two locks. Take a function that pairs two records and needs both of their mutexes: called as pair(a, b) on one thread and pair(b, a) on another, each thread takes its first lock and waits forever for the one the other holds. That is the entire mechanism — a cycle in who waits for whom.
Because it is a cycle rather than a coincidence, breaking it is structural. The C++ standard library ships a multi-lock function for exactly this case, documented as locking all the given lockable objects using a deadlock avoidance algorithm — you hand it the whole set instead of taking them one at a time in caller order. A scoped guard wrapper is preferred, so an early return or a throw cannot strand a lock.
The other remedy costs nothing at run time: pick a key every caller can compute, such as an account id, and always acquire in that order.
What never works is anything that only changes timing: a retry loop or a well-placed sleep makes the cycle rarer, never impossible.
Rule: never take a second lock in an order the caller chose; impose a global order or acquire the set atomically, and never call code you do not control while holding one.
Waiting for a condition without losing the wakeup
~2 min
A condition variable is not a message you send and forget. The waiter must re-test its predicate in a loop, because a wait may return with no notification at all — and the writer must change the state under the mutex, even when it is atomic.
The waiter takes the mutex and checks the predicate first, in case it is already true and already notified. Then it waits: the wait atomically releases the mutex and suspends, and atomically re-acquires it before returning. That indivisibility is the point — without it a notification could land between your check and your sleep and be lost.
On waking, check again and keep waiting if the predicate is still false. A wait may return spuriously, with no notification behind it; that is specified behavior, not a defective platform. The predicate-taking overload performs all three steps for you:
std::unique_lock lk(m);
cv.wait(lk, []{ return ready; }); // re-tests for you
An if in place of that loop is a bug even on the days it passes.
The writer's side carries the rule people most often get wrong. Acquire the mutex, modify the shared variable, then notify — the notification may be sent after releasing the lock, which avoids waking a thread only for it to block again. The mutex is required even when the shared variable is itself atomic: holding it is what correctly publishes the change to a waiting thread.
Rule: mutate the condition under the mutex and re-test it in a loop after every wake — treat a bare if around a wait as a defect on sight.
Atomics, compare and swap, and the cost of a shared cache line
~2 min
An atomic read modify write is one operation rather than a read plus a write, and it must see the latest value — which is what makes a retry loop work. What atomics never make cheap is sharing, because coherence moves whole cache lines.
An atomic object is one where a concurrent read and write is well defined instead of a data race. Of its three operation kinds — load, store, and read modify write — the third carries the power: one atomic operation, not several, required to read the latest write in that variable's modification order.
Compare and swap (CAS) is the read modify write that makes non-blocking algorithms possible. It compares the atomic against the value you expected; if they match it stores your new value, and if not it loads the current value back into your expected variable — which is what makes it a loop: read, compute, swap, retry with the value you now hold.
while (!top.compare_exchange_weak(node->next, node))
; // a failed attempt refreshed node->next for us
The weak form may fail even when the values are equal, which is why it belongs in a loop.
The hardware bill arrives at a granularity that is not your variables. Coherence works in cache lines, typically 64 bytes on x86-64, which is why the standard exposes a constant for the minimum offset needed to avoid false sharing. Two counters that no thread accesses together still bounce a line between cores if they land in the same one.
Rule: reach for an atomic when one location carries the whole decision, and check where hot counters land: the cache line, not the variable, is what contends.
Thread-safe pieces, unsafe wholes
~2 min
Making every method atomic makes every sequence of methods racy. That is why concurrent maps ship compound operations, why their size is documented as an estimate, and why their iterators never throw — they show state at or since creation, not a snapshot.
A concurrent hash map teaches this well. Retrievals do not lock and generally do not block, and they reflect the most recently completed update as of when they began. There is a real edge: an update for a key happens-before any retrieval of that key reporting the updated value.
Then come the caveats, where assumptions break. Iterators are weakly consistent: they never throw a concurrent-modification exception, and they reflect the table's state at some point at or since the iterator was created. Aggregate methods such as size reflect transient states the docs call adequate for monitoring and explicitly not for program control — so a loop that reads size and acts on it is a race condition built out of a thread-safe class.
The compound operations exist for that reason: putIfAbsent, compute and merge perform the entire invocation atomically, which the two-call version you would otherwise write cannot.
Publication is the other half. A Java object's final fields are guaranteed correct only to a thread that sees the reference after construction finished, so leaking this from a constructor lets another thread read a final field as its default.
Rule: decide what has to be atomic, the call or the sequence, and never let a reference escape before its constructor returns.
Running tasks, and actually stopping them
~2 min
A pool decouples tasks from threads, and its two sharpest edges share a shape: a failure nobody reads, and a shutdown nobody waits for. An exception inside a task is stored in its future and stays silent until someone calls get.
Submitting work to an executor hands you a future, and the memory rules are clean: what you did before submitting is ordered before the task runs, and the task's actions before whatever follows retrieving its result.
The trap lives in that last clause. An exception thrown inside a submitted task is not printed, not logged and not rethrown — it is wrapped in the future and surfaces only when something calls get. Fire off tasks, never read their futures, and you have designed a program that fails silently.
Shutdown needs the same discipline. An orderly shutdown stops accepting new work, lets already-submitted tasks run, and returns immediately — waiting for termination is a separate call. The forceful variant tries to stop running tasks, typically by interrupting them; the docs are blunt that tasks which do not respond to interrupts may never terminate.
That is cancellation in general: a request, never a kill. Go's context carries a deadline and a cancellation signal, and canceling one cancels every context derived from it. Failing to call the returned cancel function leaks that child and its children until the parent is canceled.
Rule: for every task name who reads its result, for every pool who waits for termination, and for every cancellable operation where it checks.
Suspending instead of blocking, passing instead of sharing
~2 min
A coroutine suspends by returning to its caller rather than holding a thread, which removes blocking but not races: shared state can change across every suspension point. Channels avoid shared state entirely, at the price of a new failure set.
A coroutine is a function that can suspend and be resumed later. The common implementations are stackless: suspending returns to the caller and the data needed to resume is stored away from the stack, so one thread can carry thousands of in-flight operations.
Every suspension point is a place where other work runs, so an invariant you held across an await may not survive it; the concurrency is real even where the parallelism is not. Lifetimes get sharp too: a coroutine copies by-value parameters into its own state, but by-reference parameters stay references, so resuming after the referent has died is a dangling reference.
Channels take the other route — hand the value over instead of sharing it. A channel whose capacity is zero or absent is unbuffered: communication succeeds only when a sender and receiver are both ready, making it a synchronization point as much as a transfer.
Its failure modes are abrupt: sending on a closed channel causes a run-time panic, and so does closing a channel twice or closing a nil one. A nil channel is never ready, so a send or receive on one blocks forever.
Rule: decide whether a value is shared or handed over and keep it that way — and let exactly one sender own the close, since closing twice panics.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.