AlgoMaster Logo

Race Conditions and Critical Sections

High Priority16 min readUpdated September 30, 2026
Listen to this chapter
Unlock Audio

Threads in the same process share memory, so two threads can read and change the same variable at the same time. Without coordination, this produces wrong results with no crash and no error. This class of bug is called a race condition.

This chapter covers what a race condition is, the common patterns that cause them, what a critical section is, and how to protect shared state in real code.

The Shared Counter Problem

Let's start with a simple example. You have a shared counter that starts at zero. You create two threads, and each thread increments the counter one million times. When both threads finish, you print the counter.

You would expect 2,000,000. But if you run this without any synchronization, you will often get a smaller number, something like 1,300,000. If you run it again, you get a different number. On a machine with more cores, the difference can be even larger.

The program does not crash or throw an exception. It simply returns a wrong result, and the result changes from one run to the next.

Why count++ Is Not One Step

So what went wrong? The code that updates the counter is a single line: count++.

count++ looks like one operation, but the CPU actually performs it in three steps:

  1. Read: Load the current value of count from memory into a register
  2. Modify: Add one to that value
  3. Write: Store the new value back to memory

With one thread, this always gives the correct result. With two threads, the operating system can pause a thread between any of these steps, or the two threads can run these steps at the same time on different cores. When that happens, one thread can overwrite the other thread's update.

Walking Through a Lost Update

Let's go through one such case step by step. The counter is at 5.

Two increments ran, but count only moved from 5 to 6Read count (5)Read count (5)5 + 1 = 6Write 65 + 1 = 6Write 6Thread Acount = 5Thread BThread Acount = 5Thread B
7 / 7
algomaster.io

Thread A reads count and gets 5. Before it writes anything back, thread B also reads count, and it also gets 5. Thread A adds one and writes 6. Thread B adds one and also writes 6.

Two increments ran, but the counter only moved from 5 to 6. This is called a lost update.

Across two million increments, this can happen many times. How many updates are lost depends on how the threads were scheduled in that particular run, which is why the final count is different every time.

What is a Race Condition?

The counter bug is a race condition. Here is the precise definition.

A race condition needs two things:

  1. Shared state: Data that more than one thread can access.
  2. Mutation: At least one thread modifies that data.

If the data is not shared, or if it is shared but never changes after it is created, a race condition cannot happen. This is why the fixes later in this chapter either control access to shared state or remove one of these two ingredients.

Why Race Conditions Are Hard to Find

Race conditions are hard to find because they are non-deterministic. The wrong ordering might happen only once in a million runs. Your unit tests can pass, and the code can work fine on your laptop. Then it fails in production, under real load, on a machine with many cores.

Debugging them is also difficult. Adding a print statement or stepping through the code in a debugger changes the timing between threads, and the bug may stop showing up.

So testing alone cannot prove that concurrent code is correct. You also need to reason about the code. The next section covers the patterns that most commonly lead to race conditions, so you know what to look for.

Common Race Condition Patterns

Pattern 1: Read-Modify-Write

The first pattern is the one we just saw: read-modify-write. A thread reads a value, computes a new value from it, and writes the result back.

Take a bank balance of $100. Two deposits of $50 run at the same time.

Scroll
StepThread 1 (deposit $50)Thread 2 (deposit $50)Balance
1Read balance: 100100
2Read balance: 100100
3Compute 100 + 50 = 150100
4Compute 100 + 50 = 150100
5Write 150150
6Write 150150

Both threads read 100, both compute 150, and both write 150. The account should hold $200, but it holds $150.

Whenever the new value depends on the old value, and two threads can update it at the same time, you have a possible race condition.

Pattern 2: Check-Then-Act

The second pattern is check-then-act. A thread checks a condition, and then takes an action based on the result of that check. The problem is that the condition can change between the check and the action.

Consider a ticket booking system with one seat left.

Two people now hold the same seatSeat available? (yes, 1 left)Seat available? (yes, 1 left)Book seat (0 left)Book seat (-1 left)User A requestseats left = 1User B requestUser A requestseats left = 1User B request
5 / 5
algomaster.io

User A's request checks if a seat is available, and the answer is yes. Before it books the seat, user B's request runs the same check, and it also sees one seat available. Both requests then book the seat. Now two people hold the same seat, and the seat count may even become -1.

The same pattern shows up in lazy initialization. Two threads both check if an object is null, both see null, and both create it. You end up with two instances of something that should exist only once, like a connection pool or a singleton.

Two threads call getInstance() simultaneously. Both see instance == null, both create a new object. Now you have two instances of what should be a singleton, and one is orphaned.

Pattern 3: Combining Thread-Safe Operations

The third pattern appears when you combine operations that are individually thread-safe.

Suppose you use a concurrent hash map to count word frequencies. A call to get is thread-safe, and a call to put is thread-safe. So you might read the current count, add one, and write it back. But the sequence is not thread-safe.

Two threads can both read a count of 3, both increment it to 4, and both write back 4. One update is lost. This is another example of the read-modify-write race condition.

The same problem can happen when one operation updates multiple values. For example, transferring money between two accounts requires subtracting from one account and adding to another. Even if each update is thread-safe on its own, another thread could read the balances between those two steps and see an inconsistent state, with the money missing from both accounts.

Race Condition vs Data Race

Before we look at solutions, there is a related term you should know: a data race. It is often confused with a race condition, but the two are different.

A data race is a low-level memory problem. It happens when two threads access the same memory location concurrently, at least one of them writes, and there is no synchronization between them. In C++, a data race is undefined behavior.

A race condition is a logic problem. The program produces a wrong result because of the order in which threads ran.

