Skip to content

ConcurrencyFoundations

Threads and race conditions

What a thread is, why thread order is unpredictable, and the race conditions that follow, with a step-through demo and code in Java, Python, C#, Go and TypeScript.

Foundation27 min core · 32 min full depth

A concert has one seat left, 14C. Ana and Raj press “Buy” within the same millisecond. Two server threads run the same booking code:

  1. Check that 14C is free.
  2. Mark it taken.
  3. Send a ticket.

Both checks run before either write. So both threads see a free seat, and both people get a ticket. The code has no bug you can point to. Every line is correct, every test passes, and the logs show two valid bookings.

This is a race condition. The result depends on the order in which the two threads’ steps happen to mix, and some orders are wrong. This page explains what threads are, and why their order is out of your hands. Then it shows the shapes races take. The next pages cover the tools that fix them.

A process is a running program with its own memory. It has its own heap, its own open files, and its own address space that no other process can see.

A thread is one line of execution inside a process. Every thread has its own stack (local variables and the call chain) and its own program counter. It shares everything else with the other threads in the same process: the heap, static fields, open sockets.

Threads share the heap, each keeps its own stack

One process

Thread B

Thread A

stack: locals, call chain

program counter

stack: locals, call chain

program counter

heap: objects, static fields
shared by every thread

One process

Thread B

Thread A

stack: locals, call chain

program counter

stack: locals, call chain

program counter

heap: objects, static fields
shared by every thread

Scroll or pinch to zoom, drag to pan

That split is the whole story of this track. A local variable inside a method lives on one thread’s stack, so no other thread can touch it. An object on the heap can be reached by any thread that holds a reference to it. That is where every race lives.

Threads exist for two different reasons, and it helps to keep them apart:

  • Concurrency is about structure: dealing with many things at once. A web server has 500 requests in flight, and most are waiting on a database. Threads (or async tasks) let you write each request as straight-line code while the others wait. This helps even on one CPU core.
  • Parallelism is about speed: doing many things at the same instant, on several cores. Resizing 10,000 images on 8 cores can be close to 8 times faster than on one.

A program can be concurrent without being parallel. Node.js serves thousands of requests on one thread, for example. The races on this page happen in both kinds. Parallelism only adds one thing: two threads can run their steps at exactly the same moment, on two cores.

