Free lesson · Concurrency Interview Mastery
The bug that only happens sometimes
Let me describe the worst kind of bug there is, and then give it a name.
You write some code. It passes every test you throw at it. It works on your machine, every single time you run it. It works in code review. It ships. And then, once a week, at three in the morning, in production, under real load, something goes wrong - a balance is off by a few dollars, a counter is short, a record gets a value that should be impossible. You can't reproduce it. You add logging; the bug stops happening because the logging changed the timing. You remove the logging; it comes back. You start to wonder if you're losing your mind.
You're not. You've met a race condition, and it's the central villain of this entire course. Everything we've built so far - the interleaving model, the non-atomic counter++, the shared heap between threads - has been quietly assembling the pieces of this exact bug. Now we put them together and watch it happen. And this is the lesson where the course's animations do their most important work, because a race condition is the purest example of a thing you cannot understand from prose alone. You have to see it.
What a race condition actually is
Here's the definition, and it's worth memorizing word for word because it's the one interviewers listen for:
A race condition is when the correctness of a program depends on the relative timing or interleaving of multiple threads - when the answer you get depends on who happens to get there first.
Sit with that phrase: who gets there first. That's why it's called a race. The threads are racing, and your program's correctness is betting on the outcome of a race you don't control and can't predict. Sometimes thread A wins and everything's fine. Sometimes thread B wins and data gets corrupted. Same code, same inputs - different winner, different result.
The deep problem is that a correct program should give the same answer regardless of timing. A program with a race condition gives different answers depending on the scheduler's split-second decisions. You've written code whose correctness is at the mercy of luck.
Watch it happen
Let's make it real. The classic example is a bank account, because the stakes are obvious and the bug is brutal. Two threads, one shared account with a balance of
00. Each thread deposits $50. The balance should end at $200. Watch the animation above before you read on - let it play, and keep your eyes on three things: the balance in the middle, and the two threads on either side.Now let me narrate what you just saw, because the whole skill is learning to call the moment things break.
Set up what to watch. Both threads do the same innocent thing: read the current balance, add fifty, write it back. Read, add, write - the read-modify-write pattern from lesson one, the one that is not atomic. The balance starts at 100. If the threads took turns cleanly, A would read 100, write 150; then B would read 150, write 200. Perfect.
Call the moment. But watch the frame where it goes wrong. Thread A reads the balance: 100. And before A writes anything back - right there, in that gap - thread B also reads the balance. B sees 100 too, because A hasn't written yet. Now both threads are holding 100. Both compute 100 + 50 = 150. Thread A writes 150. Then thread B writes 150, painting right over A's update. Two fifty-dollar deposits went in. The balance reads 150. Fifty dollars vanished.
Generalize. That gap - between A's read and A's write, where B slipped in - that is the race. The fifty dollars didn't go anywhere mysterious; it was simply overwritten by a thread working from a stale value. And here's the part that should chill you: nothing crashed. No exception. No error in the logs. The program ran cleanly and silently gave the wrong answer. A bank that ran this code would lose money and have no idea why.
Important: Replay that animation a few times, and each time watch a different thing. Once, watch only thread A and ignore B entirely - A's logic is flawless in isolation. Then watch only B - also flawless. The bug is not in either thread. It lives in the relationship between them, in the interleaving. This is the single hardest thing about concurrency to internalize: code can be perfectly correct on its own and catastrophically wrong when run alongside a copy of itself. You will never see the bug by reading one thread. You only see it by watching the timeline where both threads' steps shuffle together.
The two classic shapes every race takes
Once you've seen one race, you start seeing the pattern everywhere. Almost every race condition is one of two shapes. Learn to name them and you'll spot them on sight.
Shape one: read-modify-write. This is the bank account and the counter. A thread reads a value, computes a new value from it, and writes it back - and another thread interleaves between the read and the write, so the second thread's work is computed from a value that's about to be stale.
// READ-MODIFY-WRITE race
balance = balance + amount; // read balance, add, write back - 3 steps, interruptible
Shape two: check-then-act. A thread checks a condition, then acts on the assumption that the condition is still true - but another thread changes the condition in the gap between the check and the act.
// CHECK-THEN-ACT race
if (account == null) { // CHECK: is it null?
account = new Account(); // ACT: create it
}
// Two threads both pass the check while account is still null,
// then both create a new Account. One silently overwrites the other.
Check-then-act is sneakier because it doesn't look like arithmetic. But it's the same disease: a decision made on information that another thread invalidates before you act on it. The "check if the file exists, then create it" bug, the "check if we have capacity, then add one" bug, the lazy-initialization bug above - all check-then-act races.
Warning: The most dangerous thing about both shapes is that the code reads correctly to a human. "Add the amount to the balance." "If it's null, create it." Your eye approves it instantly, because sequentially it's flawless. The bug is invisible to sequential reading. You have to train yourself to look at shared-state access and ask, "what if another thread runs between these two steps?" That question is the entire skill. Most engineers never develop it, which is exactly why the ones who have are worth more.
The critical section
Now the concept that points us toward the fix. Look back at the bank account. The dangerous part wasn't the whole program - it was specifically the three steps where the shared balance got read and written. That stretch of code has a name:
A critical section is a region of code that accesses shared state and must not be executed by more than one thread at the same time.
The balance update is a critical section. The check-then-act initialization is a critical section. The defining property is that the operation needs to happen as one indivisible unit - no other thread should be allowed to observe or modify the shared state partway through. If two threads are inside the same critical section at once, you have a race.
So the whole problem reduces to a clean statement: identify your critical sections, and ensure that only one thread can be inside each one at a time. That property - at most one thread in the critical section - is called mutual exclusion, and achieving it is what the entire next several modules are about.
flowchart TD
A[Thread A wants in] --> G{Critical Section<br/>guarded by a lock}
B[Thread B wants in] --> G
G -->|only ONE enters| CS[read balance · add · write balance]
CS -->|exits, releases| G
G -.->|the other WAITS<br/>then enters| CS
This flowchart shows the idea of a critical section protected by mutual exclusion. Two threads, A and B, both want to enter the critical section, which contains the read, add, and write steps for the balance. A lock guards the entrance, and only one thread is allowed to enter at a time. While one thread is inside doing the read-modify-write as a single unit, the other thread must wait. When the first thread exits and releases the lock, the waiting thread is allowed in. The point is that mutual exclusion turns the unsafe interleaving into safe, one-at-a-time access.
Why your tests won't save you
I need to drive home why race conditions are uniquely terrifying, because it changes how you have to think.
The interleaving that breaks the bank account is rare. Most of the time, the threads happen to take turns cleanly and you get the right answer. The bad interleaving - B reading in the exact gap between A's read and write - might happen one run in a thousand, or one in a million, depending on timing. So your tests run, get the right answer over and over, and pass. The bug is real and present in the code the entire time; the test just keeps hitting the lucky interleaving.
This is why concurrency bugs are sometimes called Heisenbugs - they change or vanish when you try to observe them. Adding a print statement or a debugger changes the timing, which changes the interleaving, which makes the rare bad case even rarer or moves it somewhere else.
Interview: This leads to the single most important mindset shift for concurrency interviews: you cannot test your way to concurrency correctness - you have to reason your way there. When an interviewer shows you concurrent code, they don't want "I'd write a test." They want you to trace the interleavings by hand and find the one that breaks. The strong move is to literally say: "Let me check for a race - what happens if a second thread runs right here, between these two lines?" and then walk it. That verbal habit - pausing at shared-state access and probing the gap - is what signals you actually understand concurrency rather than just having read about it.
A subtle distinction: data race vs. race condition
There's a finer point here that impresses interviewers when you get it right, because most people use the terms loosely. A data race and a race condition are related but not identical.
A data race is the specific low-level situation where two threads access the same memory location concurrently, at least one of them is writing, and there's no synchronization ordering between them. It's a precise, almost mechanical definition - it's about unsynchronized concurrent memory access. The bank account write is a data race.
A race condition is the broader, higher-level problem: the program's correctness depends on timing. You can have a race condition without a data race. Imagine two threads that each correctly use a thread-safe queue (so no data race - every individual operation is properly synchronized), but the program assumes thread A enqueues before thread B dequeues, and that ordering isn't guaranteed. Each operation is individually safe, yet the program is still wrong because its correctness hinges on an ordering it doesn't enforce. That's a race condition with no data race.
Note: The practical takeaway: fixing all your data races (by synchronizing every shared access) does not automatically fix all your race conditions. You can have perfectly synchronized individual operations and still have a logic-level timing bug in how those operations are ordered relative to each other. Synchronization fixes data races; correct design fixes race conditions. Most race conditions involve a data race, which is why the terms blur - but knowing they're different, and that one can exist without the other, is a genuine depth signal.
The fix, previewed
I won't fully solve this yet - mutual exclusion gets its own modules - but you deserve to know where we're headed so the problem doesn't feel hopeless.
There are two broad families of fixes. The first is locking: put a lock around the critical section so only one thread can be inside at a time. The bank account becomes "acquire the lock, read-modify-write the balance, release the lock," and now B physically cannot read until A has finished writing and released. The race is gone. The cost is that threads sometimes wait for the lock, and locks bring their own hazards (deadlock, contention) that we'll spend real time on.
The second family is atomic operations: use a special hardware-supported instruction that does read-modify-write as one truly indivisible step that nothing can interleave into. No lock, no waiting - just an operation the CPU guarantees can't be split. That's the foundation of lock-free programming, which we cover later.
Tip: Both fixes attack the same root: they make the critical section indivisible, either by excluding other threads (locks) or by making the operation physically atomic (atomic instructions). When you face a race, your first question is always "what's the critical section?" and your second is "what's the cheapest way to make it indivisible?" Everything else is detail.
What you've got now
You can now name the villain precisely: a race condition is correctness that depends on interleaving, and it takes one of two shapes - read-modify-write or check-then-act. You can identify the critical section, the stretch of shared-state access that must be made indivisible. You understand why tests don't catch these and why reasoning must. And you know the fix is mutual exclusion, achieved by locks or by atomic operations.
Most importantly, you've watched a race happen - seen the exact frame where two correct threads produced a wrong answer together. Hold that image. When concurrent code confuses you later, replay it in your head: where's the gap, and what happens if another thread slips into it?
Next, we step back and look at the bigger picture: the different models engineers use to structure concurrent programs. Locks and shared memory are one model - but some of the most robust concurrent systems in the world deliberately avoid shared memory altogether, and understanding why is the next piece of the map.
Quiz: Two threads run
if (instance == null) instance = new Service();to lazily create a single shared Service. What kind of race is this, and what can go wrong? A) Read-modify-write; the counter loses an update B) Check-then-act; both threads can pass the null check before either assigns, so two Services get created and one silently overwrites the other C) No race - the null check makes it safe D) Deadlock; the threads block each otherAnswer: B. This is a classic check-then-act race. Both threads can evaluate
instance == nullas true before either assigns, so both construct aService. The critical section is the check-and-create together; it must be made indivisible (via a lock or atomic) for the lazy initialization to be safe.