Scroll
Data raceRace condition
What it isUnsynchronized concurrent access to one memory location, with at least one writeA wrong result caused by the order in which threads ran
LevelMemory accessProgram logic
Found by toolsOften, e.g. ThreadSanitizer or Go's -race flagRarely, you have to reason about orderings
ExampleUnsynchronized count++get then put on a concurrent map

You can have one without the other. The concurrent hash map example has no data race, because every individual access is synchronized. But it still has a race condition, because the get and the put from two threads can interleave. The unsynchronized count++ from the start of the chapter has both.

So removing every data race does not, by itself, make your program correct.

What is a Critical Section?

So how do you fix a race condition? To answer that, we need the idea of a critical section.

In the counter example, the critical section is the read, the add, and the write. In the booking example, it is the seat availability check together with the booking.

The goal is to make the critical section behave as if it were a single atomic step. Other threads should see the state either before the section ran or after it finished, never in between.

To do that, you make sure only one thread can be inside the critical section at any time. Other threads that want to enter have to wait until it is free.

Requirements for a Correct Solution

In operating systems, there are three requirements for a correct solution to the critical section problem:

  1. Mutual exclusion: If one thread is executing inside the critical section, no other thread can be inside it at the same time. This is the property that prevents the race condition.
  2. Progress: If no thread is inside the critical section and some threads want to enter, one of them must be allowed in. The system should not get stuck when the critical section is free.
  3. Bounded waiting: There is a limit on how many times other threads can enter ahead of a waiting thread, so no thread waits forever.

Protecting the Critical Section

In practice, there are three common ways to handle a critical section.

Option 1: Locks

The first is using a lock, also called a mutex. A thread acquires the lock before entering the critical section and releases it when it is done. If another thread already holds the lock, the new thread has to wait.

In Java, you can use a synchronized block or a ReentrantLock. With ReentrantLock, make sure the lock is always released, even if an exception occurs, usually by releasing it inside a finally block.

Option 2: Atomic Operations

The second option is using atomic operations. For a simple case like a counter, you can use an atomic integer and call increment-and-get. The increment happens atomically, so two threads cannot interfere with each other during the update.

Under the hood, this is typically implemented using hardware instructions such as compare-and-swap.

Atomics work well for a single variable. When several values must change together, like the two balances in a transfer, use a lock instead.

Option 3: Avoid Sharing Mutable Data

The third option is to avoid sharing mutable data in the first place. Remember that a race condition needs both shared state and a thread that modifies it. Remove either one and the race is gone.

  • Thread-local data: Each thread keeps its own local data and the results are combined later.
  • Immutable objects: Objects that cannot be changed after they are created are safe to share without locks.
  • Message passing: Pass data between threads through a queue or channel instead of having multiple threads modify the same memory.

Here is the counter from the start of the chapter, rewritten so each thread counts into its own local variable:

No lock is needed, and this version is usually faster too, because the threads never compete for the same memory.

How Much Code to Put Inside the Lock

Once you use a lock, you need to decide how much code goes inside the critical section.

If the critical section is too large, performance suffers. For example, if you hold a lock during a network call, every other thread that needs the lock has to wait for that call to finish.

If the critical section is too small, the race condition can come back. A common mistake is to lock the check, release the lock, and then lock the action separately. Another thread can still run between the check and the action, so this is the same check-then-act problem as before.

The critical section should contain everything that must happen together to keep the data correct, and slow work like I/O should stay outside of it when possible.

Visibility

A critical section also solves another problem: visibility.

CPUs cache values, and compilers may reorder instructions for performance. Because of this, when one thread updates a value, another thread is not always guaranteed to see that update unless the threads synchronize.

For example, suppose one thread keeps running while a running flag is true, and another thread sets running to false. Without proper synchronization, the first thread may keep seeing the old value and continue looping.

Locks solve this problem as well. When a thread releases a lock, the changes it made become visible to the next thread that acquires the same lock.

In Java, you can also mark a variable as volatile. This ensures that when one thread updates the variable, other threads see the latest value.

Finding Race Conditions in an Interview

When you are given a piece of concurrent code in an interview, you can use a simple process to find race conditions.

  1. List the shared mutable state. Which variables, fields, or collections can more than one thread access, and which of them change?
  2. Look at where that state is read or written, and check for the three patterns: read-modify-write, check-then-act, and multi-step updates.
  3. For each one, ask what happens if another thread runs between these two lines. Walk through a specific ordering, like we did with the counter going from 5 to 6.
  4. Choose the fix: a lock, an atomic variable, an atomic method on a concurrent collection, or removing the shared state.

When you explain your answer, describe the specific ordering of threads that causes the bug. For example: "Thread A reads 5, thread B reads 5, both write 6, so one increment is lost." This makes your reasoning clear and easy to follow.

Summary

  • A race condition happens when a program's result depends on how threads interleave. It requires shared state and at least one thread that writes to it.
  • count++ is not one step. It is a read, an add, and a write, and another thread can run between any of them.
  • Most race conditions come from three patterns: read-modify-write, check-then-act, and combining thread-safe operations into a sequence that is not thread-safe.
  • A data race is unsynchronized access to the same memory. A race condition is a logic bug. Removing data races alone does not make a program correct.
  • A critical section is code that must run as one unit. You can protect it with a lock, replace it with an atomic operation, or remove the shared state.
  • Keep everything that must happen together inside the critical section, and keep slow work like I/O outside it.
  • Synchronization also provides visibility. volatile gives visibility but not atomicity.

Quiz

Quiz: Race Conditions and Critical Sections

10 quizzes