Kind Memory at start Start time Switch between two Practical count per machine
OS thread (Java platform, C#, Python) 512 KB to 1 MB stack reserved 10 to 50 µs 1 to 5 µs in kernel Thousands
Goroutine, Java virtual thread A few KB, grows on demand About 1 µs Under 1 µs, in user space Hundreds of thousands to millions
Async task (Node, asyncio, C# Task) A closure and a promise, under 1 KB Under 1 µs A function call Millions

The numbers vary by OS and version. The orders of magnitude are what matter. They explain why a server can’t start one OS thread per connection at 100,000 connections. That is why goroutines, Java 21’s virtual threads and async/await exist. The thread pools and async page covers that choice. For races, all three behave the same. Anything that can pause between two steps can race.

The operating system’s scheduler decides which threads run, on which cores, and for how long. It can stop a thread after any machine instruction, and run another. That happens when the time slice runs out, when a higher-priority thread wakes, or when the thread touches memory that isn’t in RAM. On a machine with several cores, two threads also run truly at once, and race to the same memory.

So the order of two threads’ steps is decided at run time. It depends on things your code can’t see: how many cores are free, what else the machine is doing, the state of the CPU’s cache. Run the same program twice, and you get two different orders.

Most orders are harmless. The bug only appears in the few that put one thread’s steps in the wrong place in the middle of another’s. That is why races pass tests and fail in production. A server doing 5,000 requests a second runs the same code 18 million times an hour, each time in its own order.

Interviewer

Two threads each print “1”, “2”, “3”. What can the output be?

What they are testing

Whether you know the scheduler can switch at any point. Each thread’s own lines stay in order. But the two sequences can mix in any way at all. So “1 1 2 3 2 3” and “1 2 3 1 2 3” are both possible (20 orders in total). The red flag is saying “1 1 2 2 3 3”, as if the threads take turns. Nothing makes threads alternate.

A race condition is a bug where the result depends on that order. You can’t find one by reading one thread’s code, because each thread’s code is correct. You find one by asking what happens when another thread runs in the middle of it.

Every race needs three things:

  1. Shared state: two threads can reach the same object.
  2. Mutable state: the object can change after it’s created.
  3. Unsynchronized access: at least one thread writes, and nothing orders its steps relative to the other thread’s.

That list is also the list of fixes, cheapest first:

  1. Don’t share: keep the state inside one thread. This is called confinement.
  2. Don’t mutate: immutable objects can be shared freely.
  3. Synchronize: use a lock or an atomic operation.

count++ looks like one step. It is three: load the value into a register, add one, and store it back. Say thread B loads between A’s load and A’s store. Then both load 0, both store 1, and one increment is gone. Nothing throws, and nothing is logged. The number is just low.

You are the scheduler below. Each press of Step runs one line of that thread. Play A, B, A, B, A, B, and watch the count end at 1. Then press Run 1,000 schedules to see how often a random order loses an update. Finally, switch to the atomic version to see the problem disappear.

count0
A · Thread
  1. tmp = count // load
  2. tmp = tmp + 1 // add
  3. count = tmp // store

ready

B · Thread
  1. tmp = count // load
  2. tmp = tmp + 1 // add
  3. count = tmp // store

ready

Two threads each run count++ once on a shared count of 0. You are the scheduler: press Step A or Step B to run one line of that thread. Try A, B, A, B, A, B.

In real code the same bug looks like this: two threads each add one a million times.

Main.javacounter.pyProgram.csmain.goexample.ts
// Two threads each add 1 to a shared counter a million times. Expect 2,000,000; get less.
public class Main {
// Shared and mutable, written by two threads, with nothing coordinating them.
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable work = () -> {
for (int i = 0; i < 1_000_000; i++) {
// One line, three steps: load count, add 1, store it. A thread switch or another
// core between the load and the store makes one increment overwrite the other.
count++;
}
};
Thread a = new Thread(work);
Thread b = new Thread(work);
a.start();
b.start();
// join() waits for each thread to finish, and also makes its writes visible here.
a.join();
b.join();
System.out.println("count = " + count); // usually well below 2000000, different every run
}
}

Run it, and you get a different wrong answer every time. In Java, C# and Go, it’s typically 1.0 to 1.7 million. Each language’s version is worth a look:

  • Java, C#, Go: the two threads run on two cores at once, so loads and stores really do overlap. The JIT compiler may also keep count in a register for a while, which loses even more.
  • Python: the GIL (global interpreter lock) lets only one thread run Python bytecode at a time. It still loses updates, because the interpreter can switch threads between the read and the write. CPython 3.10 and later only switch at calls and loop jumps. So a bare count += 1 rarely loses in a quick test. That makes the bug rarer, not gone. And free-threaded Python (3.13t) has no GIL at all.
  • TypeScript: Node runs your code on one thread, so count++ alone can’t race. But say an await sits between the read and the write, as it does when the value comes from Redis or a database. Then a thousand concurrent requests all read 0, and all write 1. One thread, the same race.

The fixes, from cheapest to change to cheapest to run:

Main.javacounter.pyProgram.csmain.goexample.ts
import java.util.concurrent.atomic.AtomicInteger;
// Two fixes for the lost update, from the cheapest change to the cheapest to run.
public class Main {
// Fix 1: make the increment one indivisible step. incrementAndGet is a single CPU
// instruction (lock xadd on x86), so no other thread can land between load and store.
static final AtomicInteger shared = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(() -> { for (int i = 0; i < 1_000_000; i++) shared.incrementAndGet(); });
Thread b = new Thread(() -> { for (int i = 0; i < 1_000_000; i++) shared.incrementAndGet(); });
a.start(); b.start();
a.join(); b.join();
check(shared.get());
// Fix 2: don't share at all while working. Each thread counts in its own local variable
// (on its own stack, invisible to the other), and the totals meet once, after join().
// Two million uncontended adds plus one combine: no atomic traffic between cores.
int[] partial = new int[2];
Thread[] workers = new Thread[2];
for (int w = 0; w < 2; w++) {
final int slot = w;
workers[w] = new Thread(() -> {
int local = 0; // confined to this thread
for (int i = 0; i < 1_000_000; i++) local++;
partial[slot] = local; // each thread writes only its own slot
});
workers[w].start();
}
for (Thread t : workers) t.join(); // join makes partial[] writes visible here
check(partial[0] + partial[1]);
}
static void check(int count) {
if (count != 2_000_000) throw new AssertionError("lost updates: " + count);
System.out.println("count = " + count); // count = 2000000, every run
}
}
  • An atomic increment (AtomicInteger, Interlocked, atomic.Int64) makes load, add and store a single CPU instruction that no other core can interrupt. It’s the right tool for one number. Python has no atomic integer in the standard library, so a lock is the portable fix.
  • Confinement: each thread counts in a local variable on its own stack. The totals meet once, at the end. Nothing is shared while the work runs. So there is nothing to protect, and no traffic between cores. It’s the fastest version by far, and the hardest to get wrong.
  • TypeScript: keep the read and the write in one synchronous run, with no await between them. When the value lives in a store, send one atomic command instead of a read and a write. Examples are Redis INCR and SQL UPDATE ... SET n = n + 1.

