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.
A concert has one seat left, 14C. Ana and Raj press “Buy” within the same millisecond. Two server threads run the same booking code:
- Check that 14C is free.
- Mark it taken.
- 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.
What a thread is
Section titled “What a thread is”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.
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.
What a thread costs
Section titled “What a thread costs”| 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.
Why the order is out of your hands
Section titled “Why the order is out of your hands”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.
Two threads each print “1”, “2”, “3”. What can the output be?
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.
Race conditions
Section titled “Race conditions”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:
- Shared state: two threads can reach the same object.
- Mutable state: the object can change after it’s created.
- 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:
- Don’t share: keep the state inside one thread. This is called confinement.
- Don’t mutate: immutable objects can be shared freely.
- Synchronize: use a lock or an atomic operation.
Read-modify-write: the lost update
Section titled “Read-modify-write: the lost update”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.
- tmp = count // load
- tmp = tmp + 1 // add
- count = tmp // store
ready
- tmp = count // load
- tmp = tmp + 1 // add
- 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.
// 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 }}import sysimport threading
# Make the interpreter switch threads far more often than its 5 ms default, so the race shows# up in a short run. It changes how often the bug appears, not whether the code is correct.sys.setswitchinterval(1e-6)
def plus_one(n: int) -> int: # Stands in for any work between the read and the write: a validation, a log call. return n + 1
class Counter: def __init__(self) -> None: self.count = 0 # shared and mutable, written by two threads, nothing coordinating them
def work(self) -> None: for _ in range(200_000): # Read, compute, write. The GIL runs one thread at a time, but it can hand the # interpreter to the other thread between the read and the write. CPython 3.10+ # happens to switch only at calls and loop jumps, which is why a bare `count += 1` # rarely loses in a quick test. Rarely is not never, and free-threaded Python (3.13t) # has no GIL at all. self.count = plus_one(self.count)
if __name__ == "__main__": c = Counter() threads = [threading.Thread(target=c.work) for _ in range(2)] for t in threads: t.start() for t in threads: t.join() print("count =", c.count) # usually below 400000, different every runusing System;using System.Threading;
// Two threads each add 1 to a shared counter a million times. Expect 2,000,000; get less.static class Program{ // Shared and mutable, written by two threads, with nothing coordinating them. static int _count;
static void Main() { void Work() { for (int i = 0; i < 1_000_000; i++) { // One line, three steps: load, add 1, store. Two threads that load the same value // both store value + 1, and one increment is gone. _count++; } } var a = new Thread(Work); var 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(); Console.WriteLine($"count = {_count}"); // usually well below 2000000, different every run }}package main
import ( "fmt" "sync")
func main() { // Shared and mutable, written by two goroutines, with nothing coordinating them. // `go run -race` stops this program with "WARNING: DATA RACE". count := 0 var wg sync.WaitGroup for range 2 { wg.Add(1) go func() { defer wg.Done() for range 1_000_000 { // One statement, three steps: load, add 1, store. Two goroutines on two cores that // load the same value both store value + 1, and one increment is gone. count++ } }() } // Wait blocks until both goroutines call Done, and makes their writes visible here. wg.Wait() fmt.Println("count =", count) // usually well below 2000000, different every run}// Node runs JavaScript on one thread, so count++ on its own can't race. Put an await between the// read and the write, as real code does when it loads a value from a cache or a database, and// the same lost update appears on one thread.let count = 0;
// Stands in for any I/O: a Redis GET, a database read. Even a resolved promise yields.const load = async (): Promise<number> => count;
async function increment(): Promise<void> { const seen = await load(); // read, then give up the thread until the read completes // While this request waited, every other request read the same value. count = seen + 1; // write back a value computed from a stale read}
async function main(): Promise<void> { // A thousand requests in flight at once, the way a server handles them. await Promise.all(Array.from({ length: 1000 }, () => increment())); console.log("count =", count); // count = 1, every run: 999 increments lost}
main();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
countin 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 += 1rarely 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 anawaitsits 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:
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 }}import sysimport threadingfrom concurrent.futures import ThreadPoolExecutor
sys.setswitchinterval(1e-6) # same aggressive switching as the broken version
def plus_one(n: int) -> int: return n + 1
# Fix 1: guard the read-compute-write with a lock. Python has no atomic integer in the# standard library; a Lock is the portable tool, and it stays correct without the GIL.class Counter: def __init__(self) -> None: self._lock = threading.Lock() self.count = 0 # guarded by _lock
def work(self) -> None: for _ in range(200_000): with self._lock: # read and write happen with no other thread in between self.count = plus_one(self.count)
# Fix 2: don't share while working. The local lives in this thread's frame only.def count_locally(n: int) -> int: local = 0 for _ in range(n): local = plus_one(local) return local
if __name__ == "__main__": c = Counter() threads = [threading.Thread(target=c.work) for _ in range(2)] for t in threads: t.start() for t in threads: t.join() assert c.count == 400_000, c.count print("count =", c.count) # count = 400000, every run
# The pool hands each result back through a future, so the partial counts meet only once. with ThreadPoolExecutor(max_workers=2) as pool: total = sum(pool.map(count_locally, [200_000, 200_000])) assert total == 400_000, total print("count =", total) # count = 400000using System;using System.Threading;using System.Threading.Tasks;
// Two fixes for the lost update, from the cheapest change to the cheapest to run.static class Program{ static int _shared;
static void Main() { // Fix 1: make the increment one indivisible step. Interlocked.Increment compiles to a // single locked CPU instruction, so no other thread can land between load and store. var a = new Thread(() => { for (int i = 0; i < 1_000_000; i++) Interlocked.Increment(ref _shared); }); var b = new Thread(() => { for (int i = 0; i < 1_000_000; i++) Interlocked.Increment(ref _shared); }); a.Start(); b.Start(); a.Join(); b.Join(); Check(_shared);
// Fix 2: don't share while working. Each task counts in its own local and returns it; // the totals meet once, when the results are awaited. No traffic between cores. Task<int> Count() => Task.Run(() => { int local = 0; // confined to this task for (int i = 0; i < 1_000_000; i++) local++; return local; }); int[] parts = Task.WhenAll(Count(), Count()).Result; Check(parts[0] + parts[1]); }
static void Check(int count) { if (count != 2_000_000) throw new InvalidOperationException($"lost updates: {count}"); Console.WriteLine($"count = {count}"); // count = 2000000, every run }}package main
import ( "fmt" "sync" "sync/atomic")
func check(count int64) { if count != 2_000_000 { panic(fmt.Sprint("lost updates: ", count)) } fmt.Println("count =", count) // count = 2000000, every run, and clean under -race}
func main() { // Fix 1: make the increment one indivisible step. Add compiles to a single locked CPU // instruction, so no other goroutine can land between load and store. var shared atomic.Int64 var wg sync.WaitGroup for range 2 { wg.Add(1) go func() { defer wg.Done() for range 1_000_000 { shared.Add(1) } }() } wg.Wait() check(shared.Load())
// Fix 2: share by communicating. Each goroutine counts in its own local variable and sends // the total over a channel once. The send is the only point where the two meet. totals := make(chan int64) for range 2 { go func() { var local int64 // confined to this goroutine for range 1_000_000 { local++ } totals <- local }() } check(<-totals + <-totals)}// On Node's one thread, a run of code with no await in it can't be interrupted. So the fix for// a read-modify-write is to keep the read and the write in the same synchronous run.let count = 0;
async function increment(): Promise<void> { await Promise.resolve(); // any I/O the request needs happens first... count = count + 1; // ...then read and write with nothing in between that can yield}
// When the value lives somewhere else (Redis, a database), the same rule moves there: send one// atomic command instead of a read and a write. A Map stands in for Redis's INCR here.const store = new Map<string, number>();async function incr(key: string): Promise<number> { await Promise.resolve(); // the network round trip const next = (store.get(key) ?? 0) + 1; // runs inside the store, as one step store.set(key, next); return next;}
async function main(): Promise<void> { await Promise.all(Array.from({ length: 1000 }, () => increment())); if (count !== 1000) throw new Error(`lost updates: ${count}`); console.log("count =", count); // count = 1000
await Promise.all(Array.from({ length: 1000 }, () => incr("hits"))); if (store.get("hits") !== 1000) throw new Error("lost updates"); console.log("hits =", store.get("hits")); // hits = 1000}
main();- 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
awaitbetween them. When the value lives in a store, send one atomic command instead of a read and a write. Examples are RedisINCRand SQLUPDATE ... 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.
- if (seat.owner != null) return "sold out"
- seat.owner = me
- sendTicket(me)
ready
- if (seat.owner != null) return "sold out"
- seat.owner = me
- 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.
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")); }}import threadingfrom typing import Callable
seats: dict[str, str] = {} # seat -> customer_lock = threading.Lock()
# 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.gap: Callable[[], object] = lambda: None
def book_broken(seat: str, who: str) -> bool: # Each dict operation is atomic in CPython, but two of them are not one operation: both # threads can pass the check before either one writes. if seat in seats: return False gap() seats[seat] = who # the second write silently replaces the first customer return True
def book(seat: str, who: str) -> bool: # Fixed: the check and the write happen under one lock. dict.setdefault would also be a # single step in CPython today, but the language doesn't promise it, and a lock still holds # once the booking grows a second step (charge the card, write an audit row). with _lock: if seat in seats: return False seats[seat] = who return True
# Ana and Raj book seat 14C at the same moment. Returns how many bookings succeeded.def race(booking: Callable[[str, str], bool]) -> int: seats.clear() ok: list[bool] = [] threads = [threading.Thread(target=lambda w=w: ok.append(booking("14C", w))) for w in ("Ana", "Raj")] for t in threads: t.start() for t in threads: t.join() return sum(ok)
if __name__ == "__main__": both_checked = threading.Barrier(2) gap = both_checked.wait broken = race(book_broken) gap = lambda: None assert broken == 2, "expected a double booking" print(f"broken: {broken} tickets for 14C, owner {seats['14C']}")
fixed = race(book) assert fixed == 1, "expected exactly one booking" print(f"fixed: {fixed} ticket for 14C, owner {seats['14C']}")using System;using System.Collections.Concurrent;using System.Threading;
// Seat booking on a thread-safe map. Every single call is safe; the booking still isn't.static class Program{ // seat -> customer. ConcurrentDictionary makes each lookup and write thread safe. static readonly ConcurrentDictionary<string, string> Seats = new();
// The gap between check and act. A no-op in real use; Main sets it to a barrier so both // threads sit in the gap at once, on every run. static Action _gap = () => { };
// Broken: two thread-safe calls are not one thread-safe operation. static bool BookBroken(string seat, string who) { if (Seats.ContainsKey(seat)) return false; _gap(); Seats[seat] = who; // the second write silently overwrites the first customer return true; }
// Fixed: one call that checks and acts as a single atomic step on the map. static bool Book(string seat, string who) => Seats.TryAdd(seat, who);
// Ana and Raj book seat 14C at the same moment. Returns how many bookings succeeded. static int Race(Func<string, string, bool> book) { Seats.Clear(); var ok = new bool[2]; var ana = new Thread(() => ok[0] = book("14C", "Ana")); var raj = new Thread(() => ok[1] = book("14C", "Raj")); ana.Start(); raj.Start(); ana.Join(); raj.Join(); return (ok[0] ? 1 : 0) + (ok[1] ? 1 : 0); }
static void Main() { using var bothChecked = new Barrier(2); _gap = () => bothChecked.SignalAndWait(); int broken = Race(BookBroken); _gap = () => { }; if (broken != 2) throw new InvalidOperationException("expected a double booking"); Console.WriteLine($"broken: {broken} tickets for 14C, owner {Seats["14C"]}");
int fixedCount = Race(Book); if (fixedCount != 1) throw new InvalidOperationException("expected exactly one booking"); Console.WriteLine($"fixed: {fixedCount} ticket for 14C, owner {Seats["14C"]}"); }}package main
import ( "fmt" "sync")
// seats maps seat -> customer. sync.Map makes each Load and Store safe on its own.var seats sync.Map
// gap is the window between check and act. A no-op in real use; main sets it to a barrier so// both goroutines sit in the gap at once, on every run.var gap = func() {}
// bookBroken: two safe calls are not one safe operation. Both goroutines can pass the check// before either one stores. No data race here, so -race stays silent: this is a race condition,// not a data race.func bookBroken(seat, who string) bool { if _, taken := seats.Load(seat); taken { return false } gap() seats.Store(seat, who) // the second store silently replaces the first customer return true}
// book checks and acts in one atomic step on the map.func book(seat, who string) bool { _, taken := seats.LoadOrStore(seat, who) return !taken}
// race has Ana and Raj book seat 14C at the same moment and counts the successes.func race(booking func(seat, who string) bool) int { seats.Clear() var ok [2]bool var wg sync.WaitGroup for i, who := range []string{"Ana", "Raj"} { wg.Add(1) go func() { defer wg.Done() ok[i] = booking("14C", who) // each goroutine writes only its own slot }() } wg.Wait() n := 0 for _, b := range ok { if b { n++ } } return n}
func owner() any { v, _ := seats.Load("14C"); return v }
func main() { var arrived sync.WaitGroup arrived.Add(2) gap = func() { arrived.Done(); arrived.Wait() } broken := race(bookBroken) gap = func() {} if broken != 2 { panic("expected a double booking") } fmt.Println("broken:", broken, "tickets for 14C, owner", owner())
fixed := race(book) if fixed != 1 { panic("expected exactly one booking") } fmt.Println("fixed:", fixed, "ticket for 14C, owner", owner())}// Seat booking on one Node thread. Each Map call is atomic (nothing else runs during it); an// await between the check and the write is where another request gets in.const seats = new Map<string, string>(); // seat -> customer
// Stands in for the work real code does between checking and writing: charging a card,// loading the customer's profile.const chargeCard = async (): Promise<void> => {};
async function bookBroken(seat: string, who: string): Promise<boolean> { if (seats.has(seat)) return false; await chargeCard(); // yields: the other request runs its check now and sees the seat free seats.set(seat, who); // the second write silently replaces the first customer return true;}
// Fixed: claim the seat in the same synchronous run as the check, then do the slow work, and// give the seat back if that work fails.async function book(seat: string, who: string): Promise<boolean> { if (seats.has(seat)) return false; seats.set(seat, who); // check and claim with no await in between try { await chargeCard(); return true; } catch (e) { seats.delete(seat); // release the claim so someone else can book throw e; }}
async function race(booking: (seat: string, who: string) => Promise<boolean>): Promise<number> { seats.clear(); const ok = await Promise.all([booking("14C", "Ana"), booking("14C", "Raj")]); return ok.filter(Boolean).length;}
async function main(): Promise<void> { const broken = await race(bookBroken); if (broken !== 2) throw new Error("expected a double booking"); console.log(`broken: ${broken} tickets for 14C, owner ${seats.get("14C")}`);
const fixed = await race(book); if (fixed !== 1) throw new Error("expected exactly one booking"); console.log(`fixed: ${fixed} ticket for 14C, owner ${seats.get("14C")}`);}
main();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.
Our seat map is a ConcurrentHashMap, so booking is thread safe. Agree?
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.
Data race vs race condition
Section titled “Data race vs race condition”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
ConcurrentHashMaphas no data race. Every access goes through the map’s own synchronization, andgo run -race booking.gois 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.
Visibility, the third shape
Section titled “Visibility, the third shape”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.
Finding races in code you’re given
Section titled “Finding races in code you’re given”Interviewers often hand over a class and ask whether it’s thread safe. Work through it the same way every time:
- What is shared? Fields, statics, anything passed to another thread or stored in a shared collection. Locals and parameters of primitive type are not.
- What changes after construction? Final or
readonlyfields holding immutable values are safe to share. Anything with a setter or a mutating method is not. - 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.
- Find every read-modify-write and check-then-act on that state, including across methods.
Say one method does a
get, another does aput, and the caller calls them in sequence. That is still one check-then-act. - 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.
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.
Each language, honestly
Section titled “Each language, honestly”| 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 |
How races fail in production
Section titled “How races fail in production”| 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 |
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- Brian Goetz et al., Java Concurrency in Practice, chapters 2 and 3. Still the clearest treatment of thread safety, atomicity and confinement, whatever language you use.
- The Go Memory Model and Data Race Detector.
- PEP 703: Making the Global Interpreter Lock Optional, on free-threaded Python.
- Next: Locks and atomics, the tools that make a group of steps indivisible.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.