Check-then-act: deciding on stale information

Section titled “Check-then-act: deciding on stale information”

The booking bug is the other shape. A thread checks a condition, then acts on it. But by the time it acts, the condition may no longer be true. Every “if free, take it” has this gap. It shows up in seats, parking spots, usernames, the last item in stock, and lazy initialization (if (instance == null) instance = new ...).

Try it with Ana and Raj. Play A, B, A, B, then switch to the locked version and play the same order.

seat 14C owner—tickets sent0
A · Ana
  1. if (seat.owner != null) return "sold out"
  2. seat.owner = me
  3. sendTicket(me)

ready

B · Raj
  1. if (seat.owner != null) return "sold out"
  2. seat.owner = me
  3. sendTicket(me)

ready

Ana (A) and Raj (B) both try to book seat 14C. Each thread checks the seat, takes it, and sends a ticket. Try A, B, A, B.

Here is the part people miss: using a thread-safe collection does not fix it. Each call on a ConcurrentHashMap is safe. But two safe calls are not one safe operation. Between containsKey and put, the other thread can do both.

Main.javaexample.pyProgram.csmain.goexample.ts
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CyclicBarrier;
// Seat booking on a thread-safe map. Every single call is safe; the booking still isn't.
public class Main {
// seat -> customer. ConcurrentHashMap makes each get, put or containsKey thread safe.
static final ConcurrentHashMap<String, String> seats = new ConcurrentHashMap<>();
// The gap between check and act. A no-op in real use (or a payment call, a log write);
// main sets it to a barrier so both threads sit in the gap at once, on every run.
static Runnable gap = () -> {};
// Broken: two thread-safe calls are not one thread-safe operation. Both threads can pass
// the check before either one puts.
static boolean bookBroken(String seat, String who) {
if (seats.containsKey(seat)) return false;
gap.run();
seats.put(seat, who); // the second put silently overwrites the first customer
return true;
}
// Fixed: one call that checks and acts as a single atomic step on the map.
static boolean book(String seat, String who) {
return seats.putIfAbsent(seat, who) == null; // null means "it was free, and now it's mine"
}
interface Booking { boolean book(String seat, String who); }
// Ana and Raj book seat 14C at the same moment. Returns how many bookings succeeded.
static int race(Booking booking) throws InterruptedException {
seats.clear();
boolean[] ok = new boolean[2];
Thread ana = new Thread(() -> ok[0] = booking.book("14C", "Ana"));
Thread raj = new Thread(() -> ok[1] = booking.book("14C", "Raj"));
ana.start(); raj.start();
ana.join(); raj.join();
return (ok[0] ? 1 : 0) + (ok[1] ? 1 : 0);
}
public static void main(String[] args) throws Exception {
CyclicBarrier bothChecked = new CyclicBarrier(2);
gap = () -> { try { bothChecked.await(); } catch (Exception e) { throw new RuntimeException(e); } };
int broken = race(Main::bookBroken);
gap = () -> {};
if (broken != 2) throw new AssertionError("expected a double booking");
System.out.println("broken: " + broken + " tickets for 14C, owner " + seats.get("14C"));
int fixed = race(Main::book);
if (fixed != 1) throw new AssertionError("expected exactly one booking");
System.out.println("fixed: " + fixed + " ticket for 14C, owner " + seats.get("14C"));
}
}

The broken version holds both threads in the gap with a barrier. So the double booking happens on every run, instead of once in a million. The fix is a single call that checks and acts as one step: putIfAbsent, TryAdd, LoadOrStore. When no such call exists, a lock around both steps does the same job (see the Python version). Locks are the subject of the next page.

In TypeScript, the fix is to claim the seat in the same synchronous run as the check. Do the slow work after that, and release the claim if the work fails.

Interviewer

Our seat map is a ConcurrentHashMap, so booking is thread safe. Agree?

What they are testing

Whether you can see that thread-safe parts don’t make a thread-safe whole. The expected answer names the check-then-act gap. It fixes the gap with one atomic map call, or a lock around both steps. The red flag is agreeing because “the map is concurrent”. It’s the most common red flag in LLD rounds.

The two terms get used as synonyms, and they aren’t.

  • A data race is a low-level fact. Two threads access the same memory location at the same time, at least one writes, and nothing orders the two accesses. Go’s race detector, ThreadSanitizer and Java’s memory model all define and detect this. The unprotected count++ is a data race.
  • A race condition is a logic bug: the program’s result depends on timing. The booking on a ConcurrentHashMap has no data race. Every access goes through the map’s own synchronization, and go run -race booking.go is silent. Yet it is still a race condition.

You can have either one without the other. So a clean race detector run is good news, not proof. The tool can’t know your invariant, such as “one ticket per seat”. Only you can.

There is one more kind of bug. Here the problem isn’t the order of steps. It’s whether one thread sees another’s write at all. CPUs keep values in per-core caches, and compilers keep them in registers. So thread B can keep reading an old value of a flag long after A set it. This has its own section on the locks and atomics page, because the same tools fix it.

Interviewers often hand over a class and ask whether it’s thread safe. Work through it the same way every time:

  1. What is shared? Fields, statics, anything passed to another thread or stored in a shared collection. Locals and parameters of primitive type are not.
  2. What changes after construction? Final or readonly fields holding immutable values are safe to share. Anything with a setter or a mutating method is not.
  3. What must stay true? Name the invariant, such as “one ticket per seat”, “balance never negative”, or “size equals the number of items”. The invariant tells you which fields must change together.
  4. Find every read-modify-write and check-then-act on that state, including across methods. Say one method does a get, another does a put, and the caller calls them in sequence. That is still one check-then-act.
  5. Is every access guarded by the same thing? One method that reads without the lock, or uses a different lock, breaks the guarantee for all the others.
Say this out loud

The shared state is the seats map, and the invariant is one owner per seat. book checks and then writes, so two threads can both pass the check. I’d make it one atomic step with putIfAbsent. Or I’d hold a lock around both, if booking needs more steps.

Language Threads you get The trap
Java OS threads; virtual threads since Java 21 count++ on a field races across cores; the JIT can cache a field in a register
Python OS threads, one running bytecode at a time (the GIL) The GIL prevents corrupting the interpreter, not your logic. Threads speed up I/O, not CPU work; use processes for that
C# OS threads, the thread pool, Task Same as Java. Task.Run puts work on the pool, so two tasks are two threads
Go Goroutines spread across all cores Races are real parallel races; run tests with -race, which catches data races (not race conditions)
TypeScript One thread per process; worker_threads for more No two lines run at once, but any await lets another request in, so check-then-act across an await breaks the same way
Symptom Usually
A counter or total that’s slightly low, never high Lost updates on a read-modify-write
Two winners: double booking, two accounts with one username Check-then-act; the database’s unique constraint is the last line of defence, so keep it
A map that loops forever or loses entries A non-thread-safe map (Java’s HashMap, Go’s map) written from two threads; Go crashes with “concurrent map writes”
ConcurrentModificationException One thread iterates a collection while another modifies it
A bug that disappears when you add logging or a debugger Logging takes a lock and adds delay, which changes the interleaving. The race is still there
Interviewer asksWhat's the difference between concurrency and parallelism?

Concurrency is structuring a program as several tasks in progress at once. It can make progress on one task while another waits. Parallelism is running several things at the same instant, on several cores. Node.js serving 10,000 connections on one thread is concurrent, but not parallel. A matrix multiply split across 8 cores is parallel. Races can happen in both, because both mix steps.

Interviewer asksPython has a GIL. Do Python programs need locks?

Yes. The GIL makes single bytecode instructions safe, so the interpreter’s own data structures can’t be corrupted. But the interpreter can switch threads between any two bytecodes. x = x + 1 is a load, an add and a store. Two threads can mix those steps and lose an update. Also, the GIL isn’t part of the language: free-threaded builds from Python 3.13 remove it. Any multi-step update of shared state needs a lock.

Interviewer asksNode.js is single-threaded. Can it have race conditions?

Yes. Two lines of JavaScript never run at the same time. But an await suspends the function, and lets other callbacks run. Say a function reads state, awaits, then writes based on what it read. Another request can run in between, and the same lost update or double booking happens. The fix is to keep the read and the write in one synchronous run. Or push the operation into one atomic command in the database or cache.

Interviewer asksIs a class with only synchronized (or locked) methods always thread safe?

No. Each method is atomic. But a caller can combine two calls, like if (!list.contains(x)) list.add(x). That is a check-then-act across them. Either the class offers the compound operation (addIfAbsent), or the caller holds the same lock around both calls. Thread safety is a property of how the operations combine, not of each method.

Interviewer asksHow would you test that a class is thread safe?

Run the operation from many threads at once, many times. Then check an invariant at the end: the counter equals threads times iterations, or each seat has one owner. Start the threads behind a latch, so they really overlap. For a specific race, force the bad order on purpose: put a barrier inside the gap, as the booking example does. Run Go tests with -race, and use ThreadSanitizer for C and C++. Passing still isn’t proof. So the design argument (what is shared, and what guards it) matters as much as the test.

Go deeperWhy a local variable is always safe, and when it isn't2 min

A local variable lives in the current thread’s stack frame, which no other thread can reach. So int local = 0; local++ can’t race. The exception is what a local refers to. In Java, C#, Python and TypeScript, a local that holds an object reference points into the shared heap. That object may also have been handed to another thread. For example, it was put in a shared list, or captured by a lambda that another thread runs. Then the object is shared, even though the variable isn’t.

Lambdas and closures are the usual leak. In Go, a closure that captures a loop variable and runs as a goroutine shares that variable with the loop. (Go 1.22 gave each iteration its own variable for this reason.) In Java, captured locals must be effectively final. That blocks sharing the variable, but not sharing the object it points to.

Idea Remember
Thread Own stack and program counter, shares the heap with every other thread in the process
Scheduler Can switch threads between any two steps; on many cores, steps really run at once
Race condition Result depends on interleaving; needs shared, mutable, unsynchronized state
Read-modify-write count++ is load, add, store; overlap loses updates silently
Check-then-act The check is stale by the time you act; thread-safe parts don’t make a safe whole
Data race vs race condition Unordered access to one memory location vs a timing-dependent logic bug; you can have either alone
Fixes, cheapest first Don’t share (confine), don’t mutate (immutable), then an atomic or a lock
Node and Python Races happen across await and between bytecodes; one thread or a GIL doesn’t prevent them